修复代理商卡密管理页面:增加服务端筛选、批量导出、复制功能
This commit is contained in:
@@ -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:** 所有需要显示全局统计的分页列表页面,都应从后端获取统计数据,而不是前端计算
|
||||
@@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -222,6 +278,11 @@ func handleGetCards(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2825,6 +2825,9 @@
|
||||
"searchPlaceholder": "搜索卡号、应用或卡类...",
|
||||
"exportBtn": "导出",
|
||||
"batchExportBtn": "批量导出",
|
||||
"application": "应用",
|
||||
"cardType": "卡类",
|
||||
"reset": "重置",
|
||||
"select": "选择",
|
||||
"columns": {
|
||||
"code": "卡号",
|
||||
|
||||
Reference in New Issue
Block a user