refactor:重构租户端UI风格
This commit is contained in:
1 parent
ddade6ec54
commit
13628293ad
25 files changed
+1656
-637
No files matched your search
@@ -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>
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user