feat: 重构云端变量记录页面,支持动态列和服务端分页

- 使用项目 DataTable 组件替换原生 table
- 根据记录数据动态生成表格列
- 支持服务端分页、搜索、日期筛选
- 添加单条/批量删除确认对话框
- 添加路由配置和国际化文本
- 更新 pnpm-lock.yaml 修复 Docker 构建问题
This commit is contained in:
2026-05-07 19:10:25 +08:00
parent fa281040d0
commit d01e20dfd5
6 changed files with 336 additions and 1500 deletions
@@ -1,10 +1,20 @@
<script setup lang="ts">
import { Search, Trash2 } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import type { ColumnDef } from '@tanstack/vue-table'
import { List, Loader2, Search, Trash2 } from 'lucide-vue-next'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import { BasicPage } from '@/components/global-layout'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import { Input } from '@/components/ui/input'
import api from '@/services/api'
interface Variable {
@@ -28,16 +38,159 @@ interface VariableRecord {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const loading = ref(true)
const variable = ref<Variable | null>(null)
const records = ref<VariableRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const pageSize = ref(10)
const startDate = ref('')
const endDate = ref('')
const searchQuery = ref('')
const selectedIds = ref<number[]>([])
const deleteDialogOpen = ref(false)
const deleteTargetId = ref<number | null>(null)
const batchDeleteDialogOpen = ref(false)
const dynamicKeys = computed(() => {
const keys = new Set<string>()
records.value.forEach((record) => {
if (record.data && typeof record.data === 'object') {
Object.keys(record.data).forEach(key => keys.add(key))
}
})
return Array.from(keys)
})
const columns = computed<ColumnDef<VariableRecord>[]>(() => {
const cols: ColumnDef<VariableRecord>[] = [SelectColumn as ColumnDef<VariableRecord>]
cols.push({
accessorKey: 'id',
header: () => 'ID',
cell: ({ row }) => h('span', { class: 'text-muted-foreground font-mono text-xs' }, String(row.original.id)),
size: 80,
})
if (variable.value?.scope === 'user') {
cols.push({
accessorKey: 'user',
header: () => t('admin.cloudVariables.records.user'),
cell: ({ row }) => {
const user = row.original.user
if (user) {
return h(Badge, { variant: 'outline' }, () => user.username)
}
return h('span', { class: 'text-muted-foreground' }, '-')
},
size: 120,
})
}
dynamicKeys.value.forEach((key) => {
cols.push({
id: `data_${key}`,
header: () => key,
accessorFn: row => row.data?.[key],
cell: ({ row }) => {
const value = row.original.data?.[key]
if (value === null || value === undefined) {
return h('span', { class: 'text-muted-foreground' }, '-')
}
if (typeof value === 'object') {
return h('pre', { class: 'text-xs bg-muted px-2 py-1 rounded max-w-xs overflow-auto max-h-20' }, JSON.stringify(value, null, 2))
}
if (typeof value === 'boolean') {
return h(Badge, { variant: value ? 'default' : 'secondary' }, () => String(value))
}
return h('span', { class: 'truncate max-w-xs', title: String(value) }, String(value))
},
size: 150,
})
})
if (dynamicKeys.value.length === 0) {
cols.push({
id: 'data',
header: () => t('admin.cloudVariables.records.data'),
cell: ({ row }) => {
const data = row.original.data
if (!data) {
return h('span', { class: 'text-muted-foreground' }, '-')
}
return h('pre', { class: 'text-xs bg-muted px-2 py-1 rounded max-w-md overflow-auto max-h-20' }, JSON.stringify(data, null, 2))
},
})
}
cols.push({
accessorKey: 'created_at',
header: () => t('admin.cloudVariables.records.createdAt'),
cell: ({ row }) => {
const createdAt = row.original.created_at
if (!createdAt) return '-'
try {
const date = new Date(createdAt)
if (Number.isNaN(date.getTime())) return '-'
return h('span', { class: 'text-muted-foreground text-xs' }, date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}))
}
catch {
return '-'
}
},
size: 160,
})
cols.push({
id: 'actions',
header: () => h('span', { class: 'sr-only' }, t('common.actions')),
cell: ({ row }) => {
return h(Button, {
variant: 'ghost',
size: 'icon',
class: 'h-8 w-8 text-destructive hover:text-destructive',
onClick: () => confirmDelete(row.original.id),
}, () => h(Trash2, { class: 'h-4 w-4' }))
},
enableSorting: false,
enableHiding: false,
size: 50,
})
return cols
})
const serverPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: total.value,
onPageChange: (newPage: number) => {
page.value = newPage
},
onPageSizeChange: (newPageSize: number) => {
pageSize.value = newPageSize
page.value = 1
},
}))
const table = generateVueTable<VariableRecord>({
get data() { return records.value },
get loading() { return loading.value },
get columns() { return columns.value },
serverPagination: serverPagination.value,
})
watch([page, pageSize], () => {
fetchRecords()
})
async function fetchVariable() {
try {
@@ -45,7 +198,7 @@ async function fetchVariable() {
const vars = data?.variables || []
variable.value = vars.find((v: Variable) => String(v.id) === route.params.id) || null
if (variable.value && variable.value.var_type !== 'stream') {
toast.error('该变量不是记录类型')
toast.error(t('admin.cloudVariables.records.notStreamType'))
router.back()
}
}
@@ -55,18 +208,16 @@ async function fetchVariable() {
}
async function fetchRecords() {
if (!variable.value)
return
if (!variable.value) return
loading.value = true
try {
const params = new URLSearchParams()
params.append('page', String(page.value))
params.append('page_size', String(pageSize.value))
if (startDate.value)
params.append('start_date', startDate.value)
if (endDate.value)
params.append('end_date', endDate.value)
if (startDate.value) params.append('start_date', startDate.value)
if (endDate.value) params.append('end_date', endDate.value)
if (searchQuery.value) params.append('search', searchQuery.value)
const data = await api.get<{ records: VariableRecord[], total: number }>(`/dev/cloud-variables/${variable.value.id}/records?${params}`)
records.value = data?.records || []
@@ -80,55 +231,61 @@ async function fetchRecords() {
}
}
async function handleDeleteSelected() {
if (selectedIds.value.length === 0) {
toast.error('请选择要删除的记录')
return
}
function confirmDelete(id: number) {
deleteTargetId.value = id
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTargetId.value || !variable.value) return
try {
await api.delete(`/dev/cloud-variables/${variable.value?.id}/records`, {
ids: selectedIds.value,
await api.delete(`/dev/cloud-variables/${variable.value.id}/records`, {
ids: [deleteTargetId.value],
})
toast.success('删除成功')
selectedIds.value = []
toast.success(t('admin.cloudVariables.records.deleteSuccess'))
fetchRecords()
}
catch (error: any) {
toast.error(error.message || '删除失败')
toast.error(error.message || t('admin.cloudVariables.records.deleteFailed'))
}
finally {
deleteTargetId.value = null
}
}
function toggleSelectAll() {
if (selectedIds.value.length === records.value.length) {
function openBatchDeleteDialog() {
const selected = table.getSelectedRowModel().rows.map(row => row.original.id)
if (selected.length === 0) {
toast.error(t('admin.cloudVariables.records.noSelection'))
return
}
selectedIds.value = selected
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
if (!variable.value || selectedIds.value.length === 0) return
try {
await api.delete(`/dev/cloud-variables/${variable.value.id}/records`, {
ids: selectedIds.value,
})
toast.success(t('admin.cloudVariables.records.batchDeleteSuccess'))
table.resetRowSelection()
fetchRecords()
}
catch (error: any) {
toast.error(error.message || t('admin.cloudVariables.records.batchDeleteFailed'))
}
finally {
selectedIds.value = []
}
else {
selectedIds.value = records.value.map(r => r.id)
}
}
function toggleSelect(id: number) {
const index = selectedIds.value.indexOf(id)
if (index > -1) {
selectedIds.value.splice(index, 1)
}
else {
selectedIds.value.push(id)
}
}
function formatDate(date: string) {
return new Date(date).toLocaleString('zh-CN')
}
function formatData(data: Record<string, any>) {
try {
return JSON.stringify(data, null, 2)
}
catch {
return String(data)
}
function handleSearch() {
page.value = 1
fetchRecords()
}
onMounted(async () => {
@@ -141,134 +298,101 @@ onMounted(async () => {
<template>
<BasicPage
title="变量记录"
description="查看云端变量的记录数据"
:title="t('admin.cloudVariables.records.title')"
:description="t('admin.cloudVariables.records.description', { key: variable?.key || '' })"
:breadcrumbs="[
{ title: '云端变量', href: '/admin/cloud-variables' },
{ title: '变量记录' },
{ title: t('admin.cloudVariables.title'), href: '/admin/cloud-variables' },
{ title: t('admin.cloudVariables.records.title') },
]"
sticky
>
<div class="space-y-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<UiLabel class="text-sm">开始日期</UiLabel>
<UiInput
v-model="startDate"
type="date"
class="w-40"
<div class="flex items-center gap-2">
<div class="relative">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="t('admin.cloudVariables.records.searchPlaceholder')"
class="pl-8 w-[250px] h-9"
@keydown.enter="handleSearch"
/>
</div>
<div class="flex items-center gap-2">
<UiLabel class="text-sm">结束日期</UiLabel>
<UiInput
v-model="endDate"
type="date"
class="w-40"
/>
</div>
<UiButton @click="fetchRecords">
<Search class="h-4 w-4 mr-2" />
查询
</UiButton>
<Input
v-model="startDate"
type="date"
class="w-36 h-9"
:placeholder="t('admin.cloudVariables.records.startDate')"
/>
<Input
v-model="endDate"
type="date"
class="w-36 h-9"
:placeholder="t('admin.cloudVariables.records.endDate')"
/>
<Button variant="outline" size="sm" class="h-9" @click="handleSearch">
<Search class="h-4 w-4 mr-1" />
{{ t('admin.cloudVariables.records.search') }}
</Button>
</div>
<div class="flex items-center gap-2">
<UiButton
v-if="selectedIds.length > 0"
<Button
variant="destructive"
@click="handleDeleteSelected"
size="sm"
:disabled="table.getSelectedRowModel().rows.length === 0"
@click="openBatchDeleteDialog"
>
<Trash2 class="h-4 w-4 mr-2" />
删除选中 ({{ selectedIds.length }})
</UiButton>
<Trash2 class="h-4 w-4 mr-1" />
{{ t('admin.cloudVariables.records.batchDelete') }}
<span v-if="table.getSelectedRowModel().rows.length > 0" class="ml-1">
({{ table.getSelectedRowModel().rows.length }})
</span>
</Button>
</div>
</div>
<div class="border rounded-lg">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="w-10 p-3 text-left">
<UiCheckbox
:checked="selectedIds.length === records.length && records.length > 0"
@update:checked="toggleSelectAll"
/>
</th>
<th class="p-3 text-left font-medium">
ID
</th>
<th v-if="variable?.scope === 'user'" class="p-3 text-left font-medium">
用户
</th>
<th class="p-3 text-left font-medium">
数据
</th>
<th class="p-3 text-left font-medium">
创建时间
</th>
</tr>
</thead>
<tbody>
<tr
v-for="record in records"
:key="record.id"
class="border-t hover:bg-muted/30"
>
<td class="p-3">
<UiCheckbox
:checked="selectedIds.includes(record.id)"
@update:checked="toggleSelect(record.id)"
/>
</td>
<td class="p-3 text-muted-foreground">
{{ record.id }}
</td>
<td v-if="variable?.scope === 'user'" class="p-3">
<span v-if="record.user">{{ record.user.username }}</span>
<span v-else class="text-muted-foreground">-</span>
</td>
<td class="p-3">
<pre class="text-xs bg-muted p-2 rounded max-w-md overflow-auto max-h-32">{{ formatData(record.data) }}</pre>
</td>
<td class="p-3 text-muted-foreground">
{{ formatDate(record.created_at) }}
</td>
</tr>
<tr v-if="records.length === 0">
<td :colspan="variable?.scope === 'user' ? 5 : 4" class="p-8 text-center text-muted-foreground">
暂无记录
</td>
</tr>
</tbody>
</table>
<div v-if="variable" class="flex items-center gap-4 text-sm text-muted-foreground">
<div class="flex items-center gap-1.5">
<List class="h-4 w-4" />
<span>{{ t('admin.cloudVariables.types.stream') }}</span>
</div>
<span>·</span>
<span>{{ t('admin.cloudVariables.records.total', { count: total }) }}</span>
<span v-if="variable.max_records > 0">·</span>
<span v-if="variable.max_records > 0">{{ t('admin.cloudVariables.records.maxRecords', { count: variable.max_records }) }}</span>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
{{ total }} 条记录
</p>
<div class="flex items-center gap-2">
<UiButton
variant="outline"
size="sm"
:disabled="page <= 1"
@click="page--; fetchRecords()"
>
上一页
</UiButton>
<span class="text-sm"> {{ page }} </span>
<UiButton
variant="outline"
size="sm"
:disabled="page * pageSize >= total"
@click="page++; fetchRecords()"
>
下一页
</UiButton>
</div>
</div>
<DataTable :columns="columns" :data="records" :loading="loading" :table="table" :server-pagination="serverPagination" @refresh="fetchRecords" />
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudVariables.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.cloudVariables.records.deleteRecord') }}
</template>
<template #description>
{{ t('admin.cloudVariables.records.deleteConfirm') }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudVariables.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.cloudVariables.records.batchDelete') }}
</template>
<template #description>
{{ t('admin.cloudVariables.records.batchDeleteConfirm', { count: selectedIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
+24 -1
View File
@@ -1935,7 +1935,30 @@
"appRequired": "Please select application",
"scope": "Scope"
},
"createDescription": "Add a new cloud variable"
"createDescription": "Add a new cloud variable",
"records": {
"title": "Variable Records",
"description": "View records for variable {key}",
"user": "User",
"data": "Data",
"createdAt": "Created At",
"search": "Search",
"searchPlaceholder": "Search records...",
"startDate": "Start Date",
"endDate": "End Date",
"total": "{count} records total",
"maxRecords": "Max {count} records",
"notStreamType": "This variable is not a record type",
"deleteRecord": "Delete Record",
"deleteConfirm": "Are you sure you want to delete this record?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"batchDelete": "Batch Delete",
"batchDeleteConfirm": "Are you sure you want to delete {count} selected records?",
"batchDeleteSuccess": "Batch delete successful",
"batchDeleteFailed": "Batch delete failed",
"noSelection": "Please select records to delete"
}
},
"cloudFunction": {
"title": "Cloud Function",
+24 -1
View File
@@ -1924,7 +1924,30 @@
"appRequired": "请选择应用",
"scope": "作用域"
},
"createDescription": "添加新的云端变量"
"createDescription": "添加新的云端变量",
"records": {
"title": "变量记录",
"description": "查看变量 {key} 的记录数据",
"user": "用户",
"data": "数据",
"createdAt": "创建时间",
"search": "查询",
"searchPlaceholder": "搜索记录...",
"startDate": "开始日期",
"endDate": "结束日期",
"total": "共 {count} 条记录",
"maxRecords": "最大 {count} 条",
"notStreamType": "该变量不是记录类型",
"deleteRecord": "删除记录",
"deleteConfirm": "确定要删除该条记录吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"batchDelete": "批量删除",
"batchDeleteConfirm": "确定要删除选中的 {count} 条记录吗?",
"batchDeleteSuccess": "批量删除成功",
"batchDeleteFailed": "批量删除失败",
"noSelection": "请选择要删除的记录"
}
},
"cloudFunction": {
"title": "云端函数",
+6
View File
@@ -281,6 +281,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/admin/cloud-variables/[id].vue'),
meta: { title: '编辑云端变量 - 管理后台' },
},
{
path: 'cloud-variables/:id/records',
name: 'AdminCloudVariableRecords',
component: () => import('@/pages/admin/cloud-variables/[id]/records.vue'),
meta: { title: '变量记录 - 管理后台' },
},
{
path: 'cloud-function',
name: 'AdminCloudFunction',
-3
View File
@@ -14,9 +14,6 @@ import type {
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
import type {
_ExtractParamParserType,
} from 'vue-router/experimental'
declare module 'vue-router' {
interface TypesConfig {