improve(ui):优化表格移动端显示效果

This commit is contained in:
chengqian committed 2026-08-12 19:08:03 +08:00
1 parent 0ec970a182
commit 16793c82ef
20 files changed
+474 -79

No files matched your search

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useIsMobile } from '@/composables/useIsMobile'
const props = withDefaults(defineProps<{
modelValue: number
@@ -18,6 +19,8 @@ const emit = defineEmits<{
change: [page: number]
}>()
const { isMobile } = useIsMobile()
// 当前 pageSize 不在候选列表时兜底追加,避免 select 找不到选中项
const sizeOptions = computed(() => {
const opts = [...props.pageSizeOptions]
@@ -40,14 +43,18 @@ function handlePageSize(size: number) {
</script>
<template>
<n-pagination
v-if="total > 0"
:page="modelValue"
:page-size="pageSize"
:item-count="total"
:page-sizes="sizeOptions"
:show-size-picker="showSizeChanger"
@update:page="handlePage"
@update:page-size="handlePageSize"
/>
</template>
<div v-if="total > 0" class="flex w-full min-w-0 flex-col items-center justify-center gap-2 sm:flex-row">
<span v-if="isMobile" class="text-xs text-gray-500">共 {{ total }} 条</span>
<n-pagination
class="max-w-full"
:page="modelValue"
:page-size="pageSize"
:item-count="total"
:page-sizes="sizeOptions"
:simple="isMobile"
:show-size-picker="showSizeChanger && !isMobile"
@update:page="handlePage"
@update:page-size="handlePageSize"
/>
</div>
</template>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType, VNodeChild } from 'vue'
import type { DataTableColumn, DataTableColumns, PaginationProps } from 'naive-ui'
import BasePagination from '@/components/common/BasePagination.vue'
import Icon from '@/components/common/Icon.vue'
import { useIsMobile } from '@/composables/useIsMobile'
/**
* 响应式数据列表:桌面端(≥ breakpoint)渲染 NDataTable,移动端渲染卡片列表。
* 卡片字段 / 标题 / 徽章 / 副标题 / 操作列全部从 columns 派生,并复用各列 render 函数,
* 因此页面只需把 <n-data-table> 换成 <ResponsiveDataTable> 并配置几个卡片 key 即可。
*
* 页面原有 pagination / loading / #empty 插槽的用法保持不变:
* <ResponsiveDataTable
* remote v-model:page="page" v-model:page-size="pageSize"
* :item-count="total" :columns="columns" :data="rows"
* :row-key="(row) => row.id"
* card-title-key="model" card-badge-key="status"
* card-subtitle-key="created_at"
* :card-fields="[{ key: 'token', full: true }, 'cost']"
* card-actions-key="actions" :row-click="openDetail"
* >
* <template #empty>...</template>
* </ResponsiveDataTable>
*/
// 模板里无法直接输出 render 函数返回的 VNode,用一个小渲染组件承接
const VNodeRenderer = defineComponent({
name: 'VNodeRenderer',
props: {
node: {
type: [Object, String, Number, Boolean, Array] as PropType<VNodeChild>,
required: true,
},
},
setup(props) {
return () => props.node
},
})
/** 卡片网格字段:key 对应列,full=true 时占满整行 */
interface CardField {
key: string
title?: string
full?: boolean
}
const props = withDefaults(
defineProps<{
// ---------- 透传给 NDataTable ----------
columns: DataTableColumns<any>
data: any[]
loading?: boolean
rowKey: (row: any) => string | number
remote?: boolean
itemCount?: number
page?: number
pageSize?: number
pageSizes?: number[]
showSizePicker?: boolean
scrollX?: number
showPagination?: boolean // false 时桌面端和移动端均不显示分页(用于固定数量列表)
// ---------- 卡片模式配置 ----------
cardTitleKey?: string // 卡片主标题列 key
cardBadgeKey?: string // 卡片头部右侧徽章列 key(如 status)
cardSubtitleKey?: string // 主标题下副标题列 key(如 created_at)
cardFields?: (CardField | string)[] // 卡片网格字段;缺省取除 title/badge/subtitle/actions 外全部列
cardActionsKey?: string // 操作列 key,渲染在卡片底部(默认 'actions')
rowClick?: (row: any) => void // 提供后整张卡片可点击
breakpoint?: number // 卡片切换断点(默认 1024 = lg,与 viewport-table 桌面布局一致)
fillHeight?: boolean // 桌面端让表格撑满父容器高度、body 内部滚动(配合 viewport-table-panel 使用)
}>(),
{
loading: false,
remote: true,
itemCount: 0,
page: 1,
pageSize: 20,
pageSizes: () => [10, 20, 50, 100],
showSizePicker: true,
scrollX: 0,
showPagination: true,
cardActionsKey: 'actions',
breakpoint: 1024,
fillHeight: false,
},
)
const emit = defineEmits<{
'update:page': [page: number]
'update:page-size': [size: number]
}>()
const { isMobile } = useIsMobile(props.breakpoint)
// 各区域对应列
const titleCol = computed(() => props.columns.find((c) => c.key === props.cardTitleKey))
const badgeCol = computed(() => props.columns.find((c) => c.key === props.cardBadgeKey))
const subtitleCol = computed(() => props.columns.find((c) => c.key === props.cardSubtitleKey))
const actionsCol = computed(() => props.columns.find((c) => c.key === props.cardActionsKey))
// NDataTable 的分页状态必须完整放在 pagination 对象中,顶层同名属性不会生效。
const paginationProp = computed<false | PaginationProps>(() => {
if (!props.showPagination) return false
return {
page: props.page,
pageSize: props.pageSize,
itemCount: props.itemCount,
pageSizes: props.pageSizes,
showSizePicker: props.showSizePicker,
prefix: ({ itemCount }) => `共 ${itemCount ?? 0} 条`,
}
})
function desktopRowProps(row: any) {
if (!props.rowClick) return {}
return {
style: 'cursor: pointer',
onClick: (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (target?.closest('button, a, input, select, textarea, [role="button"]')) return
props.rowClick?.(row)
},
}
}
// 网格字段:按 cardFields 顺序取,未指定则用除标题/徽章/副标题/操作外的全部列
const fields = computed(() => {
const keys = props.cardFields?.length
? props.cardFields.map((f) => (typeof f === 'string' ? { key: f } : f))
: props.columns
.filter((c) => ![props.cardTitleKey, props.cardBadgeKey, props.cardSubtitleKey, props.cardActionsKey].includes(c.key))
.map((c) => ({ key: c.key }))
return keys
.map((f) => {
const col = props.columns.find((c) => c.key === f.key) || null
return {
key: f.key,
full: !!f.full,
title: f.title ?? col?.title ?? f.key,
col,
}
})
.filter((f) => f.col)
})
// 优先用列 render 输出,否则取原始值
function renderCell(row: any, col: DataTableColumn<any> | null): VNodeChild {
if (col?.render) return col.render(row)
const v = row?.[col?.key ?? '']
if (v == null || v === '') return '--'
return String(v)
}
</script>
<template>
<!-- 桌面端(≥ breakpoint):NDataTable 原样透传 -->
<n-data-table
v-if="!isMobile"
:flex-height="fillHeight"
:class="{ 'flex-1 min-h-0': fillHeight }"
:remote="remote"
:pagination="paginationProp"
:loading="loading"
:columns="columns"
:scroll-x="scrollX"
:data="data"
:row-key="rowKey"
:row-props="desktopRowProps"
@update:page="(p: number) => emit('update:page', p)"
@update:page-size="(s: number) => emit('update:page-size', s)"
>
<template #empty>
<slot name="empty" />
</template>
</n-data-table>
<!-- 移动端:每行 = 一张卡片 -->
<div v-else class="space-y-3">
<!-- 加载中 -->
<div v-if="loading" class="flex justify-center py-6">
<div class="spinner h-5 w-5 text-primary-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="data.length === 0">
<slot name="empty">
<div class="empty-state">
<p class="empty-state-title">暂无数据</p>
</div>
</slot>
</div>
<template v-else>
<div
v-for="row in data"
:key="rowKey(row)"
class="card p-4"
:class="{ 'cursor-pointer transition-transform active:scale-[0.995]': rowClick }"
@click="rowClick && rowClick(row)"
>
<!-- 卡片头部:主标题 + 副标题 + 状态徽章 + 可点击箭头 -->
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<VNodeRenderer v-if="titleCol" :node="renderCell(row, titleCol)" />
<VNodeRenderer
v-if="subtitleCol && subtitleCol.key !== titleCol?.key"
:node="renderCell(row, subtitleCol)"
/>
</div>
<div v-if="badgeCol" class="flex flex-shrink-0 items-center">
<VNodeRenderer :node="renderCell(row, badgeCol)" />
</div>
<Icon
v-if="rowClick"
name="chevronRight"
size="sm"
class="mt-0.5 flex-shrink-0 text-gray-300"
/>
</div>
<!-- 字段网格:label:value,full 列占整行 -->
<dl v-if="fields.length" class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2.5">
<div v-for="f in fields" :key="f.key" class="min-w-0" :class="{ 'col-span-2': f.full }">
<dt class="text-xs text-gray-400">{{ f.title }}</dt>
<dd class="mt-0.5">
<VNodeRenderer :node="renderCell(row, f.col)" />
</dd>
</div>
</dl>
<!-- 操作列:卡片底部操作栏 -->
<div v-if="actionsCol" class="mt-3 flex items-center justify-end gap-2 border-t border-gray-100 pt-3" @click.stop>
<VNodeRenderer :node="renderCell(row, actionsCol)" />
</div>
</div>
<!-- 分页 -->
<div v-if="showPagination && itemCount > 0" class="flex justify-center pt-1">
<BasePagination
:model-value="page"
:page-size="pageSize"
:total="itemCount"
:page-size-options="pageSizes"
:show-size-changer="showSizePicker"
@update:model-value="(p: number) => emit('update:page', p)"
@update:page-size="(s: number) => emit('update:page-size', s)"
/>
</div>
</template>
</div>
</template>
+32
View File
@@ -0,0 +1,32 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
/**
* 响应式断点检测:当前视口宽度 < breakpoint 时视为移动端。
* 默认 768(对应 Tailwind 的 md 断点),超过则在桌面/移动之间实时切换。
*
* 用法:
* const { isMobile } = useIsMobile()
* watch(isMobile, v => { ... })
*/
export function useIsMobile(breakpoint = 768) {
const isMobile = ref(false)
let mql: MediaQueryList | null = null
function update(e: MediaQueryListEvent | MediaQueryList) {
isMobile.value = e.matches
}
onMounted(() => {
mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
update(mql)
// Safari < 14 需要 addListener,现代浏览器用 addEventListener
mql.addEventListener('change', update)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', update)
mql = null
})
return { isMobile }
}
+9 -2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, formatMoney, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
@@ -190,8 +191,9 @@ onMounted(() => {
<!-- Table -->
<div class="viewport-table-panel p-0 overflow-hidden">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -202,6 +204,11 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="orders"
:row-key="(row: any) => row.id"
card-title-key="order_no"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="['order_type', 'final_amount']"
card-actions-key="actions"
@update:page="fetchOrders"
@update:page-size="handlePageSizeChange"
>
@@ -212,7 +219,7 @@ onMounted(() => {
<p class="empty-state-description">您的订单记录将显示在这里</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
</template>
+8 -2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NInput } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
@@ -145,12 +146,17 @@ onMounted(fetchHistory)
<h3 class="text-base font-semibold text-gray-900">兑换记录</h3>
</div>
<n-data-table
<ResponsiveDataTable
:show-pagination="false"
:loading="historyLoading"
:columns="columns"
:scroll-x="tableScrollX(columns)"
:data="recentRedemptions"
:row-key="(row: any) => row.id"
card-title-key="code"
card-badge-key="type"
card-subtitle-key="created_at"
:card-fields="['value']"
>
<template #empty>
<div class="flex flex-col items-center justify-center px-4 py-12 text-center">
@@ -160,7 +166,7 @@ onMounted(fetchHistory)
<p class="text-sm text-gray-500">暂无兑换记录</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
</template>
@@ -4,6 +4,7 @@ import type { DataTableColumns } from 'naive-ui'
import { NInput, NInputNumber, NSelect } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
@@ -182,7 +183,7 @@ onMounted(fetchTransactions)
<DateRangePicker
v-model:start="filters.start_date"
v-model:end="filters.end_date"
@change="handleSearch"
@change="applyFilters"
/>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">类型</label>
@@ -249,8 +250,9 @@ onMounted(fetchTransactions)
<!-- Transactions Table -->
<div class="viewport-table-panel relative z-0">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -261,6 +263,10 @@ onMounted(fetchTransactions)
:scroll-x="tableScrollX(columns)"
:data="transactions"
:row-key="(row: any) => row.id"
card-title-key="amount"
card-badge-key="type"
card-subtitle-key="created_at"
:card-fields="['balance_after', 'username', 'model_name', { key: 'request_id', full: true }, { key: 'description', full: true }]"
@update:page="fetchTransactions"
@update:page-size="handlePageSizeChange"
>
@@ -271,7 +277,7 @@ onMounted(fetchTransactions)
<p class="empty-state-description">交易记录将在 API 调用和充值后展示</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
</template>
+8 -2
View File
@@ -4,6 +4,7 @@ import type { DataTableColumns } from 'naive-ui'
import { NInput, NInputNumber, NModal } from 'naive-ui'
import { useRoute } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { dispatchPayment } from '@/utils/payment'
@@ -672,12 +673,17 @@ onBeforeUnmount(() => {
<h4 class="text-sm font-semibold text-gray-900 mb-3">兑换记录</h4>
<!-- History table -->
<n-data-table
<ResponsiveDataTable
:show-pagination="false"
:loading="redeemHistoryLoading"
:columns="redeemHistoryColumns"
:scroll-x="tableScrollX(redeemHistoryColumns)"
:data="redeemHistory"
:row-key="(row: any) => row.id"
card-title-key="code"
card-badge-key="type"
card-subtitle-key="created_at"
:card-fields="['value']"
>
<template #empty>
<div class="flex flex-col items-center justify-center py-8 text-center">
@@ -687,7 +693,7 @@ onBeforeUnmount(() => {
<p class="text-sm text-gray-500">暂无兑换记录</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<template #footer>
<div class="flex justify-end gap-3">
+9 -2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NTag } from 'naive-ui'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import ApiKeyEditModal from '@/components/common/ApiKeyEditModal.vue'
import type { ApiKeyData } from '@/components/common/ApiKeyEditModal.vue'
import Icon from '@/components/common/Icon.vue'
@@ -317,8 +318,9 @@ onMounted(() => {
<!-- Keys Table -->
<div class="viewport-table-panel">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -329,6 +331,11 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="keys"
:row-key="(row: ApiKey) => row.id"
card-title-key="name"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="[{ key: 'key_prefix', full: true }, 'model_count', 'expires_at', { key: 'limits', full: true }]"
card-actions-key="actions"
@update:page="fetchKeys"
@update:page-size="handlePageSizeChange"
>
@@ -339,7 +346,7 @@ onMounted(() => {
<p class="empty-state-description">创建第一个密钥以开始使用 AI 模型</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Create Modal -->
+11 -2
View File
@@ -5,6 +5,7 @@ import { NTag } from 'naive-ui'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
const router = useRouter()
const activeSection = ref('quickstart')
@@ -1333,11 +1334,15 @@ const errorColumns = computed<DataTableColumns<any>>(() => [
<!-- Parameters Table -->
<div v-if="ep.params.length > 0">
<h5 class="text-sm font-semibold text-gray-800 mb-2">请求参数</h5>
<n-data-table
<ResponsiveDataTable
:show-pagination="false"
:columns="paramColumns"
:scroll-x="tableScrollX(paramColumns)"
:data="ep.params"
:row-key="(row:any)=>row.field"
card-title-key="field"
card-badge-key="required"
:card-fields="['type', { key: 'desc', full: true }]"
/>
</div>
@@ -1459,11 +1464,15 @@ const errorColumns = computed<DataTableColumns<any>>(() => [
<h3 class="font-semibold text-gray-900">错误码一览</h3>
</div>
<div class="card-body !pt-0">
<n-data-table
<ResponsiveDataTable
:show-pagination="false"
:columns="errorColumns"
:scroll-x="tableScrollX(errorColumns)"
:data="errorCodes"
:row-key="(row:any)=>row.code"
card-title-key="code"
card-badge-key="status"
:card-fields="[{ key: 'desc', full: true }, { key: 'example', full: true }]"
/>
</div>
</div>
@@ -4,6 +4,7 @@ import type { DataTableColumns } from 'naive-ui'
import request from '@/utils/request'
import { tableScrollX } from '@/utils/renderUtils'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
interface ModelItem { model_id: string; model_name: string; category: string }
interface ComparisonItem {
@@ -325,18 +326,22 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
<h3 class="text-sm font-semibold text-gray-900">详细对比</h3>
</div>
<div class="table-container">
<n-data-table
<ResponsiveDataTable
:show-pagination="false"
:columns="columns"
:scroll-x="tableScrollX(columns)"
:data="items"
:row-key="(row: ComparisonItem) => row.model_name"
card-title-key="model_name"
card-badge-key="score"
:card-fields="['requests', 'success_rate', 'avg_latency_ms', 'p95_latency_ms', 'total_cost', 'avg_cost_per_request', { key: 'token', full: true }]"
>
<template #empty>
<div class="empty-state">
<p class="text-sm text-gray-500">暂无对比数据</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
@@ -4,6 +4,7 @@ import type { DataTableColumns } from 'naive-ui'
import { NButton, NInput } from 'naive-ui'
import { useRoute } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import request from '@/utils/request'
import { tableScrollX } from '@/utils/renderUtils'
import DateTimeRangePicker from '@/components/common/DateTimeRangePicker.vue'
@@ -328,7 +329,7 @@ onMounted(() => {
<DateTimeRangePicker
v-model:start="logFilter.start_date"
v-model:end="logFilter.end_date"
@change="handleSearchLogs"
@change="handleFilter"
/>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">用户名</label>
@@ -362,8 +363,9 @@ onMounted(() => {
</div>
<!-- Table -->
<div class="viewport-table-panel p-0 overflow-hidden">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="logPage"
v-model:page-size="logPageSize"
:item-count="logTotal"
@@ -374,6 +376,12 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="logs"
:row-key="(row: RequestLog) => row.id"
card-title-key="request_id"
card-badge-key="status_code"
card-subtitle-key="created_at"
:card-fields="[{ key: 'path', full: true }, 'user', 'method', 'latency_ms', 'first_token_ms', 'audit_level', 'task_id']"
card-actions-key="actions"
:row-click="(row: RequestLog) => fetchDetail(row.id)"
@update:page="fetchRequestLogs"
@update:page-size="handlePageSizeChange"
>
@@ -384,7 +392,7 @@ onMounted(() => {
<p class="empty-state-description">API 调用的输入输出记录将显示在这里</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Detail Modal -->
@@ -501,4 +509,3 @@ onMounted(() => {
</Teleport>
</div>
</template>
+11 -3
View File
@@ -5,6 +5,7 @@ import { NButton, NTag, NInput } from 'naive-ui'
import { useRoute } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import request from '@/utils/request'
import { tableScrollX } from '@/utils/renderUtils'
@@ -266,7 +267,7 @@ onMounted(() => {
<DateTimeRangePicker
v-model:start="filterStartDate"
v-model:end="filterEndDate"
@change="handleSearch"
@change="applyFilters"
/>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">任务 ID</label>
@@ -306,8 +307,9 @@ onMounted(() => {
<!-- Table -->
<div class="viewport-table-panel relative z-0 p-0 overflow-hidden">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -318,6 +320,12 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="tasks"
:row-key="(row: TaskItem) => row.id"
card-title-key="public_task_id"
card-badge-key="status"
card-subtitle-key="submit_time"
:card-fields="['platform', 'model_name', 'cost', 'finish_time']"
card-actions-key="actions"
:row-click="openDetail"
@update:page="fetchTasks"
@update:page-size="handlePageSizeChange"
>
@@ -328,7 +336,7 @@ onMounted(() => {
<p class="empty-state-description">异步生成任务的执行记录将显示在这里</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Detail Modal -->
+19 -4
View File
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import DateTimeRangePicker from '@/components/common/DateTimeRangePicker.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import request from '@/utils/request'
import { tableScrollX } from '@/utils/renderUtils'
import { useExport } from '@/composables/useExport'
@@ -469,7 +470,7 @@ onMounted(() => {
<DateTimeRangePicker
v-model:start="filters.start_date"
v-model:end="filters.end_date"
@change="handleSearch"
@change="applyFilters"
/>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">用户名</label>
@@ -530,10 +531,11 @@ onMounted(() => {
</div>
</div>
<!-- Logs Table -->
<!-- Logs Table / 移动端卡片 -->
<div class="viewport-table-panel relative z-0 overflow-hidden">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -544,6 +546,19 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="logs"
:row-key="(row: any) => row.id"
card-title-key="model"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="[
{ key: 'user' },
{ key: 'api_key' },
{ key: 'type' },
{ key: 'token', full: true },
{ key: 'cost' },
{ key: 'latency' },
]"
card-actions-key="actions"
:row-click="openDetail"
@update:page="fetchLogs"
@update:page-size="handlePageSizeChange"
>
@@ -554,7 +569,7 @@ onMounted(() => {
<p class="empty-state-description">日志将在 API 调用后展示</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Token Tooltip -->
@@ -3,6 +3,7 @@ import { ref, computed, watch, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import Icon from '@/components/common/Icon.vue'
export interface ParamDef {
@@ -203,11 +204,15 @@ const paramTableColumns = computed<DataTableColumns<ParamDef>>(() => [
<!-- 参数表格 -->
<div v-if="currentGroup">
<n-data-table
<ResponsiveDataTable
:show-pagination="false"
:columns="paramTableColumns"
:scroll-x="tableScrollX(paramTableColumns)"
:data="currentGroup.params"
:row-key="(row:any)=>row.name"
card-title-key="name"
card-badge-key="required"
:card-fields="['type', 'default', { key: 'values', full: true }, { key: 'description', full: true }]"
/>
</div>
@@ -3,6 +3,7 @@ import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NInput } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import request from '@/utils/request'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import BaseSelect from '../../components/common/BaseSelect.vue'
@@ -197,8 +198,9 @@ function handlePageSizeChange() {
<!-- Table -->
<div class="viewport-table-panel">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -209,6 +211,10 @@ function handlePageSizeChange() {
:scroll-x="tableScrollX(columns)"
:data="list"
:row-key="(row: any) => row.id"
card-title-key="user"
card-badge-key="success"
card-subtitle-key="created_at"
:card-fields="['login_method', 'ip_address', { key: 'user_agent', full: true }, { key: 'fail_reason', full: true }]"
@update:page="fetchData"
@update:page-size="handlePageSizeChange"
>
@@ -219,7 +225,7 @@ function handlePageSizeChange() {
<p class="empty-state-description">当前没有任何登录历史记录</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
</template>
+9 -10
View File
@@ -3,6 +3,7 @@ import { ref, reactive, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NInput } from 'naive-ui'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import Icon from '@/components/common/Icon.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
@@ -227,13 +228,6 @@ const columns = computed<DataTableColumns<Ticket>>(() => [
},
])
function handleRowProps(row: Ticket) {
return {
style: 'cursor: pointer',
onClick: () => openDetail(row),
}
}
function handlePageSizeChange() {
page.value = 1
fetchTickets()
@@ -274,8 +268,9 @@ onMounted(() => {
<!-- Table -->
<div class="viewport-table-panel p-0 overflow-hidden">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -286,7 +281,11 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="tickets"
:row-key="(row: Ticket) => row.id"
:row-props="handleRowProps"
card-title-key="title"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="['id', 'category', 'urgency', 'assigned_admin']"
:row-click="openDetail"
@update:page="fetchTickets"
@update:page-size="handlePageSizeChange"
>
@@ -301,7 +300,7 @@ onMounted(() => {
</button>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Create Ticket Modal -->
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import Icon from '@/components/common/Icon.vue'
import TeamLockedBanner from '@/components/common/TeamLockedBanner.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { toast } from '@/utils/toast'
@@ -163,8 +164,9 @@ onMounted(() => {
<!-- Table -->
<div class="viewport-table-panel">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -175,6 +177,11 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="invitations"
:row-key="(row: InvitationItem) => row.id"
card-title-key="code"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="['role', 'use_count', 'creator_name', { key: 'expires_at', full: true }]"
card-actions-key="actions"
@update:page="fetchInvitations"
@update:page-size="handlePageSizeChange"
>
@@ -185,7 +192,7 @@ onMounted(() => {
<p class="empty-state-description">生成邀请链接后,记录会显示在这里</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
</template>
+10 -11
View File
@@ -5,6 +5,7 @@ import { NButton, NTag } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import TeamLockedBanner from '@/components/common/TeamLockedBanner.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import TableFilterForm, { type FilterField } from '@/components/common/TableFilterForm.vue'
@@ -376,14 +377,6 @@ const columns = computed<DataTableColumns<Member>>(() => [
},
])
// 整行点击跳详情
function handleRowProps(row: Member) {
return {
style: 'cursor: pointer',
onClick: () => goDetail(row.id),
}
}
// pageSize 变化回第 1 页并刷新
function handlePageSizeChange() {
page.value = 1
@@ -488,8 +481,9 @@ onMounted(() => {
<!-- Members Table -->
<div class="viewport-table-panel">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -500,7 +494,12 @@ onMounted(() => {
:scroll-x="tableScrollX(columns)"
:data="members"
:row-key="(row: Member) => row.id"
:row-props="handleRowProps"
card-title-key="user"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="['role', 'quota_type', 'model_count', 'month_cost', 'updated_at']"
card-actions-key="actions"
:row-click="(row: Member) => goDetail(row.id)"
@update:page="fetchMembers"
@update:page-size="handlePageSizeChange"
>
@@ -511,7 +510,7 @@ onMounted(() => {
<p class="empty-state-description">邀请第一位团队成员吧</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Invite Modal -->
@@ -5,6 +5,7 @@ import { NButton, NTag } from 'naive-ui'
import { renderBadge, formatDate, formatTokens, formatMs, tableScrollX } from '@/utils/renderUtils'
import { useRoute, useRouter } from 'vue-router'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import ApiKeyEditModal from '@/components/common/ApiKeyEditModal.vue'
import type { ApiKeyData } from '@/components/common/ApiKeyEditModal.vue'
import Icon from '@/components/common/Icon.vue'
@@ -555,8 +556,8 @@ const usageLogsColumns = computed<DataTableColumns<any>>(() => [
<!-- Keys Tab -->
<div v-if="activeTab === 'keys'" class="space-y-4">
<div class="card">
<n-data-table
<div>
<ResponsiveDataTable
remote
v-model:page="keysPage"
v-model:page-size="keysPageSize"
@@ -568,6 +569,11 @@ const usageLogsColumns = computed<DataTableColumns<any>>(() => [
:scroll-x="tableScrollX(keysColumns)"
:data="keys"
:row-key="(row: ApiKey) => row.id"
card-title-key="name"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="[{ key: 'key_prefix', full: true }, 'scope', { key: 'limit', full: true }, 'expires_at']"
card-actions-key="actions"
@update:page="fetchKeys"
@update:page-size="handleKeysPageSizeChange"
>
@@ -578,7 +584,7 @@ const usageLogsColumns = computed<DataTableColumns<any>>(() => [
<p class="empty-state-description">创建密钥以为此项目提供 AI 能力</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</div>
@@ -630,11 +636,11 @@ const usageLogsColumns = computed<DataTableColumns<any>>(() => [
</div>
<!-- Usage Logs -->
<div class="card">
<div class="space-y-3">
<div class="card-header">
<h3 class="text-base font-semibold text-gray-900">用量日志</h3>
</div>
<n-data-table
<ResponsiveDataTable
remote
v-model:page="usageLogsPage"
v-model:page-size="usageLogsPageSize"
@@ -646,6 +652,10 @@ const usageLogsColumns = computed<DataTableColumns<any>>(() => [
:scroll-x="tableScrollX(usageLogsColumns)"
:data="usageLogs"
:row-key="(row: any) => row.id"
card-title-key="model_name"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="['relay_mode', 'input_tokens', 'output_tokens', 'total_cost', 'latency_ms']"
@update:page="fetchUsageLogs"
@update:page-size="handleUsageLogsPageSizeChange"
>
@@ -656,7 +666,7 @@ const usageLogsColumns = computed<DataTableColumns<any>>(() => [
<p class="empty-state-description">项目密钥调用后将在此显示用量数据</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
</template>
</div>
+10 -10
View File
@@ -5,6 +5,7 @@ import { NButton, NInput, NInputNumber } from 'naive-ui'
import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { useConfirm } from '@/composables/useConfirm'
@@ -171,13 +172,6 @@ const columns = computed<DataTableColumns<any>>(() => [
},
])
function handleRowProps(row: any) {
return {
style: 'cursor: pointer',
onClick: () => goToDetail(row),
}
}
function handlePageSizeChange() {
page.value = 1
fetchProjects()
@@ -198,8 +192,9 @@ function handlePageSizeChange() {
</div>
<div class="viewport-table-panel">
<n-data-table
<ResponsiveDataTable
remote
fill-height
v-model:page="page"
v-model:page-size="pageSize"
:item-count="total"
@@ -210,7 +205,12 @@ function handlePageSizeChange() {
:scroll-x="tableScrollX(columns)"
:data="projects"
:row-key="(row: any) => row.id"
:row-props="handleRowProps"
card-title-key="name"
card-badge-key="status"
card-subtitle-key="created_at"
:card-fields="[{ key: 'description', full: true }, 'budget']"
card-actions-key="actions"
:row-click="goToDetail"
@update:page="fetchProjects"
@update:page-size="handlePageSizeChange"
>
@@ -221,7 +221,7 @@ function handlePageSizeChange() {
<p class="empty-state-description">创建项目来组织你的 API Key 和资源</p>
</div>
</template>
</n-data-table>
</ResponsiveDataTable>
</div>
<!-- Create/Edit Modal -->