修复代理商卡密管理页面:增加服务端筛选、批量导出、复制功能

This commit is contained in:
2026-05-10 04:27:55 +08:00
parent 7a6cc2c698
commit c495f9b417
10 changed files with 507 additions and 52 deletions
@@ -0,0 +1,3 @@
# Memory Index
- [统计卡片数据来源问题](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:** 所有需要显示全局统计的分页列表页面,都应从后端获取统计数据,而不是前端计算
+16
View File
@@ -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\")"
]
}
}
+186 -6
View File
@@ -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
}
@@ -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<Card>[] {
@@ -14,7 +15,11 @@ export function getColumns(t: Composer['t']): ColumnDef<Card>[] {
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<Card>[] {
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<Card>[] {
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<Card>[] {
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<Card>[] {
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 '-'
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { Card } from '@/pages/agent/cards/data/schema'
@@ -18,18 +20,30 @@ const emit = defineEmits<{
const { t } = useI18n()
const searchModel = computed({
get: () => props.searchFilter,
set: (value: string) => emit('update:searchFilter', value),
})
const isFiltered = computed(() => !!props.searchFilter)
function resetFilters() {
emit('update:searchFilter', '')
}
</script>
<template>
<div class="flex items-center gap-2">
<UiInput
v-model="searchModel"
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('agent.cards.searchPlaceholder')"
class="h-8 w-[200px] lg:w-[250px]"
:model-value="searchFilter"
class="h-8 w-[200px] lg:w-[300px]"
@update:model-value="emit('update:searchFilter', $event as string)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="resetFilters"
>
{{ t('agent.cards.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -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
+163 -33
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { Key, Loader2, Plus } from 'lucide-vue-next'
import { CheckCircle, Key, Plus, Users } from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import type { Card } from '@/pages/agent/cards/data/schema'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/agent/cards/components/data-table.vue'
import api, { BASE_URL } from '@/services/api'
@@ -13,31 +15,67 @@ import api, { BASE_URL } from '@/services/api'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
}
interface CardType {
id: number
name: string
application_id: number
}
const loading = ref(true)
const cards = ref<Card[]>([])
const total = ref(0)
const unusedCount = ref(0)
const usedCount = ref(0)
const expiredCount = ref(0)
const disabledCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const tableRef = ref()
const searchFilter = ref('')
const filteredCards = computed(() => {
let result = cards.value.filter(c => c)
if (searchFilter.value) {
const query = searchFilter.value.toLowerCase()
result = result.filter(c =>
c.code?.toLowerCase().includes(query)
|| c.app_name?.toLowerCase().includes(query)
|| c.card_type_name?.toLowerCase().includes(query),
const applications = ref<Application[]>([])
const cardTypes = ref<CardType[]>([])
const appFilter = ref<string>('')
const cardTypeFilter = ref<string>('')
const statusFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const searchFilter = ref<string>('')
const filteredCardTypes = computed(() => {
if (appFilter.value) {
return cardTypes.value.filter(
ct => String(ct.application_id) === appFilter.value,
)
}
return result
return cardTypes.value
})
const unusedCount = computed(() => cards.value.filter(card => card.status === 'unused').length)
const usedCount = computed(() => cards.value.filter(card => card.status === 'used').length)
const expiredCount = computed(() => cards.value.filter(card => card.status === 'expired').length)
const disabledCount = computed(() => cards.value.filter(card => card.status === 'disabled').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const cardTypeOptions = computed(() => {
return filteredCardTypes.value.map(ct => ({
label: ct.name,
value: String(ct.id),
}))
})
const statusOptions = computed(() => [
{ label: t('agent.cards.status.unused'), value: 'unused' },
{ label: t('agent.cards.status.used'), value: 'used' },
{ label: t('agent.cards.status.expired'), value: 'expired' },
{ label: t('agent.cards.status.disabled'), value: 'disabled' },
])
const serverPagination = computed(() => ({
page: currentPage.value,
@@ -54,6 +92,33 @@ const serverPagination = computed(() => ({
},
}))
async function fetchApplications() {
try {
const data = await api.get<{ apps: Application[] }>('/agent/apps')
applications.value = data?.apps || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchCardTypes() {
try {
for (const app of applications.value) {
const data = await api.get<{ cardTypes: CardType[] }>(`/agent/apps/${app.id}`)
const types = data?.cardTypes || []
for (const ct of types) {
if (!cardTypes.value.some(existing => existing.id === ct.id)) {
cardTypes.value.push({ ...ct, application_id: app.id })
}
}
}
}
catch (error) {
console.error('获取卡类列表失败:', error)
}
}
async function fetchCards() {
loading.value = true
try {
@@ -61,16 +126,36 @@ async function fetchCards() {
params.append('page', String(currentPage.value))
params.append('page_size', String(pageSize.value))
if (appFilter.value) {
params.append('application_id', appFilter.value)
}
if (cardTypeFilter.value) {
params.append('card_type_id', cardTypeFilter.value)
}
if (statusFilter.value) {
params.append('status', statusFilter.value)
}
if (startDate.value) {
params.append('start_date', startDate.value.split('T')[0])
}
if (endDate.value) {
params.append('end_date', endDate.value.split('T')[0])
}
if (searchFilter.value) {
params.append('search', searchFilter.value.trim())
}
const data = await api.get<any>(`/agent/cards?${params.toString()}`)
if (Array.isArray(data)) {
if (data?.cards) {
cards.value = data.cards
total.value = data.filtered_total || data.total || data.cards.length
unusedCount.value = data.unused_count || 0
usedCount.value = data.used_count || 0
expiredCount.value = data.expired_count || 0
disabledCount.value = data.disabled_count || 0
} else if (Array.isArray(data)) {
cards.value = data
total.value = data.length
} else if (data?.cards) {
cards.value = data.cards
total.value = data.total || data.cards.length
} else if (data?.data) {
cards.value = Array.isArray(data.data) ? data.data : []
total.value = data.total || cards.value.length
} else {
cards.value = []
total.value = 0
@@ -92,7 +177,24 @@ function goToCreate() {
function handleExport() {
const token = localStorage.getItem('token')
const url = `${BASE_URL}/agent/cards/export?token=${token}`
const params = new URLSearchParams()
if (appFilter.value)
params.append('application_id', appFilter.value)
if (cardTypeFilter.value)
params.append('card_type_id', cardTypeFilter.value)
if (statusFilter.value)
params.append('status', statusFilter.value)
if (startDate.value) {
const startDateOnly = startDate.value.includes('T') ? startDate.value.split('T')[0] : startDate.value
params.append('start_date', startDateOnly)
}
if (endDate.value) {
const endDateOnly = endDate.value.includes('T') ? endDate.value.split('T')[0] : endDate.value
params.append('end_date', endDateOnly)
}
const queryString = params.toString()
const url = `${BASE_URL}/agent/cards/export?${queryString}&token=${token}`
window.open(url, '_blank')
}
@@ -105,7 +207,18 @@ function handleBatchExport(ids: (string | number)[]) {
window.open(url, '_blank')
}
onMounted(() => {
watch(() => appFilter.value, () => {
cardTypeFilter.value = ''
})
watch([appFilter, cardTypeFilter, statusFilter, startDate, endDate, searchFilter], () => {
currentPage.value = 1
fetchCards()
})
onMounted(async () => {
await fetchApplications()
await fetchCardTypes()
fetchCards()
})
</script>
@@ -148,7 +261,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cards.unused') }}
</UiCardTitle>
<Key class="size-4 text-muted-foreground" />
<CheckCircle class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -162,7 +275,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cards.used') }}
</UiCardTitle>
<Key class="size-4 text-muted-foreground" />
<Users class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -188,22 +301,39 @@ onMounted(() => {
<UiCard>
<UiCardContent class="p-6">
<div v-if="loading" class="flex items-center justify-center py-12">
<Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
<DataTable
v-else
ref="tableRef"
:loading
:data="filteredCards"
:data="cards"
:server-pagination="serverPagination"
:search-filter="searchFilter"
@refresh="fetchCards"
@batch-export="handleBatchExport(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@export="handleExport"
@update:search-filter="searchFilter = $event"
/>
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('agent.cards.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="cardTypeFilter"
:title="t('agent.cards.cardType')"
:options="cardTypeOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('agent.cards.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
+3
View File
@@ -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",
+3
View File
@@ -2825,6 +2825,9 @@
"searchPlaceholder": "搜索卡号、应用或卡类...",
"exportBtn": "导出",
"batchExportBtn": "批量导出",
"application": "应用",
"cardType": "卡类",
"reset": "重置",
"select": "选择",
"columns": {
"code": "卡号",