diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index 4b5f49c..0562445 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -25,6 +25,24 @@ func (h *Handler) CreateQrCodeHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": qrcode}) } +// BatchCreateQrCodeHandler 批量创建二维码 +func (h *Handler) BatchCreateQrCodeHandler(c *gin.Context) { + var req BatchCreateQrCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + adminID := c.GetUint64("admin_id") + qrcodes, err := h.service.repo.BatchCreateQrCode(c.Request.Context(), adminID, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": qrcodes}) +} + // ListQrCodesHandler 列表查询二维码 func (h *Handler) ListQrCodesHandler(c *gin.Context) { var req QrCodeListRequest diff --git a/backend/internal/modules/chat/qrcode.go b/backend/internal/modules/chat/qrcode.go index e729e01..d4f7f4d 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -29,6 +29,11 @@ type CreateQrCodeRequest struct { ExpiresAt *time.Time `json:"expires_at"` } +// BatchCreateQrCodeRequest 批量创建二维码请求 +type BatchCreateQrCodeRequest struct { + Items []CreateQrCodeRequest `json:"items" binding:"required,min=1,max=20"` +} + // UpdateQrCodeRequest 更新二维码请求 type UpdateQrCodeRequest struct { Note *string `json:"note"` @@ -74,6 +79,32 @@ func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req Creat return &qrcode, nil } +// BatchCreateQrCode 批量创建二维码 +func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) { + var qrcodes []model.ChatQrCode + defaultExpires := time.Now().Add(7 * 24 * time.Hour) + + for _, item := range req.Items { + qr := model.ChatQrCode{ + ImageURL: item.ImageURL, + Status: QrCodeStatusUnused, + CreatedBy: adminID, + Note: item.Note, + ExpiresAt: item.ExpiresAt, + } + if qr.ExpiresAt == nil { + qr.ExpiresAt = &defaultExpires + } + qrcodes = append(qrcodes, qr) + } + + if err := r.db.WithContext(ctx).Create(&qrcodes).Error; err != nil { + return nil, err + } + + return qrcodes, nil +} + // ListQrCodes 列表查询二维码 func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]model.ChatQrCode, int64, error) { if req.Page < 1 { diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index 8df33b7..2b52b6b 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string { func normalizeScene(scene string) string { scene = strings.TrimSpace(strings.ToLower(scene)) switch scene { - case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement": + case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode": return scene default: return "misc" diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2cc7849..36add85 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -553,6 +553,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { // 二维码池管理 adminRoutes.POST("/chats/qrcodes", requirePerm("chat:manage"), chatHandler.CreateQrCodeHandler) + adminRoutes.POST("/chats/qrcodes/batch", requirePerm("chat:manage"), chatHandler.BatchCreateQrCodeHandler) adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler) adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler) adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 92bd16a..01cc0ab 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -11,6 +11,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AuthImage: typeof import('./src/components/AuthImage.vue')['default'] ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] @@ -62,6 +63,7 @@ declare module 'vue' { ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] + ElUpload: typeof import('element-plus/es')['ElUpload'] GuideImage: typeof import('./src/components/GuideImage.vue')['default'] InAppBrowserPrompt: typeof import('./src/components/InAppBrowserPrompt.vue')['default'] MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default'] diff --git a/frontend/package.json b/frontend/package.json index c1ae03f..49bf970 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vue-tsc -b --noEmit --watch & vite --host 0.0.0.0", + "dev": "vue-tsc -b --noEmit --watch --preserveWatchOutput & vite --host 0.0.0.0", "build": "vue-tsc -b --noEmit && vite build", "preview": "vite preview --host 0.0.0.0", "typecheck": "vue-tsc -b --noEmit", diff --git a/frontend/src/features/admin/api/adminQrCodes.ts b/frontend/src/features/admin/api/adminQrCodes.ts new file mode 100644 index 0000000..f0127f1 --- /dev/null +++ b/frontend/src/features/admin/api/adminQrCodes.ts @@ -0,0 +1,95 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' + +export type QrCodeStatus = 'unused' | 'used' | 'disabled' + +export interface ChatQrCode { + id: number + image_url: string + status: QrCodeStatus + conversation_id: number | null + used_at: string | null + expires_at: string | null + created_by: number + note: string + created_at: string + updated_at: string +} + +export interface QrCodeStats { + unused_count: number + used_count: number + disabled_count: number + total_count: number +} + +export interface QrCodeListQuery { + status?: QrCodeStatus + page?: number + limit?: number +} + +export interface CreateQrCodePayload { + image_url: string + note?: string + expires_at?: string | null +} + +export interface UpdateQrCodePayload { + note?: string + status?: QrCodeStatus + expires_at?: string | null +} + +export async function fetchQrCodes(query: QrCodeListQuery = {}) { + const { data } = await apiClient.get>>( + '/admin/chats/qrcodes', + { + params: { status: query.status || undefined, page: query.page || 1, limit: query.limit || 20 }, + silent: true, + } + ) + // 后端返回 { data: [...], pagination: { total, page, limit } },适配为 PaginatedResult + const resp = data.data as unknown as { + data: ChatQrCode[] + pagination: { total: number; page: number; limit: number } + } + return { + items: resp.data, + total: resp.pagination.total, + page: resp.pagination.page, + page_size: resp.pagination.limit, + } as PaginatedResult +} + +export async function fetchQrCodeStats() { + const { data } = await apiClient.get>('/admin/chats/qrcodes/stats', { + silent: true, + }) + return data.data +} + +export async function createQrCode(payload: CreateQrCodePayload) { + const { data } = await apiClient.post>('/admin/chats/qrcodes', payload) + return data.data +} + +export async function batchCreateQrCodes(items: CreateQrCodePayload[]) { + const { data } = await apiClient.post>('/admin/chats/qrcodes/batch', { + items, + }) + return data.data +} + +export async function updateQrCode(id: number, payload: UpdateQrCodePayload) { + const { data } = await apiClient.patch>( + `/admin/chats/qrcodes/${id}`, + payload + ) + return data.data +} + +export async function deleteQrCode(id: number) { + const { data } = await apiClient.delete>(`/admin/chats/qrcodes/${id}`) + return data.data +} diff --git a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue index 8328fda..a125865 100644 --- a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue +++ b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue @@ -5,6 +5,7 @@ import { ref } from 'vue' import type { WithdrawalDetail } from '@/features/admin/api/adminWithdrawal' import { reviewWithdrawal, confirmPayment } from '@/features/admin/api/adminWithdrawal' import { formatCent } from '@/shared/utils/money' +import AuthImage from '@/shared/components/business/AuthImage.vue' const props = defineProps<{ modelValue: boolean @@ -200,14 +201,14 @@ function accountTypeLabel(type: string) { :span="2" >
-
diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue new file mode 100644 index 0000000..c792dd6 --- /dev/null +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index 574fe16..cd8a804 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -40,7 +40,7 @@ export interface ChatMessage { sender_avatar: string is_self: boolean is_read: boolean - content_type: 'text' | 'system' + content_type: 'text' | 'image' | 'system' content: string attachment_urls: string[] created_at: string diff --git a/frontend/src/features/chats/views/ChatView.vue b/frontend/src/features/chats/views/ChatView.vue index b645eb8..94fd20b 100644 --- a/frontend/src/features/chats/views/ChatView.vue +++ b/frontend/src/features/chats/views/ChatView.vue @@ -296,6 +296,9 @@ function handleKeydown(e: KeyboardEvent) { >