feat(frontend): 前端界面优化与使用统计功能增强 (#46)
* feat(frontend): 前端界面优化与使用统计功能增强
主要改动:
1. 表格布局统一优化
- 新增 TablePageLayout 通用布局组件
- 统一所有管理页面的表格样式和交互
- 优化 DataTable、Pagination、Select 等通用组件
2. 使用统计功能增强
- 管理端: 添加完整的筛选和显示功能
- 用户端: 完善 API Key 列显示
- 后端: 优化使用统计数据结构和查询
3. 账户组件优化
- 优化 AccountStatsModal、AccountUsageCell 等组件
- 统一进度条和统计显示样式
4. 其他改进
- 完善中英文国际化
- 统一页面样式和交互体验
- 优化各视图页面的响应式布局
* fix(test): 修复 stubUsageLogRepo.ListWithFilters 测试 stub
测试用例 GET /api/v1/usage 返回 500 是因为 stub 方法未实现,
现在正确返回基于 UserID 过滤的日志数据。
* feat(frontend): 统一日期时间显示格式
**主要改动**:
1. 增强 utils/format.ts:
- 新增 formatDateOnly() - 格式: YYYY-MM-DD
- 新增 formatDateTime() - 格式: YYYY-MM-DD HH:mm:ss
2. 全局替换视图中的格式化函数:
- 移除各视图中的自定义 formatDate 函数
- 统一导入使用 @/utils/format 中的函数
- created_at/updated_at 使用 formatDateTime
- expires_at 使用 formatDateOnly
3. 受影响的视图 (8个):
- frontend/src/views/user/KeysView.vue
- frontend/src/views/user/DashboardView.vue
- frontend/src/views/user/UsageView.vue
- frontend/src/views/user/RedeemView.vue
- frontend/src/views/admin/UsersView.vue
- frontend/src/views/admin/UsageView.vue
- frontend/src/views/admin/RedeemView.vue
- frontend/src/views/admin/SubscriptionsView.vue
**效果**:
- 日期统一显示为 YYYY-MM-DD
- 时间统一显示为 YYYY-MM-DD HH:mm:ss
- 提升可维护性,避免格式不一致
* fix(frontend): 补充遗漏的时间格式化统一
**补充修复**(基于 code review 发现的遗漏):
1. 增强 utils/format.ts:
- 新增 formatTime() - 格式: HH:mm
2. 修复 4 个遗漏的文件:
- src/views/admin/UsersView.vue
* 删除 formatExpiresAt(),改用 formatDateTime()
* 修复订阅过期时间 tooltip 显示格式不一致问题
- src/views/user/ProfileView.vue
* 删除 formatMemberSince(),改用 formatDate(date, 'YYYY-MM')
* 统一会员起始时间显示格式
- src/views/user/SubscriptionsView.vue
* 修改 formatExpirationDate() 使用 formatDateOnly()
* 保留天数计算逻辑
- src/components/account/AccountStatusIndicator.vue
* 删除本地 formatTime(),改用 utils/format 中的统一函数
* 修复 rate limit 和 overload 重置时间显示
**验证**:
- TypeScript 类型检查通过 ✓
- 前端构建成功 ✓
- 所有剩余的 toLocaleString() 都是数字格式化,属于正确用法 ✓
**效果**:
- 订阅过期时间统一为 YYYY-MM-DD HH:mm:ss
- 会员起始时间统一为 YYYY-MM
- 重置时间统一为 HH:mm
- 消除所有不规范的原生 locale 方法调用
This commit is contained in:
@@ -226,7 +226,9 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.tokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Account } from '@/types'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
@@ -139,13 +140,4 @@ const statusText = computed(() => {
|
||||
return props.account.status
|
||||
})
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return 'N/A'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,21 +16,27 @@
|
||||
<div v-else-if="stats" class="space-y-0.5 text-xs">
|
||||
<!-- Requests -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Req:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>{{ t('admin.accounts.stats.requests') }}:</span
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatNumber(stats.requests)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Tokens -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>{{ t('admin.accounts.stats.tokens') }}:</span
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatTokens(stats.tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>{{ t('admin.accounts.stats.cost') }}:</span
|
||||
>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
||||
formatCurrency(stats.cost)
|
||||
}}</span>
|
||||
@@ -44,6 +50,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, WindowStats } from '@/types'
|
||||
import { formatNumber, formatCurrency } from '@/utils/format'
|
||||
@@ -52,6 +59,8 @@ const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const stats = ref<WindowStats | null>(null)
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo } from '@/types'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
@@ -113,6 +114,8 @@ const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
@@ -282,7 +285,7 @@ const loadUsage = async () => {
|
||||
try {
|
||||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
|
||||
} catch (e: any) {
|
||||
error.value = 'Failed'
|
||||
error.value = t('common.error')
|
||||
console.error('Failed to load usage:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Responses API</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -338,7 +338,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Google OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span>
|
||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
|
||||
</div>
|
||||
@@ -488,7 +488,7 @@
|
||||
value="oauth"
|
||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.types.oauth') }}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
|
||||
@@ -63,7 +63,9 @@
|
||||
value="oauth"
|
||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('admin.accounts.types.oauth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
@@ -116,7 +118,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.types.codeAssist')
|
||||
}}</span>
|
||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
|
||||
t('admin.accounts.oauth.gemini.needsProjectId')
|
||||
}}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
v-if="windowStats"
|
||||
class="mb-0.5 flex items-center justify-between"
|
||||
:title="`5h 窗口用量统计`"
|
||||
:title="t('admin.accounts.usageWindow.statsTitle')"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
|
||||
@@ -51,6 +51,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { WindowStats } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -61,6 +62,8 @@ const props = defineProps<{
|
||||
windowStats?: WindowStats | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Label background colors
|
||||
const labelClass = computed(() => {
|
||||
const colors = {
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
ref="tableWrapperRef"
|
||||
class="table-wrapper"
|
||||
:class="{
|
||||
'actions-expanded': actionsExpanded,
|
||||
'is-scrollable': isScrollable
|
||||
}"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-800">
|
||||
<thead class="table-header bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
v-for="(column, index) in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
|
||||
:class="[
|
||||
'sticky-header-cell px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
|
||||
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
|
||||
getStickyColumnClass(column, index)
|
||||
]"
|
||||
@click="column.sortable && handleSort(column.key)"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>{{ column.label }}</span>
|
||||
<!-- 操作列展开/折叠按钮 -->
|
||||
<button
|
||||
v-if="column.key === 'actions' && hasExpandableActions"
|
||||
type="button"
|
||||
@click.stop="toggleActionsExpanded"
|
||||
class="ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
|
||||
:title="actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
|
||||
>
|
||||
<!-- 展开状态:收起图标 -->
|
||||
<svg
|
||||
v-if="actionsExpanded"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
<!-- 折叠状态:展开图标 -->
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
@@ -37,7 +78,7 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<!-- Loading skeleton -->
|
||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
|
||||
@@ -84,11 +125,14 @@
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.key"
|
||||
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
|
||||
:class="[
|
||||
'whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||
getStickyColumnClass(column, colIndex)
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
@@ -99,24 +143,71 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Column } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 表格容器引用
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||||
const isScrollable = ref(false)
|
||||
|
||||
// 检查是否可滚动
|
||||
const checkScrollable = () => {
|
||||
if (tableWrapperRef.value) {
|
||||
isScrollable.value = tableWrapperRef.value.scrollWidth > tableWrapperRef.value.clientWidth
|
||||
}
|
||||
}
|
||||
|
||||
// 监听尺寸变化
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
checkScrollable()
|
||||
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(checkScrollable)
|
||||
resizeObserver.observe(tableWrapperRef.value)
|
||||
} else {
|
||||
// 降级方案:不支持 ResizeObserver 时使用 window resize
|
||||
window.addEventListener('resize', checkScrollable)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', checkScrollable)
|
||||
})
|
||||
|
||||
interface Props {
|
||||
columns: Column[]
|
||||
data: any[]
|
||||
loading?: boolean
|
||||
stickyFirstColumn?: boolean
|
||||
stickyActionsColumn?: boolean
|
||||
expandableActions?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
loading: false,
|
||||
stickyFirstColumn: true,
|
||||
stickyActionsColumn: true,
|
||||
expandableActions: true
|
||||
})
|
||||
|
||||
const sortKey = ref<string>('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const actionsExpanded = ref(false)
|
||||
|
||||
// 数据/列/展开状态变化时重新检查滚动状态
|
||||
watch(
|
||||
[() => props.data.length, () => props.columns, actionsExpanded],
|
||||
async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey.value === key) {
|
||||
@@ -140,4 +231,186 @@ const sortedData = computed(() => {
|
||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
||||
})
|
||||
})
|
||||
|
||||
// 检查是否有可展开的操作列
|
||||
const hasExpandableActions = computed(() => {
|
||||
return props.expandableActions && props.columns.some((col) => col.key === 'actions')
|
||||
})
|
||||
|
||||
// 切换操作列展开/折叠状态
|
||||
const toggleActionsExpanded = () => {
|
||||
actionsExpanded.value = !actionsExpanded.value
|
||||
}
|
||||
|
||||
// 检查第一列是否为勾选列
|
||||
const hasSelectColumn = computed(() => {
|
||||
return props.columns.length > 0 && props.columns[0].key === 'select'
|
||||
})
|
||||
|
||||
// 生成固定列的 CSS 类
|
||||
const getStickyColumnClass = (column: Column, index: number) => {
|
||||
const classes: string[] = []
|
||||
|
||||
if (props.stickyFirstColumn) {
|
||||
// 如果第一列是勾选列,固定前两列(勾选+名称)
|
||||
if (hasSelectColumn.value) {
|
||||
if (index === 0) {
|
||||
classes.push('sticky-col sticky-col-left-first')
|
||||
} else if (index === 1) {
|
||||
classes.push('sticky-col sticky-col-left-second')
|
||||
}
|
||||
} else {
|
||||
// 否则只固定第一列
|
||||
if (index === 0) {
|
||||
classes.push('sticky-col sticky-col-left')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作列固定(最后一列)
|
||||
if (props.stickyActionsColumn && column.key === 'actions') {
|
||||
classes.push('sticky-col sticky-col-right')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表格横向滚动 */
|
||||
.table-wrapper {
|
||||
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 表头容器,确保在滚动时覆盖表体内容 */
|
||||
.table-wrapper .table-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
background-color: rgb(249 250 251);
|
||||
}
|
||||
|
||||
.dark .table-wrapper .table-header {
|
||||
background-color: rgb(31 41 55);
|
||||
}
|
||||
|
||||
/* 表体保持在表头下方 */
|
||||
.table-body {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 所有表头单元格固定在顶部 */
|
||||
.sticky-header-cell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 210; /* 必须高于所有表体内容 */
|
||||
background-color: rgb(249 250 251);
|
||||
}
|
||||
|
||||
.dark .sticky-header-cell {
|
||||
background-color: rgb(31 41 55);
|
||||
}
|
||||
|
||||
/* Sticky 列基础样式 */
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
z-index: 20; /* 表体固定列 */
|
||||
}
|
||||
|
||||
/* 单列固定(无勾选列时) */
|
||||
.sticky-col-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* 双列固定(有勾选列时):第一列(勾选) */
|
||||
.sticky-col-left-first {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* 双列固定(有勾选列时):第二列(名称) */
|
||||
.sticky-col-left-second {
|
||||
left: var(--select-col-width);
|
||||
}
|
||||
|
||||
/* 操作列固定 */
|
||||
.sticky-col-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* 表头 sticky 列 - 需要比普通表头单元格更高的 z-index */
|
||||
.sticky-header-cell.sticky-col {
|
||||
z-index: 220; /* 高于普通表头单元格和表体固定列 */
|
||||
}
|
||||
|
||||
/* 表体 sticky 列背景 */
|
||||
tbody .sticky-col {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.dark tbody .sticky-col {
|
||||
background-color: rgb(17 24 39);
|
||||
}
|
||||
|
||||
/* hover 状态保持 */
|
||||
tbody tr:hover .sticky-col {
|
||||
background-color: rgb(249 250 251);
|
||||
}
|
||||
|
||||
.dark tbody tr:hover .sticky-col {
|
||||
background-color: rgb(31 41 55);
|
||||
}
|
||||
|
||||
/* 阴影只在可滚动时显示 */
|
||||
/* 单列固定右侧阴影 */
|
||||
.is-scrollable .sticky-col-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
transform: translateX(100%);
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 双列固定:只在第二列显示阴影 */
|
||||
.is-scrollable .sticky-col-left-second::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
transform: translateX(100%);
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 操作列左侧阴影 */
|
||||
.is-scrollable .sticky-col-right::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(to left, rgba(0, 0, 0, 0.08), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 暗色模式阴影 */
|
||||
.dark .is-scrollable .sticky-col-left::after,
|
||||
.dark .is-scrollable .sticky-col-left-second::after {
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
|
||||
.dark .is-scrollable .sticky-col-right::before {
|
||||
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate)
|
||||
const localEndDate = ref(props.endDate)
|
||||
const activePreset = ref<string | null>('7days')
|
||||
|
||||
const today = computed(() => new Date().toISOString().split('T')[0])
|
||||
const today = computed(() => {
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
// Helper function to format date to YYYY-MM-DD using local timezone
|
||||
const formatDateToString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const presets: DatePreset[] = [
|
||||
{
|
||||
@@ -152,7 +167,7 @@ const presets: DatePreset[] = [
|
||||
getRange: () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
const yesterday = d.toISOString().split('T')[0]
|
||||
const yesterday = formatDateToString(d)
|
||||
return { start: yesterday, end: yesterday }
|
||||
}
|
||||
},
|
||||
@@ -163,7 +178,7 @@ const presets: DatePreset[] = [
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
const start = d.toISOString().split('T')[0]
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
@@ -174,7 +189,7 @@ const presets: DatePreset[] = [
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 13)
|
||||
const start = d.toISOString().split('T')[0]
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
@@ -185,7 +200,7 @@ const presets: DatePreset[] = [
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 29)
|
||||
const start = d.toISOString().split('T')[0]
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
@@ -194,7 +209,7 @@ const presets: DatePreset[] = [
|
||||
value: 'thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
return { start, end: today.value }
|
||||
}
|
||||
},
|
||||
@@ -203,8 +218,8 @@ const presets: DatePreset[] = [
|
||||
value: 'lastMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0]
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0]
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
||||
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
||||
return { start, end }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
|
||||
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
|
||||
:title="t('admin.groups.rateAndAccounts', { rate: group.rate_multiplier, count: group.account_count || 0 })"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -40,9 +40,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { Group, GroupPlatform } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
modelValue: number[]
|
||||
groups: Group[]
|
||||
|
||||
@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (value: string | number | null) => {
|
||||
if (value === null) return
|
||||
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
if (value === null || typeof value === 'boolean') return
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
emit('update:pageSize', newPageSize)
|
||||
// Reset to first page when page size changes
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="select-options">
|
||||
<div
|
||||
v-for="option in filteredOptions"
|
||||
:key="getOptionValue(option) ?? undefined"
|
||||
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
|
||||
@click="selectOption(option)"
|
||||
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
||||
>
|
||||
@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number | null
|
||||
value: string | number | boolean | null
|
||||
label: string
|
||||
disabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: string | number | null | undefined
|
||||
modelValue: string | number | boolean | null | undefined
|
||||
options: SelectOption[] | Array<Record<string, unknown>>
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
@@ -116,8 +116,8 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number | null): void
|
||||
(e: 'change', value: string | number | null, option: SelectOption | null): void
|
||||
(e: 'update:modelValue', value: string | number | boolean | null): void
|
||||
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const getOptionValue = (
|
||||
option: SelectOption | Record<string, unknown>
|
||||
): string | number | null | undefined => {
|
||||
): string | number | boolean | null | undefined => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option[props.valueKey] as string | number | null | undefined
|
||||
return option[props.valueKey] as string | number | boolean | null | undefined
|
||||
}
|
||||
return option as string | number | null
|
||||
return option as string | number | boolean | null
|
||||
}
|
||||
|
||||
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
|
||||
]"
|
||||
:title="hasUpdate ? 'New version available' : 'Up to date'"
|
||||
:title="hasUpdate ? t('version.updateAvailable') : t('version.upToDate')"
|
||||
>
|
||||
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
|
||||
<span
|
||||
|
||||
114
frontend/src/components/layout/TablePageLayout.vue
Normal file
114
frontend/src/components/layout/TablePageLayout.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="table-page-layout" :class="{ 'mobile-mode': isMobile }">
|
||||
<!-- 固定区域:操作按钮 -->
|
||||
<div v-if="$slots.actions" class="layout-section-fixed">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<!-- 固定区域:搜索和过滤器 -->
|
||||
<div v-if="$slots.filters" class="layout-section-fixed">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
|
||||
<!-- 滚动区域:表格 -->
|
||||
<div class="layout-section-scrollable">
|
||||
<div class="card table-scroll-container">
|
||||
<slot name="table" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 固定区域:分页器 -->
|
||||
<div v-if="$slots.pagination" class="layout-section-fixed">
|
||||
<slot name="pagination" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const isMobile = ref(false)
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 1024
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 桌面端:Flexbox 布局 */
|
||||
.table-page-layout {
|
||||
@apply flex flex-col gap-6;
|
||||
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
|
||||
}
|
||||
|
||||
.layout-section-fixed {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.layout-section-scrollable {
|
||||
@apply flex-1 min-h-0 flex flex-col;
|
||||
}
|
||||
|
||||
/* 表格滚动容器 - 增强版表体滚动方案 */
|
||||
.table-scroll-container {
|
||||
@apply flex flex-col overflow-hidden h-full bg-white dark:bg-dark-800 rounded-2xl border border-gray-200 dark:border-dark-700 shadow-sm;
|
||||
}
|
||||
|
||||
.table-scroll-container :deep(.table-wrapper) {
|
||||
@apply flex-1 overflow-x-auto overflow-y-auto;
|
||||
/* 确保横向滚动条显示在最底部 */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.table-scroll-container :deep(table) {
|
||||
@apply w-full;
|
||||
min-width: max-content; /* 关键:确保表格宽度根据内容撑开,从而触发横向滚动 */
|
||||
display: table; /* 使用标准 table 布局以支持 sticky 列 */
|
||||
}
|
||||
|
||||
.table-scroll-container :deep(thead) {
|
||||
@apply bg-gray-50/80 dark:bg-dark-800/80 backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.table-scroll-container :deep(tbody) {
|
||||
/* 保持默认 table-row-group 显示,不使用 block */
|
||||
}
|
||||
|
||||
.table-scroll-container :deep(th) {
|
||||
/* 表头高度和文字加粗优化 */
|
||||
@apply px-5 py-4 text-left text-sm font-bold text-gray-900 dark:text-white border-b border-gray-200 dark:border-dark-700;
|
||||
@apply uppercase tracking-wider; /* 让表头更有设计感 */
|
||||
}
|
||||
|
||||
.table-scroll-container :deep(td) {
|
||||
@apply px-5 py-4 text-sm text-gray-700 dark:text-gray-300 border-b border-gray-100 dark:border-dark-800;
|
||||
}
|
||||
|
||||
/* 移动端:恢复正常滚动 */
|
||||
.table-page-layout.mobile-mode .table-scroll-container {
|
||||
@apply h-auto overflow-visible border-none shadow-none bg-transparent;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .layout-section-scrollable {
|
||||
@apply flex-none min-h-fit;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) {
|
||||
@apply overflow-visible;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .table-scroll-container :deep(table) {
|
||||
@apply flex-none;
|
||||
display: table;
|
||||
min-width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user