diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index 0562445..1979e1a 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -58,11 +58,11 @@ func (h *Handler) ListQrCodesHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "data": qrcodes, - "pagination": gin.H{ - "total": total, - "page": req.Page, - "limit": req.Limit, + "data": PaginatedResult{ + Items: qrcodes, + Total: total, + Page: req.Page, + PageSize: req.Limit, }, }) } diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 7e40ead..f3985de 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -12,8 +12,8 @@ import ( // 会话类型常量 const ( - ConversationTypeOrderGroup = "order_group" - ConversationTypeListingGroup = "listing_group" + ConversationTypeOrderGroup = "order_group" + ConversationTypeListingGroup = "listing_group" ConversationTypeGeneralSupport = "general_support" ) @@ -297,6 +297,7 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) var count int64 if err := tx.Model(&model.ChatQrCode{}). Where("status = ?", QrCodeStatusUnused). + Where("expires_at IS NULL OR expires_at > ?", time.Now()). Count(&count).Error; err != nil { return err } @@ -319,6 +320,7 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) // 发送站内信给所有客服 var entries []interface{} + now := time.Now() for _, admin := range csAdmins { entries = append(entries, map[string]interface{}{ "admin_user_id": admin.ID, @@ -326,13 +328,13 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) "title": "二维码库存预警", "content": alertContent, "is_read": false, - "created_at": time.Now(), - "updated_at": time.Now(), + "created_at": now, + "updated_at": now, }) } if len(entries) > 0 { - // 批量插入通知 + // 批量插入管理员通知 if err := tx.Table("admin_notifications").Create(entries).Error; err != nil { return err } diff --git a/backend/internal/modules/chat/qrcode.go b/backend/internal/modules/chat/qrcode.go index d4f7f4d..08ded98 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -36,9 +36,11 @@ type BatchCreateQrCodeRequest struct { // UpdateQrCodeRequest 更新二维码请求 type UpdateQrCodeRequest struct { - Note *string `json:"note"` - Status *string `json:"status"` - ExpiresAt *time.Time `json:"expires_at"` + ImageURL *string `json:"image_url"` + Note *string `json:"note"` + Status *string `json:"status"` + ExpiresAt *time.Time `json:"expires_at"` + ClearExpiresAt bool `json:"clear_expires_at"` } // QrCodeListRequest 列表查询请求 @@ -140,32 +142,29 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([] // GetQrCodeStats 获取二维码统计信息 func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { stats := &QrCodeStats{} + now := time.Now() - // 统计各状态数量 - type CountResult struct { - Status string - Count int64 - } - var results []CountResult - - if err := r.db.WithContext(ctx). - Model(&model.ChatQrCode{}). - Select("status, COUNT(*) as count"). - Group("status"). - Find(&results).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Count(&stats.TotalCount).Error; err != nil { return nil, err } - - for _, r := range results { - stats.TotalCount += r.Count - switch r.Status { - case QrCodeStatusUnused: - stats.UnusedCount = r.Count - case QrCodeStatusUsed: - stats.UsedCount = r.Count - case QrCodeStatusDisabled: - stats.DisabledCount = r.Count - } + if err := r.db.WithContext(ctx). + Model(&model.ChatQrCode{}). + Where("status = ?", QrCodeStatusUsed). + Count(&stats.UsedCount).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx). + Model(&model.ChatQrCode{}). + Where("status = ?", QrCodeStatusDisabled). + Count(&stats.DisabledCount).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx). + Model(&model.ChatQrCode{}). + Where("status = ?", QrCodeStatusUnused). + Where("expires_at IS NULL OR expires_at > ?", now). + Count(&stats.UnusedCount).Error; err != nil { + return nil, err } return stats, nil @@ -175,6 +174,9 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error { updates := make(map[string]interface{}) + if req.ImageURL != nil { + updates["image_url"] = *req.ImageURL + } if req.Note != nil { updates["note"] = *req.Note } @@ -187,6 +189,8 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo } if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt + } else if req.ClearExpiresAt { + updates["expires_at"] = nil } if len(updates) == 0 { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 93e94c5..d457a2e 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -113,7 +113,7 @@ func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) { if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil { t.Fatalf("failed to migrate test db: %v", err) } - repo := NewRepository(db) + repo := NewRepository(db, nil) req := CreateRequest{ Title: "测试账号", ServerRegion: "QQ", diff --git a/backend/migrations/000009_admin_notifications.sql b/backend/migrations/000009_admin_notifications.sql new file mode 100644 index 0000000..e169cc1 --- /dev/null +++ b/backend/migrations/000009_admin_notifications.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS admin_notifications ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '接收管理员ID', + type VARCHAR(32) NOT NULL COMMENT '通知类型: system系统, chat聊天等', + title VARCHAR(128) NOT NULL COMMENT '通知标题', + content TEXT NULL COMMENT '通知内容', + is_read TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_admin_notifications_user_read_created (admin_user_id, is_read, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员通知消息表'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS admin_notifications; + +-- +goose StatementEnd diff --git a/frontend/src/features/admin/api/adminQrCodes.ts b/frontend/src/features/admin/api/adminQrCodes.ts index f0127f1..70f4503 100644 --- a/frontend/src/features/admin/api/adminQrCodes.ts +++ b/frontend/src/features/admin/api/adminQrCodes.ts @@ -36,9 +36,11 @@ export interface CreateQrCodePayload { } export interface UpdateQrCodePayload { + image_url?: string note?: string status?: QrCodeStatus expires_at?: string | null + clear_expires_at?: boolean } export async function fetchQrCodes(query: QrCodeListQuery = {}) { @@ -49,17 +51,7 @@ export async function fetchQrCodes(query: QrCodeListQuery = {}) { 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 + return data.data } export async function fetchQrCodeStats() { diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index c792dd6..2148a78 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -39,8 +39,10 @@ const uploadForm = reactive({ // 编辑弹窗 const editVisible = ref(false) +const editUploading = ref(false) const editForm = reactive({ id: 0, + image_url: '', note: '', status: 'unused' as QrCodeStatus, expires_at: '' as string, @@ -144,17 +146,37 @@ async function submitUpload() { function openEdit(row: ChatQrCode) { editForm.id = row.id + editForm.image_url = row.image_url editForm.note = row.note editForm.status = row.status editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : '' editVisible.value = true } +function clearEditExpiresAt() { + editForm.expires_at = '' +} + +async function handleEditImageUpload(options: { file: File }) { + editUploading.value = true + try { + const uploaded = await uploadAdminFile(options.file, 'qrcode') + editForm.image_url = uploaded.url + ElMessage.success('图片已上传') + } catch { + ElMessage.error('图片上传失败') + } finally { + editUploading.value = false + } +} + async function submitEdit() { await updateQrCode(editForm.id, { + image_url: editForm.image_url, note: editForm.note, status: editForm.status, - expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : null, + expires_at: editForm.expires_at ? new Date(editForm.expires_at).toISOString() : undefined, + clear_expires_at: !editForm.expires_at, }) ElMessage.success('已更新') editVisible.value = false @@ -233,61 +255,67 @@ onMounted(reloadAll) - - + + - + - - - - + - + + + + - - - - + @@ -355,6 +383,27 @@ onMounted(reloadAll) + +
+ + + + {{ editForm.image_url ? '替换图片' : '上传图片' }} + + +
+
@@ -365,14 +414,17 @@ onMounted(reloadAll) - +
+ + 清空 +