fix: update dashboard data and layout

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-24 18:14:54 +08:00
co-authored by Codex
parent 82fb5eca7d
commit 91bbb2d425
67 changed files with 4022 additions and 1556 deletions
+985
View File
@@ -25396,6 +25396,991 @@
]
}
]
},
{
"type": "frame",
"id": "e0NBl",
"x": 0,
"y": 1700,
"name": "Dashboard 顶部四卡融合方案 V8 - 今日 Token 主值",
"clip": true,
"width": 1920,
"height": 360,
"fill": "$cp-bg-page",
"layout": "none",
"children": [
{
"type": "frame",
"id": "R8KGf",
"x": 0,
"y": 0,
"name": "V8 左侧导航简版",
"width": 280,
"height": 360,
"fill": "$cp-bg-surface",
"layout": "none",
"children": [
{
"type": "frame",
"id": "JUDnP",
"x": 28,
"y": 31,
"name": "V8 品牌图标",
"width": 46,
"height": 46,
"fill": "$cp-text-strong",
"cornerRadius": 13,
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "$cp-shadow-card-color",
"offset": {
"x": 0,
"y": 8
},
"blur": 18,
"spread": -16
},
"layout": "none"
},
{
"type": "text",
"id": "xvMza",
"x": 84,
"y": 34,
"name": "V8 品牌名",
"fill": "$cp-text-strong",
"content": "Codex",
"lineHeight": 1.1,
"fontFamily": "Inter",
"fontSize": 17,
"fontWeight": "720"
},
{
"type": "frame",
"id": "v4VXc",
"x": 24,
"y": 112,
"name": "V8 导航 概览",
"width": 232,
"height": 46,
"fill": "$cp-bg-nav-active",
"cornerRadius": "$cp-radius-md",
"layout": "none",
"children": [
{
"type": "icon",
"id": "jRytg",
"x": 14,
"y": 14,
"name": "V8 导航 概览 图标",
"width": 18,
"height": 18,
"icon": "layout-dashboard",
"library": "lucide",
"fill": "$cp-info"
},
{
"type": "text",
"id": "vlfMg",
"x": 44,
"y": 14,
"name": "V8 导航 概览 文字",
"fill": "$cp-text-primary",
"content": "概览",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "g1s2k9",
"x": 24,
"y": 170,
"name": "V8 导航 账号池",
"width": 232,
"height": 46,
"fill": "$cp-transparent",
"cornerRadius": "$cp-radius-md",
"layout": "none",
"children": [
{
"type": "icon",
"id": "H9GuT",
"x": 14,
"y": 14,
"name": "V8 导航 账号池 图标",
"width": 18,
"height": 18,
"icon": "users",
"library": "lucide",
"fill": "$cp-text-secondary"
},
{
"type": "text",
"id": "i8o2LE",
"x": 44,
"y": 14,
"name": "V8 导航 账号池 文字",
"fill": "$cp-text-secondary",
"content": "账号池",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "i9kBP",
"x": 24,
"y": 228,
"name": "V8 导航 API Keys",
"width": 232,
"height": 46,
"fill": "$cp-transparent",
"cornerRadius": "$cp-radius-md",
"layout": "none",
"children": [
{
"type": "icon",
"id": "y9inH",
"x": 14,
"y": 14,
"name": "V8 导航 API Keys 图标",
"width": 18,
"height": 18,
"icon": "key-round",
"library": "lucide",
"fill": "$cp-text-secondary"
},
{
"type": "text",
"id": "c2OiOP",
"x": 44,
"y": 14,
"name": "V8 导航 API Keys 文字",
"fill": "$cp-text-secondary",
"content": "API Keys",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "q3jFK",
"x": 24,
"y": 286,
"name": "V8 导航 日志",
"width": 232,
"height": 46,
"fill": "$cp-transparent",
"cornerRadius": "$cp-radius-md",
"layout": "none",
"children": [
{
"type": "icon",
"id": "KRGYq",
"x": 14,
"y": 14,
"name": "V8 导航 日志 图标",
"width": 18,
"height": 18,
"icon": "list",
"library": "lucide",
"fill": "$cp-text-secondary"
},
{
"type": "text",
"id": "BXnmz",
"x": 44,
"y": 14,
"name": "V8 导航 日志 文字",
"fill": "$cp-text-secondary",
"content": "日志",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
}
]
},
{
"type": "text",
"id": "lLDQO",
"x": 308,
"y": 34,
"name": "V8 页面标题",
"fill": "$cp-text-primary",
"content": "系统概览",
"lineHeight": 1.15,
"fontFamily": "Inter",
"fontSize": 34,
"fontWeight": "800"
},
{
"type": "text",
"id": "EsB6L",
"x": 308,
"y": 78,
"name": "V8 页面副标题",
"fill": "$cp-text-secondary",
"content": "生产环境 · 14:32 更新 · 自动刷新 30s",
"lineHeight": 1.15,
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "600"
},
{
"type": "frame",
"id": "zj6sT",
"x": 308,
"y": 126,
"name": "V8 指标卡 账号",
"width": 378,
"height": 154,
"fill": "$cp-bg-surface",
"cornerRadius": "$cp-radius-lg",
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "$cp-shadow-card-color",
"offset": {
"x": 0,
"y": 10
},
"blur": 22,
"spread": -18
},
"layout": "none",
"children": [
{
"type": "rectangle",
"cornerRadius": "$cp-radius-sm",
"id": "sCWEO",
"x": 24,
"y": 20,
"name": "V8 指标卡 账号 图标底",
"fill": "$cp-normal-bg",
"width": 34,
"height": 34
},
{
"type": "icon",
"id": "aX2A4",
"x": 32,
"y": 28,
"name": "V8 指标卡 账号 图标",
"width": 18,
"height": 18,
"icon": "users",
"library": "lucide",
"fill": "$cp-normal"
},
{
"type": "text",
"id": "LPEOG",
"x": 70,
"y": 24,
"name": "V8 指标卡 账号 标题",
"fill": "$cp-text-secondary",
"content": "账号",
"lineHeight": 1.15,
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "650"
},
{
"type": "text",
"id": "DmFQq",
"x": 24,
"y": 67,
"name": "V8 指标卡 账号 主值",
"fill": "$cp-text-primary",
"textGrowth": "fixed-width",
"width": 170,
"content": "1",
"lineHeight": 1.05,
"fontFamily": "JetBrains Mono",
"fontSize": 29,
"fontWeight": "780"
},
{
"type": "text",
"id": "wnLgY",
"x": 198,
"y": 70,
"name": "V8 指标卡 账号 主值标签",
"fill": "$cp-text-secondary",
"textGrowth": "fixed-width",
"width": 156,
"lineHeight": 1.15,
"textAlign": "right",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "frame",
"id": "o0fsEl",
"x": 24,
"y": 110,
"name": "V8 指标卡 账号 明细带",
"width": 330,
"height": 30,
"fill": "$cp-bg-subtle",
"cornerRadius": "$cp-radius-sm",
"layout": "none",
"children": [
{
"type": "text",
"id": "lXuVO",
"x": 12,
"y": 0,
"name": "V8 指标卡 账号 明细左标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 28,
"height": 30,
"content": "启用",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "q50dGO",
"x": 48,
"y": 0,
"name": "V8 指标卡 账号 明细左值",
"fill": "$cp-normal",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "1",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "text",
"id": "fvp0B",
"x": 178,
"y": 0,
"name": "V8 指标卡 账号 明细右标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 28,
"height": 30,
"content": "异常",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "zQn2U",
"x": 214,
"y": 0,
"name": "V8 指标卡 账号 明细右值",
"fill": "$cp-normal-text",
"textGrowth": "fixed-width-height",
"width": 74,
"height": 30,
"content": "0",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "PF09r",
"x": 710,
"y": 126,
"name": "V8 指标卡 流量",
"width": 378,
"height": 154,
"fill": "$cp-bg-surface",
"cornerRadius": "$cp-radius-lg",
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "$cp-shadow-card-color",
"offset": {
"x": 0,
"y": 10
},
"blur": 22,
"spread": -18
},
"layout": "none",
"children": [
{
"type": "rectangle",
"cornerRadius": "$cp-radius-sm",
"id": "P7fkTT",
"x": 24,
"y": 20,
"name": "V8 指标卡 流量 图标底",
"fill": "$cp-info-bg",
"width": 34,
"height": 34
},
{
"type": "icon",
"id": "yvbAG",
"x": 32,
"y": 28,
"name": "V8 指标卡 流量 图标",
"width": 18,
"height": 18,
"icon": "activity",
"library": "lucide",
"fill": "$cp-info"
},
{
"type": "text",
"id": "AKGO0",
"x": 70,
"y": 24,
"name": "V8 指标卡 流量 标题",
"fill": "$cp-text-secondary",
"content": "今日流量",
"lineHeight": 1.15,
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "650"
},
{
"type": "text",
"id": "HTnki",
"x": 24,
"y": 67,
"name": "V8 指标卡 流量 主值",
"fill": "$cp-text-primary",
"textGrowth": "fixed-width",
"width": 170,
"content": "0",
"lineHeight": 1.05,
"fontFamily": "JetBrains Mono",
"fontSize": 29,
"fontWeight": "780"
},
{
"type": "text",
"id": "De6kV",
"x": 198,
"y": 70,
"name": "V8 指标卡 流量 主值标签",
"fill": "$cp-text-secondary",
"textGrowth": "fixed-width",
"width": 156,
"lineHeight": 1.15,
"textAlign": "right",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "frame",
"id": "HZn6h",
"x": 24,
"y": 110,
"name": "V8 指标卡 流量 明细带",
"width": 330,
"height": 30,
"fill": "$cp-bg-subtle",
"cornerRadius": "$cp-radius-sm",
"layout": "none",
"children": [
{
"type": "text",
"id": "DvaDw",
"x": 12,
"y": 0,
"name": "V8 指标卡 流量 明细左标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 52,
"height": 30,
"content": "总请求",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "M3woR8",
"x": 70,
"y": 0,
"name": "V8 指标卡 流量 明细左值",
"fill": "$cp-info",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "95",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "text",
"id": "y2pDQI",
"x": 164,
"y": 0,
"name": "V8 指标卡 流量 明细右标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 66,
"height": 30,
"content": "RPM / TPM",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "Z1NXvl",
"x": 236,
"y": 0,
"name": "V8 指标卡 流量 明细右值",
"fill": "$cp-info-text",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "0 / 0",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
}
]
},
{
"type": "path",
"id": "U7tno",
"x": 50,
"y": 82,
"name": "V8 指标卡 流量 昨日趋势",
"geometry": "M8 14l-6-6h4v-6h4v6h4z",
"viewBox": [
0,
0,
16,
16
],
"fill": "#EF4444",
"width": 12,
"height": 12
}
]
},
{
"type": "frame",
"id": "U0rMi",
"x": 1112,
"y": 126,
"name": "V8 指标卡 Token",
"width": 378,
"height": 154,
"fill": "$cp-bg-surface",
"cornerRadius": "$cp-radius-lg",
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "$cp-shadow-card-color",
"offset": {
"x": 0,
"y": 10
},
"blur": 22,
"spread": -18
},
"layout": "none",
"children": [
{
"type": "rectangle",
"cornerRadius": "$cp-radius-sm",
"id": "C0vTR8",
"x": 24,
"y": 20,
"name": "V8 指标卡 Token 图标底",
"fill": "$cp-success-bg",
"width": 34,
"height": 34
},
{
"type": "icon",
"id": "wfEeV",
"x": 32,
"y": 28,
"name": "V8 指标卡 Token 图标",
"width": 18,
"height": 18,
"icon": "file-text",
"library": "lucide",
"fill": "$cp-success"
},
{
"type": "text",
"id": "MeqZC",
"x": 70,
"y": 24,
"name": "V8 指标卡 Token 标题",
"fill": "$cp-text-secondary",
"content": "今日 Token",
"lineHeight": 1.15,
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "650"
},
{
"type": "text",
"id": "EOcR4",
"x": 24,
"y": 67,
"name": "V8 指标卡 Token 主值",
"fill": "$cp-text-primary",
"textGrowth": "fixed-width",
"width": 170,
"content": "1M",
"lineHeight": 1.05,
"fontFamily": "JetBrains Mono",
"fontSize": 29,
"fontWeight": "780"
},
{
"type": "text",
"id": "UsUdi",
"x": 82,
"y": 70,
"name": "V8 指标卡 Token 主值标签",
"fill": "#94A3B8",
"textGrowth": "fixed-width-height",
"width": 56,
"height": 14,
"lineHeight": 1.1,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "650"
},
{
"type": "frame",
"id": "lsZ5K",
"x": 24,
"y": 110,
"name": "V8 指标卡 Token 明细带",
"width": 330,
"height": 30,
"fill": "$cp-bg-subtle",
"cornerRadius": "$cp-radius-sm",
"layout": "none",
"children": [
{
"type": "text",
"id": "WBU0t",
"x": 12,
"y": 0,
"name": "V8 指标卡 Token 明细左标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 52,
"height": 30,
"content": "总 Token",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "M5GnVY",
"x": 70,
"y": 0,
"name": "V8 指标卡 Token 明细左值",
"fill": "$cp-success",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "3.1M",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "text",
"id": "s1TME3",
"x": 158,
"y": 0,
"name": "V8 指标卡 Token 明细右标签",
"fill": "#94A3B8",
"textGrowth": "fixed-width-height",
"width": 28,
"height": 30,
"content": "计费",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "qjJSP",
"x": 194,
"y": 0,
"name": "V8 指标卡 Token 明细右值",
"fill": "#047857",
"textGrowth": "fixed-width-height",
"width": 124,
"height": 30,
"content": "$5.88 / $5.88",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
}
]
},
{
"type": "path",
"id": "n0CNjV",
"x": 78,
"y": 82,
"name": "V8 指标卡 Token 昨日趋势",
"geometry": "M8 2l6 6h-4v6h-4v-6h-4z",
"viewBox": [
0,
0,
16,
16
],
"fill": "#12B981",
"width": 12,
"height": 12
}
]
},
{
"type": "frame",
"id": "iJsob",
"x": 1514,
"y": 126,
"name": "V8 指标卡 缓存",
"width": 378,
"height": 154,
"fill": "$cp-bg-surface",
"cornerRadius": "$cp-radius-lg",
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "$cp-shadow-card-color",
"offset": {
"x": 0,
"y": 10
},
"blur": 22,
"spread": -18
},
"layout": "none",
"children": [
{
"type": "rectangle",
"cornerRadius": "$cp-radius-sm",
"id": "l6tR2",
"x": 24,
"y": 20,
"name": "V8 指标卡 缓存 图标底",
"fill": "$cp-warning-bg",
"width": 34,
"height": 34
},
{
"type": "icon",
"id": "EpH25",
"x": 32,
"y": 28,
"name": "V8 指标卡 缓存 图标",
"width": 18,
"height": 18,
"icon": "gauge",
"library": "lucide",
"fill": "$cp-warning"
},
{
"type": "text",
"id": "lX9SA",
"x": 70,
"y": 24,
"name": "V8 指标卡 缓存 标题",
"fill": "$cp-text-secondary",
"content": "缓存命中",
"lineHeight": 1.15,
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "650"
},
{
"type": "text",
"id": "QVzdr",
"x": 24,
"y": 67,
"name": "V8 指标卡 缓存 主值",
"fill": "$cp-text-primary",
"textGrowth": "fixed-width",
"width": 170,
"content": "0%",
"lineHeight": 1.05,
"fontFamily": "JetBrains Mono",
"fontSize": 29,
"fontWeight": "780"
},
{
"type": "text",
"id": "Zf9da",
"x": 198,
"y": 70,
"name": "V8 指标卡 缓存 主值标签",
"fill": "$cp-text-secondary",
"textGrowth": "fixed-width",
"width": 156,
"lineHeight": 1.15,
"textAlign": "right",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "frame",
"id": "X6O5ty",
"x": 24,
"y": 110,
"name": "V8 指标卡 缓存 明细带",
"width": 330,
"height": 30,
"fill": "$cp-bg-subtle",
"cornerRadius": "$cp-radius-sm",
"layout": "none",
"children": [
{
"type": "text",
"id": "DkvFX",
"x": 12,
"y": 0,
"name": "V8 指标卡 缓存 明细左标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "总缓存命中",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "cfgcr",
"x": 90,
"y": 0,
"name": "V8 指标卡 缓存 明细左值",
"fill": "$cp-warning",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "94.7%",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
},
{
"type": "text",
"id": "I0s8rT",
"x": 176,
"y": 0,
"name": "V8 指标卡 缓存 明细右标签",
"fill": "$cp-text-muted",
"textGrowth": "fixed-width-height",
"width": 70,
"height": 30,
"content": "首字 / 完成",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "650"
},
{
"type": "text",
"id": "A0cX1j",
"x": 252,
"y": 0,
"name": "V8 指标卡 缓存 明细右值",
"fill": "$cp-warning-text",
"textGrowth": "fixed-width-height",
"width": 72,
"height": 30,
"content": "8.5s / -",
"lineHeight": 1.15,
"textAlignVertical": "middle",
"fontFamily": "JetBrains Mono",
"fontSize": 12,
"fontWeight": "700"
}
]
},
{
"type": "path",
"id": "IgWxp",
"x": 75,
"y": 82,
"name": "V8 指标卡 缓存 昨日趋势",
"geometry": "M8 14l-6-6h4v-6h4v6h4z",
"viewBox": [
0,
0,
16,
16
],
"fill": "#EF4444",
"width": 12,
"height": 12
}
]
}
]
}
],
"variables": {
+1 -28
View File
@@ -1,7 +1,7 @@
use chrono::Utc;
use secrecy::{ExposeSecret, SecretString};
use crate::{infra::json::Page, upstream::accounts::model::AccountStatus as AcctStatus};
use crate::infra::json::Page;
use super::{types::*, AdminAccountService};
@@ -35,33 +35,6 @@ impl AdminAccountService {
.map(|account| account.map(AdminAccountMetadata::from))
.map_err(|_| AdminAccountError::Inspect)
}
pub async fn auth_status(&self) -> Result<AdminAuthStatus, AdminAccountError> {
let mut cursor = None;
let mut pool = AdminAuthPoolStatus::default();
let mut user = None;
loop {
let page = self
.store
.list_metadata(cursor, 200)
.await
.map_err(|_| AdminAccountError::List)?;
for account in page.items {
pool.record(account.status);
if user.is_none() && account.status == AcctStatus::Active {
user = Some(AdminAccountMetadata::from(account));
}
}
if page.next_cursor.is_none() {
break;
}
cursor = page.next_cursor;
}
Ok(AdminAuthStatus {
authenticated: pool.total > 0,
user,
pool,
})
}
pub async fn create(
&self,
token: Option<String>,
+2 -2
View File
@@ -20,8 +20,8 @@ use crate::{
};
pub use types::{
AdminAccountError, AdminAccountMetadata, AdminAuthPoolStatus, AdminAuthStatus,
BatchDeleteAccounts, BatchUpdateAccountStatus, ImportedAccounts, UpdatedAccountStatus,
AdminAccountError, AdminAccountMetadata, BatchDeleteAccounts, BatchUpdateAccountStatus,
ImportedAccounts, UpdatedAccountStatus,
};
#[derive(Clone)]
-32
View File
@@ -110,38 +110,6 @@ pub enum AdminAccountError {
Inactive(AcctStatus),
}
#[derive(Debug, Clone)]
pub struct AdminAuthStatus {
pub authenticated: bool,
pub user: Option<AdminAccountMetadata>,
pub pool: AdminAuthPoolStatus,
}
#[derive(Debug, Clone, Default)]
pub struct AdminAuthPoolStatus {
pub total: u32,
pub active: u32,
pub expired: u32,
pub quota_exhausted: u32,
pub refreshing: u32,
pub disabled: u32,
pub banned: u32,
}
impl AdminAuthPoolStatus {
pub(super) fn record(&mut self, status: AcctStatus) {
self.total += 1;
match status {
AcctStatus::Active => self.active += 1,
AcctStatus::Expired => self.expired += 1,
AcctStatus::QuotaExhausted => self.quota_exhausted += 1,
AcctStatus::Refreshing => self.refreshing += 1,
AcctStatus::Disabled => self.disabled += 1,
AcctStatus::Banned => self.banned += 1,
}
}
}
#[derive(Debug, Clone)]
pub struct UpdatedAccountStatus {
pub id: String,
-1
View File
@@ -1,5 +1,4 @@
//! 管理端认证与本地会话模块。
pub mod routes;
pub mod service;
pub mod session;
-114
View File
@@ -1,114 +0,0 @@
//! 管理端账号认证状态 HTTP 处理器。
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::Serialize;
use crate::{
admin::accounts::service::{AdminAccountError, AdminAuthStatus},
admin::{
accounts::routes::AdminAccountData,
auth::session::require_admin_session,
response::{AdminEnvelope, AdminError, AdminResponse},
},
http::middleware::request_id::RequestId,
runtime::state::AppState,
};
/// 管理端认证状态响应。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthStatusData {
authenticated: bool,
user: Option<AdminAccountData>,
pool: AdminAuthPoolData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAuthPoolData {
total: u32,
active: u32,
expired: u32,
quota_exhausted: u32,
refreshing: u32,
disabled: u32,
banned: u32,
}
/// `GET /api/admin/accounts/auth-status`
pub async fn auth_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let status = state
.services
.admin_accounts
.auth_status()
.await
.map_err(|error| account_error(error, request_id.clone()))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminAuthStatusData::from(status), request_id),
))
}
fn account_error(error: AdminAccountError, request_id: String) -> AdminError {
match error {
AdminAccountError::InvalidStatus(_)
| AdminAccountError::LabelTooLong
| AdminAccountError::EmptyIds
| AdminAccountError::NoImportableAccounts
| AdminAccountError::InvalidAccessTokenExpiresAt
| AdminAccountError::TokenRequired
| AdminAccountError::InvalidToken(_)
| AdminAccountError::RefreshTokenExchange(_)
| AdminAccountError::NoValidCookies => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
error.to_string(),
request_id,
),
AdminAccountError::NotFound => AdminError::new(
StatusCode::NOT_FOUND,
40401,
"Account not found",
request_id,
),
AdminAccountError::Inactive(_) => {
AdminError::new(StatusCode::CONFLICT, 40901, error.to_string(), request_id)
}
_ => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
error.to_string(),
request_id,
),
}
}
impl From<AdminAuthStatus> for AdminAuthStatusData {
fn from(status: AdminAuthStatus) -> Self {
Self {
authenticated: status.authenticated,
user: status.user.map(AdminAccountData::from),
pool: AdminAuthPoolData {
total: status.pool.total,
active: status.pool.active,
expired: status.pool.expired,
quota_exhausted: status.pool.quota_exhausted,
refreshing: status.pool.refreshing,
disabled: status.pool.disabled,
banned: status.pool.banned,
},
}
}
}
+35
View File
@@ -34,6 +34,14 @@ pub struct AdminLoginData {
pub expires_at: String,
}
/// 管理员会话状态响应。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionStatusData {
/// 当前请求是否携带有效管理端会话。
pub authenticated: bool,
}
/// 会话登录是否成功。
pub fn session_login_allowed() -> bool {
true
@@ -96,6 +104,33 @@ pub async fn login(
Ok(response)
}
/// `GET /api/admin/auth/status`
pub async fn session_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
let authenticated = state
.services
.admin_sessions
.validate(admin_session_cookie(&headers).as_deref())
.await
.map_err(|_| {
AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to validate admin session",
request_id.clone(),
)
})?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSessionStatusData { authenticated }, request_id),
))
}
/// `POST /api/admin/logout`
pub async fn logout(
State(state): State<AppState>,
+743
View File
@@ -0,0 +1,743 @@
//! 管理端 Dashboard 聚合视图。
use std::collections::HashMap;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use chrono::{DateTime, Duration, Timelike, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
admin::{
auth::session::require_admin_session,
monitoring::{
diagnostics::{
fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics,
},
event_store::AdminLogFilter,
events::{EventLevel, EventLog},
service::{AdminUsageRecord, AdminUsageSummary},
},
response::{AdminEnvelope, AdminError, AdminResponse},
},
http::middleware::request_id::RequestId,
runtime::state::AppState,
upstream::accounts::model::{Account, AccountStatus},
};
const DASHBOARD_LOG_LIMIT: u32 = 1000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTrendQuery {
pub kind: Option<DashboardTrendKind>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DashboardTrendKind {
Usage,
Latency,
Errors,
}
impl Default for DashboardTrendKind {
fn default() -> Self {
Self::Usage
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardSummaryData {
pub cards: DashboardCardsData,
pub trend: DashboardTrendData,
pub account_usage: Vec<DashboardAccountUsageData>,
pub service_statuses: Vec<DashboardServiceStatusData>,
pub event_logs: Vec<EventLog>,
pub pool_summary: AccountPoolDiagnostics,
pub capacity_info: AccountCapacityDiagnostics,
pub rotation_strategy: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCardsData {
pub accounts: DashboardAccountsCardData,
pub traffic: DashboardTrafficCardData,
pub tokens: DashboardTokenCardData,
pub cache: DashboardCacheCardData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountsCardData {
pub total: u64,
pub enabled: u64,
pub abnormal: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTrafficCardData {
pub today_requests: u64,
pub yesterday_requests: u64,
pub total_requests: u64,
pub rpm: u64,
pub tpm: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTokenCardData {
pub today_tokens: u64,
pub yesterday_tokens: u64,
pub total_tokens: u64,
pub today_cost_usd: Option<f64>,
pub total_cost_usd: Option<f64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCacheCardData {
pub today_hit_rate: Option<f64>,
pub yesterday_hit_rate: Option<f64>,
pub total_hit_rate: Option<f64>,
pub total_cached_tokens: u64,
pub first_token_latency_ms: Option<u64>,
pub completion_latency_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTrendData {
pub kind: DashboardTrendKind,
pub points: Vec<DashboardTrendPointData>,
pub summary: Vec<DashboardTrendSummaryData>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTrendPointData {
pub time: String,
pub requests: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub tokens: u64,
pub errors: u64,
pub latency: u64,
pub max_latency: u64,
pub min_latency: u64,
pub success_rate: f64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTrendSummaryData {
pub label: String,
pub value: u64,
pub ratio: Option<f64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountUsageData {
pub name: String,
pub email: String,
pub plan: String,
pub requests: u64,
pub tokens: u64,
pub quota_used_percent: Option<f64>,
pub last_used_at: Option<String>,
pub status: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardServiceStatusData {
pub label: String,
pub value: String,
pub detail: String,
pub tone: String,
}
#[derive(Debug, Clone, Copy, Default)]
struct UsageWindow {
requests: u64,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
errors: u64,
latency_sum: u64,
latency_count: u64,
max_latency: u64,
min_latency: u64,
}
impl UsageWindow {
fn tokens(self) -> u64 {
self.input_tokens + self.output_tokens
}
fn cache_hit_rate(self) -> Option<f64> {
if self.input_tokens > 0 {
Some(self.cached_tokens as f64 / self.input_tokens as f64)
} else {
None
}
}
fn avg_latency(self) -> Option<u64> {
if self.latency_count > 0 {
Some(self.latency_sum / self.latency_count)
} else {
None
}
}
}
/// `GET /api/admin/dashboard/summary`
pub async fn dashboard_summary(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let accounts = state
.services
.accounts
.list_pool_accounts()
.await
.unwrap_or_default();
let capacity = state.services.account_pool.capacity_summary_now().await;
let summary = state
.services
.usage
.summary()
.await
.unwrap_or_else(|_| empty_usage_summary());
let usage_records = state
.services
.usage
.list(None, 200)
.await
.map(|page| page.items)
.unwrap_or_default();
let logs = state
.services
.logs
.list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default())
.await
.map(|page| page.items)
.unwrap_or_default();
let pool_summary = account_pool_summary(&accounts);
let dashboard_logs = logs.iter().take(10).cloned().collect();
let trend = dashboard_trend_data(&logs, DashboardTrendKind::Usage);
let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await;
let settings = state.services.settings.current();
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
DashboardSummaryData {
cards: dashboard_cards(&accounts, &summary, &logs),
trend,
account_usage: account_usage_data(
&accounts,
&usage_records,
&quota_used_by_account,
),
service_statuses: service_status_data(&state),
event_logs: dashboard_logs,
pool_summary,
capacity_info: AccountCapacityDiagnostics::from(capacity),
rotation_strategy: Some(settings.auth.rotation_strategy.clone()),
},
request_id,
),
))
}
/// `GET /api/admin/dashboard/trend?kind=usage|latency|errors`
pub async fn dashboard_trend(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<DashboardTrendQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let logs = state
.services
.logs
.list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default())
.await
.map(|page| page.items)
.unwrap_or_default();
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
dashboard_trend_data(&logs, query.kind.unwrap_or_default()),
request_id,
),
))
}
fn dashboard_cards(
accounts: &[Account],
summary: &AdminUsageSummary,
logs: &[EventLog],
) -> DashboardCardsData {
let now = Utc::now();
let today_start = day_start(now);
let yesterday_start = today_start - Duration::days(1);
let hour_start = now - Duration::hours(1);
let today = usage_window(logs, today_start, now);
let yesterday = usage_window(logs, yesterday_start, today_start);
let hour = usage_window(logs, hour_start, now);
let total_input = summary.input_tokens.max(0) as u64;
let total_cached = summary.cached_tokens.max(0) as u64;
DashboardCardsData {
accounts: DashboardAccountsCardData {
total: accounts.len() as u64,
enabled: accounts
.iter()
.filter(|account| account.status == AccountStatus::Active)
.count() as u64,
abnormal: accounts
.iter()
.filter(|account| {
matches!(
account.status,
AccountStatus::Expired
| AccountStatus::QuotaExhausted
| AccountStatus::Disabled
| AccountStatus::Banned
)
})
.count() as u64,
},
traffic: DashboardTrafficCardData {
today_requests: today.requests,
yesterday_requests: yesterday.requests,
total_requests: summary.request_count.max(0) as u64,
rpm: hour.requests,
tpm: hour.tokens(),
},
tokens: DashboardTokenCardData {
today_tokens: today.tokens(),
yesterday_tokens: yesterday.tokens(),
total_tokens: (summary.input_tokens + summary.output_tokens).max(0) as u64,
today_cost_usd: None,
total_cost_usd: None,
},
cache: DashboardCacheCardData {
today_hit_rate: today.cache_hit_rate(),
yesterday_hit_rate: yesterday.cache_hit_rate(),
total_hit_rate: if total_input > 0 {
Some(total_cached as f64 / total_input as f64)
} else {
None
},
total_cached_tokens: total_cached,
first_token_latency_ms: today.avg_latency(),
completion_latency_ms: None,
},
}
}
fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> DashboardTrendData {
let now = Utc::now();
let current_hour = hour_start(now);
let start = current_hour - Duration::hours(23);
let mut buckets = (0..24)
.map(|index| {
let bucket_start = start + Duration::hours(index);
(bucket_start, UsageWindow::default())
})
.collect::<Vec<_>>();
for log in logs {
if log.created_at < start || log.created_at > now {
continue;
}
let log_hour = hour_start(log.created_at);
if let Some((_, bucket)) = buckets
.iter_mut()
.find(|(bucket_start, _)| *bucket_start == log_hour)
{
apply_log(bucket, log);
}
}
let points = buckets
.iter()
.map(|(bucket_start, bucket)| {
let latency = bucket.avg_latency().unwrap_or(0);
DashboardTrendPointData {
time: format!("{:02}", bucket_start.hour()),
requests: bucket.requests,
input_tokens: bucket.input_tokens,
output_tokens: bucket.output_tokens,
cached_tokens: bucket.cached_tokens,
tokens: bucket.tokens(),
errors: bucket.errors,
latency,
max_latency: bucket.max_latency,
min_latency: bucket.min_latency,
success_rate: if bucket.requests > 0 {
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0)
.round()
/ 10.0
} else {
0.0
},
}
})
.collect::<Vec<_>>();
DashboardTrendData {
kind,
summary: trend_summary(kind, &points),
points,
}
}
fn trend_summary(
kind: DashboardTrendKind,
points: &[DashboardTrendPointData],
) -> Vec<DashboardTrendSummaryData> {
match kind {
DashboardTrendKind::Usage => vec![
DashboardTrendSummaryData {
label: "输入".to_string(),
value: points.iter().map(|point| point.input_tokens).sum(),
ratio: None,
},
DashboardTrendSummaryData {
label: "输出".to_string(),
value: points.iter().map(|point| point.output_tokens).sum(),
ratio: None,
},
DashboardTrendSummaryData {
label: "缓存".to_string(),
value: points.iter().map(|point| point.cached_tokens).sum(),
ratio: None,
},
],
DashboardTrendKind::Latency => {
let samples = points
.iter()
.filter(|point| point.latency > 0)
.collect::<Vec<_>>();
let avg = if samples.is_empty() {
0
} else {
samples.iter().map(|point| point.latency).sum::<u64>() / samples.len() as u64
};
vec![
DashboardTrendSummaryData {
label: "平均".to_string(),
value: avg,
ratio: None,
},
DashboardTrendSummaryData {
label: "最高".to_string(),
value: samples
.iter()
.map(|point| point.max_latency)
.max()
.unwrap_or(0),
ratio: None,
},
DashboardTrendSummaryData {
label: "最低".to_string(),
value: samples
.iter()
.filter_map(|point| (point.min_latency > 0).then_some(point.min_latency))
.min()
.unwrap_or(0),
ratio: None,
},
]
}
DashboardTrendKind::Errors => {
let errors = points.iter().map(|point| point.errors).sum::<u64>();
let requests = points.iter().map(|point| point.requests).sum::<u64>();
let success_rate = if requests > 0 {
Some(((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0)
} else {
None
};
vec![
DashboardTrendSummaryData {
label: "错误数".to_string(),
value: errors,
ratio: None,
},
DashboardTrendSummaryData {
label: "成功率".to_string(),
value: 0,
ratio: success_rate,
},
DashboardTrendSummaryData {
label: "总请求".to_string(),
value: requests,
ratio: None,
},
]
}
}
}
fn usage_window(logs: &[EventLog], start: DateTime<Utc>, end: DateTime<Utc>) -> UsageWindow {
let mut window = UsageWindow::default();
for log in logs {
if log.created_at >= start && log.created_at < end {
apply_log(&mut window, log);
}
}
window
}
fn apply_log(window: &mut UsageWindow, log: &EventLog) {
window.requests += 1;
window.input_tokens += metadata_usage_number(&log.metadata, "inputTokens");
window.output_tokens += metadata_usage_number(&log.metadata, "outputTokens");
window.cached_tokens += metadata_usage_number(&log.metadata, "cachedTokens");
if is_error_log(log) {
window.errors += 1;
}
if let Some(latency) = log
.latency_ms
.filter(|value| *value > 0)
.map(|value| value as u64)
{
window.latency_sum += latency;
window.latency_count += 1;
window.max_latency = window.max_latency.max(latency);
window.min_latency = if window.min_latency == 0 {
latency
} else {
window.min_latency.min(latency)
};
}
}
fn metadata_usage_number(metadata: &Value, field: &str) -> u64 {
metadata
.get("usage")
.and_then(|usage| usage.get(field))
.or_else(|| metadata.get(field))
.and_then(Value::as_u64)
.unwrap_or(0)
}
fn is_error_log(log: &EventLog) -> bool {
log.level == EventLevel::Error || log.status_code.is_some_and(|status| status >= 400)
}
fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics {
let mut summary = AccountPoolDiagnostics {
total: accounts.len(),
..AccountPoolDiagnostics::default()
};
for account in accounts {
match account.status {
AccountStatus::Active => summary.active += 1,
AccountStatus::Expired => summary.expired += 1,
AccountStatus::QuotaExhausted => summary.quota_exhausted += 1,
AccountStatus::Refreshing => summary.refreshing += 1,
AccountStatus::Disabled => summary.disabled += 1,
AccountStatus::Banned => summary.banned += 1,
}
}
summary
}
fn account_usage_data(
accounts: &[Account],
usage_records: &[AdminUsageRecord],
quota_used_by_account: &HashMap<String, f64>,
) -> Vec<DashboardAccountUsageData> {
let account_by_id = accounts
.iter()
.map(|account| (account.id.as_str(), account))
.collect::<HashMap<_, _>>();
let mut rows = usage_records
.iter()
.map(|usage| {
let account = account_by_id.get(usage.account_id.as_str()).copied();
let name = account
.and_then(|account| account.label.clone())
.or_else(|| usage.label.clone())
.or_else(|| {
usage
.email
.as_ref()
.and_then(|email| email.split('@').next().map(ToString::to_string))
})
.unwrap_or_else(|| usage.account_id.chars().take(8).collect());
let status = account
.map(|account| account.status.to_string())
.unwrap_or_else(|| "active".to_string());
let quota_used_percent = quota_used_by_account
.get(&usage.account_id)
.copied()
.or_else(|| (status == "quota_exhausted").then_some(100.0));
DashboardAccountUsageData {
name,
email: usage.email.clone().unwrap_or_else(|| "-".to_string()),
plan: usage
.plan_type
.clone()
.unwrap_or_else(|| "free".to_string()),
requests: usage.request_count.max(0) as u64,
tokens: (usage.input_tokens + usage.output_tokens).max(0) as u64,
quota_used_percent,
last_used_at: usage.last_used_at.map(|value| value.to_rfc3339()),
status,
}
})
.collect::<Vec<_>>();
rows.sort_by(|a, b| b.requests.cmp(&a.requests));
rows.truncate(4);
rows
}
async fn account_quota_used_percent_by_id(
state: &AppState,
usage_records: &[AdminUsageRecord],
) -> HashMap<String, f64> {
let mut records = usage_records.iter().collect::<Vec<_>>();
records.sort_by(|a, b| b.request_count.cmp(&a.request_count));
records.truncate(4);
let mut quota_used_by_account = HashMap::with_capacity(records.len());
for usage in records {
let Ok(Some(quota_json)) = state
.services
.accounts
.get_quota_json(&usage.account_id)
.await
else {
continue;
};
if let Some(used_percent) = quota_used_percent(&quota_json) {
quota_used_by_account.insert(usage.account_id.clone(), used_percent);
}
}
quota_used_by_account
}
fn quota_used_percent(quota_json: &str) -> Option<f64> {
let quota = serde_json::from_str::<Value>(quota_json).ok()?;
quota
.pointer("/rate_limit/used_percent")
.and_then(percent_value)
}
fn percent_value(value: &Value) -> Option<f64> {
let percent = value
.as_f64()
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))?;
percent.is_finite().then_some(percent.clamp(0.0, 100.0))
}
fn service_status_data(state: &AppState) -> Vec<DashboardServiceStatusData> {
let fingerprint = fingerprint_diagnostics(&state.services.fingerprint);
vec![
DashboardServiceStatusData {
label: "客户端版本".to_string(),
value: empty_dash(fingerprint.app_version),
detail: if fingerprint.build_number.trim().is_empty() {
"-".to_string()
} else {
format!("Build {}", fingerprint.build_number)
},
tone: "info".to_string(),
},
DashboardServiceStatusData {
label: "平台架构".to_string(),
value: empty_dash(fingerprint.platform),
detail: empty_dash(fingerprint.arch),
tone: "info".to_string(),
},
DashboardServiceStatusData {
label: "Chromium".to_string(),
value: if fingerprint.chromium_version.trim().is_empty() {
"-".to_string()
} else {
format!("v{}", fingerprint.chromium_version)
},
detail: empty_dash(fingerprint.originator),
tone: "normal".to_string(),
},
DashboardServiceStatusData {
label: "更新时间".to_string(),
value: fingerprint.updated_at.unwrap_or_else(|| "-".to_string()),
detail: String::new(),
tone: "normal".to_string(),
},
DashboardServiceStatusData {
label: "User Agent".to_string(),
value: fingerprint.user_agent,
detail: String::new(),
tone: "normal".to_string(),
},
]
}
fn empty_dash(value: String) -> String {
if value.trim().is_empty() {
"-".to_string()
} else {
value
}
}
fn empty_usage_summary() -> AdminUsageSummary {
AdminUsageSummary {
account_count: 0,
request_count: 0,
empty_response_count: 0,
input_tokens: 0,
output_tokens: 0,
cached_tokens: 0,
reasoning_tokens: 0,
total_tokens: 0,
image_input_tokens: 0,
image_output_tokens: 0,
image_request_count: 0,
image_request_failed_count: 0,
}
}
fn day_start(value: DateTime<Utc>) -> DateTime<Utc> {
value
.date_naive()
.and_hms_opt(0, 0, 0)
.expect("valid midnight")
.and_utc()
}
fn hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
value
.date_naive()
.and_hms_opt(value.hour(), 0, 0)
.expect("valid hour")
.and_utc()
}
@@ -1,47 +0,0 @@
//! 管理端诊断处理器。
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use crate::{
admin::auth::session::require_admin_session,
admin::monitoring::diagnostics::{diagnostics_data, DiagnosticsInput},
admin::response::{AdminEnvelope, AdminError, AdminResponse},
http::middleware::request_id::RequestId,
runtime::state::AppState,
};
/// `GET /api/admin/diagnostics`
pub async fn diagnostics(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let config = state.services.settings.current();
let accounts = state
.services
.accounts
.list_pool_accounts()
.await
.unwrap_or_default();
let capacity = state.services.account_pool.capacity_summary_now().await;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
diagnostics_data(DiagnosticsInput {
config: config.as_ref(),
accounts: &accounts,
capacity,
fingerprint: &state.services.fingerprint,
}),
request_id,
),
))
}
+2 -2
View File
@@ -1,7 +1,7 @@
//! 管理面监控模块:日志、用量统计和诊断。
//! 管理面监控模块:日志、用量统计和 Dashboard 聚合。
pub mod dashboard;
pub mod diagnostics;
pub mod diagnostics_routes;
pub mod event_store;
pub mod events;
pub mod logs;
+5 -7
View File
@@ -13,16 +13,13 @@ use super::{
get_account_cookies, health_check_accounts, import_accounts, quota_warnings,
refresh_account, reset_account_usage, set_account_cookies, update_account,
},
auth::{
routes::auth_status,
session::{login, logout},
},
auth::session::{login, logout, session_status},
keys::routes::{
api_keys, batch_delete_api_keys, create_api_key, export_api_keys, update_api_key,
},
models::routes::refresh_models,
monitoring::{
diagnostics_routes::diagnostics,
dashboard::{dashboard_summary, dashboard_trend},
logs::{clear_logs, log_detail, logs, logs_state, update_logs_state},
usage::{usage_stats, usage_stats_summary},
},
@@ -33,10 +30,11 @@ use super::{
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/login", post(login))
.route("/api/admin/auth/status", get(session_status))
.route("/api/admin/logout", post(logout))
.route("/api/admin/settings", get(settings).post(update_settings))
.route("/api/admin/diagnostics", get(diagnostics))
.route("/api/admin/accounts/auth-status", get(auth_status))
.route("/api/admin/dashboard/summary", get(dashboard_summary))
.route("/api/admin/dashboard/trend", get(dashboard_trend))
.route("/api/admin/models/refresh", post(refresh_models))
.route("/api/admin/usage", get(usage_stats))
.route("/api/admin/usage/summary", get(usage_stats_summary))
+16 -3
View File
@@ -6,11 +6,12 @@ use std::{
use axum::{
body::Body,
extract::{Path as AxumPath, State},
http::{header, StatusCode},
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
routing::get,
Router,
Json, Router,
};
use serde_json::json;
use super::headers::{apply_static_headers, content_type_for_path};
@@ -35,7 +36,19 @@ async fn serve_index(State(state): State<AssetState>) -> Response {
serve_file(&state.dist_dir.join("index.html"), "/").await
}
async fn serve_spa_fallback(State(state): State<AssetState>) -> Response {
async fn serve_spa_fallback(State(state): State<AssetState>, uri: Uri) -> Response {
if uri.path() == "/api" || uri.path().starts_with("/api/") {
return (
StatusCode::NOT_FOUND,
Json(json!({
"code": 40401,
"message": "API route not found",
"data": null
})),
)
.into_response();
}
serve_file(&state.dist_dir.join("index.html"), "/").await
}
+15 -1
View File
@@ -7,7 +7,9 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"format": "prettier --write src \"*.{ts,json,html}\"",
"format:check": "prettier --check src \"*.{ts,json,html}\""
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
@@ -26,9 +28,21 @@
"@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"prettier": "^3.8.4",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vue-tsc": "^3.3.5"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
}
+10
View File
@@ -51,6 +51,9 @@ importers:
'@vue/tsconfig':
specifier: ^0.9.1
version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
prettier:
specifier: ^3.8.4
version: 3.8.4
tailwindcss:
specifier: ^4.3.1
version: 4.3.1
@@ -798,6 +801,11 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.8.4:
resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
engines: {node: '>=14'}
hasBin: true
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -1608,6 +1616,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prettier@3.8.4: {}
proxy-from-env@2.1.0: {}
quansync@0.2.11: {}
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import BaseToast from '@/components/base/BaseToast.vue'
import { BaseToast } from '@/components/base/BaseToast'
</script>
<template>
+2 -2
View File
@@ -13,8 +13,8 @@ export * from './modules/logs'
// 使用统计
export * from './modules/usage'
// 系统诊断
export * from './modules/diagnostics'
// Dashboard 聚合
export * from './modules/dashboard'
// 设置管理
export * from './modules/settings'
+1 -14
View File
@@ -4,19 +4,6 @@ import { requestJson } from '../request'
export interface AuthStatus {
authenticated: boolean
user?: {
email?: string
accountId?: string
} | null
pool?: {
total: number
active: number
expired: number
quotaExhausted: number
refreshing: number
disabled: number
banned: number
}
}
export interface LoginRequest {
@@ -36,7 +23,7 @@ export function login(payload: LoginRequest) {
}
export function getAuthStatus() {
return requestJson<AuthStatus>('/api/admin/accounts/auth-status')
return requestJson<AuthStatus>('/api/admin/auth/status')
}
export function logout() {
+123
View File
@@ -0,0 +1,123 @@
import { requestJson } from '../request'
import type { EventLog } from './logs'
export type DashboardTrendKind = 'usage' | 'latency' | 'errors'
export interface DashboardSummary {
cards: DashboardCards
trend: DashboardTrend
accountUsage: DashboardAccountUsage[]
serviceStatuses: DashboardServiceStatus[]
eventLogs: EventLog[]
poolSummary: DashboardAccountPoolSummary
capacityInfo: DashboardAccountCapacityInfo
rotationStrategy?: string
}
export interface DashboardCards {
accounts: DashboardAccountsCard
traffic: DashboardTrafficCard
tokens: DashboardTokenCard
cache: DashboardCacheCard
}
export interface DashboardAccountsCard {
total: number
enabled: number
abnormal: number
}
export interface DashboardTrafficCard {
todayRequests: number
yesterdayRequests: number
totalRequests: number
rpm: number
tpm: number
}
export interface DashboardTokenCard {
todayTokens: number
yesterdayTokens: number
totalTokens: number
todayCostUsd?: number | null
totalCostUsd?: number | null
}
export interface DashboardCacheCard {
todayHitRate?: number | null
yesterdayHitRate?: number | null
totalHitRate?: number | null
totalCachedTokens: number
firstTokenLatencyMs?: number | null
completionLatencyMs?: number | null
}
export interface DashboardTrend {
kind: DashboardTrendKind
points: DashboardTrendPoint[]
summary: DashboardTrendSummary[]
}
export interface DashboardTrendPoint {
time: string
requests: number
inputTokens: number
outputTokens: number
cachedTokens: number
tokens: number
errors: number
latency: number
maxLatency: number
minLatency: number
successRate: number
}
export interface DashboardTrendSummary {
label: string
value: number
ratio?: number | null
}
export interface DashboardAccountUsage {
name: string
email: string
plan: string
requests: number
tokens: number
quotaUsedPercent?: number | null
lastUsedAt?: string | null
status: 'active' | 'expired' | 'disabled' | 'banned' | 'quota_exhausted' | 'refreshing'
}
export interface DashboardServiceStatus {
label: string
value: string
detail: string
tone: 'normal' | 'info' | 'success' | 'warning' | 'danger'
}
export interface DashboardAccountPoolSummary {
total: number
active: number
expired: number
quotaExhausted: number
refreshing: number
disabled: number
banned: number
}
export interface DashboardAccountCapacityInfo {
maxConcurrentPerAccount: number
totalSlots: number
usedSlots: number
availableSlots: number
}
export function getDashboardSummary() {
return requestJson<DashboardSummary>('/api/admin/dashboard/summary')
}
export function getDashboardTrend(kind: DashboardTrendKind) {
const params = new URLSearchParams({ kind })
return requestJson<DashboardTrend>(`/api/admin/dashboard/trend?${params}`)
}
-49
View File
@@ -1,49 +0,0 @@
import { requestJson } from '../request'
// ==================== 系统诊断 ====================
export interface DiagnosticsInfo {
version?: string
uptime?: number
environment?: string
database?: {
connected: boolean
path?: string
}
accounts?: {
total?: number
active?: number
disabled?: number
banned?: number
pool?: {
total: number
active: number
expired: number
quotaExhausted: number
refreshing: number
disabled: number
banned: number
}
capacity?: {
maxConcurrentPerAccount: number
totalSlots: number
usedSlots: number
availableSlots: number
}
}
settings?: {
rotationStrategy?: string | null
}
transport?: {
backendBaseUrl?: string
fingerprint?: Record<string, unknown>
}
requests?: {
total: number
today: number
}
}
export function getDiagnostics() {
return requestJson<DiagnosticsInfo>('/api/admin/diagnostics')
}
+16 -6
View File
@@ -4,7 +4,7 @@ export interface EventLog {
id: string
requestId?: string
kind: string
level: 'info' | 'warning' | 'error'
level: 'debug' | 'info' | 'warn' | 'error'
accountId?: string
route?: string
model?: string
@@ -23,21 +23,31 @@ export interface EventLog {
export interface LogsQuery {
limit?: number
before?: string
level?: string
cursor?: string
level?: EventLog['level']
kind?: string
accountId?: string
requestId?: string
route?: string
model?: string
statusCode?: number
transport?: string
search?: string
}
export function getLogs(query: LogsQuery = {}) {
const params = new URLSearchParams()
if (query.limit) params.set('limit', String(query.limit))
if (query.before) params.set('before', query.before)
if (query.cursor) params.set('cursor', query.cursor)
if (query.level) params.set('level', query.level)
if (query.kind) params.set('kind', query.kind)
if (query.accountId) params.set('account_id', query.accountId)
if (query.accountId) params.set('accountId', query.accountId)
if (query.requestId) params.set('requestId', query.requestId)
if (query.route) params.set('route', query.route)
if (query.model) params.set('model', query.model)
if (query.statusCode) params.set('statusCode', String(query.statusCode))
if (query.transport) params.set('transport', query.transport)
if (query.search) params.set('search', query.search)
const url = `/api/admin/logs${params.toString() ? `?${params}` : ''}`
return requestJson<EventLog[]>(url)
@@ -49,7 +59,7 @@ export function getLogDetail(logId: string) {
}
export function clearLogs() {
return requestJson<{ deleted: number }>('/api/admin/logs/delete', {
return requestJson<{ cleared: number }>('/api/admin/logs/delete', {
method: 'POST',
})
}
+14 -8
View File
@@ -1,4 +1,10 @@
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import axios, {
AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
import type { ApiEnvelope } from './types'
@@ -6,7 +12,7 @@ export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code?: string,
public readonly code?: number,
public readonly requestId?: string,
) {
super(message)
@@ -25,7 +31,7 @@ const http: AxiosInstance = axios.create({
withCredentials: true,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
})
@@ -85,11 +91,11 @@ http.interceptors.response.use(
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
return (
typeof value === 'object'
&& value !== null
&& 'data' in value
&& 'code' in value
&& 'message' in value
typeof value === 'object' &&
value !== null &&
'data' in value &&
'code' in value &&
'message' in value
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
export interface ApiEnvelope<T> {
code: string
code: number
message: string
requestId: string
data: T
+10 -7
View File
@@ -3,13 +3,16 @@ import { computed } from 'vue'
type BadgeVariant = 'normal' | 'success' | 'warning' | 'danger' | 'info' | 'default'
const props = withDefaults(defineProps<{
variant?: BadgeVariant
dot?: boolean
}>(), {
variant: 'default',
dot: false,
})
const props = withDefaults(
defineProps<{
variant?: BadgeVariant
dot?: boolean
}>(),
{
variant: 'default',
dot: false,
},
)
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-(--cp-default-bg) text-(--cp-text-secondary)',
+47 -28
View File
@@ -2,44 +2,63 @@
import { LoaderCircle } from '@lucide/vue'
import { computed } from 'vue'
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'plain' | 'text' | 'ghost'
type ButtonVariant =
| 'default'
| 'primary'
| 'success'
| 'warning'
| 'danger'
| 'plain'
| 'text'
| 'ghost'
type ButtonSize = 'small' | 'default' | 'large' | 'md'
const props = withDefaults(defineProps<{
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}>(), {
variant: 'primary',
size: 'default',
loading: false,
disabled: false,
type: 'button',
})
const props = withDefaults(
defineProps<{
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'default',
loading: false,
disabled: false,
type: 'button',
},
)
const sizeClasses = {
large: 'h-10 px-4 gap-2 text-[12px] rounded-[6px]',
default: 'h-8 px-3 gap-2 text-[12px] rounded-[6px]',
md: 'h-8 px-3 gap-2 text-[12px] rounded-[6px]',
small: 'h-6 px-2 gap-1.5 text-[11px] rounded-[4px]',
large: 'h-10 px-4 gap-2 text-xs rounded-(--cp-button-radius-base)',
default: 'h-8 px-3 gap-2 text-xs rounded-(--cp-button-radius-base)',
md: 'h-8 px-3 gap-2 text-xs rounded-(--cp-button-radius-base)',
small: 'h-6 px-2 gap-1.5 text-[11px] rounded-(--cp-button-radius-small)',
}
const variantClasses = {
default: 'bg-[#F8FAFC] text-[#0E1726] hover:bg-[#F1F5F9]',
primary: 'bg-[#2563EB] text-white hover:bg-[#1D4ED8]',
success: 'bg-[#ECFDF5] text-[#047857] hover:bg-[#DDFBEA]',
warning: 'bg-[#FFFBEB] text-[#B45309] hover:bg-[#FEF3C7]',
danger: 'bg-[#FEF2F2] text-[#B91C1C] hover:bg-[#FEE2E2]',
plain: 'bg-[#EEF6FF] text-[#2563EB] hover:bg-[#DBEAFE]',
text: 'bg-transparent text-[#2563EB] hover:bg-transparent p-0 h-auto',
ghost: 'bg-transparent text-[#64748B] hover:bg-[#F8FAFC]',
default:
'bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) active:bg-(--cp-default-bg-active)',
primary:
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
success:
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
warning:
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
danger:
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
plain:
'bg-(--cp-info-bg) text-(--cp-info-text) hover:bg-(--cp-info-bg-hover) active:bg-(--cp-info-bg-active)',
text: 'bg-transparent text-(--cp-info-text) hover:bg-transparent p-0 h-auto',
ghost:
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
}
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-all cursor-pointer',
'disabled:cursor-not-allowed disabled:bg-[#F1F5F9] disabled:text-[#94A3B8]',
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-all cursor-pointer outline-none',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
+30 -18
View File
@@ -1,19 +1,22 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
as?: keyof HTMLElementTagNameMap
title?: string
description?: string
padded?: boolean
radiusClass?: string
shadowClass?: string
}>(), {
as: 'section',
title: undefined,
description: undefined,
padded: true,
radiusClass: 'rounded-(--cp-card-radius)',
shadowClass: 'shadow-(--cp-shadow-card)',
})
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
title?: string
description?: string
padded?: boolean
radiusClass?: string
shadowClass?: string
}>(),
{
as: 'section',
title: undefined,
description: undefined,
padded: true,
radiusClass: 'rounded-(--cp-card-radius)',
shadowClass: 'shadow-(--cp-shadow-card)',
},
)
</script>
<template>
@@ -22,12 +25,21 @@ const props = withDefaults(defineProps<{
class="overflow-hidden bg-(--cp-bg-surface)"
:class="[props.radiusClass, props.shadowClass, props.padded ? 'p-6 md:px-7' : undefined]"
>
<header v-if="props.title || props.description || $slots.actions" class="mb-6 flex items-start justify-between gap-5">
<header
v-if="props.title || props.description || $slots.actions"
class="mb-6 flex items-start justify-between gap-5"
>
<div class="min-w-0">
<h2 v-if="props.title" class="m-0 text-xl font-[760] leading-[1.15] text-(--cp-text-primary)">
<h2
v-if="props.title"
class="m-0 text-xl font-[760] leading-[1.15] text-(--cp-text-primary)"
>
{{ props.title }}
</h2>
<p v-if="props.description" class="mt-1.75 mb-0 text-[13px] font-semibold leading-[1.15] text-(--cp-text-secondary)">
<p
v-if="props.description"
class="mt-1.75 mb-0 text-[13px] font-semibold leading-[1.15] text-(--cp-text-secondary)"
>
{{ props.description }}
</p>
</div>
+33 -5
View File
@@ -1,14 +1,42 @@
<script setup lang="ts">
defineProps<{
import { computed } from 'vue'
import type { Component } from 'vue'
import { Inbox } from '@lucide/vue'
const props = defineProps<{
title?: string
message?: string
description?: string
icon?: Component
compact?: boolean
}>()
const resolvedTitle = computed(() => props.title ?? props.message ?? '暂无数据')
const resolvedIcon = computed(() => props.icon ?? Inbox)
</script>
<template>
<div class="grid justify-items-center gap-2 rounded-[14px] bg-(--cp-bg-subtle) p-5 text-center">
<div class="h-1 w-7 rounded-full bg-(--cp-default-border-hover)" />
<p class="m-0 text-[13px] font-bold text-(--cp-text-primary)">{{ title ?? '暂无数据' }}</p>
<p v-if="description" class="m-0 text-xs font-semibold text-(--cp-text-secondary)">{{ description }}</p>
<div
class="grid justify-items-center rounded-[14px] bg-(--cp-bg-subtle) text-center"
:class="compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8'"
>
<span
class="inline-flex items-center justify-center rounded-[10px] bg-(--cp-bg-muted) text-(--cp-text-muted)"
:class="compact ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="compact ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-[760] text-(--cp-text-primary)">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-(--cp-text-secondary)"
>
{{ description }}
</p>
<div v-if="$slots.action" class="mt-1">
<slot name="action" />
</div>
</div>
</template>
+28 -15
View File
@@ -1,25 +1,38 @@
<script setup lang="ts">
withDefaults(defineProps<{
label?: string
title?: string
size?: 'default' | 'large' | 'sm' | 'md'
variant?: 'default' | 'ghost'
active?: boolean
disabled?: boolean
}>(), {
size: 'default',
variant: 'default',
active: false,
disabled: false,
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label?: string
title?: string
size?: 'default' | 'large' | 'sm' | 'md'
variant?: 'default' | 'ghost'
active?: boolean
disabled?: boolean
}>(),
{
size: 'default',
variant: 'default',
active: false,
disabled: false,
},
)
const sizeClasses = computed(() => {
if (props.size === 'large' || props.size === 'md') return 'h-11 w-11 rounded-xl'
if (props.size === 'sm') return 'h-8 w-8 rounded-(--cp-icon-button-radius)'
return 'h-9 w-9 rounded-xl'
})
</script>
<template>
<button
class="inline-flex items-center justify-center border-0 shadow-(--cp-shadow-control) transition-colors"
class="inline-flex items-center justify-center border-0 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
:class="[
size === 'large' || size === 'md' ? 'h-11 w-11 rounded-xl' : size === 'sm' ? 'h-8 w-8 rounded-lg' : 'h-9 w-9 rounded-xl',
variant === 'ghost' ? 'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-tertiary) shadow-none' : 'bg-(--cp-bg-surface) text-(--cp-text-secondary) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal)',
sizeClasses,
variant === 'ghost'
? 'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) shadow-none'
: 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal)',
active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : '',
disabled ? 'cursor-not-allowed opacity-50' : '',
]"
+50 -22
View File
@@ -3,42 +3,61 @@ import { computed } from 'vue'
type InputSize = 'small' | 'default' | 'large'
const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
error?: string
size?: InputSize
}>(), {
placeholder: '',
disabled: false,
error: undefined,
size: 'default',
})
const props = withDefaults(
defineProps<{
placeholder?: string
type?: string
disabled?: boolean
error?: string
size?: InputSize
autocomplete?: string
}>(),
{
placeholder: '',
type: 'text',
disabled: false,
error: undefined,
size: 'default',
autocomplete: undefined,
},
)
const model = defineModel<string>({ default: '' })
const sizeClasses = {
large: 'h-10 px-4 gap-2.5 text-[14px] rounded-[4px]',
default: 'h-8 px-3 gap-2.5 text-[14px] rounded-[4px]',
small: 'h-6 px-2 gap-1.5 text-[12px] rounded-[2px]',
large: 'h-10 px-4 gap-2.5 text-sm rounded-(--cp-control-radius-base)',
default: 'h-8 px-3 gap-2.5 text-sm rounded-(--cp-control-radius-base)',
small: 'h-6 px-2 gap-1.5 text-xs rounded-(--cp-control-radius-small)',
}
const containerClasses = computed(() => [
'inline-flex w-full items-center border border-transparent transition-all',
'focus-within:bg-white focus-within:shadow-[0_8px_18px_-16px_rgba(14,23,38,0.08)]',
'inline-flex w-full items-center border transition-all',
'focus-within:bg-(--cp-bg-surface) focus-within:border-(--cp-info-border) focus-within:shadow-(--cp-shadow-control)',
sizeClasses[props.size],
props.disabled ? 'bg-[#F1F5F9]' : props.error ? 'bg-[#FEF2F2]' : 'bg-[#F8FAFC]',
props.disabled
? 'border-(--cp-disabled-border) bg-(--cp-disabled-bg)'
: props.error
? 'border-(--cp-danger-border) bg-(--cp-danger-bg)'
: 'border-transparent bg-(--cp-bg-subtle)',
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled ? 'text-[#CBD5E1]' : props.error ? 'text-[#EF4444]' : 'text-[#94A3B8]',
props.disabled
? 'text-(--cp-disabled-icon)'
: props.error
? 'text-(--cp-danger)'
: 'text-(--cp-text-muted)',
])
const inputClasses = computed(() => [
'min-w-0 flex-1 border-0 bg-transparent font-[650] leading-[1.15] outline-0',
'placeholder:text-[#94A3B8] disabled:text-[#94A3B8]',
props.error ? 'text-[#B91C1C]' : props.disabled ? 'text-[#94A3B8]' : 'text-[#0E1726]',
'placeholder:text-(--cp-text-muted) disabled:text-(--cp-disabled-text)',
props.error
? 'text-(--cp-danger-text)'
: props.disabled
? 'text-(--cp-disabled-text)'
: 'text-(--cp-text-primary)',
])
</script>
@@ -48,11 +67,20 @@ const inputClasses = computed(() => [
<span v-if="$slots.prefix" :class="iconClasses">
<slot name="prefix" />
</span>
<input v-model="model" :class="inputClasses" :placeholder="placeholder" :disabled="disabled" />
<input
v-model="model"
:class="inputClasses"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
:autocomplete="autocomplete"
/>
<span v-if="$slots.suffix" :class="iconClasses">
<slot name="suffix" />
</span>
</span>
<span v-if="error" class="text-[12px] font-[650] leading-[1.15] text-[#B91C1C]">{{ error }}</span>
<span v-if="error" class="text-xs font-[650] leading-[1.15] text-(--cp-danger-text)">{{
error
}}</span>
</label>
</template>
+72 -10
View File
@@ -1,15 +1,58 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { computed } from 'vue'
import BaseButton from './BaseButton.vue'
import BaseIconButton from './BaseIconButton.vue'
defineProps<{
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
const props = defineProps<{
title: string
description?: string
width?: string | number
variant?: ModalVariant
}>()
const open = defineModel<boolean>({ default: false })
const variant = computed(() => props.variant ?? 'default')
const modalStyle = computed(() => ({
width: typeof props.width === 'number' ? `${props.width}px` : (props.width ?? undefined),
maxWidth: '100%',
}))
const iconMap = {
default: Info,
info: Info,
warning: AlertTriangle,
danger: AlertCircle,
success: CheckCircle2,
}
const variantClasses: Record<ModalVariant, { iconBg: string; icon: string }> = {
default: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
},
info: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
},
warning: {
iconBg: 'bg-(--cp-warning-bg)',
icon: 'text-(--cp-warning)',
},
danger: {
iconBg: 'bg-(--cp-danger-bg)',
icon: 'text-(--cp-danger)',
},
success: {
iconBg: 'bg-(--cp-success-bg)',
icon: 'text-(--cp-success)',
},
}
</script>
<template>
@@ -18,27 +61,46 @@ const open = defineModel<boolean>({ default: false })
<div class="absolute inset-0 bg-(--cp-overlay-scrim)" @click="open = false" />
<section
class="relative w-[min(560px,100%)] rounded-[22px] bg-(--cp-bg-surface) shadow-(--cp-shadow-popover)"
:style="modalStyle"
role="dialog"
aria-modal="true"
:aria-label="title"
>
<header class="flex justify-between gap-4 p-6 pb-0">
<div>
<h2 class="m-0 text-lg font-[760] text-(--cp-text-primary)">{{ title }}</h2>
<p v-if="description" class="mt-2 mb-0 text-[13px] font-semibold leading-tight text-(--cp-text-secondary)">
<header class="grid grid-cols-[auto_minmax(0,1fr)_28px] gap-4 p-7 pb-0">
<span
v-if="variant !== 'default'"
class="inline-flex size-11 items-center justify-center rounded-[14px]"
:class="variantClasses[variant].iconBg"
>
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
</span>
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
<h2 class="m-0 text-lg leading-[1.15] font-[760] text-(--cp-text-primary)">
{{ title }}
</h2>
<p
v-if="description"
class="mt-2 mb-0 text-[13px] font-semibold leading-[1.45] text-(--cp-text-secondary)"
>
{{ description }}
</p>
</div>
<BaseIconButton label="关闭" @click="open = false">
<BaseIconButton
class="col-start-3"
label="关闭"
size="sm"
variant="ghost"
@click="open = false"
>
<X :size="16" />
</BaseIconButton>
</header>
<div class="p-6">
<div class="p-7" :class="description || variant !== 'default' ? 'pt-5' : 'pt-6'">
<slot />
</div>
<footer class="flex justify-end gap-3 px-6 pb-6">
<footer class="flex justify-end gap-3 px-7 pb-7">
<slot name="footer">
<BaseButton @click="open = false">取消</BaseButton>
<BaseButton variant="default" @click="open = false">取消</BaseButton>
<BaseButton variant="primary" @click="open = false">确认</BaseButton>
</slot>
</footer>
+21 -17
View File
@@ -1,22 +1,25 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
const props = withDefaults(defineProps<{
tag?: string
always?: boolean
minThumbSize?: number
wrapClass?: string
viewClass?: string
}>(), {
tag: 'div',
always: false,
minThumbSize: 32,
wrapClass: '',
viewClass: '',
})
const props = withDefaults(
defineProps<{
tag?: string
always?: boolean
minThumbSize?: number
wrapClass?: string
viewClass?: string
}>(),
{
tag: 'div',
always: false,
minThumbSize: 32,
wrapClass: '',
viewClass: '',
},
)
const emit = defineEmits<{
scroll: [payload: { scrollTop: number, scrollLeft: number }]
scroll: [payload: { scrollTop: number; scrollLeft: number }]
}>()
const wrapRef = useTemplateRef<HTMLDivElement>('wrap')
@@ -112,9 +115,10 @@ function handleTrackPointerDown(event: PointerEvent) {
const nextThumbTop = event.clientY - rect.top - thumbHeight.value / 2
const scrollRange = maxScrollTop(wrap)
const thumbRange = maxThumbTop(wrap)
wrap.scrollTop = thumbRange > 0
? (Math.max(0, Math.min(nextThumbTop, thumbRange)) / thumbRange) * scrollRange
: 0
wrap.scrollTop =
thumbRange > 0
? (Math.max(0, Math.min(nextThumbTop, thumbRange)) / thumbRange) * scrollRange
: 0
}
function handleThumbPointerDown(event: PointerEvent) {
+7 -3
View File
@@ -12,12 +12,16 @@ const model = defineModel<string>({ required: true })
</script>
<template>
<div class="inline-flex min-h-9 gap-1 rounded-xl bg-(--cp-bg-muted) p-1" role="tablist">
<div class="inline-flex h-9 gap-1 rounded-xl bg-(--cp-bg-muted) p-1" role="tablist">
<button
v-for="option in options"
:key="option.value"
class="min-w-18.5 rounded-[9px] border-0 bg-transparent px-3 text-xs font-bold leading-none text-(--cp-text-secondary) transition-colors"
:class="model === option.value ? 'bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-(--cp-shadow-control)' : ''"
class="min-w-18.5 rounded-[9px] border-0 bg-transparent px-3 text-xs font-[650] leading-none text-(--cp-text-secondary) transition-colors outline-none hover:text-(--cp-text-primary) focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="
model === option.value
? 'bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-(--cp-shadow-control)'
: ''
"
type="button"
role="tab"
:aria-selected="model === option.value"
+43 -8
View File
@@ -1,30 +1,65 @@
<script setup lang="ts">
import { ChevronDown } from '@lucide/vue'
import { computed } from 'vue'
interface SelectOption {
label: string
value: string
disabled?: boolean
}
defineProps<{
options: SelectOption[]
disabled?: boolean
}>()
type SelectSize = 'small' | 'default' | 'large'
const props = withDefaults(
defineProps<{
options: SelectOption[]
size?: SelectSize
disabled?: boolean
}>(),
{
size: 'default',
disabled: false,
},
)
const model = defineModel<string>({ required: true })
const sizeClasses: Record<SelectSize, string> = {
large: 'h-10 text-sm',
default: 'h-8 text-[13px]',
small: 'h-6 text-xs',
}
const triggerClasses = computed(() => [
'relative inline-flex w-full rounded-(--cp-control-radius-base) border transition-all',
'focus-within:border-(--cp-info-border) focus-within:bg-(--cp-bg-surface) focus-within:shadow-(--cp-shadow-control)',
sizeClasses[props.size],
props.disabled
? 'border-(--cp-disabled-border) bg-(--cp-disabled-bg) text-(--cp-disabled-text)'
: 'border-transparent bg-(--cp-bg-surface) text-(--cp-text-primary)',
])
</script>
<template>
<label class="relative inline-flex h-8 w-full rounded-(--cp-control-radius-base) bg-(--cp-default-bg)">
<label :class="triggerClasses">
<select
v-model="model"
class="h-full w-full appearance-none rounded-[inherit] border-0 bg-transparent pr-8 pl-3 text-[13px] font-semibold text-(--cp-text-primary) outline-0"
class="h-full w-full appearance-none rounded-[inherit] border-0 bg-transparent pr-8 pl-3 font-semibold text-current outline-0 disabled:text-(--cp-disabled-text)"
:disabled="disabled"
>
<option v-for="option in options" :key="option.value" :value="option.value">
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
<ChevronDown class="pointer-events-none absolute top-2 right-3 text-(--cp-text-secondary)" :size="16" />
<ChevronDown
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
:class="disabled ? 'text-(--cp-disabled-icon)' : 'text-(--cp-text-secondary)'"
:size="size === 'small' ? 14 : 16"
/>
</label>
</template>
+4 -1
View File
@@ -7,7 +7,10 @@ withDefaults(defineProps<{ label?: string }>(), {
</script>
<template>
<span class="inline-flex items-center gap-2.5 text-[13px] font-semibold text-(--cp-text-secondary)" role="status">
<span
class="inline-flex items-center gap-2.5 text-[13px] font-semibold text-(--cp-text-secondary)"
role="status"
>
<LoaderCircle class="animate-spin text-(--cp-info)" :size="16" />
<span>{{ label }}</span>
</span>
+35 -29
View File
@@ -8,23 +8,29 @@ interface TableColumn {
cellClass?: string
}
withDefaults(defineProps<{
columns: TableColumn[]
rows: object[]
tableClass?: string
headerRowClass?: string
bodyRowClass?: string
headerCellClass?: string
bodyCellClass?: string
striped?: boolean
}>(), {
tableClass: 'min-w-155',
headerRowClass: 'h-10 rounded-xl bg-(--cp-bg-subtle) text-[11px] font-bold text-(--cp-text-muted)',
bodyRowClass: 'h-13 rounded-[10px]',
headerCellClass: 'min-w-0 overflow-hidden bg-(--cp-bg-subtle) px-3 text-ellipsis whitespace-nowrap first:rounded-l-xl last:rounded-r-xl',
bodyCellClass: 'min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap text-xs font-semibold text-(--cp-text-primary) first:rounded-l-[10px] last:rounded-r-[10px]',
striped: true,
})
withDefaults(
defineProps<{
columns: TableColumn[]
rows: object[]
tableClass?: string
headerRowClass?: string
bodyRowClass?: string
headerCellClass?: string
bodyCellClass?: string
striped?: boolean
}>(),
{
tableClass: 'min-w-155',
headerRowClass:
'h-10 rounded-xl bg-(--cp-bg-subtle) text-[11px] font-bold text-(--cp-text-muted)',
bodyRowClass: 'h-13 rounded-[10px] transition-colors',
headerCellClass:
'min-w-0 overflow-hidden bg-(--cp-bg-subtle) px-3 text-ellipsis whitespace-nowrap first:rounded-l-xl last:rounded-r-xl',
bodyCellClass:
'min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap text-xs font-semibold text-(--cp-text-primary) first:rounded-l-[10px] last:rounded-r-[10px]',
striped: true,
},
)
function cellValue(row: object, key: string) {
return (row as Record<string, unknown>)[key]
@@ -52,7 +58,10 @@ function alignClass(column: TableColumn) {
}
function rowSurfaceClass(index: number, striped: boolean) {
return striped && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : 'bg-(--cp-bg-surface)'
return [
striped && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : 'bg-(--cp-bg-surface)',
'group-hover:bg-(--cp-default-bg-hover)',
]
}
</script>
@@ -64,11 +73,7 @@ function rowSurfaceClass(index: number, striped: boolean) {
:style="{ width: '100%' }"
>
<colgroup>
<col
v-for="column in columns"
:key="column.key"
:style="{ width: columnWidth(column) }"
>
<col v-for="column in columns" :key="column.key" :style="{ width: columnWidth(column) }" />
</colgroup>
<thead>
@@ -85,15 +90,16 @@ function rowSurfaceClass(index: number, striped: boolean) {
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="rowKey(row, index)"
:class="bodyRowClass"
>
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" :class="['group', bodyRowClass]">
<td
v-for="column in columns"
:key="column.key"
:class="[bodyCellClass, rowSurfaceClass(index, striped), alignClass(column), column.cellClass]"
:class="[
bodyCellClass,
rowSurfaceClass(index, striped),
alignClass(column),
column.cellClass,
]"
>
<slot :name="column.key" :row="row" :value="cellValue(row, column.key)">
{{ cellValue(row, column.key) }}
-115
View File
@@ -1,115 +0,0 @@
<script setup lang="ts">
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from '@lucide/vue'
import { useToastStore } from '@/stores/modules/toast'
const toastStore = useToastStore()
const iconMap = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
}
const titleMap = {
success: '成功',
error: '失败',
warning: '警告',
info: '信息',
}
const colorClasses = {
success: {
bg: 'bg-[#ECFDF5]',
iconBg: 'bg-[#ECFDF5]',
icon: 'text-[#12B981]',
},
error: {
bg: 'bg-white',
iconBg: 'bg-[#FEF2F2]',
icon: 'text-[#EF4444]',
},
warning: {
bg: 'bg-white',
iconBg: 'bg-[#FFFBEB]',
icon: 'text-[#F59E0B]',
},
info: {
bg: 'bg-white',
iconBg: 'bg-[#EEF6FF]',
icon: 'text-[#2563EB]',
},
}
</script>
<template>
<Teleport to="body">
<div class="fixed top-6 right-6 z-9999 flex flex-col gap-5 pointer-events-none">
<TransitionGroup name="toast" tag="div" class="flex flex-col gap-5">
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
class="pointer-events-auto flex items-start gap-3 w-90 py-4 px-4 rounded-[18px] shadow-[0_16px_34px_-18px_rgba(14,23,38,0.13)] backdrop-blur-sm transition-all"
:class="colorClasses[toast.type].bg"
>
<div
class="flex items-center justify-center shrink-0 w-8.5 h-8.5 rounded-[10px]"
:class="colorClasses[toast.type].iconBg"
>
<component :is="iconMap[toast.type]" :size="18" :class="colorClasses[toast.type].icon" />
</div>
<div class="flex flex-col gap-1.25 flex-1 min-w-0 w-57.5">
<p class="m-0 text-[13px] leading-[1.15] font-bold text-[#0E1726]">
{{ titleMap[toast.type] }}
</p>
<p class="m-0 text-[12px] leading-[1.15] font-semibold text-[#64748B]">
{{ toast.message }}
</p>
</div>
<button
type="button"
class="flex items-center justify-center shrink-0 p-0 border-0 bg-transparent w-7 h-7 rounded-md cursor-pointer opacity-50 hover:opacity-100 transition-opacity"
@click="toastStore.remove(toast.id)"
>
<X :size="16" class="text-[#94A3B8]" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-enter-active {
animation: toast-in 0.3s ease-out;
}
.toast-leave-active {
animation: toast-out 0.2s ease-in;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes toast-out {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from '@lucide/vue'
import { toast } from './toast'
const iconMap = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
}
const titleMap = {
success: '成功',
error: '失败',
warning: '警告',
info: '信息',
}
const colorClasses = {
success: {
iconBg: 'bg-(--cp-success-bg)',
icon: 'text-(--cp-success)',
},
error: {
iconBg: 'bg-(--cp-danger-bg)',
icon: 'text-(--cp-danger)',
},
warning: {
iconBg: 'bg-(--cp-warning-bg)',
icon: 'text-(--cp-warning)',
},
info: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
},
}
</script>
<template>
<Teleport to="body">
<div class="fixed top-6 right-6 z-9999 flex flex-col gap-3 pointer-events-none">
<TransitionGroup name="toast" tag="div" class="flex flex-col gap-3">
<div
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 items-center gap-3 rounded-[18px] bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-popover) transition-all"
>
<div
class="flex items-center justify-center shrink-0 w-8.5 h-8.5 rounded-[10px]"
:class="colorClasses[message.type].iconBg"
>
<component
:is="iconMap[message.type]"
:size="18"
:class="colorClasses[message.type].icon"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">
{{ message.title ?? titleMap[message.type] }}
</p>
<p
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-(--cp-text-secondary)"
>
{{ message.message }}
</p>
</div>
<button
type="button"
class="flex items-center justify-center shrink-0 p-0 border-0 bg-transparent w-7 h-7 rounded-(--cp-button-radius-base) cursor-pointer opacity-60 transition-opacity hover:bg-(--cp-bg-subtle) hover:opacity-100"
:aria-label="`关闭${message.title ?? titleMap[message.type]}通知`"
@click="toast.remove(message.id)"
>
<X :size="16" class="text-(--cp-text-muted)" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-enter-active {
animation: toast-in 0.3s ease-out;
}
.toast-leave-active {
animation: toast-out 0.2s ease-in;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes toast-out {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
}
</style>
@@ -0,0 +1,3 @@
export { default as BaseToast } from './BaseToast.vue'
export { toast } from './toast'
export type { ToastMessage, ToastOptions, ToastType } from './toast'
@@ -0,0 +1,81 @@
import { reactive } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface ToastMessage {
id: string
type: ToastType
title?: string
message: string
duration: number
}
export interface ToastOptions {
title?: string
duration?: number
}
const DEFAULT_DURATION = 3000
const messages = reactive<ToastMessage[]>([])
const timers = new Map<string, number>()
function normalizeOptions(options: number | ToastOptions = DEFAULT_DURATION) {
if (typeof options === 'number') {
return {
duration: options,
title: undefined,
}
}
return {
duration: options.duration ?? DEFAULT_DURATION,
title: options.title,
}
}
function remove(id: string) {
const timer = timers.get(id)
if (timer !== undefined) {
window.clearTimeout(timer)
timers.delete(id)
}
const index = messages.findIndex((message) => message.id === id)
if (index >= 0) {
messages.splice(index, 1)
}
}
function show(type: ToastType, message: string, options?: number | ToastOptions) {
const { duration, title } = normalizeOptions(options)
const id = `toast_${Date.now()}_${Math.random().toString(16).slice(2)}`
messages.push({
id,
type,
message,
duration,
title,
})
if (duration > 0) {
timers.set(
id,
window.setTimeout(() => {
remove(id)
}, duration),
)
}
return id
}
export const toast = {
messages,
show,
remove,
success: (message: string, options?: number | ToastOptions) => show('success', message, options),
error: (message: string, options?: number | ToastOptions) => show('error', message, options),
warning: (message: string, options?: number | ToastOptions) => show('warning', message, options),
info: (message: string, options?: number | ToastOptions) => show('info', message, options),
}
+78 -9
View File
@@ -1,31 +1,100 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { init, type EChartsType } from 'echarts/core'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { useECharts } from '@/composables/useECharts'
import '@/plugins/echarts'
const props = withDefaults(defineProps<{
option: EChartsOption
height?: number
}>(), {
height: 240,
})
const props = withDefaults(
defineProps<{
option: EChartsOption
height?: number
}>(),
{
height: 240,
},
)
const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const observer = shallowRef<ResizeObserver>()
const animationFrame = shallowRef<number>()
const style = computed(() => ({
height: `${props.height}px`,
}))
function elementHasSize(element: HTMLElement) {
return element.clientWidth > 0 && element.clientHeight > 0
}
function cancelPendingInit() {
if (animationFrame.value !== undefined) {
cancelAnimationFrame(animationFrame.value)
animationFrame.value = undefined
}
}
function ensureChart(element: HTMLElement) {
if (chart.value || !elementHasSize(element)) return
chart.value = init(element, undefined, { renderer: 'canvas' })
chart.value.setOption(chartOption.value)
}
function scheduleInit(element: HTMLElement) {
cancelPendingInit()
animationFrame.value = requestAnimationFrame(() => {
animationFrame.value = undefined
ensureChart(element)
})
}
function resize() {
chart.value?.resize()
}
function dispose() {
cancelPendingInit()
observer.value?.disconnect()
chart.value?.dispose()
observer.value = undefined
chart.value = undefined
}
watch(
() => props.option,
(option) => {
chartOption.value = option
if (chart.value) {
chart.value.setOption(option, true)
return
}
if (chartElement.value) {
scheduleInit(chartElement.value)
}
},
)
useECharts(chartElement, chartOption)
watch(
chartElement,
(element) => {
dispose()
if (!element) return
observer.value = new ResizeObserver(() => {
if (chart.value) {
resize()
return
}
ensureChart(element)
})
observer.value.observe(element)
scheduleInit(element)
},
{ immediate: true },
)
onBeforeUnmount(dispose)
</script>
<template>
-53
View File
@@ -1,53 +0,0 @@
import { init, type EChartsType } from 'echarts/core'
import type { EChartsOption } from 'echarts'
import { onBeforeUnmount, shallowRef, watch, type ShallowRef } from 'vue'
import '@/plugins/echarts'
export function useECharts(
target: Readonly<ShallowRef<HTMLElement | null>>,
option: Readonly<ShallowRef<EChartsOption>>,
) {
const chart = shallowRef<EChartsType>()
const observer = shallowRef<ResizeObserver>()
function resize() {
chart.value?.resize()
}
function dispose() {
observer.value?.disconnect()
chart.value?.dispose()
observer.value = undefined
chart.value = undefined
}
watch(
target,
(element) => {
dispose()
if (!element) return
chart.value = init(element, undefined, { renderer: 'canvas' })
chart.value.setOption(option.value)
observer.value = new ResizeObserver(resize)
observer.value.observe(element)
},
{ immediate: true },
)
watch(
option,
(nextOption) => {
chart.value?.setOption(nextOption, true)
},
{ deep: true },
)
onBeforeUnmount(dispose)
return {
chart,
resize,
dispose,
}
}
+144
View File
@@ -0,0 +1,144 @@
import type { Directive, DirectiveBinding } from 'vue'
type LoadingBindingValue =
| boolean
| {
loading?: boolean
text?: string
}
interface LoadingState {
overlay: HTMLDivElement
previousPosition: string
managedPosition: boolean
}
type LoadingElement = HTMLElement & {
__cpLoading?: LoadingState
}
const OVERLAY_CLASS = [
'absolute',
'inset-0',
'z-20',
'grid',
'min-h-18',
'place-items-center',
'rounded-[inherit]',
'bg-(--cp-bg-surface)/82',
'backdrop-blur-[4px]',
].join(' ')
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
'items-center',
'gap-2.5',
'rounded-xl',
'bg-(--cp-bg-surface)',
'py-2.25',
'pr-3.5',
'pl-3',
'text-xs',
'leading-[1.15]',
'font-[760]',
'text-(--cp-text-secondary)',
'shadow-(--cp-shadow-popover)',
].join(' ')
const MARK_CLASS = [
'inline-block',
'size-4',
'rounded-full',
'animate-spin',
'bg-[radial-gradient(circle_at_center,var(--cp-bg-surface)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-normal)_0deg_105deg,var(--cp-info)_105deg_200deg,var(--cp-bg-muted)_200deg_360deg)]',
].join(' ')
const LABEL_CLASS = 'whitespace-nowrap'
function normalizeBinding(value: LoadingBindingValue | undefined) {
if (typeof value === 'object' && value !== null) {
return {
loading: Boolean(value.loading),
text: value.text || '加载中',
}
}
return {
loading: Boolean(value),
text: '加载中',
}
}
function createOverlay(text: string) {
const overlay = document.createElement('div')
overlay.className = OVERLAY_CLASS
overlay.setAttribute('role', 'status')
overlay.setAttribute('aria-live', 'polite')
const panel = document.createElement('div')
panel.className = PANEL_CLASS
const mark = document.createElement('span')
mark.className = MARK_CLASS
mark.setAttribute('aria-hidden', 'true')
const label = document.createElement('span')
label.className = LABEL_CLASS
label.dataset.loadingLabel = ''
label.textContent = text
panel.append(mark, label)
overlay.append(panel)
return overlay
}
function showLoading(element: LoadingElement, text: string) {
if (element.__cpLoading) {
const label = element.__cpLoading.overlay.querySelector<HTMLElement>('[data-loading-label]')
if (label) label.textContent = text
return
}
const previousPosition = element.style.position
const managedPosition = window.getComputedStyle(element).position === 'static'
if (managedPosition) {
element.style.position = 'relative'
}
const overlay = createOverlay(text)
element.append(overlay)
element.setAttribute('aria-busy', 'true')
element.classList.add('overflow-hidden')
element.__cpLoading = {
overlay,
previousPosition,
managedPosition,
}
}
function hideLoading(element: LoadingElement) {
const state = element.__cpLoading
if (!state) return
state.overlay.remove()
element.removeAttribute('aria-busy')
element.classList.remove('overflow-hidden')
if (state.managedPosition) {
element.style.position = state.previousPosition
}
element.__cpLoading = undefined
}
function syncLoading(element: LoadingElement, binding: DirectiveBinding<LoadingBindingValue>) {
const { loading, text } = normalizeBinding(binding.value)
if (loading) {
showLoading(element, text)
} else {
hideLoading(element)
}
}
export const loadingDirective: Directive<LoadingElement, LoadingBindingValue> = {
mounted: syncLoading,
updated: syncLoading,
beforeUnmount: hideLoading,
}
+37 -34
View File
@@ -3,13 +3,10 @@ import { gsap } from 'gsap'
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Box,
ChartColumn,
KeyRound,
LayoutDashboard,
PanelLeftClose,
PanelLeftOpen,
Radar,
ScrollText,
Settings,
SquareTerminal,
@@ -21,13 +18,10 @@ const router = useRouter()
const navItems = [
{ label: '概览', icon: LayoutDashboard, path: '/' },
{ label: '账号池', icon: Users, path: '/accounts' },
{ label: 'API Keys', icon: KeyRound, path: '/api-keys' },
{ label: '日志', icon: ScrollText, path: '/logs' },
{ label: '用量', icon: ChartColumn, path: '/usage' },
{ label: '模型', icon: Box, path: '/models' },
{ label: '设置', icon: Settings, path: '/settings' },
{ label: '诊断', icon: Radar, path: '/diagnostics' },
{ label: '账号管理', icon: Users, path: '/accounts' },
{ label: 'API 密钥', icon: KeyRound, path: '/api-keys' },
{ label: '使用统计', icon: ScrollText, path: '/logs' },
{ label: '系统设置', icon: Settings, path: '/settings' },
]
const isActive = (path: string) => {
@@ -39,11 +33,14 @@ function navigate(path: string) {
router.push(path)
}
const props = withDefaults(defineProps<{
collapsed?: boolean
}>(), {
collapsed: false,
})
const props = withDefaults(
defineProps<{
collapsed?: boolean
}>(),
{
collapsed: false,
},
)
defineEmits<{
toggle: []
@@ -164,8 +161,7 @@ onMounted(() => {
})
if (props.collapsed) {
hideBrandLabel()
}
else {
} else {
gsap.set(brandLabelEl.value, {
opacity: 1,
x: 0,
@@ -179,8 +175,7 @@ watch(
async (collapsed) => {
if (collapsed) {
hideBrandLabel()
}
else {
} else {
brandLabelVisible.value = true
}
animateSidebarLabels(Boolean(collapsed))
@@ -197,34 +192,38 @@ watch(
<template>
<aside
ref="sidebarEl"
class="z-20 hidden h-screen shrink-0 flex-col overflow-hidden bg-white shadow-(--cp-shadow-sidebar) min-[961px]:flex"
class="z-20 hidden h-screen shrink-0 flex-col overflow-hidden bg-white px-4 shadow-(--cp-shadow-sidebar) min-[961px]:flex"
:class="collapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64'"
>
<div
class="mt-7.75 grid h-11.5 grid-cols-[46px_minmax(0,1fr)] gap-2.5"
:class="collapsed ? 'w-11.5' : 'ml-6 w-47 self-start'"
class="mt-5 grid h-11.5 grid-cols-[46px_minmax(0,1fr)] gap-2.5"
:class="collapsed ? 'w-11.5' : 'w-full'"
>
<span class="inline-flex size-11.5 items-center justify-center rounded-[13px] bg-gray-900 text-white shadow-[0_8px_18px_-16px_#0E172614]">
<SquareTerminal :size="24" />
<span
class="inline-flex size-11 items-center justify-center rounded-[13px] bg-gray-900 text-white shadow-[0_8px_18px_-16px_#0E172614]"
>
<SquareTerminal :size="22" />
</span>
<span v-show="brandLabelVisible" ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden">
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-[17px] leading-[1.1] font-[720] text-gray-900">Codex</strong>
<span class="mt-1 text-xs leading-[1.1] font-semibold text-slate-500">Proxy RS · v0.1.0</span>
<span class="mt-1 text-xs leading-[1.1] font-semibold text-slate-500"
>Proxy RS · v0.1.0</span
>
</span>
</div>
<nav
class="mt-8.75 grid gap-3"
:class="collapsed ? '' : 'ml-6 self-start'"
aria-label="主导航"
>
<nav class="mt-6 grid gap-3" :class="collapsed ? '' : 'w-full'" aria-label="主导航">
<button
v-for="item in navItems"
:key="item.label"
type="button"
class="inline-flex h-11.5 items-center rounded-xl text-sm leading-[1.15] border-0 cursor-pointer transition-colors duration-200"
:class="[
collapsed ? 'w-11.5 justify-center' : 'w-52 gap-3 pl-5.5',
collapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? 'bg-[#E9EEF5] font-bold text-gray-900'
: 'bg-transparent font-semibold text-slate-500 hover:bg-slate-50',
@@ -232,13 +231,17 @@ watch(
@click="navigate(item.path)"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200" :class="collapsed ? 'pointer-events-none w-0' : 'w-auto'">{{ item.label }}</span>
<span
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
:class="collapsed ? 'pointer-events-none w-0' : 'w-auto'"
>{{ item.label }}</span
>
</button>
</nav>
<button
class="mt-auto mb-8 inline-flex items-center justify-center rounded-xl border-0 text-slate-500 transition-colors duration-200"
:class="collapsed ? 'h-9 w-11 bg-transparent' : 'mr-6 size-9 self-end bg-slate-50'"
:class="collapsed ? 'h-9 w-11 bg-transparent' : 'size-9 self-end bg-slate-50'"
type="button"
data-sidebar-toggle
:aria-label="collapsed ? '展开侧边栏' : '收缩侧边栏'"
+100 -63
View File
@@ -80,7 +80,10 @@ function animateAccountMenuLeave(el: Element, done: () => void) {
>
<CalendarDays class="text-(--cp-info)" :size="18" />
<span class="whitespace-nowrap">最近 24 小时</span>
<ChevronDown class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180" :size="16" />
<ChevronDown
class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180"
:size="16"
/>
</button>
<button
@@ -98,81 +101,115 @@ function animateAccountMenuLeave(el: Element, done: () => void) {
data-account-trigger
@click="accountMenuOpen = !accountMenuOpen"
>
<span class="inline-flex size-7 items-center justify-center rounded-full bg-(--cp-normal) text-xs leading-[1.15] font-bold text-white">A</span>
<span
class="inline-flex size-7 items-center justify-center rounded-full bg-(--cp-normal) text-xs leading-[1.15] font-bold text-white"
>A</span
>
<span class="grid gap-1.25 pl-3 text-left">
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">admin</strong>
<span class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-secondary)">管理员</span>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)"
>admin</strong
>
<span class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
>管理员</span
>
</span>
<ChevronUp v-if="accountMenuOpen" class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180" :size="16" />
<ChevronDown v-else class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180" :size="16" />
<ChevronUp
v-if="accountMenuOpen"
class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180"
:size="16"
/>
<ChevronDown
v-else
class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180"
:size="16"
/>
</button>
<Transition
:css="false"
@enter="animateAccountMenuEnter"
@leave="animateAccountMenuLeave"
>
<div v-if="accountMenuOpen" data-account-menu class="absolute right-0 top-14 h-76 w-80 origin-top-right rounded-[18px] bg-white pt-4.5 shadow-(--cp-shadow-popover)">
<Transition :css="false" @enter="animateAccountMenuEnter" @leave="animateAccountMenuLeave">
<div
v-if="accountMenuOpen"
data-account-menu
class="absolute right-0 top-14 h-76 w-80 origin-top-right rounded-[18px] bg-white pt-4.5 shadow-(--cp-shadow-popover)"
>
<div class="mx-4.5 grid h-11 grid-cols-[44px_minmax(0,1fr)_64px] items-center gap-3">
<span class="inline-flex size-11 items-center justify-center rounded-full bg-(--cp-normal) text-sm leading-[1.15] font-bold text-white">A</span>
<span class="grid gap-1.25">
<strong class="text-[15px] leading-[1.15] font-bold text-(--cp-text-primary)">admin</strong>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">管理员</span>
</span>
<span class="inline-flex h-7 w-16 items-center gap-1.5 rounded-full bg-(--cp-success-bg) px-3">
<i class="size-1.5 rounded-full bg-(--cp-success)" />
<span class="text-xs leading-[1.15] font-[650] text-(--cp-success-text)">在线</span>
</span>
<span
class="inline-flex size-11 items-center justify-center rounded-full bg-(--cp-normal) text-sm leading-[1.15] font-bold text-white"
>A</span
>
<span class="grid gap-1.25">
<strong class="text-[15px] leading-[1.15] font-bold text-(--cp-text-primary)"
>admin</strong
>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>管理员</span
>
</span>
<span
class="inline-flex h-7 w-16 items-center gap-1.5 rounded-full bg-(--cp-success-bg) px-3"
>
<i class="size-1.5 rounded-full bg-(--cp-success)" />
<span class="text-xs leading-[1.15] font-[650] text-(--cp-success-text)">在线</span>
</span>
</div>
<div class="mx-4 mt-3.5 grid h-11 w-72 grid-cols-2 rounded-xl bg-(--cp-bg-subtle) px-4 py-2">
<span class="grid gap-1.25">
<small class="text-[11px] leading-[1.15] font-semibold text-(--cp-text-secondary)">MFA</small>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">已开启</strong>
</span>
<span class="grid gap-1.25 pl-4.5">
<small class="text-[11px] leading-[1.15] font-semibold text-(--cp-text-secondary)">会话</small>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">1 台</strong>
</span>
<div
class="mx-4 mt-3.5 grid h-11 w-72 grid-cols-2 rounded-xl bg-(--cp-bg-subtle) px-4 py-2"
>
<span class="grid gap-1.25">
<small class="text-[11px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
>MFA</small
>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)"
>已开启</strong
>
</span>
<span class="grid gap-1.25 pl-4.5">
<small class="text-[11px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
>会话</small
>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)"
>1 台</strong
>
</span>
</div>
<div class="mx-4 mt-3.5 grid w-72 gap-1.5">
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-(--cp-bg-subtle) px-3 text-left text-(--cp-text-primary) transition-colors duration-200"
type="button"
>
<User :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">个人设置</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-(--cp-bg-subtle) px-3 text-left text-(--cp-text-primary) transition-colors duration-200"
type="button"
>
<User :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">个人设置</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_34px_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-text-primary) transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
type="button"
>
<ShieldCheck :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">安全与会话</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">MFA</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_34px_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-text-primary) transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
type="button"
>
<ShieldCheck :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">安全与会话</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">MFA</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-text-primary) transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
type="button"
>
<ScrollText :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">操作记录</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-text-primary) transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
type="button"
>
<ScrollText :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">操作记录</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-danger-text) transition-colors duration-200 hover:bg-(--cp-danger-bg)"
type="button"
@click="handleLogout"
>
<LogOut class="text-(--cp-danger)" :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">退出登录</span>
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-danger-text) transition-colors duration-200 hover:bg-(--cp-danger-bg)"
type="button"
@click="handleLogout"
>
<LogOut class="text-(--cp-danger)" :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">退出登录</span>
</button>
</div>
</div>
</Transition>
+1 -1
View File
@@ -16,7 +16,7 @@ const { toggleSidebar } = uiStore
<div class="flex h-screen overflow-hidden bg-(--cp-bg-page)">
<AppSidebar :collapsed="sidebarCollapsed" @toggle="toggleSidebar" />
<main class="h-screen min-w-0 flex-1 overflow-hidden">
<BaseScrollbar>
<BaseScrollbar view-class="w-full min-w-295 p-6">
<RouterView />
</BaseScrollbar>
</main>
+2 -1
View File
@@ -5,7 +5,8 @@ import '@fontsource-variable/jetbrains-mono'
import App from './App.vue'
import { router } from './router'
import { pinia } from './stores'
import { loadingDirective } from './directives/loading'
import './styles/index.css'
createApp(App).use(pinia).use(router).mount('#app')
createApp(App).directive('loading', loadingDirective).use(pinia).use(router).mount('#app')
+1 -6
View File
@@ -3,9 +3,4 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
use([
LineChart,
GridComponent,
TooltipComponent,
CanvasRenderer,
])
use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
+7 -8
View File
@@ -9,30 +9,29 @@ export const router = createRouter({
})
// 路由守卫
router.beforeEach(async (to, _from, next) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// 登录页面不需要认证
if (to.path === '/login') {
// 如果已登录,重定向到首页
if (authStore.isAuthenticated) {
next('/')
} else {
next()
return '/'
}
return
}
// 其他页面需要认证
if (!authStore.isAuthenticated) {
if (!authStore.isAuthenticated && !authStore.sessionChecked) {
// 尝试检查认证状态
const isAuth = await authStore.checkAuth()
if (!isAuth) {
// 未认证,跳转到登录页
next('/login')
return
return '/login'
}
}
next()
if (!authStore.isAuthenticated) {
return '/login'
}
})
-1
View File
@@ -7,5 +7,4 @@ pinia.use(piniaPluginPersistedstate)
// 导出所有 store
export * from './modules/auth'
export * from './modules/toast'
export * from './modules/ui'
+10 -14
View File
@@ -1,24 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDiagnostics, login as apiLogin, logout as apiLogout } from '@/api'
import { getAuthStatus, login as apiLogin, logout as apiLogout } from '@/api'
import type { LoginRequest } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = ref(false)
const email = ref<string | undefined>()
const accountId = ref<string | undefined>()
const sessionChecked = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function checkAuth() {
try {
await getDiagnostics()
isAuthenticated.value = true
return true
const status = await getAuthStatus()
isAuthenticated.value = status.authenticated
return status.authenticated
} catch (err: any) {
isAuthenticated.value = false
return false
} finally {
sessionChecked.value = true
}
}
@@ -29,7 +30,7 @@ export const useAuthStore = defineStore('auth', () => {
await apiLogin(payload)
isAuthenticated.value = true
email.value = payload.username
sessionChecked.value = true
return true
} catch (err: any) {
@@ -48,18 +49,13 @@ export const useAuthStore = defineStore('auth', () => {
// 忽略登出错误
} finally {
isAuthenticated.value = false
email.value = undefined
accountId.value = undefined
// 手动清除 cookie(临时方案,应该由后端清除)
document.cookie = 'cpr_admin_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
sessionChecked.value = true
}
}
return {
isAuthenticated,
email,
accountId,
sessionChecked,
loading,
error,
checkAuth,
-61
View File
@@ -1,61 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: string
type: 'success' | 'error' | 'warning' | 'info'
message: string
duration?: number
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
function show(type: Toast['type'], message: string, duration = 3000) {
const id = `toast_${Date.now()}_${Math.random()}`
const toast: Toast = { id, type, message, duration }
toasts.value.push(toast)
if (duration > 0) {
setTimeout(() => {
remove(id)
}, duration)
}
return id
}
function success(message: string, duration?: number) {
return show('success', message, duration)
}
function error(message: string, duration?: number) {
return show('error', message, duration)
}
function warning(message: string, duration?: number) {
return show('warning', message, duration)
}
function info(message: string, duration?: number) {
return show('info', message, duration)
}
function remove(id: string) {
const index = toasts.value.findIndex(t => t.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
return {
toasts,
show,
success,
error,
warning,
info,
remove,
}
})
+9 -1
View File
@@ -6,7 +6,15 @@ html {
min-width: 360px;
background: var(--cp-bg-page);
color: var(--cp-text-primary);
font-family: "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family:
'Inter Variable',
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
+6 -2
View File
@@ -3,8 +3,12 @@
@import 'tailwindcss';
@theme {
--font-sans: "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--font-sans:
'Inter Variable', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
--font-mono:
'JetBrains Mono Variable', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono',
monospace;
--color-cp-page: var(--cp-bg-page);
--color-cp-surface: var(--cp-bg-surface);
--color-cp-subtle: var(--cp-bg-subtle);
+50 -82
View File
@@ -9,11 +9,17 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSpinner from '@/components/base/BaseSpinner.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import type { Account } from '@/api'
import { batchDeleteAccounts, createAccount, deleteAccount, getAccounts, refreshAccount, updateAccountLabel } from '@/api'
import {
batchDeleteAccounts,
createAccount,
deleteAccount,
getAccounts,
refreshAccount,
updateAccountLabel,
} from '@/api'
const loading = ref(true)
const accounts = ref<Account[]>([])
@@ -21,7 +27,7 @@ const searchQuery = ref('')
const selectedIds = ref<Set<string>>(new Set())
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const editingLabel = ref<{ id: string, value: string } | null>(null)
const editingLabel = ref<{ id: string; value: string } | null>(null)
const createForm = ref({
refreshToken: '',
@@ -37,31 +43,34 @@ const statusLabels: Record<Account['status'], string> = {
refreshing: '刷新中',
}
const statusTones: Record<Account['status'], 'success' | 'danger' | 'warning' | 'info' | 'normal'> = {
active: 'success',
expired: 'warning',
disabled: 'normal',
banned: 'danger',
quota_exhausted: 'warning',
refreshing: 'info',
}
const statusTones: Record<Account['status'], 'success' | 'danger' | 'warning' | 'info' | 'normal'> =
{
active: 'success',
expired: 'warning',
disabled: 'normal',
banned: 'danger',
quota_exhausted: 'warning',
refreshing: 'info',
}
const filteredAccounts = computed(() => {
if (!searchQuery.value) return accounts.value
const query = searchQuery.value.toLowerCase()
return accounts.value.filter(acc =>
(acc.email ?? '').toLowerCase().includes(query)
|| acc.label?.toLowerCase().includes(query)
|| acc.id.toLowerCase().includes(query),
return accounts.value.filter(
(acc) =>
(acc.email ?? '').toLowerCase().includes(query) ||
acc.label?.toLowerCase().includes(query) ||
acc.id.toLowerCase().includes(query),
)
})
const allSelected = computed(() =>
selectedIds.value.size === filteredAccounts.value.length && filteredAccounts.value.length > 0,
const allSelected = computed(
() =>
selectedIds.value.size === filteredAccounts.value.length && filteredAccounts.value.length > 0,
)
const indeterminate = computed(() =>
selectedIds.value.size > 0 && selectedIds.value.size < filteredAccounts.value.length,
const indeterminate = computed(
() => selectedIds.value.size > 0 && selectedIds.value.size < filteredAccounts.value.length,
)
async function loadAccounts() {
@@ -151,7 +160,7 @@ function toggleAll() {
if (selectedIds.value.size === filteredAccounts.value.length) {
selectedIds.value.clear()
} else {
filteredAccounts.value.forEach(acc => selectedIds.value.add(acc.id))
filteredAccounts.value.forEach((acc) => selectedIds.value.add(acc.id))
}
}
@@ -177,7 +186,7 @@ onMounted(() => {
</script>
<template>
<div class="w-full min-w-295 p-7">
<div class="w-full">
<header class="flex h-17 items-start justify-between">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
@@ -193,11 +202,7 @@ onMounted(() => {
<div class="mt-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<BaseInput
v-model="searchQuery"
placeholder="搜索邮箱、标签或 ID..."
class="w-80"
>
<BaseInput v-model="searchQuery" placeholder="搜索邮箱、标签或 ID..." class="w-80">
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
@@ -215,47 +220,28 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<BaseIconButton
variant="ghost"
size="md"
title="导出账号"
>
<BaseIconButton variant="ghost" size="md" title="导出账号">
<Download class="size-4.5" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="md"
title="导入账号"
>
<BaseIconButton variant="ghost" size="md" title="导入账号">
<Upload class="size-4.5" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="md"
title="刷新列表"
@click="loadAccounts"
>
<BaseIconButton variant="ghost" size="md" title="刷新列表" @click="loadAccounts">
<RefreshCw class="size-4.5" />
</BaseIconButton>
<BaseButton
variant="primary"
size="md"
@click="showCreateModal = true"
>
<BaseButton variant="primary" size="md" @click="showCreateModal = true">
<Plus class="size-4" />
添加账号
</BaseButton>
</div>
</div>
<BaseCard class="mt-5 p-0">
<BaseSpinner v-if="loading" class="py-20" />
<BaseCard v-loading="loading" class="mt-5 p-0">
<BaseEmpty
v-else-if="filteredAccounts.length === 0"
v-if="filteredAccounts.length === 0 && !loading"
message="暂无账号数据"
class="py-20"
/>
@@ -271,7 +257,7 @@ onMounted(() => {
:checked="allSelected"
:indeterminate="indeterminate"
@change="toggleAll"
>
/>
</th>
<th class="px-3">邮箱 / 标签</th>
<th class="px-3">状态</th>
@@ -296,7 +282,7 @@ onMounted(() => {
class="cursor-pointer"
:checked="selectedIds.has(account.id)"
@change="toggleSelection(account.id)"
>
/>
</td>
<td class="px-3">
<div class="flex flex-col gap-0.5">
@@ -310,7 +296,7 @@ onMounted(() => {
class="text-[13px] px-2 py-0.5 rounded border border-(--cp-border-primary) bg-(--cp-bg-primary)"
@keyup.enter="handleUpdateLabel(account.id, editingLabel.value)"
@keyup.escape="cancelEditLabel"
>
/>
<button
class="text-[12px] text-(--cp-accent-primary) hover:underline"
@click="handleUpdateLabel(account.id, editingLabel.value)"
@@ -353,14 +339,10 @@ onMounted(() => {
</span>
</td>
<td class="px-3 text-right">
<span class="text-[14px] font-mono text-(--cp-text-secondary)">
—
</span>
<span class="text-[14px] font-mono text-(--cp-text-secondary)"> — </span>
</td>
<td class="px-3 text-right">
<span class="text-[14px] font-mono text-(--cp-text-secondary)">
—
</span>
<span class="text-[14px] font-mono text-(--cp-text-secondary)"> — </span>
</td>
<td class="px-3">
<span class="text-[13px] text-(--cp-text-secondary)">
@@ -402,6 +384,8 @@ onMounted(() => {
<BaseModal
v-model="showCreateModal"
title="添加账号"
description="粘贴 Refresh Token 后创建一个可参与调度的 Codex 账号。"
variant="info"
width="540px"
>
<div class="flex flex-col gap-4">
@@ -420,20 +404,12 @@ onMounted(() => {
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
标签(可选)
</label>
<BaseInput
v-model="createForm.label"
placeholder="例如:生产账号、测试账号..."
/>
<BaseInput v-model="createForm.label" placeholder="例如:生产账号、测试账号..." />
</div>
</div>
<template #footer>
<BaseButton
variant="ghost"
@click="showCreateModal = false"
>
取消
</BaseButton>
<BaseButton variant="ghost" @click="showCreateModal = false"> 取消 </BaseButton>
<BaseButton
variant="primary"
:disabled="!createForm.refreshToken.trim()"
@@ -448,6 +424,8 @@ onMounted(() => {
<BaseModal
v-model="showDeleteModal"
title="确认删除"
description="删除后该账号将不再参与调度,此操作不可撤销。"
variant="warning"
width="480px"
>
<p class="text-[14px] text-(--cp-text-secondary)">
@@ -455,18 +433,8 @@ onMounted(() => {
</p>
<template #footer>
<BaseButton
variant="ghost"
@click="showDeleteModal = false"
>
取消
</BaseButton>
<BaseButton
variant="danger"
@click="handleBatchDelete"
>
确认删除
</BaseButton>
<BaseButton variant="ghost" @click="showDeleteModal = false"> 取消 </BaseButton>
<BaseButton variant="danger" @click="handleBatchDelete"> 确认删除 </BaseButton>
</template>
</BaseModal>
</div>
+51 -99
View File
@@ -9,7 +9,6 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSpinner from '@/components/base/BaseSpinner.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import type { ClientApiKey } from '@/api'
@@ -21,9 +20,7 @@ import {
updateApiKeyLabel,
updateApiKeyStatus,
} from '@/api'
import { useToastStore } from '@/stores/modules/toast'
const toast = useToastStore()
import { toast } from '@/components/base/BaseToast'
const loading = ref(true)
const apiKeys = ref<ClientApiKey[]>([])
@@ -33,7 +30,7 @@ const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const showKeyModal = ref(false)
const createdKey = ref('')
const editingLabel = ref<{ id: string, value: string } | null>(null)
const editingLabel = ref<{ id: string; value: string } | null>(null)
const createForm = ref({
name: '',
@@ -43,19 +40,20 @@ const createForm = ref({
const filteredKeys = computed(() => {
if (!searchQuery.value) return apiKeys.value
const query = searchQuery.value.toLowerCase()
return apiKeys.value.filter(key =>
key.name.toLowerCase().includes(query)
|| key.label?.toLowerCase().includes(query)
|| key.id.toLowerCase().includes(query),
return apiKeys.value.filter(
(key) =>
key.name.toLowerCase().includes(query) ||
key.label?.toLowerCase().includes(query) ||
key.id.toLowerCase().includes(query),
)
})
const allSelected = computed(() =>
selectedIds.value.size === filteredKeys.value.length && filteredKeys.value.length > 0,
const allSelected = computed(
() => selectedIds.value.size === filteredKeys.value.length && filteredKeys.value.length > 0,
)
const indeterminate = computed(() =>
selectedIds.value.size > 0 && selectedIds.value.size < filteredKeys.value.length,
const indeterminate = computed(
() => selectedIds.value.size > 0 && selectedIds.value.size < filteredKeys.value.length,
)
async function loadApiKeys() {
@@ -159,16 +157,19 @@ function toggleAll() {
if (selectedIds.value.size === filteredKeys.value.length) {
selectedIds.value.clear()
} else {
filteredKeys.value.forEach(key => selectedIds.value.add(key.id))
filteredKeys.value.forEach((key) => selectedIds.value.add(key.id))
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
toast.success('已复制到剪贴板')
}).catch(() => {
toast.error('复制失败')
})
navigator.clipboard
.writeText(text)
.then(() => {
toast.success('已复制到剪贴板')
})
.catch(() => {
toast.error('复制失败')
})
}
function formatDate(dateStr?: string): string {
@@ -197,7 +198,7 @@ onMounted(() => {
</script>
<template>
<div class="w-full min-w-295 p-7">
<div class="w-full">
<header class="flex h-17 items-start justify-between">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
@@ -213,11 +214,7 @@ onMounted(() => {
<div class="mt-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<BaseInput
v-model="searchQuery"
placeholder="搜索名称、标签或 ID..."
class="w-80"
>
<BaseInput v-model="searchQuery" placeholder="搜索名称、标签或 ID..." class="w-80">
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
@@ -235,47 +232,28 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<BaseIconButton
variant="ghost"
size="md"
title="导出密钥"
>
<BaseIconButton variant="ghost" size="md" title="导出密钥">
<Download class="size-4.5" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="md"
title="导入密钥"
>
<BaseIconButton variant="ghost" size="md" title="导入密钥">
<Upload class="size-4.5" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="md"
title="刷新列表"
@click="loadApiKeys"
>
<BaseIconButton variant="ghost" size="md" title="刷新列表" @click="loadApiKeys">
<RefreshCw class="size-4.5" />
</BaseIconButton>
<BaseButton
variant="primary"
size="md"
@click="showCreateModal = true"
>
<BaseButton variant="primary" size="md" @click="showCreateModal = true">
<Plus class="size-4" />
创建 API Key
</BaseButton>
</div>
</div>
<BaseCard class="mt-5 p-0">
<BaseSpinner v-if="loading" class="py-20" />
<BaseCard v-loading="loading" class="mt-5 p-0">
<BaseEmpty
v-else-if="filteredKeys.length === 0"
v-if="filteredKeys.length === 0 && !loading"
message="暂无 API Keys"
class="py-20"
/>
@@ -291,7 +269,7 @@ onMounted(() => {
:checked="allSelected"
:indeterminate="indeterminate"
@change="toggleAll"
>
/>
</th>
<th class="px-3">名称 / 标签</th>
<th class="px-3">密钥前缀</th>
@@ -314,7 +292,7 @@ onMounted(() => {
class="cursor-pointer"
:checked="selectedIds.has(key.id)"
@change="toggleSelection(key.id)"
>
/>
</td>
<td class="px-3">
<div class="flex flex-col gap-0.5">
@@ -328,7 +306,7 @@ onMounted(() => {
class="text-[13px] px-2 py-0.5 rounded border border-(--cp-border-primary) bg-(--cp-bg-primary)"
@keyup.enter="handleUpdateLabel(key.id, editingLabel.value)"
@keyup.escape="cancelEditLabel"
>
/>
<button
class="text-[12px] text-(--cp-accent-primary) hover:underline"
@click="handleUpdateLabel(key.id, editingLabel.value)"
@@ -408,6 +386,8 @@ onMounted(() => {
<BaseModal
v-model="showCreateModal"
title="创建 API Key"
description="为当前代理管理端生成一个新的访问密钥。创建后请立即保存。"
variant="info"
width="540px"
>
<div class="flex flex-col gap-4">
@@ -415,35 +395,20 @@ onMounted(() => {
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
名称 <span class="text-red-500">*</span>
</label>
<BaseInput
v-model="createForm.name"
placeholder="例如:生产环境、测试账号..."
/>
<BaseInput v-model="createForm.name" placeholder="例如:生产环境、测试账号..." />
</div>
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
标签(可选)
</label>
<BaseInput
v-model="createForm.label"
placeholder="备注信息..."
/>
<BaseInput v-model="createForm.label" placeholder="备注信息..." />
</div>
</div>
<template #footer>
<BaseButton
variant="ghost"
@click="showCreateModal = false"
>
取消
</BaseButton>
<BaseButton
variant="primary"
:disabled="!createForm.name.trim()"
@click="handleCreate"
>
<BaseButton variant="ghost" @click="showCreateModal = false"> 取消 </BaseButton>
<BaseButton variant="primary" :disabled="!createForm.name.trim()" @click="handleCreate">
创建
</BaseButton>
</template>
@@ -453,12 +418,14 @@ onMounted(() => {
<BaseModal
v-model="showKeyModal"
title="API Key 已创建"
description="密钥只会显示一次,关闭弹窗后无法再次查看完整内容。"
variant="success"
width="540px"
>
<div class="flex flex-col gap-4">
<div class="px-4 py-3 rounded-xl bg-yellow-50 border border-yellow-200">
<p class="m-0 text-[13px] font-medium text-yellow-800">
⚠️ 请妥善保存此密钥,它只会显示一次!
<div class="rounded-xl border border-(--cp-warning-border) bg-(--cp-warning-bg) px-4 py-3">
<p class="m-0 text-[13px] font-semibold text-(--cp-warning-text)">
请妥善保存此密钥,它只会显示一次。
</p>
</div>
@@ -467,14 +434,12 @@ onMounted(() => {
API Key
</label>
<div class="flex items-center gap-2">
<code class="flex-1 px-3 py-2.5 rounded-lg bg-(--cp-bg-subtle) text-[13px] font-mono text-(--cp-text-primary) break-all">
<code
class="flex-1 px-3 py-2.5 rounded-lg bg-(--cp-bg-subtle) text-[13px] font-mono text-(--cp-text-primary) break-all"
>
{{ createdKey }}
</code>
<BaseIconButton
size="md"
title="复制"
@click="copyToClipboard(createdKey)"
>
<BaseIconButton size="md" title="复制" @click="copyToClipboard(createdKey)">
<Copy class="size-4" />
</BaseIconButton>
</div>
@@ -482,12 +447,7 @@ onMounted(() => {
</div>
<template #footer>
<BaseButton
variant="primary"
@click="showKeyModal = false"
>
我已保存
</BaseButton>
<BaseButton variant="primary" @click="showKeyModal = false"> 我已保存 </BaseButton>
</template>
</BaseModal>
@@ -495,6 +455,8 @@ onMounted(() => {
<BaseModal
v-model="showDeleteModal"
title="确认删除"
description="删除后这些 API Key 将立即失效,此操作不可撤销。"
variant="warning"
width="480px"
>
<p class="text-[14px] text-(--cp-text-secondary)">
@@ -502,18 +464,8 @@ onMounted(() => {
</p>
<template #footer>
<BaseButton
variant="ghost"
@click="showDeleteModal = false"
>
取消
</BaseButton>
<BaseButton
variant="danger"
@click="handleBatchDelete"
>
确认删除
</BaseButton>
<BaseButton variant="ghost" @click="showDeleteModal = false"> 取消 </BaseButton>
<BaseButton variant="danger" @click="handleBatchDelete"> 确认删除 </BaseButton>
</template>
</BaseModal>
</div>
+25 -31
View File
@@ -2,7 +2,6 @@
import { RefreshCw } from '@lucide/vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseSpinner from '@/components/base/BaseSpinner.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import AccountOverviewCard from './components/AccountOverviewCard.vue'
@@ -13,7 +12,7 @@ import ServiceStatusCard from './components/ServiceStatusCard.vue'
import { useDashboard } from './composables/useDashboard'
const {
loading,
trendLoading,
metrics,
trendPoints,
trendSummary,
@@ -24,12 +23,12 @@ const {
capacityInfo,
rotationStrategy,
refresh,
loadTrend,
} = useDashboard()
</script>
<template>
<div class="w-full min-w-295 p-7">
<div class="w-full">
<header class="flex h-17 items-start justify-between">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
@@ -41,40 +40,35 @@ const {
</div>
<div class="flex items-center gap-2">
<BaseIconButton
variant="ghost"
size="md"
title="刷新数据"
:disabled="loading"
@click="refresh"
>
<RefreshCw class="size-4.5" :class="{ 'animate-spin': loading }" />
<BaseIconButton variant="ghost" size="md" title="刷新数据" @click="refresh">
<RefreshCw class="size-4.5" />
</BaseIconButton>
<AppTopbar class="mt-0.5" />
</div>
</header>
<BaseSpinner v-if="loading && metrics.length === 0" class="mt-20" />
<section class="mt-6 grid grid-cols-4 gap-6" aria-label="核心指标">
<MetricCard v-for="metric in metrics" :key="metric.title" :metric="metric" />
</section>
<template v-else>
<section class="mt-6 grid grid-cols-4 gap-6" aria-label="核心指标">
<MetricCard v-for="metric in metrics" :key="metric.title" :metric="metric" />
</section>
<section class="mt-6 grid grid-cols-[minmax(0,948fr)_minmax(0,608fr)] gap-7">
<RequestTrendCard :points="trendPoints" :summary="trendSummary" />
<ServiceStatusCard :items="serviceStatuses" />
</section>
<AccountOverviewCard
:accounts="accountUsage"
:pool="poolSummary"
:capacity="capacityInfo"
:rotation-strategy="rotationStrategy"
class="mt-6"
<section class="mt-6 grid grid-cols-[minmax(0,948fr)_minmax(0,608fr)] gap-7">
<RequestTrendCard
:points="trendPoints"
:summary="trendSummary"
:loading="trendLoading"
@trend-change="loadTrend"
/>
<ServiceStatusCard :items="serviceStatuses" />
</section>
<EventLogCard :rows="eventLogs" class="mt-6" />
</template>
<AccountOverviewCard
:accounts="accountUsage"
:pool="poolSummary"
:capacity="capacityInfo"
:rotation-strategy="rotationStrategy"
class="mt-6"
/>
<EventLogCard :rows="eventLogs" class="mt-6" />
</div>
</template>
@@ -3,7 +3,15 @@ import { computed } from 'vue'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import type { AccountCapacityInfo, AccountPoolSummary, AccountStatusRow, AccountUsageItem, ScheduleStat, SemanticTone } from '../types'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import type {
AccountCapacityInfo,
AccountPoolSummary,
AccountStatusRow,
AccountUsageItem,
ScheduleStat,
SemanticTone,
} from '../types'
const props = defineProps<{
accounts: AccountUsageItem[]
@@ -37,9 +45,9 @@ const strategyLabel = computed(() => {
const s = props.rotationStrategy
if (!s) return '—'
const map: Record<string, string> = {
'least_used': '最少使用优先',
'round_robin': '轮询',
'random': '随机',
least_used: '最少使用优先',
round_robin: '轮询',
random: '随机',
}
return map[s] || s
})
@@ -104,7 +112,7 @@ const statusBars = computed(() => {
{ pct: refreshing, cls: 'bg-(--cp-normal)' },
{ pct: quota, cls: 'bg-(--cp-warning)' },
{ pct: unavailable, cls: 'bg-(--cp-danger)' },
].filter(b => b.pct > 0)
].filter((b) => b.pct > 0)
})
const rowToneClasses: Record<SemanticTone, string> = {
@@ -123,7 +131,7 @@ const valueToneClasses: Record<SemanticTone, string> = {
danger: 'text-(--cp-danger-text)',
}
const loadToneClasses: Record<SemanticTone, string> = {
const quotaToneClasses: Record<SemanticTone, string> = {
normal: 'bg-(--cp-normal)',
info: 'bg-(--cp-info)',
success: 'bg-(--cp-success)',
@@ -134,61 +142,129 @@ const loadToneClasses: Record<SemanticTone, string> = {
<template>
<BaseCard as="article" :padded="false" class="h-112.5 w-full">
<div class="grid grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] gap-7 px-7 pt-6">
<div
class="grid grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] gap-7 px-7 pt-6"
>
<section class="h-100.5 w-full">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">容量、并发与分配策略</p>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
容量、并发与分配策略
</p>
<div class="mt-7.75 h-30.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.5">
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">可调度容量</span>
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>可调度容量</span
>
<div class="mt-3 grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
<strong class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)">{{ usedRatio }}</strong>
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{ usedPercent }}% 已分配</span>
<strong
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ usedRatio }}</strong
>
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>{{ usedPercent }}% 已分配</span
>
</div>
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-slate-200">
<i class="block h-2.5 rounded-full bg-(--cp-success)" :style="{ width: `${usedPercent}%` }" />
<i
class="block h-2.5 rounded-full bg-(--cp-success)"
:style="{ width: `${usedPercent}%` }"
/>
</div>
</div>
<div class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-5">
<div
class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-5"
>
<div v-for="stat in scheduleStats" :key="stat.label">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{ stat.label }}</span>
<strong class="mt-3.25 block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)">{{ stat.value }}</strong>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
stat.label
}}</span>
<strong
class="mt-3.25 block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ stat.value }}</strong
>
</div>
</div>
<div class="mt-4 h-20.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.25">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">分配策略</span>
<strong class="mt-2.5 block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ strategyLabel }}</strong>
<strong
class="mt-2.5 block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ strategyLabel }}</strong
>
</div>
</section>
<section class="w-full">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">活跃账号用量</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">24h 请求排序</p>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
总请求排序
</p>
<div class="mt-6.75 grid h-82.5 w-full gap-1.5 overflow-hidden">
<article
v-for="(account, index) in accounts"
:key="account.name"
class="grid h-19.5 w-[calc(100%-28px)] grid-cols-[34px_10px_minmax(220px,1fr)_6px_64px_2px_70px_4px_84px_6px] items-start rounded-[14px] px-3.5 transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
:class="['bg-(--cp-bg-subtle)', 'bg-white'][index % 2]"
>
<span class="mt-5.5 inline-flex size-8.5 items-center justify-center rounded-full" :class="rowToneClasses[account.tone]">
{{ account.name[0] }}
</span>
<span class="col-start-3 row-start-1 mt-4.25 flex min-w-0 items-start">
<strong class="text-sm leading-[1.15] font-[650] text-(--cp-text-primary)">{{ account.name }}</strong>
<small class="ml-1.5 mt-0.75 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)">{{ account.plan }}</small>
</span>
<span class="col-start-3 row-start-1 mt-12 min-w-0 truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)">{{ account.email }}</span>
<strong class="col-start-5 row-start-1 mt-5 w-16 font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)">{{ account.requests }}</strong>
<strong class="col-start-7 row-start-1 mt-5 w-17.5 font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)">{{ account.tokens }}</strong>
<span class="col-start-5 row-start-1 mt-12 w-20 text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)">{{ account.lastUsed }}</span>
<span class="col-start-9 row-start-1 mt-9 block h-1.5 w-21 overflow-hidden rounded-full bg-(--cp-bg-muted)">
<i class="block h-1.5 rounded-full" :class="loadToneClasses[account.tone]" :style="{ width: `${account.loadWidth}px` }" />
</span>
</article>
<BaseEmpty
v-if="accounts.length === 0"
compact
title="暂无账号用量"
description="账号产生请求后会显示 24h 排序和 Token 负载。"
class="h-full place-content-center"
/>
<template v-else>
<article
v-for="(account, index) in accounts"
:key="account.name"
class="grid h-19.5 w-full grid-cols-[34px_10px_minmax(150px,1fr)_8px_76px_8px_84px_8px_84px] items-start rounded-[14px] px-3.5 transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
:class="['bg-(--cp-bg-subtle)', 'bg-white'][index % 2]"
>
<span
class="mt-5.5 inline-flex size-8.5 items-center justify-center rounded-full"
:class="rowToneClasses[account.tone]"
>
{{ account.name[0] }}
</span>
<span class="col-start-3 row-start-1 mt-4.25 grid min-w-0 gap-4.25">
<span class="flex min-w-0 items-start">
<strong class="text-sm leading-[1.15] font-[650] text-(--cp-text-primary)">{{
account.name
}}</strong>
<small
class="ml-1.5 mt-0.75 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)"
>{{ account.plan }}</small
>
</span>
<span
class="min-w-0 truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
>{{ account.email }}</span
>
</span>
<span class="col-start-5 row-start-1 mt-5 grid min-w-0 gap-4.25">
<strong
class="w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.requests }}</strong
>
<span
class="w-20 whitespace-nowrap text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
>{{ account.lastUsed }}</span
>
</span>
<strong
class="col-start-7 row-start-1 mt-5 w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.tokens }}</strong
>
<span
class="col-start-9 row-start-1 mt-9 block h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
>
<i
class="block h-1.5 rounded-full"
:class="quotaToneClasses[account.quotaTone]"
:style="{
width: `${account.quotaPercent}%`,
minWidth: account.quotaPercent > 0 ? '7px' : '0',
}"
/>
</span>
</article>
</template>
</div>
</section>
@@ -196,21 +272,38 @@ const loadToneClasses: Record<SemanticTone, string> = {
<header class="flex h-12.5 items-start justify-between">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号状态</h2>
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">账号池健康结构</p>
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
账号池健康结构
</p>
</div>
<div class="grid justify-items-end">
<strong class="font-mono text-2xl leading-[1.05] font-[760] tabular-nums text-(--cp-success-text)">{{ availabilityRate }}</strong>
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">可用率</span>
<strong
class="font-mono text-2xl leading-[1.05] font-[760] tabular-nums text-(--cp-success-text)"
>{{ availabilityRate }}</strong
>
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-(--cp-text-secondary)"
>可用率</span
>
</div>
</header>
<div class="mt-5.5 h-10.5 w-full">
<div class="flex h-4 items-center justify-between">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">状态分布</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-danger-text)">不可用 {{ statusRows[3]?.value || '0' }}</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>状态分布</span
>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-danger-text)"
>不可用 {{ statusRows[3]?.value || '0' }}</span
>
</div>
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)">
<i v-for="(bar, bi) in statusBars" :key="bi" class="h-3" :class="bar.cls" :style="{ flexBasis: `${bar.pct}%` }" />
<i
v-for="(bar, bi) in statusBars"
:key="bi"
class="h-3"
:class="bar.cls"
:style="{ flexBasis: `${bar.pct}%` }"
/>
</div>
</div>
@@ -220,14 +313,25 @@ const loadToneClasses: Record<SemanticTone, string> = {
:key="row.label"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5 transition-colors duration-200"
>
<span class="inline-flex size-7 items-center justify-center rounded-[9px]" :class="rowToneClasses[row.tone]">
<span
class="inline-flex size-7 items-center justify-center rounded-[9px]"
:class="rowToneClasses[row.tone]"
>
<component :is="row.icon" :size="16" />
</span>
<span class="col-start-3 grid gap-1">
<strong class="text-sm leading-[1.15] font-[650] text-(--cp-text-primary)">{{ row.label }}</strong>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{ row.description }}</span>
<strong class="text-sm leading-[1.15] font-[650] text-(--cp-text-primary)">{{
row.label
}}</strong>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
row.description
}}</span>
</span>
<strong class="col-start-4 text-right font-mono text-[17px] leading-[1.15] font-[760] tabular-nums" :class="valueToneClasses[row.tone]">{{ row.value }}</strong>
<strong
class="col-start-4 text-right font-mono text-[17px] leading-[1.15] font-[760] tabular-nums"
:class="valueToneClasses[row.tone]"
>{{ row.value }}</strong
>
</div>
</div>
</section>
@@ -1,13 +1,20 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseTable from '../../../components/base/BaseTable.vue'
import type { EventLogItem, SemanticTone } from '../types'
defineProps<{
const props = defineProps<{
rows: EventLogItem[]
}>()
const filters = ['全部', '警告', '错误']
const activeFilter = ref('all')
const filters = [
{ label: '全部', value: 'all' },
{ label: '警告', value: 'warn' },
{ label: '错误', value: 'error' },
]
const eventLogColumns = [
{ key: 'time', label: '时间', width: '11.5%' },
{ key: 'level', label: '级别', width: '7.5%' },
@@ -37,34 +44,59 @@ const statusToneClasses: Record<SemanticTone, string> = {
function rowTone(row: object) {
return (row as EventLogItem).tone
}
const filteredRows = computed(() => {
if (activeFilter.value === 'warn') return props.rows.filter((row) => row.tone === 'warning')
if (activeFilter.value === 'error') return props.rows.filter((row) => row.tone === 'danger')
return props.rows
})
</script>
<template>
<BaseCard as="article" :padded="false" class="h-[350px] w-full px-7 pt-6">
<BaseCard as="article" :padded="false" class="h-87.5 w-full px-7 pt-6">
<header class="flex items-start justify-between">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">事件日志</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">最近 50 条事件</p>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
最近 50 条事件
</p>
</div>
<div class="grid h-9 w-[206px] grid-cols-[62px_58px_58px] gap-1 rounded-xl bg-(--cp-bg-muted) p-1">
<div
class="grid h-9 w-51.5 grid-cols-[62px_58px_58px] gap-1 rounded-xl bg-(--cp-bg-muted) p-1"
>
<button
v-for="filter in filters"
:key="filter"
class="h-7 rounded-[9px] border-0 text-xs leading-[1.15] font-[650]"
:class="filter === '全部' ? 'bg-white text-(--cp-text-primary) shadow-(--cp-shadow-control)' : 'bg-transparent text-(--cp-text-secondary)'"
:key="filter.value"
class="h-7 rounded-[9px] border-0 text-xs leading-[1.15] font-[650] cursor-pointer"
:class="
activeFilter === filter.value
? 'bg-white text-(--cp-text-primary) shadow-(--cp-shadow-control)'
: 'bg-transparent text-(--cp-text-secondary)'
"
type="button"
@click="activeFilter = filter.value"
>
{{ filter }}
{{ filter.label }}
</button>
</div>
</header>
<div class="mt-[17px] flex h-60 w-full justify-between overflow-hidden">
<div class="mt-4.25 flex h-60 w-full justify-between overflow-hidden">
<BaseEmpty
v-if="filteredRows.length === 0"
compact
class="w-full place-content-center"
:title="rows.length === 0 ? '暂无事件日志' : '没有匹配日志'"
:description="
rows.length === 0 ? '请求经过代理后会在这里显示最近事件。' : '调整筛选条件后再查看。'
"
/>
<BaseTable
v-else
class="min-w-0 flex-1"
:columns="eventLogColumns"
:rows="rows"
:rows="filteredRows"
table-class="min-w-full"
header-row-class="h-10 rounded-xl bg-(--cp-bg-subtle) text-xs leading-[1.15] font-bold text-(--cp-text-secondary)"
body-row-class="h-14 rounded-[10px] transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
@@ -76,7 +108,11 @@ function rowTone(row: object) {
</template>
<template #level="{ row, value }">
<span class="inline-flex h-6 w-[58px] items-center justify-center rounded-full text-xs leading-[1.15] font-bold" :class="levelToneClasses[rowTone(row)]">{{ value }}</span>
<span
class="inline-flex h-6 w-14.5 items-center justify-center rounded-full text-xs leading-[1.15] font-bold"
:class="levelToneClasses[rowTone(row)]"
>{{ value }}</span
>
</template>
<template #requestId="{ value }">
@@ -84,7 +120,9 @@ function rowTone(row: object) {
</template>
<template #statusCode="{ row, value }">
<span class="font-mono font-bold tabular-nums" :class="statusToneClasses[rowTone(row)]">{{ value }}</span>
<span class="font-mono font-bold tabular-nums" :class="statusToneClasses[rowTone(row)]">{{
value
}}</span>
</template>
<template #latency="{ value }">
@@ -21,31 +21,74 @@ const detailToneClasses: Record<SemanticTone, string> = {
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
const trendToneClasses: Record<SemanticTone, string> = {
normal: 'bg-(--cp-normal-text)',
info: 'bg-(--cp-info-text)',
success: 'bg-(--cp-success-text)',
warning: 'bg-(--cp-warning-text)',
danger: 'bg-(--cp-danger-text)',
}
</script>
<template>
<BaseCard as="article" :padded="false" radius-class="rounded-2xl" class="h-[154px] w-full px-6 pt-5">
<BaseCard as="article" :padded="false" radius-class="rounded-2xl" class="h-38.5 w-full px-6 pt-5">
<div class="flex items-start gap-3">
<span class="inline-flex size-[34px] shrink-0 items-center justify-center rounded-[10px]" :class="iconToneClasses[metric.tone]">
<span
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-[10px]"
:class="iconToneClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</span>
<span class="mt-1 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">{{ metric.title }}</span>
<span class="mt-1 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
metric.title
}}</span>
</div>
<strong class="mt-2.5 block w-full font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-(--cp-text-primary)">
{{ metric.value }}
</strong>
<div class="mt-3.25 flex h-7.75 items-end gap-2">
<strong
class="font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-(--cp-text-primary)"
>
{{ metric.value }}
</strong>
<i
v-if="metric.trend && metric.trend.direction !== 'flat'"
class="mb-1.25 block size-3"
:class="[
trendToneClasses[metric.trend.tone],
metric.trend.direction === 'up'
? '[clip-path:polygon(50%_0,100%_58%,66%_58%,66%_100%,34%_100%,34%_58%,0_58%)]'
: '[clip-path:polygon(34%_0,66%_0,66%_42%,100%_42%,50%_100%,0_42%,34%_42%)]',
]"
/>
</div>
<div class="mt-[16.6px] grid h-[30px] w-full grid-cols-2 items-center rounded-[10px] bg-(--cp-bg-subtle) px-3">
<span class="inline-flex min-w-0 w-full items-center justify-start gap-3">
<span class="shrink-0 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)">{{ metric.details[0]?.label }}</span>
<b class="min-w-0 truncate font-mono text-xs leading-[1.15] font-bold tabular-nums" :class="metric.details[0]?.tone ? detailToneClasses[metric.details[0].tone] : undefined">
<div
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-[10px] bg-(--cp-bg-subtle) px-3"
>
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,1fr)] items-baseline gap-2.5"
>
<span class="shrink-0 text-[11px] leading-none font-[650] text-(--cp-text-muted)">{{
metric.details[0]?.label
}}</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[0]?.tone ? detailToneClasses[metric.details[0].tone] : undefined"
>
{{ metric.details[0]?.value }}
</b>
</span>
<span class="inline-flex min-w-0 w-full items-center justify-start gap-3">
<span class="shrink-0 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)">{{ metric.details[1]?.label }}</span>
<b class="min-w-0 truncate font-mono text-xs leading-[1.15] font-bold tabular-nums" :class="metric.details[1]?.tone ? detailToneClasses[metric.details[1].tone] : undefined">
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,auto)] items-baseline justify-end gap-2.5"
>
<span class="shrink-0 text-[11px] leading-none font-[650] text-(--cp-text-muted)">{{
metric.details[1]?.label
}}</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[1]?.tone ? detailToneClasses[metric.details[1].tone] : undefined"
>
{{ metric.details[1]?.value }}
</b>
</span>
@@ -3,83 +3,199 @@ import { computed, ref } from 'vue'
import type { EChartsOption } from 'echarts'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import type { TrendPoint, TrendSummaryItem } from '../types'
const props = defineProps<{
points: TrendPoint[]
summary: TrendSummaryItem[]
loading?: boolean
}>()
const emit = defineEmits<{
trendChange: [tab: string]
}>()
const tabs = ['用量', '延迟', '错误']
const activeTab = ref('用量')
const hasSamples = computed(() =>
props.points.some(
(point) =>
point.requests > 0 ||
point.errors > 0 ||
point.latency > 0 ||
point.tokens > 0 ||
point.cachedTokens > 0,
),
)
const chartOption = computed<EChartsOption>(() => {
const times = props.points.map(p => `${p.time}:00`)
const getValues = () => {
if (activeTab.value === '用量') return props.points.map(p => p.tokens)
if (activeTab.value === '延迟') return props.points.map(p => p.latency)
return props.points.map(p => p.errors)
}
const values = getValues()
const colors = { '用量': '#2563EB', '延迟': '#0F9F9A', '错误': '#EF4444' }
const color = colors[activeTab.value as keyof typeof colors]
const times = props.points.map((p) => `${p.time}:00`)
const series = getSeries()
return {
grid: { left: 30, right: 0, top: 8, bottom: 24 },
xAxis: { type: 'category', data: times, axisLabel: { color: '#94A3B8', fontSize: 10, fontFamily: 'JetBrains Mono' }, axisLine: { show: false }, axisTick: { show: false } },
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#F1F5F9' } }, axisLabel: { show: false } },
series: [{
type: 'line', data: values, smooth: true, symbol: 'none', lineStyle: { color, width: 2.5 },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: color + '18' }, { offset: 1, color: color + '02' }] } },
}],
grid: { left: 4, right: 0, top: 8, bottom: 24 },
xAxis: {
type: 'category',
data: times,
axisLabel: { color: '#94A3B8', fontSize: 10, fontFamily: 'JetBrains Mono' },
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: [
{ type: 'value', splitLine: { lineStyle: { color: '#F1F5F9' } }, axisLabel: { show: false } },
{ type: 'value', min: 0, max: 100, splitLine: { show: false }, axisLabel: { show: false } },
],
series,
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: { color: '#334155', fontSize: 12, fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 600 },
textStyle: {
color: '#334155',
fontSize: 12,
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
},
extraCssText: 'border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);',
axisPointer: { type: 'line', lineStyle: { color: '#E2E8F0', type: 'dashed' } },
formatter: formatTooltip,
},
}
})
const activeSummary = computed(() => {
const pts = props.points
if (!pts.length) return []
function formatTooltip(params: unknown) {
const rows = Array.isArray(params) ? params : [params]
const title = tooltipValue(rows[0], 'axisValueLabel')
const lines = rows.map((row) => {
const name = tooltipValue(row, 'seriesName')
const value = tooltipValue(row, 'value')
const marker = tooltipValue(row, 'marker')
const unitValue = tooltipDisplayValue(name, value)
return `${marker}${name}: ${unitValue}`
})
return [title, ...lines].filter(Boolean).join('<br/>')
}
function tooltipDisplayValue(name: string, value: string): string {
if (name === '成功率') return `${value}%`
if (activeTab.value === '延迟') return formatLatency(Number(value))
return value
}
function tooltipValue(source: unknown, key: string): string {
if (typeof source !== 'object' || source === null || !(key in source)) return ''
const value = (source as Record<string, unknown>)[key]
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
function getSeries() {
if (activeTab.value === '用量') {
const totalTokens = pts.reduce((s, p) => s + p.tokens, 0)
return [
{ label: '输入', value: formatTokens(Math.floor(totalTokens * 0.45)), tone: 'info' as const },
{ label: '输出', value: formatTokens(Math.floor(totalTokens * 0.55)), tone: 'success' as const },
{ label: '缓存', value: formatTokens(0), tone: 'normal' as const },
lineSeries(
'输入',
props.points.map((p) => p.inputTokens),
'#2563EB',
true,
),
lineSeries(
'输出',
props.points.map((p) => p.outputTokens),
'#10B981',
),
lineSeries(
'缓存',
props.points.map((p) => p.cachedTokens),
'#94A3B8',
),
]
}
if (activeTab.value === '延迟') {
const withLatency = pts.filter(p => p.latency > 0)
const avg = withLatency.length ? Math.round(withLatency.reduce((s, p) => s + p.latency, 0) / withLatency.length) : 0
const max = withLatency.length ? Math.max(...withLatency.map(p => p.latency)) : 0
const min = withLatency.length ? Math.min(...withLatency.map(p => p.latency)) : 0
return [
{ label: '平均', value: avg ? `${avg}ms` : '—', tone: 'info' as const },
{ label: '最高', value: max ? `${max}ms` : '—', tone: 'warning' as const },
{ label: '最低', value: min ? `${min}ms` : '—', tone: 'success' as const },
lineSeries(
'平均',
props.points.map((p) => p.latency),
'#0F9F9A',
true,
),
lineSeries(
'最高',
props.points.map((p) => p.maxLatency),
'#F59E0B',
),
lineSeries(
'最低',
props.points.map((p) => p.minLatency),
'#10B981',
),
]
}
const totalErrors = pts.reduce((s, p) => s + p.errors, 0)
const totalRequests = pts.reduce((s, p) => s + p.requests, 0)
const errorRate = totalRequests > 0 ? ((totalErrors / totalRequests) * 100).toFixed(1) : '0'
return [
{ label: '错误数', value: String(totalErrors), tone: totalErrors > 0 ? 'danger' as const : 'normal' as const },
{ label: '成功率', value: totalRequests > 0 ? `${(100 - Number(errorRate)).toFixed(1)}%` : '—', tone: 'success' as const },
{ label: '总请求', value: String(totalRequests), tone: 'info' as const },
lineSeries(
'错误数',
props.points.map((p) => p.errors),
'#EF4444',
true,
),
lineSeries(
'成功率',
props.points.map((p) => p.successRate),
'#10B981',
false,
1,
),
lineSeries(
'总请求',
props.points.map((p) => p.requests),
'#2563EB',
),
]
}
function lineSeries(name: string, data: number[], color: string, area = false, yAxisIndex = 0) {
return {
name,
type: 'line' as const,
data,
smooth: true,
symbol: 'none',
yAxisIndex,
lineStyle: { color, width: 2.5 },
itemStyle: { color },
areaStyle: area
? {
color: {
type: 'linear' as const,
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}18` },
{ offset: 1, color: `${color}02` },
],
},
}
: undefined,
}
}
function selectTab(tab: string) {
if (activeTab.value === tab) return
activeTab.value = tab
emit('trendChange', tab)
}
const activeSummary = computed(() => {
return props.summary
})
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
function formatLatency(ms: number): string {
if (!ms) return '—'
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
</script>
@@ -87,8 +203,10 @@ function formatTokens(n: number): string {
<BaseCard as="article" :padded="false" class="h-95 w-full px-7 pt-5.5">
<header class="flex items-start justify-between">
<div class="pt-0.5">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">请求趋势</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">最近 24 小时</p>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">使用趋势</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
最近 24 小时
</p>
</div>
<div class="grid h-9.5 w-61.5 grid-cols-3 gap-1 rounded-xl bg-(--cp-bg-muted) p-1">
@@ -96,9 +214,13 @@ function formatTokens(n: number): string {
v-for="tab in tabs"
:key="tab"
class="h-7.5 rounded-[9px] border-0 text-xs leading-[1.15] font-[650] cursor-pointer transition-colors"
:class="activeTab === tab ? 'bg-white text-(--cp-text-primary) shadow-[0_10px_24px_-18px_#0E172614]' : 'bg-transparent text-(--cp-text-secondary)'"
:class="
activeTab === tab
? 'bg-white text-(--cp-text-primary) shadow-[0_10px_24px_-18px_#0E172614]'
: 'bg-transparent text-(--cp-text-secondary)'
"
type="button"
@click="activeTab = tab"
@click="selectTab(tab)"
>
{{ tab }}
</button>
@@ -106,25 +228,45 @@ function formatTokens(n: number): string {
</header>
<div class="mt-4.75 grid grid-cols-[minmax(0,1fr)_minmax(150px,180px)] gap-7.5">
<div class="h-67 w-full overflow-hidden rounded-[10px] bg-white">
<BaseChart :option="chartOption" :height="268" />
<div
v-loading="props.loading"
class="relative h-67 w-full overflow-hidden rounded-[10px] bg-white"
>
<BaseChart v-if="hasSamples" :option="chartOption" :height="268" />
<BaseEmpty
v-if="!hasSamples"
compact
title="暂无趋势数据"
description="最近 24 小时还没有可用于绘制趋势的请求日志。"
class="h-full place-content-center bg-white"
/>
</div>
<aside class="flex h-67 w-full flex-col rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5" style="gap: 36.6px">
<aside class="grid h-67 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5">
<div
v-for="item in activeSummary"
:key="item.label"
class="grid grid-cols-[minmax(0,1fr)_8px] items-start gap-x-3 gap-y-px"
class="grid grid-cols-[minmax(0,1fr)_8px] items-center gap-x-3 py-2"
>
<span class="col-span-2 text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{ item.label }}</span>
<strong class="mt-1.75 font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)">{{ item.value }}</strong>
<i class="mt-3.5 size-2 justify-self-end rounded-full" :class="{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
}" />
<span class="grid gap-1.75">
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{
item.label
}}</span>
<strong
class="font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ item.value }}</strong
>
</span>
<i
class="size-2 justify-self-end rounded-full"
:class="{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
}"
/>
</div>
</aside>
</div>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import type { SemanticTone, ServiceStatusItem } from '../types'
defineProps<{
@@ -25,27 +26,55 @@ const valueToneClasses: Record<SemanticTone, string> = {
<template>
<BaseCard as="article" :padded="false" class="h-95 w-full px-7 pt-6">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">服务状态</h2>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">指纹信息</h2>
<div class="mt-5.75 grid gap-2">
<div
v-for="item in items"
:key="item.label"
class="grid h-12.5 w-full grid-cols-[24px_16px_1fr_1fr_1fr] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span class="inline-flex size-6 items-center justify-center rounded-lg" :class="iconToneClasses[item.tone]">
<component :is="item.icon" :size="14" />
</span>
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
<strong class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ item.label }}</strong>
<span class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate">{{ item.value }}</span>
</template>
<template v-else>
<strong class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ item.label }}</strong>
<span class="col-start-4 text-[13px] leading-[1.15] font-bold" :class="valueToneClasses[item.tone]">{{ item.value }}</span>
<span class="col-start-5 text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)">{{ item.detail }}</span>
</template>
</div>
<BaseEmpty
v-if="items.length === 0"
compact
title="暂无指纹信息"
description="诊断接口返回后会显示客户端指纹信息。"
class="h-72 place-content-center"
/>
<template v-else>
<div
v-for="item in items"
:key="item.label"
class="grid h-12.5 w-full grid-cols-[24px_16px_1fr_1fr_1fr] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span
class="inline-flex size-6 items-center justify-center rounded-lg"
:class="iconToneClasses[item.tone]"
>
<component :is="item.icon" :size="14" />
</span>
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
<strong
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ item.label }}</strong
>
<span
class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate"
>{{ item.value }}</span
>
</template>
<template v-else>
<strong
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ item.label }}</strong
>
<span
class="col-start-4 text-[13px] leading-[1.15] font-bold"
:class="valueToneClasses[item.tone]"
>{{ item.value }}</span
>
<span
class="col-start-5 text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
>{{ item.detail }}</span
>
</template>
</div>
</template>
</div>
</BaseCard>
</template>
@@ -12,13 +12,21 @@ import {
Users,
} from '@lucide/vue'
import { getDiagnostics, getLogs, getUsageSummary, getUsageStats, getAccounts } from '@/api'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import type {
DashboardAccountUsage,
DashboardServiceStatus,
DashboardSummary,
DashboardTrend,
DashboardTrendKind,
} from '@/api'
import type {
AccountCapacityInfo,
AccountPoolSummary,
AccountUsageItem,
EventLogItem,
MetricCardItem,
SemanticTone,
ServiceStatusItem,
TrendPoint,
TrendSummaryItem,
@@ -26,6 +34,7 @@ import type {
export function useDashboard(): {
loading: Ref<boolean>
trendLoading: Ref<boolean>
metrics: Ref<MetricCardItem[]>
trendPoints: Ref<TrendPoint[]>
trendSummary: Ref<TrendSummaryItem[]>
@@ -36,8 +45,9 @@ export function useDashboard(): {
capacityInfo: Ref<AccountCapacityInfo | null>
rotationStrategy: Ref<string | null>
refresh: () => Promise<void>
loadTrend: (tab: string) => Promise<void>
} {
const metrics = ref<MetricCardItem[]>([])
const metrics = ref<MetricCardItem[]>(metricCards(emptyDashboardSummary()))
const trendPoints = ref<TrendPoint[]>([])
const trendSummary = ref<TrendSummaryItem[]>([])
const accountUsage = ref<AccountUsageItem[]>([])
@@ -46,285 +56,325 @@ export function useDashboard(): {
const poolSummary = ref<AccountPoolSummary | null>(null)
const capacityInfo = ref<AccountCapacityInfo | null>(null)
const rotationStrategy = ref<string | null>(null)
const loading = ref(true)
const loading = ref(false)
const trendLoading = ref(false)
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
async function loadDashboardData() {
try {
loading.value = true
// 并行加载所有数据;日志拉 200 条用于趋势聚合
const [summary, usageStats, accounts, logs, diagnostics] = await Promise.all([
getUsageSummary().catch(() => null),
getUsageStats().catch(() => null),
getAccounts().catch(() => null),
getLogs({ limit: 200 }).catch(() => null),
getDiagnostics().catch(() => null),
])
const totalAccounts = diagnostics?.accounts?.pool?.total ?? accounts?.length ?? 0
poolSummary.value = diagnostics?.accounts?.pool ?? null
capacityInfo.value = diagnostics?.accounts?.capacity ?? null
rotationStrategy.value = diagnostics?.settings?.rotationStrategy ?? null
updateMetrics(summary, totalAccounts, diagnostics)
updateAccountUsage(accounts ?? [], usageStats ?? [])
updateEventLogs((logs ?? []).slice(0, 10))
updateServiceStatus(diagnostics)
updateTrendData(logs ?? [], summary)
const summary = await getDashboardSummary()
applySummary(summary)
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
loading.value = false
}
}
function updateMetrics(summary: any, totalAccounts: number, diagnostics: any) {
const pool = diagnostics?.accounts?.pool ?? {}
const activeAccounts = pool.active ?? summary?.accountCount ?? 0
const errorAccounts = (pool.expired ?? 0) + (pool.disabled ?? 0) + (pool.banned ?? 0)
async function loadTrend(tab: string) {
const kind = trendKindFromTab(tab)
try {
trendLoading.value = true
applyTrend(await getDashboardTrend(kind))
} catch (error) {
console.error('Failed to load dashboard trend:', error)
} finally {
trendLoading.value = false
}
}
const requestCount = summary?.requestCount ?? 0
const inputTokens = summary?.inputTokens ?? 0
const outputTokens = summary?.outputTokens ?? 0
const cachedTokens = summary?.cachedTokens ?? 0
const emptyResponseCount = summary?.emptyResponseCount ?? 0
function applySummary(summary: DashboardSummary) {
metrics.value = metricCards(summary)
applyTrend(summary.trend)
accountUsage.value = summary.accountUsage.map(accountUsageItem)
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
eventLogs.value = summary.eventLogs.map((log) => ({
id: log.id,
time: formatTime(log.createdAt),
level: levelLabel(log.level),
requestId: log.requestId || '-',
route: log.route || '-',
model: log.model || '-',
statusCode: log.statusCode ? String(log.statusCode) : '-',
latency: log.latencyMs ? formatLatency(log.latencyMs) : '-',
tone: eventTone(log.level, log.statusCode),
}))
poolSummary.value = summary.poolSummary
capacityInfo.value = summary.capacityInfo
rotationStrategy.value = summary.rotationStrategy ?? null
}
metrics.value = [
function emptyDashboardSummary(): DashboardSummary {
return {
cards: {
accounts: {
total: 0,
enabled: 0,
abnormal: 0,
},
traffic: {
todayRequests: 0,
yesterdayRequests: 0,
totalRequests: 0,
rpm: 0,
tpm: 0,
},
tokens: {
todayTokens: 0,
yesterdayTokens: 0,
totalTokens: 0,
todayCostUsd: null,
totalCostUsd: null,
},
cache: {
todayHitRate: null,
yesterdayHitRate: null,
totalHitRate: null,
totalCachedTokens: 0,
firstTokenLatencyMs: null,
completionLatencyMs: null,
},
},
trend: {
kind: 'usage',
points: [],
summary: [],
},
accountUsage: [],
serviceStatuses: [],
eventLogs: [],
poolSummary: {
total: 0,
active: 0,
expired: 0,
quotaExhausted: 0,
refreshing: 0,
disabled: 0,
banned: 0,
},
capacityInfo: {
maxConcurrentPerAccount: 0,
totalSlots: 0,
usedSlots: 0,
availableSlots: 0,
},
rotationStrategy: undefined,
}
}
function metricCards(summary: DashboardSummary): MetricCardItem[] {
const { accounts, traffic, tokens, cache } = summary.cards
return [
{
title: '账号',
value: String(totalAccounts),
value: formatNumber(accounts.total),
icon: Users,
tone: 'normal',
details: [
{ label: '启用', value: String(activeAccounts), tone: activeAccounts > 0 ? 'success' : 'normal' },
{ label: '异常', value: String(errorAccounts), tone: errorAccounts > 0 ? 'danger' : 'normal' },
{
label: '启用',
value: formatNumber(accounts.enabled),
tone: accounts.enabled > 0 ? 'success' : 'normal',
},
{
label: '异常',
value: formatNumber(accounts.abnormal),
tone: accounts.abnormal > 0 ? 'danger' : 'normal',
},
],
},
{
title: '总请求',
value: formatNumber(requestCount),
title: '今日流量',
value: formatNumber(traffic.todayRequests),
icon: Activity,
tone: 'info',
trend: trendState(traffic.todayRequests, traffic.yesterdayRequests, 'info'),
details: [
{ label: '成功', value: formatNumber(requestCount - emptyResponseCount), tone: 'success' },
{ label: '空响应', value: formatNumber(emptyResponseCount), tone: emptyResponseCount > 0 ? 'warning' : 'info' },
{ label: '总请求', value: formatNumber(traffic.totalRequests), tone: 'info' },
{
label: 'RPM / TPM',
value: `${formatNumber(traffic.rpm)} / ${formatTokens(traffic.tpm)}`,
tone: 'info',
},
],
},
{
title: '总 Token',
value: formatTokens(inputTokens + outputTokens),
title: '今日 Token',
value: formatTokens(tokens.todayTokens),
icon: FileText,
tone: 'success',
trend: trendState(tokens.todayTokens, tokens.yesterdayTokens, 'success'),
details: [
{ label: '输入', value: formatTokens(inputTokens), tone: 'info' },
{ label: '输出', value: formatTokens(outputTokens), tone: 'success' },
{ label: '总 Token', value: formatTokens(tokens.totalTokens), tone: 'success' },
{
label: '计费',
value: `${formatCost(tokens.todayCostUsd)} / ${formatCost(tokens.totalCostUsd)}`,
tone: 'success',
},
],
},
{
title: '缓存命中率',
value: inputTokens > 0 ? `${((cachedTokens / inputTokens) * 100).toFixed(1)}%` : '—',
title: '缓存命中',
value: formatRate(cache.todayHitRate),
icon: Timer,
tone: cachedTokens > 0 ? 'success' : 'normal',
tone: cache.todayHitRate && cache.todayHitRate > 0 ? 'warning' : 'normal',
trend: trendState(cache.todayHitRate ?? 0, cache.yesterdayHitRate ?? 0, 'warning'),
details: [
{ label: '缓存 Token', value: formatTokens(cachedTokens), tone: cachedTokens > 0 ? 'success' : 'normal' },
{ label: '输入总量', value: formatTokens(inputTokens), tone: 'info' },
{ label: '总缓存命中', value: formatRate(cache.totalHitRate), tone: 'warning' },
{
label: '首字 / 完成',
value: `${formatLatency(cache.firstTokenLatencyMs)} / ${formatLatency(cache.completionLatencyMs)}`,
tone: 'warning',
},
],
},
]
}
function updateAccountUsage(accounts: any[], usageStats: any[]) {
// 构建 accountId → usage 的映射
const usageByAccount: Record<string, any> = {}
for (const u of usageStats) {
usageByAccount[u.accountId] = u
}
// 计算用量数据:优先用 usage,回退到 account 自带字段
const merged = accounts.map((acc) => {
const usage = usageByAccount[acc.id] ?? {}
return {
...acc,
_requestCount: usage.requestCount ?? acc.totalRequests ?? 0,
_inputTokens: usage.inputTokens ?? acc.totalInputTokens ?? 0,
_outputTokens: usage.outputTokens ?? acc.totalOutputTokens ?? 0,
_lastUsedAt: usage.lastUsedAt ?? acc.lastUsedAt ?? acc.updatedAt ?? acc.addedAt ?? null,
function applyTrend(trend: DashboardTrend) {
trendPoints.value = trend.points
trendSummary.value = trend.summary.map((item) => {
if (trend.kind === 'latency') {
return {
label: item.label,
value: formatLatency(item.value),
tone: trendSummaryTone(item.label),
}
}
})
// 按请求数降序排列,取前 4
const sorted = merged
.sort((a: any, b: any) => b._requestCount - a._requestCount)
.slice(0, 4)
const maxTokens = Math.max(...sorted.map((a: any) => a._inputTokens + a._outputTokens), 1)
accountUsage.value = sorted.map((acc: any) => {
const totalTokens = acc._inputTokens + acc._outputTokens
const loadWidth = Math.floor((totalTokens / maxTokens) * 84) // 84px = max bar width
let tone: AccountUsageItem['tone'] = 'normal'
if (acc.status === 'active') tone = 'success'
else if (acc.status === 'quota_exhausted' || acc.status === 'expired') tone = 'warning'
else if (acc.status === 'banned' || acc.status === 'disabled') tone = 'danger'
else if (acc.status === 'refreshing') tone = 'info'
return {
name: acc.label || (acc.email ? acc.email.split('@')[0] : acc.id.slice(0, 8)),
email: acc.email || '—',
plan: acc.planType || 'free',
requests: formatNumber(acc._requestCount),
tokens: formatTokens(totalTokens),
lastUsed: formatRelativeTime(acc._lastUsedAt),
tone,
loadWidth,
if (item.ratio !== null && item.ratio !== undefined) {
return {
label: item.label,
value: `${item.ratio.toFixed(1)}%`,
tone: trendSummaryTone(item.label),
}
}
const formatter =
trend.kind === 'usage' && item.label !== '总请求' ? formatTokens : formatNumber
return { label: item.label, value: formatter(item.value), tone: trendSummaryTone(item.label) }
})
}
function updateEventLogs(logs: any[]) {
if (!logs.length) {
eventLogs.value = []
return
function accountUsageItem(item: DashboardAccountUsage): AccountUsageItem {
const tone = accountTone(item.status)
const quotaPercent = quotaUsedPercent(item.quotaUsedPercent)
return {
name: item.name,
email: item.email || '-',
plan: item.plan || 'free',
requests: formatNumber(item.requests),
tokens: formatTokens(item.tokens),
lastUsed: formatRelativeTime(item.lastUsedAt ?? undefined),
tone,
quotaPercent,
quotaTone: quotaTone(quotaPercent),
}
eventLogs.value = logs.map((log) => {
let tone: EventLogItem['tone'] = 'info'
if (log.level === 'warning') tone = 'warning'
else if (log.level === 'error') tone = 'danger'
return {
id: log.id,
time: formatTime(log.createdAt),
level: log.level === 'info' ? '信息' : log.level === 'warning' ? '警告' : '错误',
requestId: log.requestId || '—',
route: log.route || '—',
model: log.model || '—',
statusCode: log.statusCode ? String(log.statusCode) : '—',
latency: log.latencyMs ? `${(log.latencyMs / 1000).toFixed(2)}s` : '—',
tone,
}
})
}
function updateServiceStatus(diagnostics: any) {
if (!diagnostics) {
serviceStatuses.value = []
return
}
const transport = diagnostics.transport ?? {}
const fingerprint = transport.fingerprint ?? {}
serviceStatuses.value = [
{
label: '客户端版本',
value: fingerprint.appVersion || '—',
detail: `Build ${fingerprint.buildNumber || '—'}`,
tone: 'info' as const,
icon: MonitorCheck,
},
{
label: '平台架构',
value: fingerprint.platform || '—',
detail: fingerprint.arch || '—',
tone: 'info' as const,
icon: MonitorCheck,
},
{
label: 'Chromium',
value: fingerprint.chromiumVersion ? `v${fingerprint.chromiumVersion}` : '—',
detail: fingerprint.originator || '—',
tone: 'normal' as const,
icon: CloudCheck,
},
{
label: '更新时间',
value: fingerprint.updatedAt ? new Date(fingerprint.updatedAt).toLocaleString('zh-CN') : '',
detail: '',
tone: 'normal' as const,
icon: RefreshCw,
},
{
label: 'User Agent',
value: fingerprint.userAgent || `${fingerprint.originator || 'Codex Desktop'}/${fingerprint.appVersion || '?'} (${fingerprint.platform || '?'}; ${fingerprint.arch || '?'})`,
detail: '',
tone: 'normal' as const,
icon: Gauge,
},
]
function quotaUsedPercent(value: number | null | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value)))
}
function updateTrendData(logs: any[], summary: any) {
if (!logs.length) {
trendPoints.value = []
trendSummary.value = [
{ label: '输入', value: '—', tone: 'info' as const },
{ label: '输出', value: '—', tone: 'success' as const },
{ label: '缓存', value: '—', tone: 'normal' as const },
]
return
function quotaTone(percent: number): SemanticTone {
if (percent >= 95) return 'danger'
if (percent >= 80) return 'warning'
if (percent > 0) return 'success'
return 'normal'
}
function serviceStatusItem(item: DashboardServiceStatus, index: number): ServiceStatusItem {
const icons = [MonitorCheck, MonitorCheck, CloudCheck, RefreshCw, Gauge]
return {
label: item.label,
value: item.value || '-',
detail: item.detail || '-',
tone: item.tone,
icon: icons[index] ?? MonitorCheck,
}
}
// 按小时分桶,取最近 24 个桶
const now = Date.now()
const buckets: Record<string, { requests: number; inputTokens: number; outputTokens: number; errors: number; latencySum: number; latencyCount: number }> = {}
for (let h = 23; h >= 0; h--) {
const key = new Date(now - h * 3600000).toISOString().slice(11, 13)
buckets[key] = { requests: 0, inputTokens: 0, outputTokens: 0, errors: 0, latencySum: 0, latencyCount: 0 }
function trendState(
current: number,
previous: number,
fallbackTone: SemanticTone,
): MetricCardItem['trend'] {
if (current > previous) return { direction: 'up', tone: 'success' }
if (current < previous) return { direction: 'down', tone: 'danger' }
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
}
function trendKindFromTab(tab: string): DashboardTrendKind {
if (tab === '延迟') return 'latency'
if (tab === '错误') return 'errors'
return 'usage'
}
function trendSummaryTone(label: string): SemanticTone {
if (label.includes('错误')) return 'danger'
if (label.includes('最高')) return 'warning'
if (label.includes('输出') || label.includes('最低') || label.includes('成功')) return 'success'
if (label.includes('缓存')) return 'normal'
return 'info'
}
function accountTone(status: DashboardAccountUsage['status']): SemanticTone {
if (status === 'active') return 'success'
if (status === 'quota_exhausted' || status === 'expired') return 'warning'
if (status === 'banned' || status === 'disabled') return 'danger'
if (status === 'refreshing') return 'info'
return 'normal'
}
function eventTone(level: string, statusCode?: number): SemanticTone {
if (level === 'warn') return 'warning'
if (level === 'error' || (typeof statusCode === 'number' && statusCode >= 400)) return 'danger'
if (level === 'debug') return 'normal'
return 'info'
}
function levelLabel(level: string): string {
const labels: Record<string, string> = {
debug: '调试',
info: '信息',
warn: '警告',
error: '错误',
}
for (const log of logs) {
const hour = log.createdAt?.slice(11, 13)
if (!hour || !buckets[hour]) continue
buckets[hour].requests++
const usage = log.metadata?.usage
if (usage) {
buckets[hour].inputTokens += usage.inputTokens || 0
buckets[hour].outputTokens += usage.outputTokens || 0
}
const code = log.statusCode
if (code && (code >= 400 || code < 0)) buckets[hour].errors++
if (log.latencyMs) {
buckets[hour].latencySum += log.latencyMs
buckets[hour].latencyCount++
}
}
const entries = Object.entries(buckets)
trendPoints.value = entries.map(([time, b]) => ({
time,
requests: b.requests,
tokens: b.inputTokens + b.outputTokens,
errors: b.errors,
latency: b.latencyCount > 0 ? Math.round(b.latencySum / b.latencyCount) : 0,
}))
const totalInput = entries.reduce((s, [, b]) => s + b.inputTokens, 0)
const totalOutput = entries.reduce((s, [, b]) => s + b.outputTokens, 0)
trendSummary.value = [
{ label: '输入', value: totalInput > 0 ? formatTokens(totalInput) : '—', tone: 'info' as const },
{ label: '输出', value: totalOutput > 0 ? formatTokens(totalOutput) : '—', tone: 'success' as const },
{ label: '缓存', value: formatTokens(summary?.cachedTokens ?? 0), tone: (summary?.cachedTokens ?? 0) > 0 ? 'success' as const : 'normal' as const },
]
return labels[level] ?? level
}
function formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`
if (num >= 1_000_000_000) return `${formatCompact(num / 1_000_000_000)}B`
if (num >= 1_000_000) return `${formatCompact(num / 1_000_000)}M`
if (num >= 1_000) return `${formatCompact(num / 1_000)}K`
return String(num)
}
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
return String(tokens)
return formatNumber(tokens)
}
function formatCompact(value: number): string {
const rounded =
value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2)
return rounded.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')
}
function formatRate(value: number | null | undefined): string {
if (value === null || value === undefined) return '-'
return `${(value * 100).toFixed(1)}%`
}
function formatCost(value: number | null | undefined): string {
if (value === null || value === undefined) return '-'
return `$${value.toFixed(4)}`
}
function formatLatency(ms: number | null | undefined): string {
if (!ms) return '-'
if (ms >= 1000) return `${formatCompact(ms / 1000)}s`
return `${ms}ms`
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '—'
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
@@ -341,15 +391,18 @@ export function useDashboard(): {
function formatTime(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 启动自动刷新
function startAutoRefresh() {
stopAutoRefresh()
autoRefreshTimer.value = setInterval(() => {
loadDashboardData()
}, 30000) // 30秒刷新一次
}, 30000)
}
function stopAutoRefresh() {
@@ -370,6 +423,7 @@ export function useDashboard(): {
return {
loading,
trendLoading,
metrics,
trendPoints,
trendSummary,
@@ -380,5 +434,6 @@ export function useDashboard(): {
capacityInfo,
rotationStrategy,
refresh: loadDashboardData,
loadTrend,
}
}
+12 -1
View File
@@ -14,14 +14,24 @@ export interface MetricCardItem {
icon: Component
tone: SemanticTone
details: MetricDetail[]
trend?: {
direction: 'up' | 'down' | 'flat'
tone: SemanticTone
}
}
export interface TrendPoint {
time: string
requests: number
inputTokens: number
outputTokens: number
cachedTokens: number
tokens: number
errors: number
latency: number
maxLatency: number
minLatency: number
successRate: number
}
export interface TrendSummaryItem {
@@ -38,7 +48,8 @@ export interface AccountUsageItem {
tokens: string
lastUsed: string
tone: SemanticTone
loadWidth: number
quotaPercent: number
quotaTone: SemanticTone
}
export interface ServiceStatusItem {
+72 -23
View File
@@ -37,18 +37,29 @@ async function handleSubmit() {
aria-label="Codex Proxy RS 登录"
>
<div class="relative h-full min-h-0 overflow-hidden">
<div class="absolute left-0 top-0 flex h-13 w-[420px] items-center gap-3.25">
<span class="inline-flex size-11 items-center justify-center rounded-[13px] bg-[#111827] text-white">
<div class="absolute left-0 top-0 flex h-13 w-105 items-center gap-3.25">
<span
class="inline-flex size-11 items-center justify-center rounded-[13px] bg-[#111827] text-white"
>
<SquareTerminal :size="22" />
</span>
<span class="grid h-10.5 content-center gap-1">
<strong class="text-[17px] leading-[1.1] font-[760] text-[#0E1726]">Codex Proxy RS</strong>
<span class="text-[11px] leading-[1.1] font-[650] text-[#64748B]">Proxy RS · v0.1.0</span>
<strong class="text-[17px] leading-[1.1] font-[760] text-[#0E1726]"
>Codex Proxy RS</strong
>
<span class="text-[11px] leading-[1.1] font-[650] text-[#64748B]"
>Proxy RS · v0.1.0</span
>
</span>
</div>
<div class="pointer-events-none absolute left-2 top-[26%] h-[238px] w-[910px] max-w-[96vw]">
<svg class="size-full overflow-visible" viewBox="0 0 910 238" fill="none" preserveAspectRatio="none">
<div class="pointer-events-none absolute left-2 top-[26%] h-59.5 w-227.5 max-w-[96vw]">
<svg
class="size-full overflow-visible"
viewBox="0 0 910 238"
fill="none"
preserveAspectRatio="none"
>
<path
d="M0 134 C154 30 304 54 440 116 C592 186 726 178 910 54"
stroke="#2563EB"
@@ -59,8 +70,13 @@ async function handleSubmit() {
</svg>
</div>
<div class="pointer-events-none absolute left-6 top-[35%] h-[210px] w-[870px] max-w-[92vw]">
<svg class="size-full overflow-visible" viewBox="0 0 870 210" fill="none" preserveAspectRatio="none">
<div class="pointer-events-none absolute left-6 top-[35%] h-52.5 w-217.5 max-w-[92vw]">
<svg
class="size-full overflow-visible"
viewBox="0 0 870 210"
fill="none"
preserveAspectRatio="none"
>
<path
d="M0 62 C138 150 286 154 424 94 C580 26 708 48 870 150"
stroke="#0F9F9A"
@@ -71,8 +87,15 @@ async function handleSubmit() {
</svg>
</div>
<div class="pointer-events-none absolute left-10.5 top-[30%] h-[210px] w-[820px] max-w-[88vw] opacity-70">
<svg class="size-full overflow-visible" viewBox="0 0 820 210" fill="none" preserveAspectRatio="none">
<div
class="pointer-events-none absolute left-10.5 top-[30%] h-52.5 w-205 max-w-[88vw] opacity-70"
>
<svg
class="size-full overflow-visible"
viewBox="0 0 820 210"
fill="none"
preserveAspectRatio="none"
>
<path
d="M0 118 C132 54 266 76 392 124 C540 178 666 156 820 86"
stroke="#93C5FD"
@@ -83,8 +106,15 @@ async function handleSubmit() {
</svg>
</div>
<div class="pointer-events-none absolute left-15 top-[45%] h-[178px] w-[780px] max-w-[84vw] opacity-70">
<svg class="size-full overflow-visible" viewBox="0 0 780 178" fill="none" preserveAspectRatio="none">
<div
class="pointer-events-none absolute left-15 top-[45%] h-44.5 w-195 max-w-[84vw] opacity-70"
>
<svg
class="size-full overflow-visible"
viewBox="0 0 780 178"
fill="none"
preserveAspectRatio="none"
>
<path
d="M0 78 C118 42 224 118 352 78 C500 32 632 96 780 62"
stroke="#99F6E4"
@@ -95,8 +125,15 @@ async function handleSubmit() {
</svg>
</div>
<div class="pointer-events-none absolute left-24 top-[56%] h-[130px] w-[680px] max-w-[74vw] opacity-60">
<svg class="size-full overflow-visible" viewBox="0 0 680 130" fill="none" preserveAspectRatio="none">
<div
class="pointer-events-none absolute left-24 top-[56%] h-32.5 w-170 max-w-[74vw] opacity-60"
>
<svg
class="size-full overflow-visible"
viewBox="0 0 680 130"
fill="none"
preserveAspectRatio="none"
>
<path
d="M0 62 C108 88 192 30 316 58 C442 86 548 42 680 66"
stroke="#CBD5E1"
@@ -107,15 +144,23 @@ async function handleSubmit() {
</svg>
</div>
<span class="pointer-events-none absolute left-[446px] top-[39%] size-1.75 rounded-full bg-[#2563EB] shadow-[0_0_14px_#2563EB66]" />
<span class="pointer-events-none absolute left-[602px] top-[47%] size-1.75 rounded-full bg-[#0F9F9A] shadow-[0_0_14px_#0F9F9A66]" />
<span class="pointer-events-none absolute left-[262px] top-[35%] size-1 rounded-full bg-[#94A3B8]" />
<span
class="pointer-events-none absolute left-111.5 top-[39%] size-1.75 rounded-full bg-[#2563EB] shadow-[0_0_14px_#2563EB66]"
/>
<span
class="pointer-events-none absolute left-150.5 top-[47%] size-1.75 rounded-full bg-[#0F9F9A] shadow-[0_0_14px_#0F9F9A66]"
/>
<span
class="pointer-events-none absolute left-65.5 top-[35%] size-1 rounded-full bg-[#94A3B8]"
/>
<div class="absolute bottom-8 left-0 grid w-[580px] max-w-full gap-3.5 min-[961px]:bottom-[64px] min-[961px]:left-14">
<div
class="absolute bottom-8 left-0 grid w-145 max-w-full gap-3.5 min-[961px]:bottom-16 min-[961px]:left-14"
>
<h1 class="m-0 text-[38px] leading-[1.08] font-[780] tracking-normal text-[#0E1726]">
安静进入控制台。
</h1>
<p class="m-0 max-w-[580px] text-sm leading-[1.45] font-medium text-[#64748B]">
<p class="m-0 max-w-145 text-sm leading-[1.45] font-medium text-[#64748B]">
几条请求轨迹,连接账号池、模型目录与管理入口。
</p>
</div>
@@ -146,7 +191,9 @@ async function handleSubmit() {
<label class="grid h-18 gap-2">
<span class="text-xs leading-[1.1] font-bold text-[#0E1726]">管理员账号</span>
<span class="inline-flex h-11.5 items-center gap-2.5 rounded-xl bg-[#EEF2F7] px-3.5 text-[#64748B] shadow-[inset_0_1px_0_#FFFFFF66] transition focus-within:bg-[#F8FAFC] focus-within:shadow-[inset_0_1px_0_#FFFFFF,0_0_0_3px_#DBEAFE]">
<span
class="inline-flex h-11.5 items-center gap-2.5 rounded-xl bg-[#EEF2F7] px-3.5 text-[#64748B] shadow-[inset_0_1px_0_#FFFFFF66] transition focus-within:bg-[#F8FAFC] focus-within:shadow-[inset_0_1px_0_#FFFFFF,0_0_0_3px_#DBEAFE]"
>
<Mail :size="17" />
<input
v-model="username"
@@ -154,13 +201,15 @@ async function handleSubmit() {
placeholder="请输入用户名"
type="text"
required
>
/>
</span>
</label>
<label class="grid h-18 gap-2">
<span class="text-xs leading-[1.1] font-bold text-[#0E1726]">访问密钥</span>
<span class="inline-flex h-11.5 items-center gap-2.5 rounded-xl bg-[#EEF2F7] px-3.5 text-[#64748B] shadow-[inset_0_1px_0_#FFFFFF66] transition focus-within:bg-[#F8FAFC] focus-within:shadow-[inset_0_1px_0_#FFFFFF,0_0_0_3px_#DBEAFE]">
<span
class="inline-flex h-11.5 items-center gap-2.5 rounded-xl bg-[#EEF2F7] px-3.5 text-[#64748B] shadow-[inset_0_1px_0_#FFFFFF66] transition focus-within:bg-[#F8FAFC] focus-within:shadow-[inset_0_1px_0_#FFFFFF,0_0_0_3px_#DBEAFE]"
>
<KeyRound :size="17" />
<input
v-model="password"
@@ -169,7 +218,7 @@ async function handleSubmit() {
type="password"
required
@keyup.enter="handleSubmit"
>
/>
</span>
</label>
+38 -67
View File
@@ -10,14 +10,11 @@ import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseSpinner from '@/components/base/BaseSpinner.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import type { EventLog } from '@/api'
import { clearLogs, getLogDetail, getLogs } from '@/api'
import { useToastStore } from '@/stores/modules/toast'
const toast = useToastStore()
import { toast } from '@/components/base/BaseToast'
const loading = ref(true)
const logs = ref<EventLog[]>([])
@@ -31,7 +28,7 @@ const selectedLog = ref<EventLog | null>(null)
const levelOptions = [
{ label: '全部级别', value: '' },
{ label: '信息', value: 'info' },
{ label: '警告', value: 'warning' },
{ label: '警告', value: 'warn' },
{ label: '错误', value: 'error' },
]
@@ -42,9 +39,9 @@ const kindOptions = [
{ label: '系统', value: 'system' },
]
const levelColors: Record<string, { bg: string, text: string }> = {
const levelColors: Record<string, { bg: string; text: string }> = {
info: { bg: 'bg-blue-50', text: 'text-blue-700' },
warning: { bg: 'bg-yellow-50', text: 'text-yellow-700' },
warn: { bg: 'bg-yellow-50', text: 'text-yellow-700' },
error: { bg: 'bg-red-50', text: 'text-red-700' },
}
@@ -53,19 +50,20 @@ const filteredLogs = computed(() => {
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(log =>
log.message.toLowerCase().includes(query)
|| log.requestId?.toLowerCase().includes(query)
|| log.route?.toLowerCase().includes(query),
result = result.filter(
(log) =>
log.message.toLowerCase().includes(query) ||
log.requestId?.toLowerCase().includes(query) ||
log.route?.toLowerCase().includes(query),
)
}
if (filterLevel.value) {
result = result.filter(log => log.level === filterLevel.value)
result = result.filter((log) => log.level === filterLevel.value)
}
if (filterKind.value) {
result = result.filter(log => log.kind === filterKind.value)
result = result.filter((log) => log.kind === filterKind.value)
}
return result
@@ -119,7 +117,7 @@ function formatTime(dateStr: string): string {
function getLevelLabel(level: string): string {
const labels: Record<string, string> = {
info: '信息',
warning: '警告',
warn: '警告',
error: '错误',
}
return labels[level] || level
@@ -131,7 +129,7 @@ onMounted(() => {
</script>
<template>
<div class="w-full min-w-295 p-7">
<div class="w-full">
<header class="flex h-17 items-start justify-between">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
@@ -147,55 +145,32 @@ onMounted(() => {
<div class="mt-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<BaseInput
v-model="searchQuery"
placeholder="搜索消息、请求 ID 或路由..."
class="w-80"
>
<BaseInput v-model="searchQuery" placeholder="搜索消息、请求 ID 或路由..." class="w-80">
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseSelect
v-model="filterLevel"
:options="levelOptions"
class="w-36"
/>
<BaseSelect v-model="filterLevel" :options="levelOptions" class="w-36" />
<BaseSelect
v-model="filterKind"
:options="kindOptions"
class="w-36"
/>
<BaseSelect v-model="filterKind" :options="kindOptions" class="w-36" />
</div>
<div class="flex items-center gap-2">
<BaseIconButton
variant="ghost"
size="md"
title="刷新列表"
@click="loadLogs"
>
<BaseIconButton variant="ghost" size="md" title="刷新列表" @click="loadLogs">
<RefreshCw class="size-4.5" />
</BaseIconButton>
<BaseButton
variant="danger"
size="md"
@click="showClearModal = true"
>
<BaseButton variant="danger" size="md" @click="showClearModal = true">
<Trash2 class="size-4" />
清空日志
</BaseButton>
</div>
</div>
<BaseCard class="mt-5 p-0">
<BaseSpinner v-if="loading" class="py-20" />
<BaseCard v-loading="loading" class="mt-5 p-0">
<BaseEmpty
v-else-if="filteredLogs.length === 0"
v-if="filteredLogs.length === 0 && !loading"
message="暂无日志记录"
class="py-20"
/>
@@ -255,8 +230,10 @@ onMounted(() => {
<span
class="text-[13px] font-mono"
:class="{
'text-green-600': log.statusCode && log.statusCode >= 200 && log.statusCode < 300,
'text-yellow-600': log.statusCode && log.statusCode >= 300 && log.statusCode < 400,
'text-green-600':
log.statusCode && log.statusCode >= 200 && log.statusCode < 300,
'text-yellow-600':
log.statusCode && log.statusCode >= 300 && log.statusCode < 400,
'text-red-600': log.statusCode && log.statusCode >= 400,
'text-(--cp-text-secondary)': !log.statusCode,
}"
@@ -291,6 +268,8 @@ onMounted(() => {
<BaseModal
v-model="showClearModal"
title="确认清空日志"
description="清空后无法恢复,新的代理事件会继续记录。"
variant="warning"
width="480px"
>
<p class="text-[14px] text-(--cp-text-secondary)">
@@ -298,18 +277,8 @@ onMounted(() => {
</p>
<template #footer>
<BaseButton
variant="ghost"
@click="showClearModal = false"
>
取消
</BaseButton>
<BaseButton
variant="danger"
@click="handleClearLogs"
>
确认清空
</BaseButton>
<BaseButton variant="ghost" @click="showClearModal = false"> 取消 </BaseButton>
<BaseButton variant="danger" @click="handleClearLogs"> 确认清空 </BaseButton>
</template>
</BaseModal>
@@ -317,6 +286,8 @@ onMounted(() => {
<BaseModal
v-model="showDetailModal"
title="日志详情"
description="查看单条事件的请求、状态和元数据。"
variant="info"
width="720px"
>
<div v-if="selectedLog" class="flex flex-col gap-4">
@@ -367,24 +338,24 @@ onMounted(() => {
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">消息</label>
<p class="m-0 px-3 py-2.5 rounded-lg bg-(--cp-bg-subtle) text-[13px] text-(--cp-text-primary)">
<p
class="m-0 px-3 py-2.5 rounded-lg bg-(--cp-bg-subtle) text-[13px] text-(--cp-text-primary)"
>
{{ selectedLog.message }}
</p>
</div>
<div v-if="selectedLog.metadata">
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">元数据</label>
<pre class="m-0 px-3 py-2.5 rounded-lg bg-(--cp-bg-subtle) text-[12px] font-mono text-(--cp-text-primary) overflow-x-auto">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
<pre
class="m-0 px-3 py-2.5 rounded-lg bg-(--cp-bg-subtle) text-[12px] font-mono text-(--cp-text-primary) overflow-x-auto"
>{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre
>
</div>
</div>
<template #footer>
<BaseButton
variant="primary"
@click="showDetailModal = false"
>
关闭
</BaseButton>
<BaseButton variant="primary" @click="showDetailModal = false"> 关闭 </BaseButton>
</template>
</BaseModal>
</div>
+42 -24
View File
@@ -6,14 +6,11 @@ import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseSpinner from '@/components/base/BaseSpinner.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import type { Settings } from '@/api'
import { getSettings, updateSettings } from '@/api'
import { useToastStore } from '@/stores/modules/toast'
const toast = useToastStore()
import { toast } from '@/components/base/BaseToast'
const loading = ref(true)
const saving = ref(false)
@@ -91,7 +88,7 @@ onMounted(() => {
</script>
<template>
<div class="w-full min-w-295 p-7">
<div class="w-full">
<header class="flex h-17 items-start justify-between">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
@@ -104,15 +101,14 @@ onMounted(() => {
<AppTopbar class="mt-0.5" />
</header>
<BaseSpinner v-if="loading" class="mt-20" />
<div v-else class="mt-6 grid gap-6 max-w-4xl">
<div v-loading="loading" class="mt-6 grid gap-6 max-w-4xl">
<!-- 模型配置 -->
<BaseCard>
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">模型配置</h2>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">默认模型</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2"
>默认模型</label
>
<BaseInput v-model="form.defaultModel" class="w-64" />
</div>
</BaseCard>
@@ -122,25 +118,37 @@ onMounted(() => {
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">调度配置</h2>
<div class="grid gap-5">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">单账号最大并发</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2"
>单账号最大并发</label
>
<BaseInput v-model="maxConcurrentPerAccountValue" type="number" class="w-48" />
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">请求间隔 (ms)</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2"
>请求间隔 (ms)</label
>
<BaseInput v-model="requestIntervalMsValue" type="number" class="w-48" />
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">轮换策略</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2"
>轮换策略</label
>
<BaseSelect v-model="form.rotationStrategy" :options="rotationOptions" class="w-48" />
</div>
<div class="flex items-center justify-between">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">跳过配额耗尽账号</label>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">调度时自动跳过已触发配额的账号</p>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1"
>跳过配额耗尽账号</label
>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">
调度时自动跳过已触发配额的账号
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="form.quotaSkipExhausted" type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" />
<input v-model="form.quotaSkipExhausted" type="checkbox" class="sr-only peer" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"
/>
</label>
</div>
</div>
@@ -152,16 +160,22 @@ onMounted(() => {
<div class="grid gap-5">
<div class="flex items-center justify-between">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">启用日志记录</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1"
>启用日志记录</label
>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">记录系统运行事件和错误</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="form.logsEnabled" type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" />
<input v-model="form.logsEnabled" type="checkbox" class="sr-only peer" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"
/>
</label>
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">日志容量上限</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2"
>日志容量上限</label
>
<BaseInput v-model="logsCapacityValue" type="number" class="w-48" />
</div>
</div>
@@ -172,12 +186,16 @@ onMounted(() => {
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">自动刷新</h2>
<div class="flex items-center justify-between">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">启用自动刷新</label>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1"
>启用自动刷新</label
>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">定时刷新账号令牌和配额信息</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="form.refreshEnabled" type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" />
<input v-model="form.refreshEnabled" type="checkbox" class="sr-only peer" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"
/>
</label>
</div>
</BaseCard>
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"target": "ES2024",
+1 -4
View File
@@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}