mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
fix: update dashboard data and layout
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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,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>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
//! 管理端认证与本地会话模块。
|
||||
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
"a_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("a_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,
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+10
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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,5 +1,5 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string
|
||||
code: number
|
||||
message: string
|
||||
requestId: string
|
||||
data: T
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : '',
|
||||
]"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 ? '展开侧边栏' : '收缩侧边栏'"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,5 +7,4 @@ pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
// 导出所有 store
|
||||
export * from './modules/auth'
|
||||
export * from './modules/toast'
|
||||
export * from './modules/ui'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user