diff --git a/backend/internal/modules/chat/qrcode.go b/backend/internal/modules/chat/qrcode.go index 016108d..23c2e5e 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -3,6 +3,8 @@ package chat import ( "context" "errors" + "strconv" + "strings" "time" "gorm.io/gorm" @@ -60,9 +62,15 @@ type UpdateQrCodeRequest struct { // QrCodeListRequest 列表查询请求 type QrCodeListRequest struct { - Status string `form:"status"` - Page int `form:"page"` - Limit int `form:"limit"` + Status string `form:"status"` + Keyword string `form:"keyword"` + WecomRenamed string `form:"wecom_renamed"` + Bound string `form:"bound"` + ExpireStatus string `form:"expire_status"` + CreatedStart string `form:"created_start"` + CreatedEnd string `form:"created_end"` + Page int `form:"page"` + Limit int `form:"limit"` } // QrCodeStats 二维码统计 @@ -167,32 +175,28 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([] req.Limit = 20 } - query := r.db.WithContext(ctx).Model(&model.ChatQrCode{}) - - // 状态过滤 - if req.Status != "" { - query = query.Where("status = ?", req.Status) + now := time.Now() + baseQuery := func() *gorm.DB { + return applyQrCodeListFilters( + r.db.WithContext(ctx). + Table("chat_qrcode_pool AS q"). + Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id"), + req, + now, + ) } // 统计总数 var total int64 - if err := query.Count(&total).Error; err != nil { + if err := baseQuery().Count(&total).Error; err != nil { return nil, 0, err } // 查询列表 var qrcodes []QrCodeListItem offset := (req.Page - 1) * req.Limit - if err := r.db.WithContext(ctx). - Table("chat_qrcode_pool AS q"). + if err := baseQuery(). Select("q.*, COALESCE(c.title, '') AS bound_conversation_title"). - Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id"). - Scopes(func(db *gorm.DB) *gorm.DB { - if req.Status != "" { - return db.Where("q.status = ?", req.Status) - } - return db - }). Order("q.id DESC"). Offset(offset). Limit(req.Limit). @@ -203,6 +207,88 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([] return qrcodes, total, nil } +func applyQrCodeListFilters(query *gorm.DB, req QrCodeListRequest, now time.Time) *gorm.DB { + if req.Status != "" { + query = query.Where("q.status = ?", req.Status) + } + + if keyword := strings.TrimSpace(req.Keyword); keyword != "" { + like := "%" + keyword + "%" + conditions := "(q.group_name LIKE ? OR q.note LIKE ? OR COALESCE(c.title, '') LIKE ?" + args := []interface{}{like, like, like} + if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { + conditions += " OR q.id = ? OR q.conversation_id = ?" + args = append(args, id, id) + } + conditions += ")" + query = query.Where(conditions, args...) + } + + if value, ok := parseQrCodeBoolFilter(req.WecomRenamed); ok { + query = query.Where("q.wecom_renamed = ?", value) + } + + if value, ok := parseQrCodeBoolFilter(req.Bound); ok { + if value { + query = query.Where("q.conversation_id IS NOT NULL") + } else { + query = query.Where("q.conversation_id IS NULL") + } + } + + switch req.ExpireStatus { + case "valid": + query = query.Where("(q.expires_at IS NULL OR q.expires_at > ?)", now) + case "expired": + query = query.Where("q.expires_at IS NOT NULL AND q.expires_at <= ?", now) + case "permanent": + query = query.Where("q.expires_at IS NULL") + case "expiring_soon": + query = query.Where("q.expires_at > ? AND q.expires_at <= ?", now, now.Add(72*time.Hour)) + } + + if start, ok := parseQrCodeFilterTime(req.CreatedStart); ok { + query = query.Where("q.created_at >= ?", start) + } + if end, ok := parseQrCodeFilterTime(req.CreatedEnd); ok { + query = query.Where("q.created_at <= ?", end) + } + + return query +} + +func parseQrCodeBoolFilter(value string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "yes": + return true, true + case "false", "0", "no": + return false, true + default: + return false, false + } +} + +func parseQrCodeFilterTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + layouts := []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + // GetQrCodeStats 获取二维码统计信息 func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { stats := &QrCodeStats{} diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index 9a3a810..a04199c 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -104,6 +104,102 @@ func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) { } } +func TestListQrCodesFilters(t *testing.T) { + db := setupQrCodeTestDB(t) + repo := NewRepository(db, nil) + now := time.Now() + expiredAt := now.Add(-time.Hour) + validAt := now.Add(7 * 24 * time.Hour) + expiringAt := now.Add(2 * time.Hour) + conversation := model.ChatConversation{ + Title: "账号群 绑定测试", + Type: ConversationTypeListingGroup, + Status: "active", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("创建群聊失败: %v", err) + } + + qrcodes := []model.ChatQrCode{ + { + ImageURL: "/api/files/object?key=qrcode/unused-a.png", + GroupName: "待用群A", + Status: QrCodeStatusUnused, + CreatedBy: 1, + Note: "六月首批", + ExpiresAt: &validAt, + CreatedAt: now.Add(-48 * time.Hour), + }, + { + ImageURL: "/api/files/object?key=qrcode/used-b.png", + GroupName: "已发群B", + Status: QrCodeStatusUsed, + ConversationID: &conversation.ID, + CreatedBy: 1, + Note: "特殊备注", + WecomRenamed: true, + ExpiresAt: &expiredAt, + CreatedAt: now.Add(-2 * time.Hour), + }, + { + ImageURL: "/api/files/object?key=qrcode/permanent-c.png", + GroupName: "永久群C", + Status: QrCodeStatusDisabled, + CreatedBy: 1, + Note: "长期备用", + CreatedAt: now.Add(-24 * time.Hour), + }, + { + ImageURL: "/api/files/object?key=qrcode/expiring-d.png", + GroupName: "临期群D", + Status: QrCodeStatusUnused, + CreatedBy: 1, + Note: "三天内到期", + ExpiresAt: &expiringAt, + CreatedAt: now.Add(-30 * time.Minute), + }, + } + if err := db.Create(&qrcodes).Error; err != nil { + t.Fatalf("创建二维码失败: %v", err) + } + + assertListIDs := func(name string, req QrCodeListRequest, want ...uint64) { + t.Helper() + req.Page = 1 + req.Limit = 20 + items, total, err := repo.ListQrCodes(t.Context(), req) + if err != nil { + t.Fatalf("%s 查询二维码失败: %v", name, err) + } + if total != int64(len(want)) || len(items) != len(want) { + t.Fatalf("%s total = %d, len = %d, want %d", name, total, len(items), len(want)) + } + for i, id := range want { + if items[i].ID != id { + t.Fatalf("%s 第 %d 条 ID = %d, want %d", name, i, items[i].ID, id) + } + } + } + + assertListIDs("状态", QrCodeListRequest{Status: QrCodeStatusUnused}, qrcodes[3].ID, qrcodes[0].ID) + assertListIDs("关键词群名", QrCodeListRequest{Keyword: "待用群"}, qrcodes[0].ID) + assertListIDs("关键词备注", QrCodeListRequest{Keyword: "特殊备注"}, qrcodes[1].ID) + assertListIDs("关键词绑定群", QrCodeListRequest{Keyword: "绑定测试"}, qrcodes[1].ID) + assertListIDs("企微改名", QrCodeListRequest{WecomRenamed: "true"}, qrcodes[1].ID) + assertListIDs("未绑定", QrCodeListRequest{Bound: "false"}, qrcodes[3].ID, qrcodes[2].ID, qrcodes[0].ID) + assertListIDs("已过期", QrCodeListRequest{ExpireStatus: "expired"}, qrcodes[1].ID) + assertListIDs("永久有效", QrCodeListRequest{ExpireStatus: "permanent"}, qrcodes[2].ID) + assertListIDs("即将过期", QrCodeListRequest{ExpireStatus: "expiring_soon"}, qrcodes[3].ID) + assertListIDs( + "上传时间", + QrCodeListRequest{ + CreatedStart: now.Add(-3 * time.Hour).Format(time.RFC3339), + CreatedEnd: now.Add(-90 * time.Minute).Format(time.RFC3339), + }, + qrcodes[1].ID, + ) +} + func TestParseQrCodeGroupNameCandidates(t *testing.T) { rawText := strings.Join([]string{ "使用微信或企业微信扫码加入", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ac9db45..e27b114 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,8 @@ - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/features/admin/api/adminQrCodes.ts b/frontend/src/features/admin/api/adminQrCodes.ts index 17b208e..7e0e3d7 100644 --- a/frontend/src/features/admin/api/adminQrCodes.ts +++ b/frontend/src/features/admin/api/adminQrCodes.ts @@ -2,6 +2,8 @@ import { apiClient } from '@/shared/api/client' import type { ApiResponse, PaginatedResult } from '@/shared/types/types' export type QrCodeStatus = 'unused' | 'used' | 'disabled' +export type QrCodeBooleanFilter = 'true' | 'false' +export type QrCodeExpireStatus = 'valid' | 'expired' | 'permanent' | 'expiring_soon' export interface ChatQrCode { id: number @@ -34,6 +36,12 @@ export interface QrCodeOcrResult { export interface QrCodeListQuery { status?: QrCodeStatus + keyword?: string + wecom_renamed?: QrCodeBooleanFilter + bound?: QrCodeBooleanFilter + expire_status?: QrCodeExpireStatus + created_start?: string + created_end?: string page?: number limit?: number } @@ -63,7 +71,17 @@ 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 }, + params: { + status: query.status || undefined, + keyword: query.keyword?.trim() || undefined, + wecom_renamed: query.wecom_renamed || undefined, + bound: query.bound || undefined, + expire_status: query.expire_status || undefined, + created_start: query.created_start || undefined, + created_end: query.created_end || undefined, + page: query.page || 1, + limit: query.limit || 20, + }, silent: true, } ) diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index 5ad9416..0b01599 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -21,6 +21,8 @@ import { updateQrCode, type ChatQrCode, type CreateQrCodePayload, + type QrCodeBooleanFilter, + type QrCodeExpireStatus, type QrCodeStatus, type QrCodeStats, } from '@/features/admin/api/adminQrCodes' @@ -40,7 +42,24 @@ const canManageQrCodes = computed(() => adminSession.hasPermission('qrcode:manag const currentPage = ref(1) const currentPageSize = ref(20) const total = ref(0) -const statusFilter = ref('') + +type QrCodeFilters = { + keyword: string + status: QrCodeStatus | '' + wecom_renamed: QrCodeBooleanFilter | '' + bound: QrCodeBooleanFilter | '' + expire_status: QrCodeExpireStatus | '' + created_range: string[] +} + +const filters = reactive({ + keyword: '', + status: '', + wecom_renamed: '', + bound: '', + expire_status: '', + created_range: [], +}) // 上传弹窗 const uploadVisible = ref(false) @@ -81,6 +100,23 @@ const statusOptions: { label: string; value: QrCodeStatus | ''; type: string }[] { label: '已停用', value: 'disabled', type: 'danger' }, ] +const renameFilterOptions: { label: string; value: QrCodeBooleanFilter }[] = [ + { label: '已改名', value: 'true' }, + { label: '未改名', value: 'false' }, +] + +const boundFilterOptions: { label: string; value: QrCodeBooleanFilter }[] = [ + { label: '已绑定', value: 'true' }, + { label: '未绑定', value: 'false' }, +] + +const expireStatusOptions: { label: string; value: QrCodeExpireStatus }[] = [ + { label: '未过期', value: 'valid' }, + { label: '即将过期', value: 'expiring_soon' }, + { label: '已过期', value: 'expired' }, + { label: '永久有效', value: 'permanent' }, +] + const statusMap = computed(() => { const m: Record = {} statusOptions.forEach(o => { @@ -104,11 +140,25 @@ const selectedRows = ref([]) const selectedCount = computed(() => selectedRows.value.length) const selectedIssuedCount = computed(() => selectedRows.value.filter(qrcodeWasIssued).length) +function filterDateToISO(value?: string) { + if (!value) return undefined + const date = new Date(value) + if (Number.isNaN(date.getTime())) return undefined + return date.toISOString() +} + async function loadList() { loading.value = true try { + const [createdStart, createdEnd] = filters.created_range || [] const res = await fetchQrCodes({ - status: statusFilter.value || undefined, + status: filters.status || undefined, + keyword: filters.keyword.trim() || undefined, + wecom_renamed: filters.wecom_renamed || undefined, + bound: filters.bound || undefined, + expire_status: filters.expire_status || undefined, + created_start: filterDateToISO(createdStart), + created_end: filterDateToISO(createdEnd), page: currentPage.value, limit: currentPageSize.value, }) @@ -133,6 +183,16 @@ function handleFilter() { loadList() } +function resetFilters() { + filters.keyword = '' + filters.status = '' + filters.wecom_renamed = '' + filters.bound = '' + filters.expire_status = '' + filters.created_range = [] + handleFilter() +} + function handlePageChange() { loadList() } @@ -452,11 +512,20 @@ onMounted(reloadAll) + - 筛选 + + + + + + + + + + + 筛选 + 重置 已选 {{ selectedCount }} 条 @@ -833,7 +956,7 @@ onMounted(reloadAll) .toolbar { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 16px; } @@ -845,6 +968,29 @@ onMounted(reloadAll) align-items: center; } +.filter-area { + flex: 1 1 auto; + flex-wrap: wrap; +} + +.action-area { + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.keyword-filter { + width: 240px; +} + +.compact-filter { + width: 128px; +} + +.date-range-filter { + width: 330px; +} + .qrcode-table { border-radius: 10px; box-shadow: none; @@ -992,6 +1138,30 @@ onMounted(reloadAll) padding: 4px 6px; } +@media (max-width: 1280px) { + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .action-area { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .keyword-filter, + .compact-filter, + .date-range-filter { + width: 100%; + } + + .filter-area, + .action-area { + align-items: stretch; + } +} + .upload-preview { display: flex; flex-direction: column;