refactor:重构租户端UI风格

This commit is contained in:
chengqian committed 2026-07-27 16:20:12 +08:00
1 parent ddade6ec54
commit 13628293ad
25 files changed
+1656 -637

No files matched your search

+446
View File
@@ -0,0 +1,446 @@
# Tenant Web 设计系统
> 适用范围:`web/tenant`
>
> 当前版本:2026-07-27
>
> 实现来源:`src/styles/main.css`、`src/views/Layout.vue`、`src/views/dashboard/DashboardPage.vue`
## 1. 设计定位
租户端采用一套轻量、明亮、低对比度的现代控制台设计语言,核心视觉方向为:
- 冷白蓝雾背景,避免传统后台常见的纯灰或纯白大面积铺底。
- 蓝紫色作为品牌主色,冰青、天蓝、薄荷绿、暖橙和珊瑚粉作为功能辅助色。
- 参考 macOS Liquid Glass 的视觉特征,以半透明材质、环境色折射、镜面高光和柔和阴影表现层级。
- 卡片边界尽量通过透明度、内高光和冷色阴影区分,避免使用过深边框。
- 信息密度保持适中,使用大圆角、充足留白和紧凑的数据排版。
- 动效服务于状态反馈,不使用持续、强烈或影响阅读的装饰动画。
## 2. 核心视觉原则
### 2.1 环境先于玻璃
液态玻璃必须建立在具有细微颜色变化的环境背景上。纯白背景没有足够的信息可供玻璃层模糊和折射,因此页面背景及侧栏内部均使用低透明度环境光晕。
### 2.2 玻璃以中性色为主
玻璃主体保持冷白透明,蓝色、紫色和青色只用于边缘折射或局部光晕,不应形成明显的彩色实心区域。
### 2.3 层级由材质区分
页面层级按以下顺序增强:
1. 页面背景:冷白底色与极淡 Mesh Gradient。
2. 大面积容器:低透明度玻璃与背景模糊。
3. 普通卡片:更高白色透明度、细描边和轻量阴影。
4. 激活与悬浮状态:镜面高光、内阴影、边缘虹彩和更明显的悬浮阴影。
### 2.4 色彩承担语义
- 蓝紫:品牌、主要操作、当前状态。
- 天蓝:请求、数据、信息类功能。
- 冰青:Token、连接、实时状态。
- 绿色:成功、安全、余额正常。
- 暖橙:提醒、费用、需要关注。
- 红色:错误、危险、超额。
- 粉色:模型、内容或次级强调。
## 3. 色彩系统
### 3.1 品牌主色
主色使用蓝紫色阶,默认品牌色为 `Primary 500`。
| Token | 色值 | 建议用途 |
| --- | --- | --- |
| `primary-50` | `#F4F3FF` | 浅色选中背景、图标底色 |
| `primary-100` | `#ECE9FF` | 聚焦环、浅色悬停 |
| `primary-200` | `#D9D4FF` | 浅色边框 |
| `primary-300` | `#BBB2FF` | 装饰、高亮边缘 |
| `primary-400` | `#9588FF` | 次级品牌强调 |
| `primary-500` | `#7568F8` | 默认品牌色、主要按钮 |
| `primary-600` | `#6558EA` | 按钮悬停、链接 |
| `primary-700` | `#5547D1` | 按压状态、强调文字 |
| `primary-800` | `#463BAB` | 深色品牌元素 |
| `primary-900` | `#3C3588` | 高对比品牌文字 |
| `primary-950` | `#251F5C` | 极深品牌背景 |
品牌渐变:
```css
linear-gradient(135deg, #7b72ff, #6457e8)
```
### 3.2 功能辅助色
| Token | 色值 | 语义 |
| --- | --- | --- |
| `accent-blue` | `#3B82F6` | 数据、请求、信息 |
| `accent-cyan` | `#06B6D4` | Token、连接、实时状态 |
| `accent-green` | `#10B981` | 成功、安全、正常 |
| `accent-amber` | `#F59E0B` | 费用、提醒、关注 |
| `accent-pink` | `#EC4899` | 模型、内容、次级强调 |
| `accent-violet` | `#8B5CF6` | 品牌扩展、智能能力 |
辅助色应优先用于小面积元素,例如图标磁贴、图表线条、状态点和数据趋势。避免在大面积容器中使用高饱和纯色。
### 3.3 中性色
中性色沿用 Slate/Gray 体系:
| 用途 | 推荐颜色 |
| --- | --- |
| 页面主标题 | `slate-900` / `#0F172A` |
| 卡片标题 | `slate-800` / `#1E293B` |
| 正文 | `slate-600` / `#475569` |
| 次级说明 | `slate-400` / `#94A3B8` |
| 禁用与占位 | `slate-300` / `#CBD5E1` |
| 分隔线 | `slate-100` 或白色透明边框 |
正文不建议使用纯黑 `#000000`,以保持整体柔和度。
### 3.4 页面背景
基础背景:
```css
#F6F8FC
```
环境 Mesh Gradient:
```css
radial-gradient(at 12% 8%, rgba(87, 168, 255, 0.08) 0, transparent 36%),
radial-gradient(at 72% 0%, rgba(137, 117, 255, 0.065) 0, transparent 40%),
radial-gradient(at 95% 70%, rgba(70, 213, 219, 0.055) 0, transparent 38%),
radial-gradient(at 45% 100%, rgba(121, 151, 255, 0.045) 0, transparent 42%)
```
背景光晕只用于提供环境色,不应成为页面的视觉主体。
## 4. 液态玻璃系统
### 4.1 基础玻璃令牌
```css
--glass-bg: rgba(255, 255, 255, 0.72);
--glass-bg-strong: rgba(255, 255, 255, 0.84);
--glass-bg-subtle: rgba(255, 255, 255, 0.62);
--glass-border: rgba(255, 255, 255, 0.82);
--glass-blur: 28px;
--glass-saturate: 1.18;
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
```
### 4.2 普通玻璃容器
适用于顶栏、悬浮工具栏和大面积弱层级容器:
- 背景:`rgba(255, 255, 255, 0.62)`。
- 模糊:`blur(28px)`。
- 饱和度:`saturate(1.18)`。
- 描边:半透明白色。
- 阴影:冷色、低透明度、大扩散范围。
### 4.3 卡片玻璃
普通内容卡片使用较高的不透明度:
- 背景:`rgba(255, 255, 255, 0.84)`。
- 圆角:`24px`。
- 描边:`rgba(255, 255, 255, 0.82)`。
- 阴影:`0 10px 34px rgba(91, 104, 151, 0.08)`。
- 内高光:`inset 0 1px 0 rgba(255, 255, 255, 0.9)`。
卡片不使用厚重灰色边框。悬停时只允许轻微上移,并适度增强阴影。
### 4.4 侧栏环境玻璃
侧栏不是纯白色容器,而是带有轻微环境色的独立玻璃层:
```css
background:
radial-gradient(circle at 4% 7%, rgba(80, 171, 255, 0.16), transparent 31%),
radial-gradient(circle at 105% 62%, rgba(139, 111, 248, 0.13), transparent 35%),
radial-gradient(circle at 28% 106%, rgba(64, 211, 211, 0.10), transparent 30%),
rgba(250, 252, 255, 0.62);
backdrop-filter: blur(32px) saturate(1.35);
```
这些环境色为菜单选中项提供可模糊和折射的内容,同时仍保持侧栏接近冷白色。
### 4.5 菜单选中玻璃
菜单选中态采用接近 macOS Liquid Glass 的纯 CSS 近似效果:
- 中性半透明白色为主体。
- 左下角冰青、右下角蓝紫作为极淡折射色。
- `blur(26px) saturate(1.7) brightness(1.06)` 增强背景折射。
- 顶部椭圆形高光模拟镜面反射。
- 底部 `1px` 虹彩线模拟玻璃边缘色散。
- 多层内阴影模拟玻璃厚度和弧面。
- 外阴影表现轻微悬浮,不使用强烈光晕。
所有菜单项默认保留 `1px` 透明边框,选中时只替换边框颜色,避免状态切换造成布局偏移。
纯 CSS 无法实现真实的动态光线折射。若未来需要内容随背景实时扭曲,应使用 SVG displacement filter 或 WebGL shader。
## 5. 圆角系统
| 层级 | 圆角 | 使用场景 |
| --- | --- | --- |
| 小 | `8px` | 标签、紧凑按钮、序号 |
| 中 | `12px` | 输入框、小图标容器 |
| 常规 | `15px–16px` | 菜单项、普通按钮 |
| 大 | `18px` | 快捷操作、内部面板 |
| 卡片 | `24px` | 数据卡片、图表卡片 |
| 容器 | `28px` | 主侧栏、大型玻璃容器 |
| 胶囊 | `9999px` | 搜索框、状态标签、圆形操作区 |
同一区域内圆角应呈现明确层级:外层容器圆角最大,内部元素依次减小。
## 6. 阴影系统
| Token | 配方 | 用途 |
| --- | --- | --- |
| `shadow-glass-sm` | `0 8px 24px rgba(76, 91, 142, 0.08)` | 顶栏、轻量浮层 |
| `shadow-glass` | `0 18px 55px rgba(76, 91, 142, 0.11)` | 大型玻璃容器 |
| `shadow-card` | 冷色外阴影 + 白色内高光 | 普通卡片 |
| `shadow-card-hover` | `0 18px 48px rgba(91, 104, 151, 0.14)` | 卡片悬停 |
| `shadow-glow` | 蓝紫色柔光 | 品牌图标和少量强调 |
阴影颜色使用蓝灰或品牌色透明值,不使用纯黑重阴影。
## 7. 字体与排版
字体栈:
```css
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
sans-serif
```
推荐排版层级:
| 类型 | 建议规格 |
| --- | --- |
| 页面标题 | `28px`,`700`,紧凑字距 |
| 区块标题 | `16px`,`600` |
| 卡片关键数据 | `28px–30px`,`700`,等宽数字 |
| 正文 | `14px`,`400–500` |
| 次级说明 | `12px`,`400` |
| 辅助标签 | `10px–11px`,`500–700` |
金额、百分比、Token 和请求数量应使用 `tabular-nums`,防止数字变化时宽度跳动。
## 8. 图标与渐变磁贴
图标以线性 SVG 为主,常规尺寸为 `16px–20px`。统计卡片和快捷入口可使用渐变图标磁贴:
| 类型 | 渐变方向 |
| --- | --- |
| Violet | `#8E7DFF → #6D5CE7` |
| Blue | `#5DB9FF → #3889EB` |
| Cyan | `#48D5D5 → #22B3BD` |
| Green | `#64DCB0 → #2CB887` |
| Amber | `#FFC861 → #F0A335` |
| Pink | `#FF8EB5 → #EB6394` |
磁贴应使用白色内高光和低透明度同色阴影,避免使用纯色平面图标块。
## 9. 图表配色
推荐图表序列:
```text
#7568F8 蓝紫,主数据
#399CF4 天蓝,次数据
#2CC5C1 冰青
#FFB552 暖橙
#F487A8 珊瑚粉
#9F74EE 紫色扩展
#67D49D 薄荷绿
#FF826F 珊瑚红
```
图表使用规范:
- 主曲线使用蓝紫,第二曲线使用天蓝。
- 填充区域透明度控制在 `0.04–0.12`。
- 网格线使用低透明度 Slate 色。
- Tooltip 使用深蓝灰半透明背景,不使用纯黑。
- 环形图扇区之间使用半透明白色描边,保持玻璃环境下的分离度。
## 10. 动效规范
基础动效时长:
- 快速反馈:`180ms–200ms`。
- 页面与卡片进入:`300ms`。
- 图表加载:约 `650ms`。
- 持续装饰动画:应谨慎使用,并控制在低频、低幅度范围。
推荐缓动:
```css
ease-out
```
允许的悬停变化:
- 上移 `1px–3px`。
- 轻微增强阴影。
- 提升玻璃背景白色透明度。
- 图标或文字改变为品牌色。
禁止通过增加边框宽度、内边距或字体粗细改变元素尺寸,避免页面发生布局抖动。
所有非必要动画必须兼容:
```css
@media (prefers-reduced-motion: reduce)
```
## 11. 响应式规范
- `< 1024px`:侧栏使用抽屉模式,完全移出视口并显示遮罩。
- `>= 1024px`:侧栏固定悬浮,主内容根据展开状态调整左边距。
- 指标卡:手机单列、平板双列、宽屏四列。
- 图表区:常规屏幕单列,超宽屏采用主趋势图与占比图组合布局。
- 快捷操作:根据容器宽度使用两列或三列。
- 顶栏搜索框在小屏幕隐藏,保留关键操作按钮。
## 12. 可访问性与降级
- 玻璃背景上的正文必须保持足够对比度,正文不使用低于 `slate-500` 的颜色。
- 状态不能只依赖颜色表达,应同时提供图标、文字或百分比。
- 交互元素应提供清晰的键盘聚焦环。
- 不支持 `backdrop-filter` 时,玻璃组件降级为高不透明度白色背景。
- 禁用状态降低透明度,但仍应保留可读文字。
- 动画应支持 `prefers-reduced-motion`。
## 13. 使用建议
### 推荐
- 使用透明白色、内高光和冷色阴影建立材质层次。
- 让背景保留极淡的环境色,为玻璃提供折射内容。
- 使用蓝紫色表达品牌和主要状态。
- 使用功能色区分数据类别和业务状态。
- 保持大圆角、低对比边界和充足留白。
### 避免
- 避免大面积高饱和蓝紫渐变。
- 避免纯黑阴影、纯黑正文和深灰粗边框。
- 避免在同一视图中使用过多发光元素。
- 避免所有卡片都设置悬浮动画,静态信息卡可保持稳定。
- 避免只有透明白色、没有环境色的“假玻璃”效果。
- 避免通过选中态边框改变元素实际尺寸。
## 14. 实现位置
| 内容 | 文件 |
| --- | --- |
| 全局主题、色彩和玻璃组件 | `src/styles/main.css` |
| 租户端侧栏与顶栏 | `src/views/Layout.vue` |
| 仪表盘信息架构与数据卡片 | `src/views/dashboard/DashboardPage.vue` |
| 趋势图 | `src/components/charts/TokenTrendChart.vue` |
| 模型费用环形图 | `src/components/charts/ModelDistChart.vue` |
后续新增页面应优先复用全局 `.card`、`.glass`、`.glass-card`、`.sidebar-link`、按钮和输入框样式,避免在页面内部重新定义相似但不一致的颜色与材质配方。
## 15. 表格规范
租户端表格统一使用 `.table-container` 和 `.table`,列表分页使用 `.table-pagination`。
### 15.1 表格容器
- 圆角:`20px`。
- 背景:半透明冷白玻璃,叠加极淡的天蓝和蓝紫环境光。
- 模糊:`blur(22px) saturate(1.2)`。
- 描边:半透明白色,不使用传统灰色实线外框。
- 阴影:冷色轻阴影与顶部内高光。
- 横向滚动条使用蓝紫渐变滑块,宽表格在移动端保持横向滚动。
当表格已经位于带有 `overflow-hidden` 的卡片内部时,使用 `.table-container-flush` 移除重复的圆角、描边和阴影。
### 15.2 表头
- 高度由 `14px` 上下内边距构成。
- 字号:`12px`。
- 字重:`650`。
- 文字颜色:`#64748B`。
- 背景:冷白玻璃叠加低透明度蓝紫渐变。
- 使用顶部镜面高光和底部柔和分隔线表现玻璃厚度。
- 相邻表头之间使用短竖向渐变分隔,不使用贯穿整行的深色边框。
- 表头内容默认不换行,长表格通过横向滚动查看。
### 15.3 数据行
- 单元格默认背景为低透明度白色。
- 偶数行使用极淡蓝灰背景,形成弱斑马纹。
- 行分隔线使用低透明度 Slate 色。
- 悬停或行内元素获得焦点时,整行显示冰蓝至淡紫的液态渐变。
- 悬停行首显示 `3px` 蓝紫色内侧指示条。
- 数字统一使用 `tabular-nums`,避免金额和计数变化造成宽度跳动。
- 表格内普通链接使用 `primary-600`,悬停时切换为 `primary-800`。
### 15.4 行内操作
- 操作按钮优先使用小尺寸幽灵按钮。
- 默认背景保持透明或极淡白色。
- 悬停时增加白色玻璃背景、浅色描边和轻量阴影。
- 状态使用圆角徽章,不通过纯文本颜色单独表达。
- 危险操作继续使用红色语义,但避免大面积红色按钮。
### 15.5 分页栏
- 使用 `.table-pagination` 与表格卡片连接。
- 背景为冷白半透明渐变,与表头形成上下呼应。
- 顶部分隔使用白色透明边缘,而不是旧式灰色边框。
- 翻页按钮使用小型玻璃按钮,悬停时显示品牌色。
- 禁用按钮降低对比度并移除阴影。
- 手机端允许分页信息和按钮自动换行。
### 15.6 使用示例
```vue
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>状态</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td>{{ item.name }}</td>
<td><span class="badge badge-success">正常</span></td>
<td class="text-right"><button class="btn btn-ghost btn-sm">查看</button></td>
</tr>
</tbody>
</table>
</div>
<div class="table-pagination">
<span>共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm">上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button class="btn btn-ghost btn-sm">下一页</button>
</div>
</div>
```
+1 -1
View File
@@ -10,7 +10,7 @@
<meta name="keywords" content="Team-API, 大模型网关, API Gateway, 多租户, OpenAI, Claude, Gemini, DeepSeek, AI代理, LLM Gateway, 开源, 自托管, 计费引擎, 团队管理, API管理" />
<meta name="author" content="qianfree" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#14b8a6" />
<meta name="theme-color" content="#7b8ff5" />
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
@@ -2,13 +2,14 @@
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Chart as ChartJS,
Legend,
Tooltip,
type ChartData,
type ChartOptions,
type ChartData
} from 'chart.js'
import Icon from '@/components/common/Icon.vue'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -22,118 +23,101 @@ const props = withDefaults(defineProps<{
}>
loading?: boolean
}>(), {
data: () => []
data: () => [],
})
const COLORS = [
'#14b8a6', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
'#10b981', '#e11d48', '#0ea5e9', '#a855f7', '#eab308',
'#d946ef', '#22d3ee', '#4ade80', '#fb923c', '#818cf8'
]
const colors = ['#7568f8', '#42a4f5', '#2cc5c1', '#ffb552', '#f487a8', '#9f74ee', '#67d49d', '#ff826f']
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(n)
function formatCost(value: number): string {
if (value === 0) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
return `$${value.toFixed(4)}`
}
const totalCost = computed(() => props.data.reduce((sum, item) => sum + Number(item.total_cost || 0), 0))
const chartData = computed<ChartData<'doughnut'>>(() => ({
labels: props.data.map(d => d.model_name),
labels: props.data.map((item) => item.model_name),
datasets: [{
data: props.data.map(d => d.total_cost),
backgroundColor: props.data.map((_, i) => COLORS[i % COLORS.length]),
borderWidth: 2,
borderColor: '#ffffff',
hoverOffset: 6
}]
data: props.data.map((item) => item.total_cost),
backgroundColor: props.data.map((_, index) => colors[index % colors.length]),
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.92)',
hoverBorderColor: '#ffffff',
hoverOffset: 7,
borderRadius: 4,
}],
}))
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
responsive: true,
maintainAspectRatio: true,
cutout: '65%',
cutout: '70%',
animation: { duration: 650, easing: 'easeOutQuart' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.9)',
padding: 10,
cornerRadius: 8,
backgroundColor: 'rgba(30, 36, 61, 0.92)',
padding: 12,
cornerRadius: 12,
callbacks: {
label: (ctx) => {
const total = ctx.dataset.data.reduce((s, v) => s + (v as number), 0)
const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : '0'
return ` $${(ctx.parsed as number).toFixed(4)} (${pct}%)`
}
}
}
}
label: (context) => {
const value = Number(context.parsed) || 0
const percent = totalCost.value > 0 ? ((value / totalCost.value) * 100).toFixed(1) : '0'
return ` ${formatCost(value)} · ${percent}%`
},
},
},
},
}))
const totalCost = computed(() => props.data.reduce((s, d) => s + d.total_cost, 0))
const modelList = computed(() =>
props.data.map((d, i) => ({
...d,
total_tokens: d.input_tokens + d.output_tokens,
color: COLORS[i % COLORS.length],
percent: totalCost.value > 0 ? ((d.total_cost / totalCost.value) * 100).toFixed(1) : '0'
}))
props.data.slice(0, 5).map((item, index) => ({
...item,
color: colors[index % colors.length],
percent: totalCost.value > 0 ? ((item.total_cost / totalCost.value) * 100).toFixed(1) : '0',
})),
)
</script>
<template>
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">模型使用分布</h3>
<span class="text-xs text-gray-400">按费用排序</span>
<div class="card p-5 sm:p-6">
<div class="mb-4 flex items-start justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">模型费用占比</h2>
<p class="mt-0.5 text-xs text-slate-400">按模型统计调用成本</p>
</div>
<Icon name="more" size="md" class="text-slate-400" />
</div>
<div v-if="loading" class="h-64 flex items-center justify-center">
<div v-if="loading" class="flex h-[300px] items-center justify-center">
<div class="spinner h-8 w-8 text-primary-500"></div>
</div>
<div v-else-if="data.length === 0" class="h-64 flex flex-col items-center justify-center text-gray-400">
<svg class="h-12 w-12 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<p class="text-sm">暂无模型数据</p>
<div v-else-if="data.length === 0" class="flex h-[300px] flex-col items-center justify-center text-slate-400">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-violet-50 text-violet-400">
<Icon name="chart" size="lg" />
</div>
<p class="mt-3 text-sm font-medium text-slate-500">暂无模型数据</p>
<p class="mt-1 text-xs">模型产生费用后即可查看占比</p>
</div>
<template v-else>
<div class="flex flex-col lg:flex-row gap-6">
<!-- Doughnut Chart -->
<div class="flex-shrink-0 w-full lg:w-48 flex items-center justify-center">
<div class="w-40 h-40">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- Model Table -->
<div class="flex-1 min-w-0 overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100">
<th class="pb-2 text-left font-medium text-gray-500 text-xs">模型</th>
<th class="pb-2 text-right font-medium text-gray-500 text-xs">请求</th>
<th class="pb-2 text-right font-medium text-gray-500 text-xs">Token</th>
<th class="pb-2 text-right font-medium text-gray-500 text-xs">费用</th>
</tr>
</thead>
<tbody>
<tr v-for="item in modelList" :key="item.model_name" class="border-b border-gray-50 last:border-0">
<td class="py-2 pr-3">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="{ backgroundColor: item.color }"></span>
<span class="text-gray-700 truncate max-w-[120px]" :title="item.model_name">{{ item.model_name }}</span>
</div>
</td>
<td class="py-2 text-right text-gray-600 tabular-nums">{{ formatNumber(item.requests) }}</td>
<td class="py-2 text-right text-gray-600 tabular-nums">{{ formatNumber(item.total_tokens) }}</td>
<td class="py-2 text-right">
<span class="text-emerald-600 font-medium tabular-nums">${{ item.total_cost.toFixed(4) }}</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="flex min-h-[300px] flex-col items-center gap-5 sm:flex-row 2xl:flex-col">
<div class="relative h-52 w-52 flex-shrink-0">
<Doughnut :data="chartData" :options="chartOptions" />
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span class="text-xs text-slate-400">总费用</span>
<strong class="mt-1 max-w-28 truncate text-xl font-bold tracking-tight text-slate-800">{{ formatCost(totalCost) }}</strong>
</div>
</div>
</template>
<div class="w-full min-w-0 flex-1 space-y-3">
<div v-for="item in modelList" :key="item.model_name" class="flex items-center gap-3">
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: item.color, boxShadow: `0 0 0 4px ${item.color}18` }"></span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-slate-600" :title="item.model_name">{{ item.model_name }}</p>
<p class="mt-0.5 text-[10px] text-slate-400">{{ formatCost(item.total_cost) }}</p>
</div>
<span class="text-xs font-semibold tabular-nums text-slate-700">{{ item.percent }}%</span>
</div>
</div>
</div>
</div>
</template>
@@ -2,18 +2,19 @@
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
PointElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Filler,
type ChartData,
type ChartOptions,
type ChartData
} from 'chart.js'
import Icon from '@/components/common/Icon.vue'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
@@ -26,151 +27,163 @@ const props = withDefaults(defineProps<{
requests: number
}>
loading?: boolean
days?: number
}>(), {
data: () => []
data: () => [],
days: 7,
})
const formatTokenLabel = (value: number): string => {
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + 'B'
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + 'M'
if (value >= 1_000) return (value / 1_000).toFixed(1) + 'K'
const emit = defineEmits<{
changeDays: [days: number]
}>()
function formatCompact(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return String(value)
}
const formatDateLabel = (dateStr: string): string => {
if (!dateStr) return ''
const parts = dateStr.split('-')
if (parts.length === 3) return `${parts[1]}/${parts[2]}`
return dateStr
function formatDateLabel(date: string): string {
const parts = date.split('-')
return parts.length === 3 ? `${parts[1]}-${parts[2]}` : date
}
const chartData = computed<ChartData<'line'>>(() => ({
labels: props.data.map(d => formatDateLabel(d.date)),
labels: props.data.map((item) => formatDateLabel(item.date)),
datasets: [
{
label: '输入 Token',
data: props.data.map(d => d.input_tokens),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
label: 'Token 用量',
data: props.data.map((item) => item.input_tokens + item.output_tokens),
borderColor: '#7568f8',
backgroundColor: 'rgba(117, 104, 248, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 2,
tension: 0.42,
pointRadius: 2.5,
pointHoverRadius: 5,
borderWidth: 2,
yAxisID: 'y'
pointBackgroundColor: '#ffffff',
pointBorderColor: '#7568f8',
pointBorderWidth: 2,
borderWidth: 2.25,
yAxisID: 'y',
},
{
label: '输出 Token',
data: props.data.map(d => d.output_tokens),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: true,
tension: 0.4,
pointRadius: 2,
pointHoverRadius: 5,
borderWidth: 2,
yAxisID: 'y'
},
{
label: '费用 ($)',
data: props.data.map(d => d.total_cost),
borderColor: '#f59e0b',
backgroundColor: 'transparent',
label: '请求数',
data: props.data.map((item) => item.requests),
borderColor: '#399cf4',
backgroundColor: 'rgba(57, 156, 244, 0.04)',
fill: false,
tension: 0.4,
pointRadius: 2,
tension: 0.42,
pointRadius: 2.5,
pointHoverRadius: 5,
borderWidth: 2,
borderDash: [5, 5],
yAxisID: 'y1'
}
]
pointBackgroundColor: '#ffffff',
pointBorderColor: '#399cf4',
pointBorderWidth: 2,
borderWidth: 2.25,
yAxisID: 'y1',
},
],
}))
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
interaction: { mode: 'index', intersect: false },
animation: { duration: 650, easing: 'easeOutQuart' },
plugins: {
legend: {
position: 'top',
align: 'end',
align: 'start',
labels: {
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 6,
boxHeight: 6,
padding: 16,
font: { size: 12 }
}
boxWidth: 7,
boxHeight: 7,
padding: 18,
color: '#718096',
font: { size: 11, weight: 500 },
},
},
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.9)',
titleFont: { size: 12 },
bodyFont: { size: 12 },
backgroundColor: 'rgba(30, 36, 61, 0.92)',
titleColor: '#ffffff',
bodyColor: '#e2e8f0',
padding: 12,
cornerRadius: 8,
cornerRadius: 12,
displayColors: true,
callbacks: {
label: (ctx) => {
const label = ctx.dataset.label || ''
const value = ctx.parsed.y ?? 0
if (label.includes('费用')) return `${label}: $${value.toFixed(4)}`
return `${label}: ${formatTokenLabel(value)}`
}
}
}
label: (context) => {
const value = context.parsed.y ?? 0
return `${context.dataset.label}: ${formatCompact(value)}`
},
},
},
},
scales: {
x: {
border: { display: false },
grid: { display: false },
ticks: {
font: { size: 11 },
color: '#9ca3af',
maxRotation: 0
}
ticks: { color: '#9aa7bd', font: { size: 10 }, maxRotation: 0 },
},
y: {
position: 'left',
grid: { color: 'rgba(0,0,0,0.04)' },
border: { display: false },
grid: { color: 'rgba(148, 163, 184, 0.14)', lineWidth: 1 },
ticks: {
font: { size: 11 },
color: '#9ca3af',
callback: (value) => formatTokenLabel((value as number) ?? 0)
color: '#9aa7bd',
font: { size: 10 },
padding: 8,
callback: (value) => formatCompact(Number(value) || 0),
},
beginAtZero: true
beginAtZero: true,
},
y1: {
position: 'right',
border: { display: false },
grid: { display: false },
ticks: {
font: { size: 11 },
color: '#f59e0b',
callback: (value) => '$' + ((value as number) ?? 0).toFixed(2)
color: '#9aa7bd',
font: { size: 10 },
padding: 8,
callback: (value) => formatCompact(Number(value) || 0),
},
beginAtZero: true
}
}
beginAtZero: true,
},
},
}))
</script>
<template>
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Token 使用趋势</h3>
<div class="card p-5 sm:p-6">
<div class="mb-2 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">使用趋势</h2>
<p class="mt-0.5 text-xs text-slate-400">Token 消耗与请求量变化</p>
</div>
<div class="inline-flex w-fit items-center gap-1 rounded-xl border border-white/90 bg-white/60 p-1 shadow-sm">
<button
v-for="option in [7, 30, 90]"
:key="option"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-all"
:class="days === option ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'"
@click="emit('changeDays', option)"
>
{{ option }} 天
</button>
</div>
</div>
<div v-if="loading" class="h-64 flex items-center justify-center">
<div v-if="loading" class="flex h-[300px] items-center justify-center">
<div class="spinner h-8 w-8 text-primary-500"></div>
</div>
<div v-else-if="data.length === 0" class="h-64 flex flex-col items-center justify-center text-gray-400">
<svg class="h-12 w-12 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<p class="text-sm">暂无趋势数据</p>
<div v-else-if="data.length === 0" class="flex h-[300px] flex-col items-center justify-center text-slate-400">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 text-primary-400">
<Icon name="chart" size="lg" />
</div>
<p class="mt-3 text-sm font-medium text-slate-500">暂无趋势数据</p>
<p class="mt-1 text-xs">完成 API 调用后即可查看趋势</p>
</div>
<div v-else class="h-64">
<div v-else class="h-[300px]">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -233,10 +233,10 @@ onBeforeUnmount(() => {
.announcement-content ul, .announcement-content ol { padding-left: 1.5em; margin: 0.5em 0; }
.announcement-content li { margin: 0.2em 0; }
.announcement-content blockquote {
border-left: 3px solid #14b8a6;
border-left: 3px solid #7b8ff5;
padding: 0.5em 1em;
margin: 0.5em 0;
background: #f0fdfa;
background: #f2f4ff;
border-radius: 0 8px 8px 0;
}
.announcement-content code {
@@ -260,7 +260,7 @@ onBeforeUnmount(() => {
color: inherit;
}
.announcement-content a {
color: #14b8a6;
color: #6d80f0;
text-decoration: underline;
}
.announcement-content img {
@@ -419,7 +419,7 @@ function delay(ms: number) {
height: 28px;
border-radius: 50%;
border: 3px solid #e5e7eb;
border-top-color: #14b8a6;
border-top-color: #7b8ff5;
animation: sc-spin 0.6s linear infinite;
flex-shrink: 0;
}
@@ -20,7 +20,7 @@
<!-- Grid Pattern -->
<div
class="absolute inset-0"
style="background-image: linear-gradient(rgba(20,184,166,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(20,184,166,0.03) 1px, transparent 1px); background-size: 64px 64px"
style="background-image: linear-gradient(rgba(123,143,245,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(123,143,245,0.04) 1px, transparent 1px); background-size: 64px 64px"
></div>
</div>
+3 -3
View File
@@ -11,9 +11,9 @@ declare const __APP_VERSION__: string
console.log(
'%c Team-API %c ' + __APP_VERSION__ + ' %c Tenant ',
'background: #14b8a6; color: white; padding: 4px 8px; border-radius: 4px 0 0 4px; font-weight: bold;',
'background: #0d9488; color: white; padding: 4px 8px;',
'background: #115e59; color: white; padding: 4px 8px; border-radius: 0 4px 4px 0;',
'background: #7b8ff5; color: white; padding: 4px 8px; border-radius: 4px 0 0 4px; font-weight: bold;',
'background: #6d80f0; color: white; padding: 4px 8px;',
'background: #4b58c0; color: white; padding: 4px 8px; border-radius: 0 4px 4px 0;',
)
console.log(
'%cAGPL v3.0 开源协议',
+414 -70
View File
@@ -1,18 +1,26 @@
@import "tailwindcss";
@theme {
/* 主色 - Teal/Cyan */
--color-primary-50: #f0fdfa;
--color-primary-100: #ccfbf1;
--color-primary-200: #99f6e4;
--color-primary-300: #5eead4;
--color-primary-400: #2dd4bf;
--color-primary-500: #14b8a6;
--color-primary-600: #0d9488;
--color-primary-700: #0f766e;
--color-primary-800: #115e59;
--color-primary-900: #134e4a;
--color-primary-950: #042f2e;
/* 主色 - 蓝紫色系 */
--color-primary-50: #f4f3ff;
--color-primary-100: #ece9ff;
--color-primary-200: #d9d4ff;
--color-primary-300: #bbb2ff;
--color-primary-400: #9588ff;
--color-primary-500: #7568f8;
--color-primary-600: #6558ea;
--color-primary-700: #5547d1;
--color-primary-800: #463bab;
--color-primary-900: #3c3588;
--color-primary-950: #251f5c;
/* 多彩辅助色(与设计图对齐) */
--color-accent-blue: #3B82F6;
--color-accent-cyan: #06B6D4;
--color-accent-pink: #EC4899;
--color-accent-amber: #F59E0B;
--color-accent-green: #10B981;
--color-accent-violet: #8B5CF6;
/* 深色模式背景 */
--color-dark-50: #f8fafc;
@@ -27,19 +35,19 @@
--color-dark-900: #0f172a;
--color-dark-950: #020617;
/* 阴影 */
--shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.08);
--shadow-glass-sm: 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-glow: 0 0 20px rgba(20, 184, 166, 0.25);
--shadow-glow-lg: 0 0 40px rgba(20, 184, 166, 0.35);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.08);
--shadow-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
/* 阴影 - 轻量精致(对齐设计图) */
--shadow-glass: 0 18px 55px rgba(76, 91, 142, 0.11);
--shadow-glass-sm: 0 8px 24px rgba(76, 91, 142, 0.08);
--shadow-glow: 0 10px 30px rgba(117, 104, 248, 0.28);
--shadow-glow-lg: 0 18px 46px rgba(117, 104, 248, 0.34);
--shadow-card: 0 10px 34px rgba(91, 104, 151, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9);
--shadow-card-hover: 0 18px 48px rgba(91, 104, 151, 0.14);
--shadow-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
/* 背景渐变 */
--background-image-gradient-primary: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
--background-image-gradient-dark: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
--background-image-mesh-gradient: radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.12) 0px, transparent 50%), radial-gradient(at 80% 0%, rgba(6, 182, 212, 0.08) 0px, transparent 50%), radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.08) 0px, transparent 50%);
/* 背景渐变(对齐设计图) */
--background-image-gradient-primary: linear-gradient(135deg, #7b72ff, #6457e8);
--background-image-gradient-dark: linear-gradient(135deg, #1e293b, #0f172a);
--background-image-mesh-gradient: radial-gradient(at 12% 8%, rgba(87, 168, 255, 0.08) 0px, transparent 36%), radial-gradient(at 72% 0%, rgba(137, 117, 255, 0.065) 0px, transparent 40%), radial-gradient(at 95% 70%, rgba(70, 213, 219, 0.055) 0px, transparent 38%), radial-gradient(at 45% 100%, rgba(121, 151, 255, 0.045) 0px, transparent 42%);
/* 动画 */
--animate-fade-in: fade-in 0.3s ease-out;
@@ -75,8 +83,8 @@
to { background-position: 200% 0; }
}
@keyframes glow {
from { box-shadow: 0 0 20px rgba(20, 184, 166, 0.25); }
to { box-shadow: 0 0 30px rgba(20, 184, 166, 0.4); }
from { box-shadow: 0 0 20px rgba(123, 143, 245, 0.22); }
to { box-shadow: 0 0 30px rgba(123, 143, 245, 0.38); }
}
}
@@ -88,11 +96,32 @@ html {
}
body {
@apply bg-gray-50 text-gray-900 min-h-screen;
@apply text-gray-900 min-h-screen;
background: #f6f8fc;
background-attachment: fixed;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
/* 液态玻璃配方变量 + 多彩发光色(供 .glass-* / .icon-tile-* 引用) */
:root {
--glass-bg: rgba(255, 255, 255, 0.72);
--glass-bg-strong: rgba(255, 255, 255, 0.84);
--glass-bg-subtle: rgba(255, 255, 255, 0.62);
--glass-border: rgba(255, 255, 255, 0.82);
--glass-blur: 28px;
--glass-saturate: 1.18;
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
--glow-violet: 139, 92, 246;
--glow-blue: 59, 130, 246;
--glow-cyan: 6, 182, 212;
--glow-pink: 236, 72, 153;
--glow-amber: 245, 158, 11;
--glow-green: 16, 185, 129;
--glow-teal: 20, 184, 166;
}
/* 自定义滚动条 - 默认隐藏,悬停或聚焦时显示 */
* {
scrollbar-width: thin;
@@ -224,29 +253,68 @@ body.modal-open {
@apply mt-1 text-xs text-red-500;
}
/* ============ 玻璃效果 ============ */
/* ============ 液态玻璃效果 ============ */
/* 大面积磨砂(header、sticky 栏) */
.glass {
@apply bg-white/80 backdrop-blur-xl;
background: var(--glass-bg-subtle);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
}
/* 液态玻璃卡片:弱透明底 + 模糊 + 高光描边 + 冷投影 + 内高光 */
.glass-card {
@apply bg-white/70 backdrop-blur-xl rounded-2xl;
@apply border border-white/20 shadow-glass;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
border-radius: 1rem;
box-shadow: var(--shadow-glass), var(--glass-highlight);
@apply transition-all duration-300;
}
/* 降级:不支持 backdrop-filter 时用不透明白底兜底,保证可读性 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background: rgba(255, 255, 255, 0.92); }
.glass-card { background: rgba(255, 255, 255, 0.95); }
}
/* ============ 卡片样式 ============ */
.card {
@apply bg-white rounded-2xl;
@apply border border-gray-100 shadow-card;
@apply transition-all duration-300;
border-radius: 1.5rem;
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-card);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.card { background: rgba(255, 255, 255, 0.98); }
}
.card-hover {
@apply hover:-translate-y-0.5 hover:shadow-card-hover hover:border-gray-200;
}
.card-glass {
@apply bg-white/70 backdrop-blur-xl;
@apply border border-white/20 shadow-glass;
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.card-glass { background: rgba(255, 255, 255, 0.98); }
}
/* ============ 彩色发光图标磁贴(设计图风格) ============ */
.icon-tile {
@apply flex items-center justify-center rounded-2xl h-14 w-14 text-white flex-shrink-0;
}
.icon-tile-violet { background: linear-gradient(135deg, #A78BFA, #8B5CF6); box-shadow: 0 8px 24px rgba(var(--glow-violet), 0.35); }
.icon-tile-blue { background: linear-gradient(135deg, #60A5FA, #3B82F6); box-shadow: 0 8px 24px rgba(var(--glow-blue), 0.35); }
.icon-tile-cyan { background: linear-gradient(135deg, #22D3EE, #06B6D4); box-shadow: 0 8px 24px rgba(var(--glow-cyan), 0.35); }
.icon-tile-pink { background: linear-gradient(135deg, #F472B6, #EC4899); box-shadow: 0 8px 24px rgba(var(--glow-pink), 0.35); }
.icon-tile-amber { background: linear-gradient(135deg, #FBBF24, #F59E0B); box-shadow: 0 8px 24px rgba(var(--glow-amber), 0.35); }
.icon-tile-green { background: linear-gradient(135deg, #34D399, #10B981); box-shadow: 0 8px 24px rgba(var(--glow-green), 0.35); }
.icon-tile-red { background: linear-gradient(135deg, #F87171, #EF4444); box-shadow: 0 8px 24px rgba(239, 68, 68, 0.35); }
.icon-tile-gray { background: linear-gradient(135deg, #D1D5DB, #9CA3AF); box-shadow: 0 8px 24px rgba(156, 163, 175, 0.25); }
.card-header {
@apply border-b border-gray-100 px-6 py-4;
}
@@ -259,28 +327,33 @@ body.modal-open {
/* ============ 统计卡片 ============ */
.stat-card {
@apply bg-white rounded-2xl border border-gray-100 shadow-card transition-all duration-300 p-5 flex items-start gap-4;
@apply transition-all duration-300 p-6;
position: relative;
overflow: hidden;
border-radius: 1.5rem;
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-card);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.stat-card { background: rgba(255, 255, 255, 0.98); }
}
/* 统计图标:彩色发光渐变磁贴(设计图风格) */
.stat-icon {
@apply h-12 w-12 rounded-xl flex items-center justify-center text-xl;
}
.stat-icon-primary {
@apply bg-primary-100 text-primary-600;
}
.stat-icon-success {
@apply bg-emerald-100 text-emerald-600;
}
.stat-icon-warning {
@apply bg-amber-100 text-amber-600;
}
.stat-icon-danger {
@apply bg-red-100 text-red-600;
@apply h-14 w-14 rounded-2xl flex items-center justify-center text-xl text-white flex-shrink-0;
box-shadow: 0 12px 30px rgba(96, 89, 214, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.stat-icon-primary { background: linear-gradient(135deg, #22D3EE, #06B6D4); box-shadow: 0 8px 24px rgba(var(--glow-cyan), 0.35); }
.stat-icon-success { background: linear-gradient(135deg, #34D399, #10B981); box-shadow: 0 8px 24px rgba(var(--glow-green), 0.35); }
.stat-icon-warning { background: linear-gradient(135deg, #FBBF24, #F59E0B); box-shadow: 0 8px 24px rgba(var(--glow-amber), 0.35); }
.stat-icon-danger { background: linear-gradient(135deg, #F87171, #EF4444); box-shadow: 0 8px 24px rgba(239, 68, 68, 0.35); }
.stat-value {
@apply text-2xl font-bold text-gray-900 truncate;
@apply text-3xl font-bold text-gray-900 truncate leading-none;
}
.stat-label {
@apply text-sm text-gray-500;
@apply text-sm text-gray-500 mb-1;
}
.stat-trend {
@apply mt-1 flex items-center gap-1 text-xs font-medium;
@@ -294,25 +367,199 @@ body.modal-open {
/* ============ 表格样式 ============ */
.table-container {
@apply overflow-x-auto rounded-xl border border-gray-200;
position: relative;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 1.25rem;
background:
radial-gradient(circle at 0 0, rgba(92, 177, 255, 0.055), transparent 28%),
radial-gradient(circle at 100% 100%, rgba(132, 105, 246, 0.045), transparent 30%),
rgba(255, 255, 255, 0.42);
box-shadow:
0 10px 30px rgba(75, 91, 137, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
backdrop-filter: blur(22px) saturate(1.2);
-webkit-backdrop-filter: blur(22px) saturate(1.2);
scrollbar-width: thin;
scrollbar-color: rgba(117, 104, 248, 0.28) transparent;
}
.table-container::-webkit-scrollbar {
height: 8px;
}
.table-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.24);
}
.table-container::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 9999px;
background: linear-gradient(90deg, rgba(65, 168, 244, 0.4), rgba(117, 104, 248, 0.42));
background-clip: padding-box;
}
.table-container-flush {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.table {
@apply w-full text-sm;
width: 100%;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.table th {
@apply px-4 py-3 text-left font-medium;
@apply text-gray-600 bg-gray-50;
@apply border-b border-gray-200;
position: relative;
padding: 0.875rem 1rem;
white-space: nowrap;
text-align: left;
font-size: 0.75rem;
font-weight: 650;
letter-spacing: 0.025em;
color: #64748b;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(242, 246, 255, 0.72)),
linear-gradient(100deg, rgba(63, 163, 247, 0.065), rgba(117, 104, 248, 0.075));
border-bottom: 1px solid rgba(191, 203, 225, 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
inset 0 -1px 0 rgba(255, 255, 255, 0.45);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.82);
}
.table td {
@apply px-4 py-3 text-gray-700;
@apply border-b border-gray-100;
padding: 0.9375rem 1rem;
vertical-align: middle;
color: #475569;
background: rgba(255, 255, 255, 0.3);
border-bottom: 1px solid rgba(226, 232, 240, 0.62);
transition:
background-color 180ms ease,
box-shadow 180ms ease,
color 180ms ease;
}
.table th:first-child,
.table td:first-child {
padding-left: 1.25rem;
}
.table th:last-child,
.table td:last-child {
padding-right: 1.25rem;
}
.table thead th:not(:last-child)::after {
position: absolute;
top: 30%;
right: 0;
width: 1px;
height: 40%;
background: linear-gradient(transparent, rgba(148, 163, 184, 0.2), transparent);
content: '';
}
.table tr:last-child td {
@apply border-b-0;
border-bottom: 0;
}
.table tbody tr {
@apply transition-colors duration-150 hover:bg-gray-50;
transition: filter 180ms ease;
}
.table tbody tr:nth-child(even) td {
background: rgba(247, 249, 255, 0.42);
}
.table tbody tr:hover td,
.table tbody tr:focus-within td {
color: #334155;
background:
linear-gradient(90deg, rgba(235, 247, 255, 0.76), rgba(244, 240, 255, 0.68)),
rgba(255, 255, 255, 0.74);
}
.table tbody tr:hover td:first-child,
.table tbody tr:focus-within td:first-child {
box-shadow:
inset 3px 0 0 rgba(117, 104, 248, 0.62),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.table tbody tr:hover td:last-child,
.table tbody tr:focus-within td:last-child {
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.5);
}
.table tbody a:not(.btn) {
font-weight: 550;
color: #6558ea;
transition: color 160ms ease;
}
.table tbody a:not(.btn):hover {
color: #463bab;
}
.table tbody .btn-ghost {
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.34);
}
.table tbody .btn-ghost:hover {
border-color: rgba(255, 255, 255, 0.88);
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 16px rgba(76, 91, 142, 0.08);
}
@media (max-width: 640px) {
.table th {
padding: 0.75rem 0.875rem;
font-size: 0.6875rem;
}
.table td {
padding: 0.8125rem 0.875rem;
}
.table th:first-child,
.table td:first-child {
padding-left: 1rem;
}
.table th:last-child,
.table td:last-child {
padding-right: 1rem;
}
}
.table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.72);
background:
linear-gradient(180deg, rgba(250, 252, 255, 0.46), rgba(242, 246, 255, 0.58)),
rgba(255, 255, 255, 0.42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.table-pagination > p,
.table-pagination > span {
color: #64748b;
font-size: 0.75rem;
}
.table-pagination .btn {
min-width: 4.25rem;
border: 1px solid rgba(255, 255, 255, 0.86);
background: rgba(255, 255, 255, 0.52);
box-shadow:
0 5px 14px rgba(76, 91, 142, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
color: #64748b;
}
.table-pagination .btn:hover:not(:disabled) {
border-color: rgba(217, 212, 255, 0.9);
background: rgba(255, 255, 255, 0.86);
box-shadow:
0 7px 18px rgba(76, 91, 142, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 1);
color: #6558ea;
}
.table-pagination .btn:disabled {
border-color: rgba(226, 232, 240, 0.58);
background: rgba(248, 250, 252, 0.42);
box-shadow: none;
color: #cbd5e1;
}
@media (max-width: 640px) {
.table-pagination {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
}
/* ============ 徽章样式 ============ */
@@ -340,8 +587,16 @@ body.modal-open {
/* ============ 下拉菜单 ============ */
.dropdown {
@apply absolute z-50 bg-white rounded-xl border border-gray-200 shadow-lg py-1;
@apply absolute z-50 rounded-xl py-1;
@apply origin-top-right animate-scale-in;
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--glass-highlight);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.dropdown { background: rgba(255, 255, 255, 0.98); }
}
.dropdown-item {
@apply px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer transition-colors;
@@ -410,25 +665,114 @@ body.modal-open {
/* ============ 侧边栏 ============ */
.sidebar {
@apply fixed inset-y-0 left-0 z-40 w-64 bg-white;
@apply border-r border-gray-200 flex flex-col;
@apply transition-transform duration-300;
@apply fixed z-40 w-64;
@apply flex flex-col;
@apply transition-all duration-300;
top: 1.25rem;
bottom: 1.25rem;
left: 1.25rem;
border-radius: 1.75rem;
background:
radial-gradient(circle at 4% 7%, rgba(80, 171, 255, 0.16), transparent 31%),
radial-gradient(circle at 105% 62%, rgba(139, 111, 248, 0.13), transparent 35%),
radial-gradient(circle at 28% 106%, rgba(64, 211, 211, 0.1), transparent 30%),
rgba(250, 252, 255, 0.62);
backdrop-filter: blur(32px) saturate(1.35);
-webkit-backdrop-filter: blur(32px) saturate(1.35);
border: 1px solid rgba(255, 255, 255, 0.76);
box-shadow:
0 20px 60px rgba(71, 91, 140, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.94),
inset -1px 0 0 rgba(115, 139, 199, 0.06);
overflow: hidden;
}
.sidebar-header {
@apply h-16 px-6 flex items-center gap-3 border-b border-gray-100;
@apply h-20 px-5 flex items-center gap-3;
}
.sidebar-nav {
@apply flex-1 overflow-y-auto px-3 py-4;
@apply flex-1 overflow-y-auto px-3 py-2;
}
.sidebar-link {
@apply flex items-center gap-3 rounded-xl px-3 py-2.5;
@apply flex items-center gap-3 px-3 py-2.5;
position: relative;
overflow: hidden;
border-radius: 0.95rem;
border: 1px solid transparent;
@apply text-sm font-medium text-gray-600;
@apply transition-all duration-200;
@apply hover:bg-gray-100 hover:text-gray-900;
@apply hover:bg-white/70 hover:text-gray-900;
}
.sidebar-link-active {
@apply bg-primary-50 text-primary-600;
@apply hover:bg-primary-100;
isolation: isolate;
color: #3f4b63;
background:
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.72), transparent 46%),
radial-gradient(circle at 94% 110%, rgba(143, 124, 255, 0.15), transparent 44%),
radial-gradient(circle at 4% 105%, rgba(80, 202, 255, 0.16), transparent 42%),
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.22));
backdrop-filter: blur(26px) saturate(1.7) brightness(1.06);
-webkit-backdrop-filter: blur(26px) saturate(1.7) brightness(1.06);
border-color: rgba(255, 255, 255, 0.9);
box-shadow:
0 9px 24px rgba(48, 63, 101, 0.13),
0 1px 2px rgba(48, 63, 101, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.94),
inset 1px 0 0 rgba(255, 255, 255, 0.46),
inset -1px 0 0 rgba(255, 255, 255, 0.26),
inset 0 -1px 0 rgba(83, 110, 173, 0.14);
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.78);
}
.sidebar-link-active:hover {
color: #374258;
background:
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.82), transparent 48%),
radial-gradient(circle at 94% 110%, rgba(143, 124, 255, 0.12), transparent 46%),
radial-gradient(circle at 4% 105%, rgba(80, 202, 255, 0.13), transparent 44%),
linear-gradient(135deg, rgba(255, 255, 255, 0.56), rgba(255, 255, 255, 0.25));
box-shadow:
0 10px 26px rgba(48, 63, 101, 0.13),
0 1px 3px rgba(48, 63, 101, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 1),
inset 1px 0 0 rgba(255, 255, 255, 0.52),
inset -1px 0 0 rgba(255, 255, 255, 0.3),
inset 0 -1px 0 rgba(83, 110, 173, 0.11);
}
.sidebar-link-active::before {
position: absolute;
top: -74%;
left: 8%;
width: 74%;
height: 108%;
border-radius: 9999px;
background: radial-gradient(ellipse, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.12) 55%, transparent 72%);
filter: blur(0.5px);
content: '';
pointer-events: none;
}
.sidebar-link-active::after {
position: absolute;
right: 12%;
bottom: 0;
left: 12%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(93, 211, 255, 0.3), rgba(150, 126, 255, 0.26), transparent);
filter: blur(0.15px);
content: '';
pointer-events: none;
}
.sidebar-link > * {
position: relative;
z-index: 1;
}
@media (max-width: 1023px) {
.sidebar-mobile-hidden {
transform: translateX(calc(-100% - 2rem));
}
}
@media (min-width: 1024px) {
.sidebar-mobile-hidden {
transform: translateX(0);
}
}
.sidebar-section {
@apply mb-6;
+48 -25
View File
@@ -20,6 +20,7 @@ const authStore = useTenantAuthStore()
const sidebarCollapsed = ref(false)
const mobileOpen = ref(false)
const userMenuOpen = ref(false)
const globalSearch = ref('')
const announcePanelOpen = ref(false)
const announceDetailItem = ref<any>(null)
const consoleAnnouncements = ref<any[]>([])
@@ -107,6 +108,18 @@ function closeUserMenu() {
userMenuOpen.value = false
}
function submitGlobalSearch() {
const keyword = globalSearch.value.trim().toLowerCase()
if (!keyword) return
const match = navItems.value.find((item) => item.label.toLowerCase().includes(keyword))
if (!match) {
toast.info('未找到匹配的功能入口')
return
}
router.push(match.path)
globalSearch.value = ''
}
function toggleAnnouncePanel() {
announcePanelOpen.value = !announcePanelOpen.value
}
@@ -229,7 +242,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen overflow-x-hidden">
<!-- Maintenance Banner -->
<MaintenanceBanner />
<AnnouncementBanner v-if="consoleAnnouncements.length" :announcements="consoleAnnouncements" />
@@ -241,14 +254,15 @@ onBeforeUnmount(() => {
<aside
class="sidebar"
:class="[
sidebarCollapsed ? 'w-[72px]' : 'w-64',
{ '-translate-x-full lg:translate-x-0': !mobileOpen }
sidebarCollapsed ? 'w-[76px]' : 'w-64',
{ 'sidebar-mobile-hidden': !mobileOpen }
]"
>
<!-- Sidebar Header -->
<div class="sidebar-header">
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow bg-gradient-to-br from-primary-500 to-primary-600">
<span class="text-white font-bold text-sm">{{ tenantInfo.name.charAt(0) || 'T' }}</span>
<div class="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl shadow-glow bg-gradient-to-br from-blue-400 via-primary-500 to-violet-500">
<span class="absolute h-5 w-8 -rotate-12 rounded-[50%] border border-white/60"></span>
<span class="relative text-white font-bold text-sm">{{ tenantInfo.name.charAt(0) || 'T' }}</span>
</div>
<transition name="fade">
<div v-if="!sidebarCollapsed" class="flex min-w-0 flex-col overflow-hidden">
@@ -283,19 +297,16 @@ onBeforeUnmount(() => {
</nav>
<!-- Sidebar Footer -->
<div class="mt-auto border-t border-gray-100 p-3">
<div class="mt-auto p-3">
<button
@click="toggleSidebar"
class="sidebar-link w-full"
:title="sidebarCollapsed ? '展开' : '收起'"
class="flex h-11 w-full items-center rounded-2xl border border-white/80 bg-white/45 text-sm font-medium text-slate-500 shadow-sm transition-all hover:bg-white/75 hover:text-primary-600 hover:shadow-md"
:class="sidebarCollapsed ? 'justify-center px-0' : 'justify-start gap-3 px-3.5'"
:title="sidebarCollapsed ? '展开菜单' : '收起菜单'"
>
<Icon
:name="sidebarCollapsed ? 'chevronDoubleRight' : 'chevronDoubleLeft'"
size="md"
class="h-5 w-5 flex-shrink-0"
/>
<Icon :name="sidebarCollapsed ? 'chevronDoubleRight' : 'chevronDoubleLeft'" size="md" class="flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">收起</span>
<span v-if="!sidebarCollapsed">收起菜单</span>
</transition>
</button>
</div>
@@ -313,31 +324,43 @@ onBeforeUnmount(() => {
<!-- Main Content Area -->
<div
class="relative min-h-screen transition-all duration-300"
:class="[sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64']"
:class="[sidebarCollapsed ? 'lg:ml-[116px]' : 'lg:ml-[296px]']"
>
<!-- Header -->
<header class="glass sticky top-0 z-20 border-b border-gray-200/50">
<div class="flex h-16 items-center justify-between px-4 md:px-6">
<header class="sticky top-0 z-20 px-3 pt-3 md:px-5 md:pt-5">
<div class="glass flex h-16 items-center justify-between rounded-2xl border border-white/70 px-3 shadow-glass-sm md:px-5">
<!-- Left: Mobile Menu + Title -->
<div class="flex items-center gap-4">
<div class="flex min-w-0 items-center gap-3">
<button
@click="toggleMobile"
class="btn-ghost btn-icon lg:hidden"
>
<Icon name="menu" size="md" />
</button>
<div class="hidden lg:block">
<h1 class="text-lg font-semibold text-gray-900">{{ pageTitle }}</h1>
<div class="hidden min-w-0 lg:block">
<h1 class="truncate text-base font-semibold text-slate-800">{{ pageTitle }}</h1>
</div>
</div>
<form class="mx-4 hidden max-w-sm flex-1 md:block" @submit.prevent="submitGlobalSearch">
<div class="relative">
<Icon name="search" size="sm" class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
<input
v-model="globalSearch"
type="search"
placeholder="搜索功能入口..."
class="h-10 w-full rounded-full border border-white/80 bg-white/65 pl-10 pr-4 text-sm text-slate-700 outline-none transition-all placeholder:text-slate-400 focus:border-primary-200 focus:bg-white focus:ring-4 focus:ring-primary-100/70"
/>
</div>
</form>
<!-- Right: Actions -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<!-- Announcements -->
<div class="relative" data-announce-panel>
<button
@click="toggleAnnouncePanel"
class="relative flex h-9 w-9 items-center justify-center rounded-xl text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
class="relative flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/65 text-slate-400 shadow-sm transition-all hover:-translate-y-0.5 hover:bg-white hover:text-primary-600"
title="平台公告"
>
<Icon name="megaphone" size="md" />
@@ -405,7 +428,7 @@ onBeforeUnmount(() => {
<!-- Notifications -->
<router-link
to="/tenant/notifications"
class="relative flex h-9 w-9 items-center justify-center rounded-xl text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
class="relative flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/65 text-slate-400 shadow-sm transition-all hover:-translate-y-0.5 hover:bg-white hover:text-primary-600"
title="通知中心"
>
<Icon name="bell" size="md" />
@@ -421,7 +444,7 @@ onBeforeUnmount(() => {
<router-link
v-if="canViewWallet"
to="/tenant/wallet"
class="flex h-9 items-center gap-1.5 rounded-full bg-primary-50 border border-primary-200/60 px-3 text-primary-600 hover:bg-primary-100 hover:border-primary-300 hover:shadow-sm hover:shadow-primary-500/10 transition-all duration-200"
class="flex h-10 items-center gap-1.5 rounded-full border border-white/80 bg-white/65 px-3 text-primary-600 shadow-sm transition-all hover:-translate-y-0.5 hover:bg-white"
title="钱包"
>
<Icon name="currencyDollar" size="sm" />
@@ -496,7 +519,7 @@ onBeforeUnmount(() => {
</header>
<!-- Page Content -->
<main class="p-4 md:p-6 lg:p-8">
<main class="px-3 pb-5 pt-4 md:px-5 md:pb-6 lg:pt-5">
<router-view />
</main>
</div>
+435 -306
View File
@@ -1,21 +1,25 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import Icon from '@/components/common/Icon.vue'
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import request from '@/utils/request'
// ===== State =====
const loading = ref(false)
const chartsLoading = ref(false)
const memberUsageLoading = ref(false)
const alertsLoading = ref(false)
interface DayStats {
requests: number
input_tokens: number
output_tokens: number
total_cost: number
}
const selectedDays = ref(7)
interface WalletInfo {
balance: number
frozen_balance: number
available: number
warning_threshold: number
}
// ===== Data =====
interface DayStats { requests: number; input_tokens: number; output_tokens: number; total_cost: number }
interface WalletInfo { balance: number; frozen_balance: number; available: number; warning_threshold: number }
interface DashboardData {
today: DayStats | null
month: DayStats | null
@@ -77,6 +81,12 @@ interface BudgetAlert {
}>
}
const authStore = useTenantAuthStore()
const loading = ref(false)
const chartsLoading = ref(false)
const memberUsageLoading = ref(false)
const alertsLoading = ref(false)
const selectedDays = ref(7)
const dashboardData = ref<DashboardData | null>(null)
const trendData = ref<TrendPoint[]>([])
const modelData = ref<ModelItem[]>([])
@@ -84,138 +94,150 @@ const memberUsageData = ref<MemberUsageItem[]>([])
const predictionData = ref<PredictionData | null>(null)
const alertsData = ref<BudgetAlert | null>(null)
// ===== Safe Helpers =====
const safeDay = (d: DayStats | null | undefined): DayStats =>
d || { requests: 0, input_tokens: 0, output_tokens: 0, total_cost: 0 }
const safeWallet = (w: WalletInfo | null | undefined): WalletInfo =>
w || { balance: 0, frozen_balance: 0, available: 0, warning_threshold: 0 }
const safeDay = (day: DayStats | null | undefined): DayStats =>
day || { requests: 0, input_tokens: 0, output_tokens: 0, total_cost: 0 }
function formatNumber(n: any): string {
const v = Number(n) || 0
if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B'
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M'
if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K'
return String(v)
const safeWallet = (wallet: WalletInfo | null | undefined): WalletInfo =>
wallet || { balance: 0, frozen_balance: 0, available: 0, warning_threshold: 0 }
function formatNumber(value: unknown): string {
const number = Number(value) || 0
if (number >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(1)}B`
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return number.toLocaleString('zh-CN')
}
function formatCost(n: any): string {
const v = Number(n) || 0
if (v >= 1) return '$' + v.toFixed(2)
if (v >= 0.01) return '$' + v.toFixed(4)
return '$' + v.toFixed(6)
function formatCost(value: unknown): string {
const number = Number(value) || 0
if (number === 0) return '$0.00'
if (number >= 1) return `$${number.toFixed(2)}`
if (number >= 0.01) return `$${number.toFixed(4)}`
return `$${number.toFixed(6)}`
}
// ===== Core Stats Cards =====
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
if (hour < 12) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
})
const formattedDate = computed(() =>
new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(new Date()),
)
const coreStats = computed(() => {
if (!dashboardData.value) return []
const today = safeDay(dashboardData.value.today)
const month = safeDay(dashboardData.value.month)
const totalMonthTokens = month.input_tokens + month.output_tokens
const monthTokens = month.input_tokens + month.output_tokens
return [
{
label: '今日请求',
value: formatNumber(today.requests),
sub: `输入 ${formatNumber(today.input_tokens)} / 输出 ${formatNumber(today.output_tokens)}`,
sub: `输入 ${formatNumber(today.input_tokens)} · 输出 ${formatNumber(today.output_tokens)}`,
trend: '实时统计',
icon: 'play',
iconClass: 'bg-blue-100 text-blue-600',
color: '#7667f6',
soft: 'rgba(118, 103, 246, 0.14)',
path: 'M0 36 C16 39, 22 28, 38 30 S58 15, 74 22 S96 40, 114 30 S136 9, 154 20 S176 28, 200 12',
},
{
label: '本月请求',
value: formatNumber(month.requests),
sub: `日均 ${formatNumber(month.requests / (new Date().getDate() || 1))}`,
sub: `日均 ${formatNumber(month.requests / Math.max(new Date().getDate(), 1))} 次`,
trend: '本月累计',
icon: 'chart',
iconClass: 'bg-emerald-100 text-emerald-600',
color: '#3b9df8',
soft: 'rgba(59, 157, 248, 0.14)',
path: 'M0 38 C14 40, 24 34, 36 25 S58 20, 72 32 S92 42, 108 28 S128 7, 144 18 S165 34, 200 13',
},
{
label: '本月 Token',
value: formatNumber(totalMonthTokens),
sub: `输入 ${formatNumber(month.input_tokens)} / 输出 ${formatNumber(month.output_tokens)}`,
value: formatNumber(monthTokens),
sub: `输入 ${formatNumber(month.input_tokens)} · 输出 ${formatNumber(month.output_tokens)}`,
trend: '调用消耗',
icon: 'bolt',
iconClass: 'bg-amber-100 text-amber-600',
color: '#22c7b7',
soft: 'rgba(34, 199, 183, 0.14)',
path: 'M0 38 C18 40, 22 30, 38 32 S60 15, 78 22 S96 44, 118 27 S140 5, 156 18 S180 31, 200 14',
},
{
label: '本月消费',
value: formatCost(month.total_cost),
sub: `今日 ${formatCost(today.total_cost)}`,
trend: '费用明细',
icon: 'creditCard',
iconClass: 'bg-rose-100 text-rose-600',
color: '#9a58ee',
soft: 'rgba(154, 88, 238, 0.14)',
path: 'M0 39 C18 41, 24 32, 39 29 S58 15, 75 25 S95 42, 112 31 S133 8, 150 20 S174 30, 200 13',
},
]
})
// ===== Account Stats Cards =====
const accountStats = computed(() => {
if (!dashboardData.value) return []
const d = dashboardData.value
const w = safeWallet(d.wallet)
const pred = predictionData.value
const wallet = safeWallet(dashboardData.value.wallet)
const prediction = predictionData.value
let predictionValue = '安全'
let predictionTone = 'text-emerald-600'
let predictionDescription = prediction?.message || '余额状态稳定'
const balanceCard = {
label: '可用余额',
value: formatCost(w.available),
sub: `冻结 ${formatCost(w.frozen_balance)}`,
icon: 'wallet',
iconClass: 'bg-primary-100 text-primary-600',
if (prediction?.will_exhaust && prediction.days_until_exhaust !== undefined) {
predictionValue = `${prediction.days_until_exhaust} 天`
predictionTone = prediction.days_until_exhaust <= 7 ? 'text-red-500' : 'text-amber-500'
predictionDescription = `预计 ${prediction.exhaust_date || '近期'} 耗尽`
}
const keyCard = {
label: '活跃 Key',
value: String(d.active_keys || 0),
sub: '个 API 密钥',
icon: 'key',
iconClass: 'bg-violet-100 text-violet-600',
}
const memberCard = {
label: '团队成员',
value: String(d.member_count || 0),
sub: '位活跃成员',
icon: 'users',
iconClass: 'bg-cyan-100 text-cyan-600',
}
let predValue = '—'
let predSub = '计算中...'
let predClass = 'bg-gray-100 text-gray-600'
if (pred) {
if (!pred.will_exhaust) {
predValue = '安全'
predSub = pred.message || '近期无消耗'
predClass = 'bg-emerald-100 text-emerald-600'
} else if (pred.days_until_exhaust !== undefined) {
predValue = `${pred.days_until_exhaust} 天`
predSub = `预计 ${pred.exhaust_date || '—'} 耗尽`
predClass = pred.days_until_exhaust <= 7 ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-600'
}
}
const predCard = {
label: '余额预测',
value: predValue,
sub: predSub,
icon: 'trendingUp',
iconClass: predClass,
}
return [balanceCard, keyCard, memberCard, predCard]
return [
{ label: '可用余额', value: formatCost(wallet.available), description: `冻结 ${formatCost(wallet.frozen_balance)}`, icon: 'wallet', tone: 'text-violet-600', background: 'bg-violet-50' },
{ label: '活跃密钥', value: String(dashboardData.value.active_keys || 0), description: '个 API Key', icon: 'key', tone: 'text-blue-600', background: 'bg-blue-50' },
{ label: '团队成员', value: String(dashboardData.value.member_count || 0), description: '位活跃成员', icon: 'users', tone: 'text-cyan-600', background: 'bg-cyan-50' },
{ label: '余额预测', value: predictionValue, description: predictionDescription, icon: 'trendingUp', tone: predictionTone, background: 'bg-emerald-50' },
]
})
// ===== Budget Alerts =====
const hasAlerts = computed(() => {
if (!alertsData.value) return false
return (alertsData.value.members?.length || 0) > 0 || (alertsData.value.projects?.length || 0) > 0
const quickActions = [
{ label: 'API 密钥', description: '创建和管理密钥', icon: 'key', path: '/tenant/api-keys', tone: 'quick-violet' },
{ label: '在线体验', description: '调试模型能力', icon: 'terminal', path: '/tenant/playground', tone: 'quick-blue' },
{ label: '成员管理', description: '管理团队成员', icon: 'users', path: '/tenant/members', tone: 'quick-cyan' },
{ label: '钱包充值', description: '查看余额与账单', icon: 'wallet', path: '/tenant/wallet', tone: 'quick-amber' },
{ label: '可用模型', description: '浏览模型列表', icon: 'cube', path: '/tenant/models', tone: 'quick-pink' },
{ label: '用量日志', description: '追踪调用明细', icon: 'chart', path: '/tenant/usage-logs', tone: 'quick-green' },
]
const alertItems = computed(() => {
const members = (alertsData.value?.members || []).map((item) => ({
id: `member-${item.id}`,
name: item.display_name || item.username,
percent: item.usage_percent,
type: '成员额度',
}))
const projects = (alertsData.value?.projects || []).map((item) => ({
id: `project-${item.id}`,
name: item.name,
percent: item.usage_percent,
type: '项目预算',
}))
return [...members, ...projects].slice(0, 3)
})
// ===== Data Loading =====
function ensureArray<T>(val: any): T[] {
if (Array.isArray(val)) return val
return []
function ensureArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []
}
async function fetchDashboard() {
loading.value = true
try {
const res: any = await request.get('/tenant/dashboard')
dashboardData.value = res.data?.data || null
const response: any = await request.get('/tenant/dashboard')
dashboardData.value = response.data?.data || null
} catch {
dashboardData.value = null
} finally {
@@ -226,12 +248,12 @@ async function fetchDashboard() {
async function fetchCharts() {
chartsLoading.value = true
try {
const [trendRes, modelRes]: any[] = await Promise.all([
const [trendResponse, modelResponse]: any[] = await Promise.all([
request.get('/tenant/dashboard/token-trends', { params: { days: selectedDays.value } }),
request.get('/tenant/dashboard/model-distribution', { params: { days: selectedDays.value } }),
])
trendData.value = ensureArray(trendRes.data?.data?.list)
modelData.value = ensureArray(modelRes.data?.data?.list)
trendData.value = ensureArray(trendResponse.data?.data?.list)
modelData.value = ensureArray(modelResponse.data?.data?.list)
} catch {
trendData.value = []
modelData.value = []
@@ -242,8 +264,8 @@ async function fetchCharts() {
async function fetchPrediction() {
try {
const res: any = await request.get('/tenant/dashboard/balance-prediction')
predictionData.value = res.data?.data || null
const response: any = await request.get('/tenant/dashboard/balance-prediction')
predictionData.value = response.data?.data || null
} catch {
predictionData.value = null
}
@@ -252,8 +274,8 @@ async function fetchPrediction() {
async function fetchAlerts() {
alertsLoading.value = true
try {
const res: any = await request.get('/tenant/dashboard/budget-alerts')
alertsData.value = res.data?.data || null
const response: any = await request.get('/tenant/dashboard/budget-alerts')
alertsData.value = response.data?.data || null
} catch {
alertsData.value = null
} finally {
@@ -264,10 +286,10 @@ async function fetchAlerts() {
async function fetchMemberUsage() {
memberUsageLoading.value = true
try {
const res: any = await request.get('/tenant/dashboard/member-usage-ranking', {
params: { days: selectedDays.value, limit: 10 }
const response: any = await request.get('/tenant/dashboard/member-usage-ranking', {
params: { days: selectedDays.value, limit: 8 },
})
memberUsageData.value = ensureArray(res.data?.data?.list)
memberUsageData.value = ensureArray(response.data?.data?.list)
} catch {
memberUsageData.value = []
} finally {
@@ -276,245 +298,352 @@ async function fetchMemberUsage() {
}
function refreshAll() {
fetchDashboard()
fetchCharts()
fetchPrediction()
fetchAlerts()
fetchMemberUsage()
void fetchDashboard()
void fetchCharts()
void fetchPrediction()
void fetchAlerts()
void fetchMemberUsage()
}
watch(selectedDays, () => {
fetchCharts()
fetchMemberUsage()
void fetchCharts()
void fetchMemberUsage()
})
onMounted(() => {
refreshAll()
})
onMounted(refreshAll)
</script>
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="page-header">
<div class="flex items-center justify-between">
<div>
<h1 class="page-title">控制台</h1>
<p class="page-description">API 使用量和消费概览</p>
</div>
<button class="btn btn-secondary btn-sm" @click="refreshAll">
<Icon name="refresh" size="sm" />
刷新
</button>
<div class="dashboard-shell">
<section class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="mb-1 text-sm font-medium text-primary-500">{{ authStore.tenant?.name || '租户控制台' }}</p>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 md:text-[28px]">
{{ greeting }},{{ authStore.user?.username || 'Admin' }} <span class="inline-block origin-bottom-right animate-wave">👋</span>
</h1>
<p class="mt-1 text-sm text-slate-400">今天是 {{ formattedDate }},查看团队 API 的最新运行状态。</p>
</div>
</div>
<button class="refresh-button" :disabled="loading || chartsLoading" @click="refreshAll">
<Icon name="refresh" size="sm" :class="{ 'animate-spin': loading || chartsLoading }" />
刷新数据
</button>
</section>
<!-- ===== Row 1: Core Stats ===== -->
<div v-if="loading" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="stat-card">
<div class="skeleton h-12 w-12 rounded-xl"></div>
<div class="flex-1">
<div class="skeleton h-4 w-16 mb-2"></div>
<div class="skeleton h-7 w-24"></div>
</div>
<section v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div v-for="index in 4" :key="index" class="stat-card h-[190px]">
<div class="skeleton h-4 w-20"></div>
<div class="skeleton mt-5 h-9 w-32"></div>
<div class="skeleton mt-4 h-4 w-44"></div>
</div>
</div>
<div v-else-if="dashboardData" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="stat in coreStats" :key="stat.label" class="stat-card">
<div class="stat-icon" :class="stat.iconClass">
<Icon :name="stat.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<p class="stat-label">{{ stat.label }}</p>
<p class="stat-value">{{ stat.value }}</p>
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ stat.sub }}</p>
</div>
</div>
</div>
</section>
<!-- ===== Row 2: Account Stats ===== -->
<div v-if="loading" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="'acc'+i" class="stat-card">
<div class="skeleton h-12 w-12 rounded-xl"></div>
<div class="flex-1">
<div class="skeleton h-4 w-16 mb-2"></div>
<div class="skeleton h-7 w-24"></div>
<section v-else-if="dashboardData" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<article
v-for="stat in coreStats"
:key="stat.label"
class="stat-card metric-card group"
:style="{ '--metric-color': stat.color, '--metric-soft': stat.soft }"
>
<div class="relative z-10 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-slate-500">{{ stat.label }}</p>
<p class="mt-3 truncate text-[28px] font-bold tracking-tight text-slate-900">{{ stat.value }}</p>
<p class="mt-3 flex items-center gap-1.5 text-xs font-medium" :style="{ color: stat.color }">
<Icon name="trendingUp" size="xs" />
{{ stat.trend }}
</p>
</div>
<div class="metric-icon">
<Icon :name="stat.icon" size="lg" />
</div>
</div>
</div>
</div>
<div v-else-if="dashboardData" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="stat in accountStats" :key="stat.label" class="stat-card">
<div class="stat-icon" :class="stat.iconClass">
<Icon :name="stat.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<p class="stat-label">{{ stat.label }}</p>
<p class="stat-value">{{ stat.value }}</p>
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ stat.sub }}</p>
</div>
</div>
</div>
<p class="relative z-10 mt-2 truncate text-xs text-slate-400">{{ stat.sub }}</p>
<svg class="metric-wave" viewBox="0 0 200 50" preserveAspectRatio="none" aria-hidden="true">
<path :d="`${stat.path} L200 50 L0 50 Z`" :fill="stat.soft" />
<path :d="stat.path" fill="none" :stroke="stat.color" stroke-width="2" stroke-linecap="round" />
</svg>
</article>
</section>
<!-- ===== Row 3: Charts ===== -->
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-gray-900">数据趋势</h2>
<div class="flex items-center gap-1 p-1 rounded-xl bg-gray-100">
<button
v-for="d in [7, 30, 90]"
:key="d"
class="tab"
:class="{ 'tab-active': selectedDays === d }"
@click="selectedDays = d"
>
{{ d }} 天
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TokenTrendChart :data="trendData" :loading="chartsLoading" />
<section class="grid grid-cols-1 gap-5 2xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,.9fr)]">
<TokenTrendChart :data="trendData" :loading="chartsLoading" :days="selectedDays" @change-days="selectedDays = $event" />
<ModelDistChart :data="modelData" :loading="chartsLoading" />
</div>
</section>
<!-- ===== Row 4: Member Usage + Budget Alerts ===== -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Member Usage Ranking -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">成员用量排名</h3>
<span class="text-xs text-gray-400">近 {{ selectedDays }} 天</span>
</div>
<div v-if="memberUsageLoading" class="space-y-3">
<div v-for="i in 3" :key="i" class="flex items-center gap-3">
<div class="skeleton h-8 w-8 rounded-full"></div>
<div class="flex-1">
<div class="skeleton h-4 w-24 mb-1"></div>
<div class="skeleton h-3 w-32"></div>
</div>
<div class="skeleton h-4 w-16"></div>
<section class="grid grid-cols-1 gap-5 2xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,.9fr)]">
<div class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100/80 px-5 py-4 sm:px-6">
<div>
<h2 class="text-base font-semibold text-slate-900">成员用量排行</h2>
<p class="mt-0.5 text-xs text-slate-400">近 {{ selectedDays }} 天团队调用费用</p>
</div>
<router-link to="/tenant/members" class="text-xs font-medium text-primary-600 transition-colors hover:text-primary-700">查看全部 →</router-link>
</div>
<div v-else-if="memberUsageData.length === 0" class="py-8 text-center text-gray-400 text-sm">
暂无成员用量数据
<div v-if="memberUsageLoading" class="space-y-3 p-5 sm:p-6">
<div v-for="index in 4" :key="index" class="skeleton h-12 rounded-xl"></div>
</div>
<div v-else class="space-y-1">
<div
v-for="(member, idx) in memberUsageData"
:key="member.user_id"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 transition-colors"
>
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
:class="idx < 3 ? 'bg-primary-100 text-primary-600' : 'bg-gray-100 text-gray-500'"
>
{{ idx + 1 }}
</span>
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-xs font-medium flex-shrink-0">
{{ (member.display_name || member.username || '?').charAt(0).toUpperCase() }}
<div v-else-if="memberUsageData.length === 0" class="flex min-h-64 flex-col items-center justify-center px-6 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 text-primary-400">
<Icon name="users" size="lg" />
</div>
<p class="mt-3 text-sm font-medium text-slate-600">暂无成员用量</p>
<p class="mt-1 text-xs text-slate-400">成员产生调用后将在这里展示</p>
</div>
<div v-else class="divide-y divide-slate-100/80 px-5 sm:px-6">
<div v-for="(member, index) in memberUsageData" :key="member.user_id" class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-3.5">
<span class="flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold" :class="index < 3 ? 'bg-primary-50 text-primary-600' : 'bg-slate-50 text-slate-400'">{{ index + 1 }}</span>
<div class="flex min-w-0 items-center gap-3">
<div class="member-avatar" :style="{ '--avatar-hue': `${(member.user_id * 43) % 360}` }">{{ (member.display_name || member.username).charAt(0).toUpperCase() }}</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-slate-700">{{ member.display_name || member.username }}</p>
<p class="truncate text-xs text-slate-400">{{ formatNumber(member.requests) }} 次请求 · {{ formatNumber(member.input_tokens + member.output_tokens) }} Token</p>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ member.display_name || member.username }}</p>
<p class="text-xs text-gray-400 truncate">{{ formatNumber(member.requests) }} 次请求 / {{ formatNumber((member.input_tokens || 0) + (member.output_tokens || 0)) }} Token</p>
</div>
<span class="text-sm font-semibold text-emerald-600 tabular-nums flex-shrink-0">
{{ formatCost(member.total_cost) }}
</span>
<span class="text-sm font-semibold tabular-nums text-slate-700">{{ formatCost(member.total_cost) }}</span>
</div>
</div>
</div>
<!-- Budget Alerts -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">预算告警</h3>
<Icon v-if="hasAlerts" name="exclamationTriangle" size="md" class="text-amber-500" />
<Icon v-else name="checkCircle" size="md" class="text-emerald-500" />
</div>
<div v-if="alertsLoading" class="space-y-3">
<div v-for="i in 2" :key="i" class="skeleton h-16 rounded-xl"></div>
</div>
<div v-else-if="!hasAlerts" class="py-8 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-emerald-100 mb-3">
<Icon name="checkCircle" size="lg" class="text-emerald-500" />
<div class="space-y-5">
<div class="card p-5 sm:p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">快捷操作</h2>
<p class="mt-0.5 text-xs text-slate-400">常用功能快速直达</p>
</div>
<Icon name="bolt" size="md" class="text-primary-400" />
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-2">
<router-link v-for="action in quickActions" :key="action.path" :to="action.path" class="quick-action group">
<div class="quick-action-icon" :class="action.tone">
<Icon :name="action.icon" size="md" />
</div>
<p class="mt-2 text-sm font-semibold text-slate-700 group-hover:text-primary-600">{{ action.label }}</p>
<p class="mt-0.5 line-clamp-1 text-[11px] text-slate-400">{{ action.description }}</p>
</router-link>
</div>
<p class="text-sm text-gray-500">所有预算使用正常</p>
<p class="text-xs text-gray-400 mt-1">没有超过 80% 的预算项</p>
</div>
<div v-else class="space-y-3">
<template v-if="alertsData?.members?.length">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">成员额度</p>
<div
v-for="m in alertsData.members"
:key="'m-'+m.id"
class="flex items-center gap-3 p-3 rounded-xl bg-amber-50 border border-amber-100"
>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900 truncate">{{ m.display_name || m.username }}</span>
<span class="text-xs font-semibold" :class="m.usage_percent >= 100 ? 'text-red-600' : 'text-amber-600'">
{{ m.usage_percent }}%
</span>
</div>
<div class="progress">
<div
class="progress-bar"
:style="{ width: Math.min(m.usage_percent, 100) + '%' }"
:class="m.usage_percent >= 100 ? '!bg-gradient-to-r !from-red-500 !to-red-400' : '!bg-gradient-to-r !from-amber-500 !to-amber-400'"
></div>
</div>
<div class="flex justify-between mt-1">
<span class="text-xs text-gray-500">已用 {{ formatCost(m.used_cost) }}</span>
<span class="text-xs text-gray-500">额度 {{ formatCost(m.quota_limit) }}</span>
</div>
</div>
<div class="card p-5 sm:p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">账户概览</h2>
<p class="mt-0.5 text-xs text-slate-400">余额、成员与密钥状态</p>
</div>
</template>
<template v-if="alertsData?.projects?.length">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider mt-4">项目预算</p>
<div
v-for="p in alertsData.projects"
:key="'p-'+p.id"
class="flex items-center gap-3 p-3 rounded-xl bg-amber-50 border border-amber-100"
>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900 truncate">{{ p.name }}</span>
<span class="text-xs font-semibold" :class="p.usage_percent >= 100 ? 'text-red-600' : 'text-amber-600'">
{{ p.usage_percent }}%
</span>
</div>
<div class="progress">
<div
class="progress-bar"
:style="{ width: Math.min(p.usage_percent, 100) + '%' }"
:class="p.usage_percent >= 100 ? '!bg-gradient-to-r !from-red-500 !to-red-400' : '!bg-gradient-to-r !from-amber-500 !to-amber-400'"
></div>
</div>
<div class="flex justify-between mt-1">
<span class="text-xs text-gray-500">已用 {{ formatCost(p.used_cost) }}</span>
<span class="text-xs text-gray-500">预算 {{ formatCost(p.budget_limit) }}</span>
</div>
<span v-if="!alertsLoading" class="status-dot" :class="alertItems.length ? 'status-warning' : 'status-ok'">{{ alertItems.length ? '需关注' : '运行正常' }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div v-for="item in accountStats" :key="item.label" class="rounded-2xl border border-white/80 bg-white/55 p-3.5">
<div class="mb-3 flex h-8 w-8 items-center justify-center rounded-xl" :class="[item.background, item.tone]">
<Icon :name="item.icon" size="sm" />
</div>
<p class="text-xs text-slate-400">{{ item.label }}</p>
<p class="mt-1 truncate text-base font-bold" :class="item.tone">{{ item.value }}</p>
<p class="mt-1 truncate text-[11px] text-slate-400">{{ item.description }}</p>
</div>
</template>
</div>
<div v-if="alertItems.length" class="mt-4 space-y-2 border-t border-slate-100 pt-4">
<div v-for="alert in alertItems" :key="alert.id" class="flex items-center gap-3 rounded-xl bg-amber-50/80 px-3 py-2.5">
<Icon name="exclamationTriangle" size="sm" class="flex-shrink-0 text-amber-500" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-slate-700">{{ alert.name }}</p>
<p class="text-[10px] text-slate-400">{{ alert.type }}</p>
</div>
<span class="text-xs font-bold text-amber-600">{{ alert.percent }}%</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ===== Empty State ===== -->
<div v-if="!loading && !dashboardData" class="card">
<div class="empty-state">
<Icon name="chart" size="xl" class="empty-state-icon" />
<p class="empty-state-title">暂无数据</p>
<p class="empty-state-description">API 使用数据将在您首次调用后展示</p>
<p class="empty-state-description">API 使用数据将在首次调用后展示</p>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.refresh-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 1rem;
border: 1px solid rgba(255, 255, 255, 0.88);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.68);
box-shadow: 0 8px 24px rgba(87, 102, 151, 0.08);
color: #64748b;
font-size: 0.8125rem;
font-weight: 600;
transition: all 180ms ease;
}
.refresh-button:hover:not(:disabled) {
transform: translateY(-1px);
background: white;
color: #6558ea;
}
.refresh-button:disabled {
cursor: wait;
opacity: 0.65;
}
.metric-card {
min-height: 190px;
transition: transform 220ms ease, box-shadow 220ms ease;
}
.metric-card:hover {
transform: translateY(-3px);
box-shadow: 0 20px 45px rgba(81, 94, 143, 0.14);
}
.metric-card::before {
position: absolute;
top: -35%;
right: -18%;
width: 9rem;
height: 9rem;
border-radius: 9999px;
background: var(--metric-soft);
filter: blur(6px);
content: '';
}
.metric-icon {
display: flex;
height: 3.5rem;
width: 3.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 1.125rem;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.9), var(--metric-soft));
box-shadow: 0 12px 26px var(--metric-soft), inset 0 1px 0 rgba(255, 255, 255, 0.9);
color: var(--metric-color);
}
.metric-wave {
position: absolute;
right: 1.25rem;
bottom: 0.75rem;
left: 1.25rem;
height: 3rem;
opacity: 0.9;
}
.member-avatar {
display: flex;
height: 2.25rem;
width: 2.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: linear-gradient(145deg, hsl(var(--avatar-hue) 75% 72%), hsl(var(--avatar-hue) 68% 58%));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
color: white;
font-size: 0.75rem;
font-weight: 700;
}
.quick-action {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.88);
border-radius: 1.125rem;
background: rgba(255, 255, 255, 0.55);
padding: 0.875rem;
text-align: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition: all 200ms ease;
}
.quick-action:hover {
transform: translateY(-2px);
border-color: rgba(187, 178, 255, 0.65);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 12px 26px rgba(82, 96, 145, 0.1);
}
.quick-action-icon {
display: flex;
height: 2.75rem;
width: 2.75rem;
margin: 0 auto;
align-items: center;
justify-content: center;
border-radius: 0.9rem;
color: white;
box-shadow: 0 10px 22px rgba(83, 93, 149, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.quick-violet { background: linear-gradient(145deg, #8e7dff, #6d5ce7); }
.quick-blue { background: linear-gradient(145deg, #5db9ff, #3889eb); }
.quick-cyan { background: linear-gradient(145deg, #48d5d5, #22b3bd); }
.quick-amber { background: linear-gradient(145deg, #ffc861, #f0a335); }
.quick-pink { background: linear-gradient(145deg, #ff8eb5, #eb6394); }
.quick-green { background: linear-gradient(145deg, #64dcb0, #2cb887); }
.status-dot {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 9999px;
padding: 0.3rem 0.55rem;
font-size: 0.6875rem;
font-weight: 700;
}
.status-dot::before {
height: 0.4rem;
width: 0.4rem;
border-radius: 9999px;
background: currentColor;
content: '';
}
.status-ok {
background: #ecfdf5;
color: #10b981;
}
.status-warning {
background: #fffbeb;
color: #f59e0b;
}
@keyframes wave {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(14deg); }
75% { transform: rotate(-8deg); }
}
.animate-wave {
animation: wave 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.metric-card,
.quick-action,
.refresh-button,
.animate-wave {
animation: none;
transition: none;
}
}
</style>
@@ -75,28 +75,28 @@ const coreStats = computed(() => {
value: formatNumber(today.requests),
sub: `输入 ${formatNumber(today.input_tokens)} / 输出 ${formatNumber(today.output_tokens)}`,
icon: 'play',
iconClass: 'bg-blue-100 text-blue-600',
iconClass: 'icon-tile-blue',
},
{
label: '本月请求',
value: formatNumber(month.requests),
sub: `日均 ${formatNumber(month.requests / (new Date().getDate() || 1))}`,
icon: 'chart',
iconClass: 'bg-emerald-100 text-emerald-600',
iconClass: 'icon-tile-green',
},
{
label: '本月 Token',
value: formatNumber(totalMonthTokens),
sub: `输入 ${formatNumber(month.input_tokens)} / 输出 ${formatNumber(month.output_tokens)}`,
icon: 'bolt',
iconClass: 'bg-amber-100 text-amber-600',
iconClass: 'icon-tile-amber',
},
{
label: '本月消费',
value: formatCost(month.total_cost),
sub: `今日 ${formatCost(today.total_cost)}`,
icon: 'creditCard',
iconClass: 'bg-rose-100 text-rose-600',
iconClass: 'icon-tile-pink',
},
]
})
@@ -165,16 +165,16 @@ onMounted(() => {
</div>
<!-- ===== Row 1: Core Stats ===== -->
<div v-if="loading" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-if="loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="i in 4" :key="i" class="stat-card">
<div class="skeleton h-12 w-12 rounded-xl"></div>
<div class="skeleton h-14 w-14 rounded-2xl"></div>
<div class="flex-1">
<div class="skeleton h-4 w-16 mb-2"></div>
<div class="skeleton h-7 w-24"></div>
<div class="skeleton h-8 w-24"></div>
</div>
</div>
</div>
<div v-else-if="overviewData" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-else-if="overviewData" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="stat in coreStats" :key="stat.label" class="stat-card">
<div class="stat-icon" :class="stat.iconClass">
<Icon :name="stat.icon" size="lg" />
@@ -182,13 +182,13 @@ onMounted(() => {
<div class="min-w-0 flex-1">
<p class="stat-label">{{ stat.label }}</p>
<p class="stat-value">{{ stat.value }}</p>
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ stat.sub }}</p>
<p class="text-xs text-gray-400 mt-1 truncate">{{ stat.sub }}</p>
</div>
</div>
</div>
<!-- ===== Row 2: Quota Status ===== -->
<div v-if="overviewData?.quota" class="card p-5">
<div v-if="overviewData?.quota" class="card p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Icon name="shield" size="md" class="text-primary-500" />
+1 -1
View File
@@ -210,7 +210,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-6 py-3 border-t border-gray-100">
<div v-if="totalPages > 1" class="table-pagination">
<span class="text-xs text-gray-500">共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button
@@ -203,7 +203,7 @@ onMounted(fetchTransactions)
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<div v-else-if="transactions.length > 0" class="table-container border-0 rounded-none">
<div v-else-if="transactions.length > 0" class="table-container table-container-flush">
<table class="table">
<thead>
<tr>
@@ -245,7 +245,7 @@ onMounted(fetchTransactions)
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
<div v-if="total > pageSize" class="table-pagination">
<p class="text-sm text-gray-500">
第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页
</p>
+12 -12
View File
@@ -759,7 +759,7 @@ onBeforeUnmount(() => {
.balance-hero {
position: relative; overflow: hidden;
border-radius: 1rem;
background: linear-gradient(135deg, #0f766e 0%, #115e59 40%, #042f2e 100%);
background: linear-gradient(135deg, #5b6ce0 0%, #4b58c0 40%, #262c5e 100%);
}
.balance-hero-bg {
position: absolute; inset: 0;
@@ -768,11 +768,11 @@ onBeforeUnmount(() => {
.balance-hero-orb { position: absolute; border-radius: 9999px; }
.balance-hero-orb-1 {
top: -5rem; right: -4rem; height: 18rem; width: 18rem;
background: radial-gradient(circle, rgba(45, 212, 191, 0.15) 0%, transparent 70%);
background: radial-gradient(circle, rgba(151, 165, 248, 0.22) 0%, transparent 70%);
}
.balance-hero-orb-2 {
bottom: -3rem; left: -3rem; height: 14rem; width: 14rem;
background: radial-gradient(circle, rgba(94, 234, 212, 0.1) 0%, transparent 70%);
background: radial-gradient(circle, rgba(178, 189, 250, 0.16) 0%, transparent 70%);
}
.balance-hero-grid {
position: absolute; inset: 0;
@@ -849,8 +849,8 @@ onBeforeUnmount(() => {
}
.amount-pill:hover { border-color: #d1d5db; color: #111827; }
.amount-pill-active {
border-color: #14b8a6; background-color: #f0fdfa; color: #0f766e;
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.1), 0 0 16px rgba(20, 184, 166, 0.12);
border-color: #7b8ff5; background-color: #f2f4ff; color: #4b58c0;
box-shadow: 0 0 0 1px rgba(123, 143, 245, 0.1), 0 0 16px rgba(123, 143, 245, 0.12);
}
.amount-pill-symbol {
font-size: 0.75rem; font-weight: 500;
@@ -867,9 +867,9 @@ onBeforeUnmount(() => {
}
.pay-method-card:hover { border-color: #d1d5db; }
.pay-method-card-active {
border-color: #14b8a6;
background-color: rgba(240, 253, 250, 0.5);
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.1), 0 0 16px rgba(20, 184, 166, 0.08);
border-color: #7b8ff5;
background-color: rgba(242, 244, 255, 0.5);
box-shadow: 0 0 0 1px rgba(123, 143, 245, 0.1), 0 0 16px rgba(123, 143, 245, 0.08);
}
.pay-method-icon {
height: 2.25rem; width: 2.25rem; border-radius: 0.5rem;
@@ -887,15 +887,15 @@ onBeforeUnmount(() => {
display: flex; align-items: flex-start; gap: 0.5rem;
padding: 0.75rem 0.875rem;
border-radius: 0.75rem;
background-color: #f0fdfa;
border: 1px solid #ccfbf1;
background-color: #f2f4ff;
border: 1px solid #e3e7ff;
}
.recharge-note-icon {
flex-shrink: 0; margin-top: 0.0625rem;
color: #14b8a6;
color: #7b8ff5;
}
.recharge-note p {
font-size: 0.75rem; line-height: 1.5; color: #0f766e;
font-size: 0.75rem; line-height: 1.5; color: #4b58c0;
}
/* ==========================================
+1 -1
View File
@@ -256,7 +256,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="card-footer flex justify-end">
<div v-if="total > pageSize" class="table-pagination card-footer flex justify-end">
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" :disabled="page <= 1" @click="page--; fetchKeys()">上一页</button>
<span class="text-sm text-gray-500">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
@@ -119,7 +119,7 @@ const maxTrendCost = computed(() => {
return max || 1
})
const modelColors = ['#14b8a6', '#f59e0b', '#8b5cf6', '#ef4444']
const modelColors = ['#7b8ff5', '#f7b955', '#5b9df9', '#f6a5c0']
function modelColor(idx: number): string {
return modelColors[idx % modelColors.length]
}
+1 -1
View File
@@ -114,7 +114,7 @@ const categoryLabel: Record<string, string> = {
}
const categoryBadgeStyle: Record<string, string> = {
chat: 'background:rgba(20,184,166,0.1);color:#0d9488',
chat: 'background:rgba(123,143,245,0.1);color:#6d80f0',
embedding: 'background:rgba(139,92,246,0.1);color:#7c3aed',
image: 'background:rgba(245,158,11,0.1);color:#d97706',
audio: 'background:rgba(16,185,129,0.1);color:#059669',
@@ -239,10 +239,6 @@ onMounted(() => {
</div>
<!-- Table -->
<div class="card p-0 overflow-hidden">
<div class="card-header">
<h2 class="text-lg font-semibold text-gray-900">请求记录</h2>
</div>
<!-- Loading -->
<div v-if="logsLoading" class="p-8 text-center">
<div class="spinner mx-auto mb-3"></div>
@@ -258,7 +254,7 @@ onMounted(() => {
<!-- Table -->
<div v-else>
<div class="table-container">
<div class="table-container table-container-flush request-audit-table">
<table class="table">
<thead>
<tr>
@@ -273,7 +269,7 @@ onMounted(() => {
<th class="min-w-30">审计级别</th>
<th class="min-w-25">任务</th>
<th class="min-w-30">时间</th>
<th class="min-w-20"></th>
<th class="audit-action-column">操作</th>
</tr>
</thead>
<tbody>
@@ -314,7 +310,7 @@ onMounted(() => {
<td>
<span class="text-xs text-gray-500">{{ log.created_at ? new Date(log.created_at).toLocaleString() : '-' }}</span>
</td>
<td>
<td class="audit-action-column">
<button class="btn btn-ghost btn-sm text-primary-600" @click="fetchDetail(log.id)">详情</button>
</td>
</tr>
@@ -323,7 +319,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="logTotalPages > 1" class="flex items-center justify-between px-6 py-3 border-t border-gray-100">
<div v-if="logTotalPages > 1" class="table-pagination">
<span class="text-xs text-gray-500">共 {{ logTotal }} 条记录</span>
<div class="flex items-center gap-2">
<button
@@ -460,3 +456,45 @@ onMounted(() => {
</Teleport>
</div>
</template>
<style scoped>
.request-audit-table {
position: relative;
}
.request-audit-table .audit-action-column {
position: sticky;
right: 0;
z-index: 3;
width: 6rem;
min-width: 6rem;
text-align: right;
box-shadow: -14px 0 24px -20px rgba(55, 65, 105, 0.42);
}
.request-audit-table .table thead .audit-action-column {
z-index: 5;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(239, 244, 255, 0.94)),
linear-gradient(100deg, rgba(63, 163, 247, 0.08), rgba(117, 104, 248, 0.1));
backdrop-filter: blur(24px) saturate(1.35);
-webkit-backdrop-filter: blur(24px) saturate(1.35);
}
.request-audit-table .table tbody tr .audit-action-column {
background: rgba(251, 252, 255, 0.94);
backdrop-filter: blur(22px) saturate(1.25);
-webkit-backdrop-filter: blur(22px) saturate(1.25);
}
.request-audit-table .table tbody tr:nth-child(even) .audit-action-column {
background: rgba(246, 248, 255, 0.95);
}
.request-audit-table .table tbody tr:hover .audit-action-column,
.request-audit-table .table tbody tr:focus-within .audit-action-column {
background:
linear-gradient(90deg, rgba(235, 247, 255, 0.95), rgba(244, 240, 255, 0.96)),
rgba(255, 255, 255, 0.96);
}
</style>
@@ -272,7 +272,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-6 py-3 border-t border-gray-100">
<div v-if="totalPages > 1" class="table-pagination">
<span class="text-xs text-gray-500">共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" :disabled="page <= 1" @click="prevPage">上一页</button>
+46 -4
View File
@@ -294,7 +294,7 @@ onMounted(() => {
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<div v-else-if="logs.length > 0" class="overflow-auto">
<div v-else-if="logs.length > 0" class="table-container table-container-flush usage-log-table">
<table class="table">
<thead>
<tr>
@@ -308,7 +308,7 @@ onMounted(() => {
<th class="min-w-30">用时</th>
<th class="min-w-25">状态</th>
<th class="min-w-35">时间</th>
<th class="w-16"></th>
<th class="usage-action-column">操作</th>
</tr>
</thead>
<tbody>
@@ -427,7 +427,7 @@ onMounted(() => {
</td>
<!-- 详情按钮 -->
<td>
<td class="usage-action-column">
<button
class="btn btn-ghost btn-sm p-1.5"
title="查看详情"
@@ -448,7 +448,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
<div v-if="total > pageSize" class="table-pagination">
<p class="text-sm text-gray-500">
第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页,共 {{ total }} 条
</p>
@@ -997,3 +997,45 @@ onMounted(() => {
</BaseModal>
</div>
</template>
<style scoped>
.usage-log-table {
position: relative;
}
.usage-log-table .usage-action-column {
position: sticky;
right: 0;
z-index: 3;
width: 5.5rem;
min-width: 5.5rem;
text-align: right;
box-shadow: -14px 0 24px -20px rgba(55, 65, 105, 0.42);
}
.usage-log-table .table thead .usage-action-column {
z-index: 5;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(239, 244, 255, 0.94)),
linear-gradient(100deg, rgba(63, 163, 247, 0.08), rgba(117, 104, 248, 0.1));
backdrop-filter: blur(24px) saturate(1.35);
-webkit-backdrop-filter: blur(24px) saturate(1.35);
}
.usage-log-table .table tbody tr .usage-action-column {
background: rgba(251, 252, 255, 0.94);
backdrop-filter: blur(22px) saturate(1.25);
-webkit-backdrop-filter: blur(22px) saturate(1.25);
}
.usage-log-table .table tbody tr:nth-child(even) .usage-action-column {
background: rgba(246, 248, 255, 0.95);
}
.usage-log-table .table tbody tr:hover .usage-action-column,
.usage-log-table .table tbody tr:focus-within .usage-action-column {
background:
linear-gradient(90deg, rgba(235, 247, 255, 0.95), rgba(244, 240, 255, 0.96)),
rgba(255, 255, 255, 0.96);
}
</style>
@@ -180,7 +180,7 @@ const methodLabel: Record<string, string> = {
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="card-footer flex justify-between items-center">
<div v-if="total > pageSize" class="table-pagination card-footer">
<span class="text-sm text-gray-500">共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button
+1 -1
View File
@@ -312,7 +312,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-6 py-3 border-t border-gray-100">
<div v-if="totalPages > 1" class="table-pagination">
<span class="text-xs text-gray-500">共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button
@@ -184,7 +184,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-6 py-4 border-t border-gray-100">
<div v-if="totalPages > 1" class="table-pagination">
<p class="text-xs text-gray-500">共 {{ total }} 条记录</p>
<div class="flex items-center gap-2">
<button
+1 -1
View File
@@ -170,7 +170,7 @@ function goToDetail(item: any) {
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="card-footer flex justify-end">
<div v-if="total > pageSize" class="table-pagination card-footer flex justify-end">
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" :disabled="page <= 1" @click="page--; fetchProjects()">上一页</button>
<span class="text-sm text-gray-500">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>