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

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
@@ -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": "卡号",