diff --git a/.claude/projects/D--Code-verify/memory/MEMORY.md b/.claude/projects/D--Code-verify/memory/MEMORY.md new file mode 100644 index 0000000..7fba5af --- /dev/null +++ b/.claude/projects/D--Code-verify/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [统计卡片数据来源问题](stats-pagination-issue.md) — 分页列表页面统计卡片显示不正确的原因和解决方案 \ No newline at end of file diff --git a/.claude/projects/D--Code-verify/memory/stats-pagination-issue.md b/.claude/projects/D--Code-verify/memory/stats-pagination-issue.md new file mode 100644 index 0000000..e6b3a82 --- /dev/null +++ b/.claude/projects/D--Code-verify/memory/stats-pagination-issue.md @@ -0,0 +1,87 @@ +--- +name: 统计卡片数据来源问题 +description: 记录分页列表页面统计卡片显示不正确的原因和解决方案 +type: feedback +--- + +## 问题现象 + +在管理员后台和代理后台的列表页面(如卡密管理、设备管理、用户管理、工单管理),统计卡片显示的数量不正确。例如: +- 卡密管理页面显示"未使用20张",但实际数据库中有更多 +- 表格只显示当前页的数据,但统计卡片应该显示全局总数 + +## 根本原因 + +前端使用 `computed` 从当前页面数据计算统计值: + +```typescript +// 错误做法:只统计当前页数据 +const unusedCount = computed(() => cards.value.filter(card => card.status === 'unused').length) +``` + +当使用服务端分页时,`cards.value` 只包含当前页的数据(如20条),而不是全局数据。因此统计值只反映当前页的情况。 + +## 解决方案 + +### 后端修改 + +在 API 响应中添加 `stats` 字段,返回全局统计数据: + +```go +// 获取统计数据(无筛选条件时的全局统计) +var statsUnused, statsUsed, statsBanned int64 +database.DB.Model(&model.Card{}).Where("status = ?", "unused").Count(&statsUnused) +// ... 其他状态 + +response.Success(c, gin.H{ + "cards": cards, + "total": total, + "stats": gin.H{ + "unused": statsUnused, + "used": statsUsed, + "banned": statsBanned, + }, +}) +``` + +### 前端修改 + +1. 添加 `stats` ref 变量: +```typescript +const stats = ref({ unused: 0, used: 0, banned: 0 }) +``` + +2. 使用后端返回的统计数据: +```typescript +const unusedCount = computed(() => stats.value.unused) +const usedCount = computed(() => stats.value.used) +const bannedCount = computed(() => stats.value.banned) +``` + +3. 从 API 响应中提取统计: +```typescript +if (data?.stats) { + stats.value = data.stats +} +``` + +## 已修复的页面 + +- `frontend/src/pages/admin/cards/index.vue` - 卡密管理(管理员) +- `frontend/src/pages/agent/cards/index.vue` - 卡密管理(代理) +- `frontend/src/pages/admin/devices/index.vue` - 设备管理 +- `frontend/src/pages/admin/users/index.vue` - 用户管理 +- `frontend/src/pages/admin/tickets.vue` - 工单管理 + +## 相关后端修改 + +- `backend/internal/router/admin/cards.go` - 添加 stats 返回 +- `backend/internal/router/agent/agent.go` - handleGetCards 添加 stats 返回 +- `backend/internal/router/admin/devices.go` - handleGetDevices 添加 stats 返回 +- `backend/internal/router/admin/users.go` - handleGetUsers 已有统计返回 +- `backend/internal/router/admin/tickets.go` - 已有 /tickets/stats 端点 + +## 设计原则 + +**Why:** 服务端分页时,前端只能访问当前页数据,无法获取全局统计 +**How to apply:** 所有需要显示全局统计的分页列表页面,都应从后端获取统计数据,而不是前端计算 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..17d7b7a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git push *)", + "Bash(xargs grep -l -i \"授权\")", + "Bash(git config *)", + "Bash(npm run *)", + "Bash(go build *)", + "Bash(go vet *)", + "Bash(go run *)", + "Bash(curl -s \"http://localhost:9998/api/v1/dev/cards?page=1&page_size=20\" -H \"Authorization: Bearer test\")" + ] + } +} diff --git a/backend/internal/router/agent/agent.go b/backend/internal/router/agent/agent.go index 03622de..f762b0c 100644 --- a/backend/internal/router/agent/agent.go +++ b/backend/internal/router/agent/agent.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "strconv" + "strings" "time" "verification-platform-backend/internal/database" "verification-platform-backend/internal/model" @@ -22,6 +23,7 @@ func SetupAgentRoutes(r *gin.RouterGroup) { r.GET("/finance", handleGetFinance) r.GET("/profile", handleGetProfile) r.PUT("/profile", handleUpdateProfile) + r.GET("/cards/export", handleExportCards) } func handleGetStats(c *gin.Context) { @@ -157,10 +159,65 @@ func handleGetCards(c *gin.Context) { page := c.DefaultQuery("page", "1") pageSize := c.DefaultQuery("page_size", "20") + applicationID := c.Query("application_id") + cardTypeID := c.Query("card_type_id") + status := c.Query("status") + search := c.Query("search") + startDate := c.Query("start_date") + endDate := c.Query("end_date") + + baseCondition := "agent_id = ? OR (agent_id IS NULL AND creator_id = ?)" - // 统计总数:包括 agent_id 或 creator_id 等于当前��户的卡密 var total int64 - database.DB.Model(&model.Card{}).Where("agent_id = ? OR (agent_id IS NULL AND creator_id = ?)", userID, userID).Count(&total) + database.DB.Model(&model.Card{}).Where(baseCondition, userID, userID).Count(&total) + + var unusedCount int64 + database.DB.Model(&model.Card{}).Where("("+baseCondition+") AND status = ?", userID, userID, "unused").Count(&unusedCount) + + var usedCount int64 + database.DB.Model(&model.Card{}).Where("("+baseCondition+") AND status = ?", userID, userID, "used").Count(&usedCount) + + var expiredCount int64 + database.DB.Model(&model.Card{}).Where("("+baseCondition+") AND status = ?", userID, userID, "expired").Count(&expiredCount) + + var disabledCount int64 + database.DB.Model(&model.Card{}).Where("("+baseCondition+") AND status = ?", userID, userID, "disabled").Count(&disabledCount) + + query := database.DB.Model(&model.Card{}).Where(baseCondition, userID, userID) + + if applicationID != "" { + appID, err := strconv.ParseUint(applicationID, 10, 32) + if err == nil { + query = query.Where("application_id = ?", uint(appID)) + } + } + + if cardTypeID != "" { + ctID, err := strconv.ParseUint(cardTypeID, 10, 32) + if err == nil { + query = query.Where("card_type_id = ?", uint(ctID)) + } + } + + if status != "" { + query = query.Where("status = ?", status) + } + + if search != "" { + searchPattern := "%" + search + "%" + query = query.Where("card_key LIKE ?", searchPattern) + } + + if startDate != "" { + query = query.Where("created_at >= ?", startDate+" 00:00:00") + } + + if endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + + var filteredTotal int64 + query.Count(&filteredTotal) var cards []model.Card offset := 0 @@ -173,8 +230,7 @@ func handleGetCards(c *gin.Context) { limit = pageSizeInt } - // 查询卡密:包括 agent_id 或 creator_id 等于当前用户的卡密 - database.DB.Where("agent_id = ? OR (agent_id IS NULL AND creator_id = ?)", userID, userID).Order("created_at DESC").Limit(limit).Offset(offset).Find(&cards) + query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&cards) // 获取关联数据 cardTypeIDs := make([]uint, 0) @@ -220,8 +276,13 @@ func handleGetCards(c *gin.Context) { } response.Success(c, gin.H{ - "cards": cardList, - "total": total, + "cards": cardList, + "total": total, + "filtered_total": filteredTotal, + "unused_count": unusedCount, + "used_count": usedCount, + "expired_count": expiredCount, + "disabled_count": disabledCount, }) } @@ -451,3 +512,122 @@ func handleUpdateProfile(c *gin.Context) { response.Success(c, nil) } + +func handleExportCards(c *gin.Context) { + userID := c.GetUint("user_id") + + token := c.Query("token") + if token == "" { + response.Error(c, 401, "未授权") + return + } + + baseCondition := "agent_id = ? OR (agent_id IS NULL AND creator_id = ?)" + query := database.DB.Model(&model.Card{}).Where(baseCondition, userID, userID) + + if applicationID := c.Query("application_id"); applicationID != "" { + appID, err := strconv.ParseUint(applicationID, 10, 32) + if err == nil { + query = query.Where("application_id = ?", uint(appID)) + } + } + + if cardTypeID := c.Query("card_type_id"); cardTypeID != "" { + ctID, err := strconv.ParseUint(cardTypeID, 10, 32) + if err == nil { + query = query.Where("card_type_id = ?", uint(ctID)) + } + } + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + + if startDate := c.Query("start_date"); startDate != "" { + query = query.Where("created_at >= ?", startDate+" 00:00:00") + } + + if endDate := c.Query("end_date"); endDate != "" { + query = query.Where("created_at <= ?", endDate+" 23:59:59") + } + + if ids := c.Query("ids"); ids != "" { + idList := []uint{} + for _, idStr := range splitIDs(ids) { + if id, err := strconv.ParseUint(idStr, 10, 32); err == nil { + idList = append(idList, uint(id)) + } + } + if len(idList) > 0 { + query = database.DB.Model(&model.Card{}).Where("id IN ? AND ("+baseCondition+")", idList, userID, userID) + } + } + + var cards []model.Card + query.Order("created_at DESC").Find(&cards) + + cardTypeIDs := make([]uint, 0) + appIDs := make([]uint, 0) + for _, card := range cards { + cardTypeIDs = append(cardTypeIDs, card.CardTypeID) + appIDs = append(appIDs, card.ApplicationID) + } + + cardTypeMap := make(map[uint]model.CardType) + if len(cardTypeIDs) > 0 { + var cardTypes []model.CardType + database.DB.Where("id IN ?", cardTypeIDs).Find(&cardTypes) + for _, ct := range cardTypes { + cardTypeMap[ct.ID] = ct + } + } + + appMap := make(map[uint]model.Application) + if len(appIDs) > 0 { + var apps []model.Application + database.DB.Where("id IN ?", appIDs).Find(&apps) + for _, app := range apps { + appMap[app.ID] = app + } + } + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=cards_export.csv") + + c.Writer.Write([]byte("\xEF\xBB\xBF")) + c.Writer.Write([]byte("卡号,应用,卡类,状态,创建时间,使用时间\n")) + + for _, card := range cards { + ct := cardTypeMap[card.CardTypeID] + app := appMap[card.ApplicationID] + statusMap := map[string]string{"unused": "未使用", "used": "已使用", "expired": "已过期", "disabled": "已禁用"} + statusText := statusMap[card.Status] + if statusText == "" { + statusText = card.Status + } + usedAt := "" + if card.UsedAt != nil { + usedAt = card.UsedAt.Format("2006-01-02 15:04:05") + } + line := fmt.Sprintf("%s,%s,%s,%s,%s,%s\n", + card.CardKey, + app.Name, + ct.Name, + statusText, + card.CreatedAt.Format("2006-01-02 15:04:05"), + usedAt, + ) + c.Writer.Write([]byte(line)) + } +} + +func splitIDs(ids string) []string { + result := []string{} + for _, id := range strings.Split(ids, ",") { + id = strings.TrimSpace(id) + if id != "" { + result = append(result, id) + } + } + return result +} diff --git a/frontend/src/pages/agent/cards/components/columns.ts b/frontend/src/pages/agent/cards/components/columns.ts index e2e2ad7..b951afd 100644 --- a/frontend/src/pages/agent/cards/components/columns.ts +++ b/frontend/src/pages/agent/cards/components/columns.ts @@ -5,6 +5,7 @@ import { h } from 'vue' import type { Card } from '../data/schema' +import { Copy } from '@/components/sva-ui/copy' import Badge from '@/components/ui/badge/Badge.vue' export function getColumns(t: Composer['t']): ColumnDef[] { @@ -14,7 +15,11 @@ export function getColumns(t: Composer['t']): ColumnDef[] { header: () => t('agent.cards.columns.code'), cell: ({ row }) => { const code = row.getValue('code') as string - return h('span', { class: 'font-mono text-sm' }, code) + if (!code) return '-' + return h('div', { class: 'flex items-center space-x-2' }, [ + h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, code), + h(Copy, { class: 'h-4 w-4', size: 'sm', content: code }), + ]) }, }, { @@ -22,7 +27,7 @@ export function getColumns(t: Composer['t']): ColumnDef[] { header: () => t('agent.cards.columns.appName'), cell: ({ row }) => { const appName = row.getValue('app_name') as string - return h(Badge, { variant: 'secondary' }, () => appName) + return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-' }, }, { @@ -30,7 +35,7 @@ export function getColumns(t: Composer['t']): ColumnDef[] { header: () => t('agent.cards.columns.cardTypeName'), cell: ({ row }) => { const cardTypeName = row.getValue('card_type_name') as string - return h('span', { class: 'font-medium' }, cardTypeName) + return cardTypeName ? h(Badge, { variant: 'outline' }, () => cardTypeName) : '-' }, }, { @@ -57,7 +62,13 @@ export function getColumns(t: Composer['t']): ColumnDef[] { try { const date = new Date(createdAt) if (Number.isNaN(date.getTime())) return '-' - return date.toLocaleDateString('zh-CN') + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) } catch { return '-' @@ -73,7 +84,13 @@ export function getColumns(t: Composer['t']): ColumnDef[] { try { const date = new Date(usedAt) if (Number.isNaN(date.getTime())) return '-' - return date.toLocaleDateString('zh-CN') + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) } catch { return '-' diff --git a/frontend/src/pages/agent/cards/components/data-table-toolbar.vue b/frontend/src/pages/agent/cards/components/data-table-toolbar.vue index 80478ec..d432f6c 100644 --- a/frontend/src/pages/agent/cards/components/data-table-toolbar.vue +++ b/frontend/src/pages/agent/cards/components/data-table-toolbar.vue @@ -1,9 +1,11 @@ diff --git a/frontend/src/pages/agent/cards/data/schema.ts b/frontend/src/pages/agent/cards/data/schema.ts index a2f9dba..0749f6c 100644 --- a/frontend/src/pages/agent/cards/data/schema.ts +++ b/frontend/src/pages/agent/cards/data/schema.ts @@ -1,6 +1,8 @@ export interface Card { id: number code: string + application_id: number + card_type_id: number app_name: string card_type_name: string status: string diff --git a/frontend/src/pages/agent/cards/index.vue b/frontend/src/pages/agent/cards/index.vue index 77acae3..3147a8a 100644 --- a/frontend/src/pages/agent/cards/index.vue +++ b/frontend/src/pages/agent/cards/index.vue @@ -1,11 +1,13 @@ @@ -148,7 +261,7 @@ onMounted(() => { {{ t('agent.cards.unused') }} - +
@@ -162,7 +275,7 @@ onMounted(() => { {{ t('agent.cards.used') }} - +
@@ -188,22 +301,39 @@ onMounted(() => { -
- -
- + > + +
diff --git a/frontend/src/plugins/i18n/en.json b/frontend/src/plugins/i18n/en.json index 4e8fa69..ff5a080 100644 --- a/frontend/src/plugins/i18n/en.json +++ b/frontend/src/plugins/i18n/en.json @@ -2824,6 +2824,9 @@ "searchPlaceholder": "Search card code, app or card type...", "exportBtn": "Export", "batchExportBtn": "Batch Export", + "application": "Application", + "cardType": "Card Type", + "reset": "Reset", "select": "Select", "columns": { "code": "Card Code", diff --git a/frontend/src/plugins/i18n/zh.json b/frontend/src/plugins/i18n/zh.json index 335860f..03f06f2 100644 --- a/frontend/src/plugins/i18n/zh.json +++ b/frontend/src/plugins/i18n/zh.json @@ -2825,6 +2825,9 @@ "searchPlaceholder": "搜索卡号、应用或卡类...", "exportBtn": "导出", "batchExportBtn": "批量导出", + "application": "应用", + "cardType": "卡类", + "reset": "重置", "select": "选择", "columns": { "code": "卡号",