From f6f98491b34b65eca3ee09915291f77fdd6983f4 Mon Sep 17 00:00:00 2001 From: lsy Date: Sun, 15 Mar 2026 04:26:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8F=B0=E4=B8=8E=E8=B7=AF=E7=94=B1=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理台体验升级与日志分页/搜索/状态码展示 - 令牌过期与允许渠道控制,站点新增/修改自动测试 - 调用令牌按模型匹配选择,移除模型 TTL 并优化冷却逻辑 - 新增迁移与文档同步 --- .helloagents/CHANGELOG.md | 59 + .helloagents/modules/admin-ui.md | 15 +- .helloagents/modules/channels.md | 1 + .helloagents/modules/models.md | 1 - .helloagents/modules/proxy.md | 11 +- .helloagents/modules/settings.md | 2 - .helloagents/modules/tokens.md | 7 +- .helloagents/modules/usage.md | 6 +- apps/ui/index.html | 7 + apps/ui/src/App.tsx | 1090 +++++++++++++---- apps/ui/src/core/constants.ts | 17 +- apps/ui/src/core/types.ts | 34 + apps/ui/src/core/utils.ts | 140 ++- apps/ui/src/features/AppLayout.tsx | 40 +- apps/ui/src/features/DashboardView.tsx | 89 +- apps/ui/src/features/LoginView.tsx | 88 +- apps/ui/src/features/ModelsView.tsx | 95 +- apps/ui/src/features/SettingsView.tsx | 32 +- apps/ui/src/features/SitesView.tsx | 141 ++- apps/ui/src/features/TokensView.tsx | 299 ++++- apps/ui/src/features/UsageView.tsx | 291 ++++- apps/ui/src/styles.css | 56 + .../0006_add_channel_model_err_count.sql | 1 + .../migrations/0007_add_token_expires_at.sql | 1 + .../migrations/0008_add_call_token_models.sql | 1 + apps/worker/src/db/schema.sql | 20 + apps/worker/src/middleware/tokenAuth.ts | 10 +- apps/worker/src/routes/channels.ts | 13 +- apps/worker/src/routes/models.ts | 4 - apps/worker/src/routes/newapiUsers.ts | 4 - apps/worker/src/routes/proxy.ts | 221 +++- apps/worker/src/routes/settings.ts | 18 - apps/worker/src/routes/tokens.ts | 63 +- apps/worker/src/routes/usage.ts | 76 +- .../src/services/call-token-selector.ts | 1 + .../src/services/channel-call-token-repo.ts | 15 + .../src/services/channel-call-token-types.ts | 1 + .../services/channel-model-capabilities.ts | 49 +- apps/worker/src/services/channel-testing.ts | 8 +- apps/worker/src/services/settings.ts | 17 - tests/ui/usage-status.test.ts | 10 +- 41 files changed, 2476 insertions(+), 578 deletions(-) create mode 100644 apps/worker/migrations/0006_add_channel_model_err_count.sql create mode 100644 apps/worker/migrations/0007_add_token_expires_at.sql create mode 100644 apps/worker/migrations/0008_add_call_token_models.sql diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index eef62b4..7ac114b 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -29,6 +29,9 @@ - **[usage/proxy]**: 使用日志记录上游失败详情并在日志状态列展示 - 方案: [202603141815_usage-log-status](archive/2026-03/202603141815_usage-log-status/) - 文件: apps/worker/migrations/0005_add_usage_error_fields.sql, apps/worker/src/db/schema.sql, apps/worker/src/services/usage.ts, apps/worker/src/routes/proxy.ts, apps/ui/src/core/types.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md +- **[admin-ui/usage]**: 状态列改为“状态码 + 错误码”,成功展示 `200 OK` + - 方案: [202603141925_usage-status-label](archive/2026-03/202603141925_usage-status-label/) + - 文件: apps/worker/src/routes/proxy.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md - **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总) - 类型: 微调(无方案包) - 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts @@ -168,6 +171,62 @@ - 类型: 微调(无方案包) - 文件: apps/worker/src/routes/proxy.ts +### 快速修改 + +- **[admin-ui]**: 修复模型广场渲染语法错误 — by lsy + - 类型: 快速修改(无方案包) + - 文件: apps/ui/src/features/ModelsView.tsx:16 +- **[admin-ui]**: 站点搜索加入防抖与使用日志状态码/摘要展示优化 — by lsy + - 类型: 快速修改(无方案包) + - 文件: apps/ui/src/features/SitesView.tsx, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx +- **[admin-ui]**: 提交操作增加即时防重复锁,避免连点重复创建 — by lsy + - 类型: 快速修改(无方案包) + - 文件: apps/ui/src/App.tsx + +## [0.9.4] - 2026-03-15 + +### 变更 +- **[usage/admin-ui]**: 使用日志改为服务端分页并支持渠道/令牌/模型搜索 — by lsy + - 方案: [202603150145_usage-log-pagination-and-channel-disable](archive/2026-03/202603150145_usage-log-pagination-and-channel-disable/) + - 决策: usage-log-pagination-and-channel-disable#D001(使用 offset+limit+total 分页协议) +- **[channels]**: 连通性测试不再覆盖已禁用渠道状态 — by lsy + - 方案: [202603150145_usage-log-pagination-and-channel-disable](archive/2026-03/202603150145_usage-log-pagination-and-channel-disable/) + +## [0.9.2] - 2026-03-14 + +### 变更 +- **[usage/proxy]**: 状态列仅展示日志状态码并支持错误详情查看,冷却改为窗口内连续失败触发 — by lsy + - 方案: [202603142337_usage-status-cooldown-tuning](archive/2026-03/202603142337_usage-status-cooldown-tuning/) + - 决策: usage-status-cooldown-tuning#D001(连续失败阈值冷却) + +## [0.9.1] - 2026-03-14 + +### 变更 +- **[proxy/models/settings/admin-ui]**: 移除模型能力 TTL,路由不再兜底并支持失败冷却配置 — by lsy + - 方案: [202603142241_remove-model-ttl-fix-routing](archive/2026-03/202603142241_remove-model-ttl-fix-routing/) + - 决策: remove-model-ttl-fix-routing#D001(移除模型能力 TTL) + +## [0.9.2] - 2026-03-15 + +### 变更 +- **[proxy/usage/admin-ui]**: 冷却仅对上游失败触发,使用日志列表仅展示状态码 — by lsy + - 方案: [202603150017_usage-status-cooldown-migration](archive/2026-03/202603150017_usage-status-cooldown-migration/) + - 决策: usage-status-cooldown-migration#D001(冷却仅针对上游/网络失败) + +## [0.9.3] - 2026-03-15 + +### 变更 +- **[proxy/usage]**: 所有调用写入日志,失败计数仅在成功时清零 — by lsy + - 方案: [202603150017_usage-status-cooldown-migration](archive/2026-03/202603150017_usage-status-cooldown-migration/) + - 决策: usage-status-cooldown-migration#D001(冷却仅针对上游/网络失败) + +## [0.9.0] - 2026-03-14 + +### 新增 +- **[admin-ui]**: 管理台体验升级(通知分级、确认弹窗、空状态 CTA、视觉主题与动效) — by lsy + - 方案: [202603142024_admin-ui-ux-upgrade](archive/2026-03/202603142024_admin-ui-ux-upgrade/) + - 决策: admin-ui-ux-upgrade#D001(统一通知与确认对话管理) + ## [0.8.4] - 2026-02-25 ### 变更 diff --git a/.helloagents/modules/admin-ui.md b/.helloagents/modules/admin-ui.md index f7472ba..956fc39 100644 --- a/.helloagents/modules/admin-ui.md +++ b/.helloagents/modules/admin-ui.md @@ -24,16 +24,25 @@ - openai/Anthropic/gemini 站点支持 base_url 为空时自动填充官方地址 - done-hub 站点仅使用 base_url,不再维护多地址输入 - 站点连通测试提示令牌成功/失败统计 -- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度 -- 使用日志状态列展示详细失败原因(状态码/错误码/摘要) -- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀 +- 站点新增/修改完成后自动触发连通测试并提示结果 +- 使用日志支持服务端分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度 +- 使用日志支持按渠道/令牌/模型/状态码搜索并与分页联动 +- 使用日志状态列仅展示状态码,错误详情弹窗仅展示元信息(不展示错误片段) +- 令牌管理采用列表视图(默认每页 10 条),生成/编辑使用弹窗表单,支持过期时间(北京时间)与允许渠道(默认全开)并默认复制,列表展示创建时间、前缀、过期时间与渠道限制摘要 - 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`) - 系统设置增加定时签到开关与时间(中国时间) +- 系统设置提供模型失败冷却分钟数配置 - 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择 - Token 仅本地保存,不回显 - 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定 - 渠道/令牌列表在移动端以卡片形式展示,操作区改为网格按钮排列 - 数据面板、模型广场、使用日志表格在移动端支持横向滚动与字号收紧 +- 全局通知支持成功/提示/错误/信息分级展示,可关闭并自动消退 +- 删除类操作必须经过确认弹窗 +- 主要异步操作提供 loading 与按钮禁用态 +- 弹窗支持遮罩/ESC 关闭并自动聚焦首字段 +- 空状态提供清晰 CTA 引导下一步 +- 全局字体通过外部字体源加载,背景包含暖调渐变与轻纹理 ## 依赖关系 - `auth` / `channels` / `models` / `tokens` / `usage` / `dashboard` / `settings` 模块 diff --git a/.helloagents/modules/channels.md b/.helloagents/modules/channels.md index 712b0ea..51b4514 100644 --- a/.helloagents/modules/channels.md +++ b/.helloagents/modules/channels.md @@ -29,6 +29,7 @@ - 创建渠道时可传入自定义 `id`,未提供则自动生成 - 连通性测试会对调用令牌逐个调用 `/v1/models` 并汇总模型 - 至少一枚令牌测试成功时更新 `models_json` 并返回成功/失败统计 +- 连通性测试不会覆盖已禁用渠道的状态(保持 `disabled`) - New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json` - New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新 - New API 分组列表从 `group_name` 字段解析,空时返回 `default` diff --git a/.helloagents/modules/models.md b/.helloagents/modules/models.md index 78cc506..cce144f 100644 --- a/.helloagents/modules/models.md +++ b/.helloagents/modules/models.md @@ -11,7 +11,6 @@ ## 行为规范 - 仅聚合启用状态的渠道 - 使用“模型广场测试结果”作为可用模型来源 -- 模型结果带 TTL(默认 2 小时,来自 settings),过期后回退到渠道声明模型列表(避免模型广场清空) - 模型归一化支持字符串数组与对象数组(对象取 `id` 字段) ## 依赖关系 diff --git a/.helloagents/modules/proxy.md b/.helloagents/modules/proxy.md index fda1332..c9b13d3 100644 --- a/.helloagents/modules/proxy.md +++ b/.helloagents/modules/proxy.md @@ -14,7 +14,8 @@ ## 行为规范 - 基于令牌鉴权 - 按渠道权重随机排序 -- 调用令牌按顺序选择,未配置时回退 `channels.api_key` +- 调用令牌按模型列表匹配选择,未配置或无列表时回退 `channels.api_key` +- 调用令牌模型列表来自渠道连通测试写回 - done-hub 仅使用 `base_url`,不再按多地址切换 - 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段 - 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage @@ -26,8 +27,12 @@ - 允许 `metadata_json.endpoint_overrides` 覆盖 chat/embedding/image 上游地址(可用 `{model}` 占位) - 允许 `metadata_json.header_override` 与 `metadata_json.query_override` 注入额外上游请求头/查询参数 - Anthropic 默认注入 `anthropic-version=2023-06-01`,可通过 `header_override` 覆盖 -- 路由时优先使用模型能力表(TTL 内测试成功的模型),无能力记录时回退渠道声明模型列表 -- 上游失败/超时会记录到能力表并进入冷却期,冷却期内跳过对应渠道/模型 +- 路由时优先使用模型能力表记录(测试成功的模型),无能力记录时回退渠道声明模型列表 +- 当模型在任意渠道中都不被支持时返回 `no_available_channels`,不回退到全部渠道 +- 上游失败/超时会记录到能力表并进入冷却期(需在窗口内连续失败达到阈值),冷却期内跳过对应渠道/模型 +- 失败计数仅在成功响应时清零 +- 仅 429/5xx/408/timeout/exception 计入冷却,4xx 请求错误不触发冷却 +- 映射模型场景失败时同时记录下游模型错误,确保冷却窗口生效 - 上游成功响应会刷新能力表,清空错误并延长可用窗口 ## 依赖关系 diff --git a/.helloagents/modules/settings.md b/.helloagents/modules/settings.md index 632ed29..20e7728 100644 --- a/.helloagents/modules/settings.md +++ b/.helloagents/modules/settings.md @@ -4,7 +4,6 @@ - 维护系统级配置(如日志保留天数) - 为使用日志与面板提供统一配置读取 - 维护定时签到执行时间 -- 维护模型能力 TTL(模型广场与路由过滤窗口) - 维护模型失败冷却时间(运行时失败的渠道/模型冷却窗口) ## 接口定义 @@ -17,7 +16,6 @@ - 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间) - 更新签到时间后会触发 `CheckinScheduler` 重新调度 - 若更新后当前时间已超过新签到时间,当日可再次触发自动签到 -- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期 - 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型 ## 依赖关系 diff --git a/.helloagents/modules/tokens.md b/.helloagents/modules/tokens.md index a6e6f80..e4fca1d 100644 --- a/.helloagents/modules/tokens.md +++ b/.helloagents/modules/tokens.md @@ -15,8 +15,13 @@ ## 行为规范 - 令牌可通过 reveal 接口再次查看 - 默认允许所有渠道 +- 可选限制允许的渠道列表(`allowed_channels`),为空或未设置表示全开 - 额度消耗由代理调用记录 -- 管理台列表展示创建时间与 key_prefix 前缀 +- `expires_at` 为可选字段,留空表示永不过期 +- 管理台以北京时间输入过期时间,服务端统一转为 UTC 存储 +- 令牌过期后直接拒绝请求(`token_expired`),不自动更改状态 +- 管理台支持编辑名称/额度/状态/过期时间/允许渠道,新增令牌默认复制到剪贴板 +- 管理台列表展示创建时间与 key_prefix 前缀、过期时间 ## 依赖关系 - `tokens` 表 diff --git a/.helloagents/modules/usage.md b/.helloagents/modules/usage.md index d90d9b6..132a1be 100644 --- a/.helloagents/modules/usage.md +++ b/.helloagents/modules/usage.md @@ -9,12 +9,14 @@ - `GET /api/usage` 使用日志查询 ## 行为规范 -- 代理调用统一写入 `usage_logs` +- 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回) - 保留天数可配置,查询时触发清理 - 查询结果附带渠道与令牌名称 +- `GET /api/usage` 支持 `offset/limit` 分页并返回 `total` +- 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤) - usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度 - 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段 -- usage_logs 记录上游失败详情(status/code/message),用于日志状态展示 +- usage_logs 记录上游失败详情(status/code/message),日志列表仅展示状态码,详情弹窗仅展示元信息(不展示错误片段) ## 依赖关系 - `usage_logs` 表 diff --git a/apps/ui/index.html b/apps/ui/index.html index ba117e7..94a0a86 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -6,6 +6,13 @@ name="viewport" content="width=device-width, initial-scale=1.0" /> + + + + api-workers diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 98b46fb..cc05db9 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "hono/jsx/dom"; import { createApiFetch } from "./core/api"; @@ -11,6 +12,7 @@ import { initialData, initialSettingsForm, initialSiteForm, + initialTokenForm, tabs, } from "./core/constants"; import { @@ -24,6 +26,8 @@ import type { AdminData, CheckinSummary, DashboardData, + NoticeMessage, + NoticeTone, Settings, SettingsForm, Site, @@ -31,9 +35,11 @@ import type { SiteType, TabId, Token, - UsageLog, + UsageQuery, + UsageResponse, + TokenForm, } from "./core/types"; -import { toggleStatus } from "./core/utils"; +import { toChinaDateTimeInput, toChinaIsoFromInput, toggleStatus } from "./core/utils"; import { AppLayout } from "./features/AppLayout"; import { DashboardView } from "./features/DashboardView"; import { LoginView } from "./features/LoginView"; @@ -80,6 +86,24 @@ const DEFAULT_BASE_URL_BY_TYPE: Partial> = { gemini: "https://generativelanguage.googleapis.com", }; +type ConfirmState = { + title: string; + message: string; + confirmLabel?: string; + tone?: NoticeTone; + onConfirm: () => Promise | void; +}; + +const buildActionKey = (scope: string, id?: string) => + id ? `${scope}:${id}` : scope; + +const initialUsageQuery: UsageQuery = { + channel: "", + token: "", + model: "", + status: "", +}; + /** * Renders the admin console application. * @@ -98,7 +122,7 @@ const App = () => { return pathToTab[normalized] ?? "dashboard"; }); const [loading, setLoading] = useState(false); - const [notice, setNotice] = useState(""); + const [notice, setNotice] = useState(null); const [data, setData] = useState(initialData); const [settingsForm, setSettingsForm] = useState(initialSettingsForm); @@ -111,6 +135,16 @@ const App = () => { }); const [tokenPage, setTokenPage] = useState(1); const [tokenPageSize, setTokenPageSize] = useState(10); + const [editingToken, setEditingToken] = useState(null); + const [tokenForm, setTokenForm] = + useState(initialTokenForm); + const [usagePage, setUsagePage] = useState(1); + const [usagePageSize, setUsagePageSize] = useState(50); + const [usageTotal, setUsageTotal] = useState(0); + const [usageFilters, setUsageFilters] = + useState(initialUsageQuery); + const [usageQuery, setUsageQuery] = + useState(initialUsageQuery); const [editingSite, setEditingSite] = useState(null); const [siteForm, setSiteForm] = useState(() => ({ ...initialSiteForm, @@ -121,6 +155,10 @@ const App = () => { null, ); const [checkinLastRun, setCheckinLastRun] = useState(null); + const [, setPendingActions] = useState>(() => new Set()); + const pendingActionsRef = useRef>(new Set()); + const [confirmState, setConfirmState] = useState(null); + const [confirmPending, setConfirmPending] = useState(false); const updateToken = useCallback((next: string | null) => { setToken(next); @@ -131,6 +169,81 @@ const App = () => { } }, []); + const pushNotice = useCallback((tone: NoticeTone, message: string) => { + setNotice({ tone, message, id: Date.now() }); + }, []); + + const dismissNotice = useCallback(() => { + setNotice(null); + }, []); + + useEffect(() => { + if (!notice) { + return; + } + const timer = window.setTimeout(() => { + setNotice(null); + }, 4500); + return () => window.clearTimeout(timer); + }, [notice]); + + const startAction = useCallback((key: string) => { + if (pendingActionsRef.current.has(key)) { + return; + } + pendingActionsRef.current.add(key); + setPendingActions(new Set(pendingActionsRef.current)); + }, []); + + const endAction = useCallback((key: string) => { + pendingActionsRef.current.delete(key); + setPendingActions(new Set(pendingActionsRef.current)); + }, []); + + const isActionPending = useCallback( + (key: string) => pendingActionsRef.current.has(key), + [], + ); + + const openConfirm = useCallback((state: ConfirmState) => { + setConfirmState(state); + }, []); + + const closeConfirm = useCallback(() => { + if (!confirmPending) { + setConfirmState(null); + } + }, [confirmPending]); + + const handleConfirm = useCallback(async () => { + if (!confirmState || confirmPending) { + return; + } + setConfirmPending(true); + try { + await confirmState.onConfirm(); + } finally { + setConfirmPending(false); + setConfirmState(null); + } + }, [confirmPending, confirmState]); + + useEffect(() => { + if (!confirmState) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeConfirm(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [confirmState, closeConfirm]); + const apiFetch = useMemo( () => createApiFetch(token, () => updateToken(null)), [token, updateToken], @@ -141,6 +254,22 @@ const App = () => { setData((prev) => ({ ...prev, dashboard })); }, [apiFetch]); + const handleDashboardRefresh = useCallback(async () => { + const actionKey = buildActionKey("dashboard:refresh"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + try { + await loadDashboard(); + pushNotice("success", "数据已刷新"); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, [endAction, isActionPending, loadDashboard, pushNotice, startAction]); + const loadSites = useCallback(async () => { const result = await apiFetch<{ sites: Site[]; @@ -166,10 +295,43 @@ const App = () => { setData((prev) => ({ ...prev, tokens: result.tokens })); }, [apiFetch]); - const loadUsage = useCallback(async () => { - const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=200"); - setData((prev) => ({ ...prev, usage: result.logs })); - }, [apiFetch]); + const loadUsage = useCallback( + async (options?: { + page?: number; + pageSize?: number; + query?: UsageQuery; + }) => { + const page = options?.page ?? usagePage; + const pageSize = options?.pageSize ?? usagePageSize; + const query = options?.query ?? usageQuery; + const params = new URLSearchParams(); + const offset = Math.max(0, (page - 1) * pageSize); + params.set("limit", String(pageSize)); + params.set("offset", String(offset)); + const channel = query.channel.trim(); + const token = query.token.trim(); + const model = query.model.trim(); + const status = query.status.trim(); + if (channel) { + params.set("channel", channel); + } + if (token) { + params.set("token", token); + } + if (model) { + params.set("model", model); + } + if (status) { + params.set("status", status); + } + const result = await apiFetch( + `/api/usage?${params.toString()}`, + ); + setData((prev) => ({ ...prev, usage: result.logs })); + setUsageTotal(result.total ?? result.logs.length); + }, + [apiFetch, usagePage, usagePageSize, usageQuery], + ); const loadSettings = useCallback(async () => { const settings = await apiFetch("/api/settings"); @@ -179,7 +341,7 @@ const App = () => { const loadTab = useCallback( async (tabId: TabId) => { setLoading(true); - setNotice(""); + dismissNotice(); try { if (tabId === "dashboard") { await loadDashboard(); @@ -191,7 +353,7 @@ const App = () => { await loadModels(); } if (tabId === "tokens") { - await loadTokens(); + await Promise.all([loadTokens(), loadSites()]); } if (tabId === "usage") { await loadUsage(); @@ -200,12 +362,21 @@ const App = () => { await loadSettings(); } } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); } finally { setLoading(false); } }, - [loadDashboard, loadSites, loadModels, loadSettings, loadTokens, loadUsage], + [ + dismissNotice, + loadDashboard, + loadModels, + loadSettings, + loadSites, + loadTokens, + loadUsage, + pushNotice, + ], ); useEffect(() => { @@ -234,12 +405,20 @@ const App = () => { session_ttl_hours: String(data.settings.session_ttl_hours ?? 12), admin_password: "", checkin_schedule_time: data.settings.checkin_schedule_time ?? "00:10", + model_failure_cooldown_minutes: String( + data.settings.model_failure_cooldown_minutes ?? 10, + ), }); }, [data.settings]); const handleLogin = useCallback( async (event: Event) => { event.preventDefault(); + const actionKey = buildActionKey("login:submit"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); const form = event.currentTarget as HTMLFormElement; const formData = new FormData(form); const password = String(formData.get("password") ?? ""); @@ -249,12 +428,22 @@ const App = () => { body: JSON.stringify({ password }), }); updateToken(result.token); - setNotice(""); + dismissNotice(); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch, updateToken], + [ + apiFetch, + dismissNotice, + endAction, + isActionPending, + pushNotice, + startAction, + updateToken, + ], ); const handleLogout = useCallback(async () => { @@ -286,6 +475,10 @@ const App = () => { [], ); + const handleTokenFormChange = useCallback((patch: Partial) => { + setTokenForm((prev) => ({ ...prev, ...patch })); + }, []); + const handleSitePageChange = useCallback((next: number) => { setSitePage(next); }, []); @@ -312,14 +505,123 @@ const App = () => { setTokenPage(1); }, []); + const handleUsagePageChange = useCallback( + async (next: number) => { + if (next === usagePage) { + return; + } + const actionKey = buildActionKey("usage:load"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + setUsagePage(next); + try { + await loadUsage({ page: next }); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, + [ + endAction, + isActionPending, + loadUsage, + pushNotice, + startAction, + usagePage, + ], + ); + + const handleUsagePageSizeChange = useCallback( + async (next: number) => { + const actionKey = buildActionKey("usage:load"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + setUsagePageSize(next); + setUsagePage(1); + try { + await loadUsage({ page: 1, pageSize: next }); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, + [endAction, isActionPending, loadUsage, pushNotice, startAction], + ); + + const handleUsageFiltersChange = useCallback( + (patch: Partial) => { + setUsageFilters((prev) => ({ ...prev, ...patch })); + }, + [], + ); + + const handleUsageSearch = useCallback(async () => { + const actionKey = buildActionKey("usage:load"); + if (isActionPending(actionKey)) { + return; + } + const nextQuery = { + channel: usageFilters.channel.trim(), + token: usageFilters.token.trim(), + model: usageFilters.model.trim(), + status: usageFilters.status.trim(), + }; + startAction(actionKey); + setUsageQuery(nextQuery); + setUsagePage(1); + setUsageFilters(nextQuery); + try { + await loadUsage({ page: 1, query: nextQuery }); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, [ + endAction, + isActionPending, + loadUsage, + pushNotice, + startAction, + usageFilters.channel, + usageFilters.model, + usageFilters.status, + usageFilters.token, + ]); + + const handleUsageClear = useCallback(async () => { + const actionKey = buildActionKey("usage:load"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + setUsageFilters(initialUsageQuery); + setUsageQuery(initialUsageQuery); + setUsagePage(1); + try { + await loadUsage({ page: 1, query: initialUsageQuery }); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, [endAction, isActionPending, loadUsage, pushNotice, startAction]); + const handleTabChange = useCallback((tabId: TabId) => { const nextPath = tabToPath[tabId]; const normalized = normalizePath(window.location.pathname); if (normalized !== nextPath) { history.pushState(null, "", nextPath); } + dismissNotice(); setActiveTab(tabId); - }, []); + }, [dismissNotice]); const closeSiteModal = useCallback(() => { setEditingSite(null); @@ -331,13 +633,15 @@ const App = () => { setEditingSite(null); setSiteForm({ ...initialSiteForm }); setSiteModalOpen(true); - setNotice(""); - }, []); + dismissNotice(); + }, [dismissNotice]); const openTokenCreate = useCallback(() => { + setEditingToken(null); + setTokenForm({ ...initialTokenForm }); setTokenModalOpen(true); - setNotice(""); - }, []); + dismissNotice(); + }, [dismissNotice]); const startSiteEdit = useCallback((site: Site) => { setEditingSite(site); @@ -379,149 +683,38 @@ const App = () => { call_tokens: tokenForms, }); setSiteModalOpen(true); - setNotice(""); - }, []); + dismissNotice(); + }, [dismissNotice]); const closeTokenModal = useCallback(() => { setTokenModalOpen(false); + setEditingToken(null); + setTokenForm({ ...initialTokenForm }); }, []); - const handleSiteSubmit = useCallback( - async (event: Event) => { - event.preventDefault(); - const siteName = siteForm.name.trim(); - const normalizedName = siteName.toLowerCase(); - const nameExists = data.sites.some( - (site) => - site.name.trim().toLowerCase() === normalizedName && - site.id !== editingSite?.id, - ); - if (nameExists) { - setNotice("站点名称已存在,请使用其他名称"); - return; - } - const baseUrlValue = siteForm.base_url.trim(); - if (!baseUrlValue && !DEFAULT_BASE_URL_BY_TYPE[siteForm.site_type]) { - setNotice("基础 URL 不能为空"); - return; - } - const callTokens = siteForm.call_tokens - .map((token, index) => ({ - id: token.id, - name: token.name.trim() || `调用令牌${index + 1}`, - api_key: token.api_key.trim(), - })) - .filter((token) => token.api_key.length > 0); - if (callTokens.length === 0) { - setNotice("至少填写一个调用令牌"); - return; - } - if ( - siteForm.site_type === "new-api" && - siteForm.checkin_enabled && - (!siteForm.system_token.trim() || !siteForm.system_userid.trim()) - ) { - setNotice("启用签到需要填写系统令牌与 User ID"); - return; - } - try { - const body = { - name: siteName, - base_url: baseUrlValue, - weight: Number(siteForm.weight), - status: siteForm.status, - site_type: siteForm.site_type, - system_token: siteForm.system_token.trim(), - system_userid: siteForm.system_userid.trim(), - checkin_url: siteForm.checkin_url.trim() || null, - checkin_enabled: siteForm.checkin_enabled, - call_tokens: callTokens, - }; - if (editingSite) { - await apiFetch(`/api/sites/${editingSite.id}`, { - method: "PATCH", - body: JSON.stringify(body), - }); - setNotice("站点已更新"); - } else { - await apiFetch("/api/sites", { - method: "POST", - body: JSON.stringify(body), - }); - setNotice("站点已创建"); - } - closeSiteModal(); - await loadSites(); - } catch (error) { - setNotice((error as Error).message); - } - }, - [apiFetch, siteForm, closeSiteModal, data.sites, editingSite, loadSites], - ); - - const handleTokenSubmit = useCallback( - async (event: Event) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - const formData = new FormData(form); - const payload: Record = {}; - formData.forEach((value, key) => { - payload[key] = value; - }); - try { - const result = await apiFetch<{ token: string }>("/api/tokens", { - method: "POST", - body: JSON.stringify({ - name: payload.name, - quota_total: payload.quota_total - ? Number(payload.quota_total) - : null, - }), - }); - setNotice(`新令牌: ${result.token}`); - form.reset(); - setTokenModalOpen(false); - setTokenPage(1); - await loadTokens(); - } catch (error) { - setNotice((error as Error).message); - } - }, - [apiFetch, loadTokens], - ); - - const handleSettingsSubmit = useCallback( - async (event: Event) => { - event.preventDefault(); - const retention = Number(settingsForm.log_retention_days); - const sessionTtlHours = Number(settingsForm.session_ttl_hours); - const payload: Record = { - log_retention_days: retention, - session_ttl_hours: sessionTtlHours, - checkin_schedule_time: - settingsForm.checkin_schedule_time.trim() || "00:10", - }; - const password = settingsForm.admin_password.trim(); - if (password) { - payload.admin_password = password; - } - try { - await apiFetch("/api/settings", { - method: "PUT", - body: JSON.stringify(payload), - }); - await loadSettings(); - setSettingsForm((prev) => ({ ...prev, admin_password: "" })); - setNotice("设置已更新"); - } catch (error) { - setNotice((error as Error).message); - } - }, - [apiFetch, loadSettings, settingsForm], - ); + const openTokenEdit = useCallback((tokenItem: Token) => { + setEditingToken(tokenItem); + setTokenForm({ + name: tokenItem.name ?? "", + quota_total: + tokenItem.quota_total === null || tokenItem.quota_total === undefined + ? "" + : String(tokenItem.quota_total), + status: tokenItem.status ?? "active", + expires_at: toChinaDateTimeInput(tokenItem.expires_at ?? null), + allowed_channels: tokenItem.allowed_channels ?? [], + }); + setTokenModalOpen(true); + dismissNotice(); + }, [dismissNotice]); const handleSiteTest = useCallback( async (id: string) => { + const actionKey = buildActionKey("site:test", id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { const result = await apiFetch<{ models: Array<{ id: string }>; @@ -541,70 +734,372 @@ const App = () => { summary.failed > 0 ? `,失败 ${summary.failed}` : "" }` : ""; - setNotice(`连通测试完成,模型数 ${modelCount}${detail}`); + pushNotice("success", `连通测试完成,模型数 ${modelCount}${detail}`); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch, loadSites], + [endAction, isActionPending, loadSites, pushNotice, startAction, apiFetch], ); const handleSiteTestAll = useCallback(async () => { - if (data.sites.length === 0) { - setNotice("暂无站点可测试"); + const actionKey = buildActionKey("site:testAll"); + if (isActionPending(actionKey)) { return; } - setNotice("正在执行一键测试..."); + if (data.sites.length === 0) { + pushNotice("warning", "暂无站点可测试"); + return; + } + startAction(actionKey); + pushNotice("info", "正在执行一键测试..."); const results: SiteTestResult[] = []; - for (const site of data.sites) { - if (site.status !== "active") { - results.push({ status: "skipped" }); - continue; + try { + for (const site of data.sites) { + if (site.status !== "active") { + results.push({ status: "skipped" }); + continue; + } + try { + await apiFetch(`/api/channels/${site.id}/test`, { + method: "POST", + }); + results.push({ status: "success" }); + } catch (_error) { + results.push({ status: "failed" }); + } + } + await loadSites(); + const summary = summarizeSiteTests(results); + const testedTotal = summary.success + summary.failed; + if (testedTotal === 0) { + pushNotice("info", `已跳过 ${summary.skipped} 个禁用站点,暂无可测试站点。`); + return; + } + let message = + summary.failed > 0 + ? `一键测试完成,成功 ${summary.success}/${testedTotal},失败 ${summary.failed}。` + : `一键测试完成,成功 ${summary.success}/${testedTotal}。`; + if (summary.skipped > 0) { + message += ` 已跳过 ${summary.skipped} 个禁用站点。`; + } + pushNotice(summary.failed > 0 ? "warning" : "success", message); + } finally { + endAction(actionKey); + } + }, [apiFetch, data.sites, endAction, isActionPending, loadSites, pushNotice, startAction]); + + const handleSiteSubmit = useCallback( + async (event: Event) => { + event.preventDefault(); + const actionKey = buildActionKey("site:submit"); + if (isActionPending(actionKey)) { + return; + } + const siteName = siteForm.name.trim(); + const normalizedName = siteName.toLowerCase(); + const nameExists = data.sites.some( + (site) => + site.name.trim().toLowerCase() === normalizedName && + site.id !== editingSite?.id, + ); + if (nameExists) { + pushNotice("warning", "站点名称已存在,请使用其他名称"); + return; + } + const baseUrlValue = siteForm.base_url.trim(); + if (!baseUrlValue && !DEFAULT_BASE_URL_BY_TYPE[siteForm.site_type]) { + pushNotice("warning", "基础 URL 不能为空"); + return; + } + const callTokens = siteForm.call_tokens + .map((token, index) => ({ + id: token.id, + name: token.name.trim() || `调用令牌${index + 1}`, + api_key: token.api_key.trim(), + })) + .filter((token) => token.api_key.length > 0); + if (callTokens.length === 0) { + pushNotice("warning", "至少填写一个调用令牌"); + return; + } + if ( + siteForm.site_type === "new-api" && + siteForm.checkin_enabled && + (!siteForm.system_token.trim() || !siteForm.system_userid.trim()) + ) { + pushNotice("warning", "启用签到需要填写系统令牌与 User ID"); + return; + } + startAction(actionKey); + try { + const body = { + name: siteName, + base_url: baseUrlValue, + weight: Number(siteForm.weight), + status: siteForm.status, + site_type: siteForm.site_type, + system_token: siteForm.system_token.trim(), + system_userid: siteForm.system_userid.trim(), + checkin_url: siteForm.checkin_url.trim() || null, + checkin_enabled: siteForm.checkin_enabled, + call_tokens: callTokens, + }; + let siteId = editingSite?.id ?? null; + let actionLabel = "创建"; + if (editingSite) { + await apiFetch(`/api/sites/${editingSite.id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); + actionLabel = "更新"; + } else { + const created = await apiFetch<{ id: string }>("/api/sites", { + method: "POST", + body: JSON.stringify(body), + }); + siteId = created.id; + } + closeSiteModal(); + await loadSites(); + if (siteId) { + pushNotice("info", `站点已${actionLabel},正在自动测试...`); + await handleSiteTest(siteId); + } else { + pushNotice("success", `站点已${actionLabel}`); + } + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, + [ + apiFetch, + closeSiteModal, + data.sites, + editingSite, + endAction, + isActionPending, + loadSites, + handleSiteTest, + pushNotice, + siteForm, + startAction, + ], + ); + + const handleTokenSubmit = useCallback( + async (event: Event) => { + event.preventDefault(); + const actionKey = buildActionKey("token:submit"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + try { + const name = tokenForm.name.trim(); + if (!name) { + pushNotice("warning", "请输入令牌名称"); + return; + } + const quotaInput = tokenForm.quota_total.trim(); + const quotaTotal = + quotaInput.length === 0 ? null : Number(quotaInput); + if (quotaInput.length > 0 && Number.isNaN(quotaTotal)) { + pushNotice("warning", "额度需为数字"); + return; + } + const expiresAtInput = tokenForm.expires_at.trim(); + const expiresAtIso = toChinaIsoFromInput(expiresAtInput); + if (expiresAtInput && !expiresAtIso) { + pushNotice("warning", "过期时间格式无效"); + return; + } + const allowedChannels = tokenForm.allowed_channels.filter(Boolean); + if (editingToken) { + await apiFetch(`/api/tokens/${editingToken.id}`, { + method: "PATCH", + body: JSON.stringify({ + name, + quota_total: quotaTotal, + status: tokenForm.status, + expires_at: expiresAtIso, + allowed_channels: allowedChannels, + }), + }); + pushNotice("success", "令牌已更新"); + setTokenModalOpen(false); + setEditingToken(null); + setTokenForm({ ...initialTokenForm }); + await loadTokens(); + return; + } + + const result = await apiFetch<{ token: string }>("/api/tokens", { + method: "POST", + body: JSON.stringify({ + name, + quota_total: quotaTotal, + status: tokenForm.status, + expires_at: expiresAtIso, + allowed_channels: allowedChannels, + }), + }); + let message = `新令牌: ${result.token}`; + try { + await navigator.clipboard.writeText(result.token); + message = "新令牌已复制到剪贴板,请妥善保存。"; + } catch (_clipboardError) { + // keep token in message if clipboard fails + } + pushNotice("success", message); + setTokenModalOpen(false); + setTokenForm({ ...initialTokenForm }); + setTokenPage(1); + await loadTokens(); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, + [ + apiFetch, + editingToken, + endAction, + initialTokenForm, + isActionPending, + loadTokens, + pushNotice, + startAction, + tokenForm.expires_at, + tokenForm.allowed_channels, + tokenForm.name, + tokenForm.quota_total, + tokenForm.status, + ], +); + + const handleSettingsSubmit = useCallback( + async (event: Event) => { + event.preventDefault(); + const actionKey = buildActionKey("settings:submit"); + if (isActionPending(actionKey)) { + return; + } + const retention = Number(settingsForm.log_retention_days); + const sessionTtlHours = Number(settingsForm.session_ttl_hours); + const failureCooldownMinutes = Number( + settingsForm.model_failure_cooldown_minutes, + ); + if ( + Number.isNaN(retention) || + retention < 1 || + Number.isNaN(sessionTtlHours) || + sessionTtlHours < 1 + ) { + pushNotice("warning", "请填写有效的保留天数与会话时长"); + return; + } + if ( + Number.isNaN(failureCooldownMinutes) || + failureCooldownMinutes < 1 + ) { + pushNotice("warning", "失败冷却需为正整数"); + return; + } + startAction(actionKey); + const payload: Record = { + log_retention_days: retention, + session_ttl_hours: sessionTtlHours, + checkin_schedule_time: + settingsForm.checkin_schedule_time.trim() || "00:10", + model_failure_cooldown_minutes: failureCooldownMinutes, + }; + const password = settingsForm.admin_password.trim(); + if (password) { + payload.admin_password = password; } try { - await apiFetch(`/api/channels/${site.id}/test`, { - method: "POST", + await apiFetch("/api/settings", { + method: "PUT", + body: JSON.stringify(payload), }); - results.push({ status: "success" }); - } catch (_error) { - results.push({ status: "failed" }); + await loadSettings(); + setSettingsForm((prev) => ({ ...prev, admin_password: "" })); + pushNotice("success", "设置已更新"); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } - } - await loadSites(); - const summary = summarizeSiteTests(results); - const testedTotal = summary.success + summary.failed; - if (testedTotal === 0) { - setNotice(`已跳过 ${summary.skipped} 个禁用站点,暂无可测试站点。`); - return; - } - let message = - summary.failed > 0 - ? `一键测试完成,成功 ${summary.success}/${testedTotal},失败 ${summary.failed}。` - : `一键测试完成,成功 ${summary.success}/${testedTotal}。`; - if (summary.skipped > 0) { - message += ` 已跳过 ${summary.skipped} 个禁用站点。`; - } - setNotice(message); - }, [apiFetch, data.sites, loadSites]); + }, + [ + apiFetch, + endAction, + isActionPending, + loadSettings, + pushNotice, + settingsForm, + startAction, + ], + ); const handleSiteDelete = useCallback( async (id: string) => { + const actionKey = buildActionKey("site:delete", id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { await apiFetch(`/api/sites/${id}`, { method: "DELETE" }); await loadSites(); - setNotice("站点已删除"); + pushNotice("success", "站点已删除"); if (editingSite?.id === id) { closeSiteModal(); } } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch, closeSiteModal, editingSite, loadSites], + [ + apiFetch, + closeSiteModal, + editingSite, + endAction, + isActionPending, + loadSites, + pushNotice, + startAction, + ], + ); + + const requestSiteDelete = useCallback( + (site: Site) => { + openConfirm({ + title: "删除站点", + message: `确定删除“${site.name || "该站点"}”吗?此操作不可恢复。`, + confirmLabel: "删除站点", + tone: "error", + onConfirm: () => handleSiteDelete(site.id), + }); + }, + [handleSiteDelete, openConfirm], ); const handleSiteToggle = useCallback( async (id: string, status: string) => { + const actionKey = buildActionKey("site:toggle", id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { const next = toggleStatus(status); await apiFetch(`/api/sites/${id}`, { @@ -612,52 +1107,86 @@ const App = () => { body: JSON.stringify({ status: next }), }); await loadSites(); - setNotice(`站点已${next === "active" ? "启用" : "停用"}`); + pushNotice("success", `站点已${next === "active" ? "启用" : "停用"}`); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch, loadSites], + [endAction, isActionPending, loadSites, pushNotice, startAction, apiFetch], ); const handleTokenDelete = useCallback( async (id: string) => { + const actionKey = buildActionKey("token:delete", id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { await apiFetch(`/api/tokens/${id}`, { method: "DELETE" }); await loadTokens(); - setNotice("令牌已删除"); + pushNotice("success", "令牌已删除"); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch, loadTokens], + [endAction, isActionPending, loadTokens, pushNotice, startAction, apiFetch], + ); + + const requestTokenDelete = useCallback( + (token: Token) => { + openConfirm({ + title: "删除令牌", + message: `确定删除“${token.name || "该令牌"}”吗?此操作不可恢复。`, + confirmLabel: "删除令牌", + tone: "error", + onConfirm: () => handleTokenDelete(token.id), + }); + }, + [handleTokenDelete, openConfirm], ); const handleTokenReveal = useCallback( async (id: string) => { + const actionKey = buildActionKey("token:reveal", id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { const result = await apiFetch<{ token: string | null }>( `/api/tokens/${id}/reveal`, ); if (!result.token) { - setNotice("未找到令牌"); + pushNotice("warning", "未找到令牌"); return; } try { await navigator.clipboard.writeText(result.token); - setNotice(`令牌已复制到剪贴板:${result.token}`); + pushNotice("success", "令牌已复制到剪贴板。"); } catch (_clipboardError) { - setNotice(`令牌: ${result.token}`); + pushNotice("info", `令牌: ${result.token}`); } } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch], + [endAction, isActionPending, pushNotice, startAction, apiFetch], ); const handleTokenToggle = useCallback( async (id: string, status: string) => { + const actionKey = buildActionKey("token:toggle", id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { const next = toggleStatus(status); await apiFetch(`/api/tokens/${id}`, { @@ -665,15 +1194,22 @@ const App = () => { body: JSON.stringify({ status: next }), }); await loadTokens(); - setNotice(`令牌已${next === "active" ? "启用" : "停用"}`); + pushNotice("success", `令牌已${next === "active" ? "启用" : "停用"}`); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } }, - [apiFetch, loadTokens], + [endAction, isActionPending, loadTokens, pushNotice, startAction, apiFetch], ); const handleCheckinRunAll = useCallback(async () => { + const actionKey = buildActionKey("site:checkinAll"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { const result = await apiFetch<{ results: Array<{ @@ -691,24 +1227,34 @@ const App = () => { await loadSites(); setCheckinSummary(result.summary); setCheckinLastRun(result.runs_at); - setNotice( + pushNotice( + result.summary.failed > 0 ? "warning" : "success", result.summary.failed > 0 ? "一键签到完成,有部分站点失败。" : "一键签到完成。", ); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } - }, [apiFetch, loadSites]); + }, [apiFetch, endAction, isActionPending, loadSites, pushNotice, startAction]); const handleUsageRefresh = useCallback(async () => { + const actionKey = buildActionKey("usage:refresh"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); try { await loadUsage(); - setNotice("日志已刷新"); + pushNotice("success", "日志已刷新"); } catch (error) { - setNotice((error as Error).message); + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); } - }, [loadUsage]); + }, [endAction, isActionPending, loadUsage, pushNotice, startAction]); const filteredSites = useMemo( () => filterSites(data.sites, siteSearch), @@ -736,6 +1282,10 @@ const App = () => { const start = (tokenPage - 1) * tokenPageSize; return data.tokens.slice(start, start + tokenPageSize); }, [data.tokens, tokenPage, tokenPageSize]); + const usageTotalPages = useMemo( + () => Math.max(1, Math.ceil(usageTotal / usagePageSize)), + [usagePageSize, usageTotal], + ); useEffect(() => { setSitePage((prev) => Math.min(prev, siteTotalPages)); @@ -749,6 +1299,10 @@ const App = () => { setTokenPage((prev) => Math.min(prev, tokenTotalPages)); }, [tokenTotalPages]); + useEffect(() => { + setUsagePage((prev) => Math.min(prev, usageTotalPages)); + }, [usageTotalPages]); + const activeLabel = useMemo( () => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台", [activeTab], @@ -757,13 +1311,27 @@ const App = () => { const renderContent = () => { if (loading) { return ( -
- 加载中... +
+
+ + 正在加载数据... +
+
+
+
+
+
); } if (activeTab === "dashboard") { - return ; + return ( + + ); } if (activeTab === "channels") { return ( @@ -780,13 +1348,14 @@ const App = () => { lastRun={checkinLastRun} siteSearch={siteSearch} siteSort={siteSort} + isActionPending={isActionPending} onCreate={openSiteCreate} onCloseModal={closeSiteModal} onEdit={startSiteEdit} onSubmit={handleSiteSubmit} onTest={handleSiteTest} onToggle={handleSiteToggle} - onDelete={handleSiteDelete} + onDelete={requestSiteDelete} onPageChange={handleSitePageChange} onPageSizeChange={handleSitePageSizeChange} onSearchChange={handleSiteSearchChange} @@ -809,25 +1378,50 @@ const App = () => { tokenTotal={tokenTotal} tokenTotalPages={tokenTotalPages} isTokenModalOpen={isTokenModalOpen} + isActionPending={isActionPending} + sites={data.sites} onCreate={openTokenCreate} onCloseModal={closeTokenModal} onPageChange={handleTokenPageChange} onPageSizeChange={handleTokenPageSizeChange} + tokenForm={tokenForm} + editingToken={editingToken} onSubmit={handleTokenSubmit} + onFormChange={handleTokenFormChange} + onEdit={openTokenEdit} onReveal={handleTokenReveal} onToggle={handleTokenToggle} - onDelete={handleTokenDelete} + onDelete={requestTokenDelete} /> ); } if (activeTab === "usage") { - return ; + return ( + + ); } if (activeTab === "settings") { return ( @@ -841,7 +1435,8 @@ const App = () => { }; return ( -
+
+ ); diff --git a/apps/ui/src/core/constants.ts b/apps/ui/src/core/constants.ts index eb2e266..5ec9456 100644 --- a/apps/ui/src/core/constants.ts +++ b/apps/ui/src/core/constants.ts @@ -1,4 +1,10 @@ -import type { AdminData, SettingsForm, SiteForm, TabItem } from "./types"; +import type { + AdminData, + SettingsForm, + SiteForm, + TabItem, + TokenForm, +} from "./types"; export const apiBase = import.meta.env.VITE_API_BASE ?? ""; @@ -43,4 +49,13 @@ export const initialSettingsForm: SettingsForm = { session_ttl_hours: "12", admin_password: "", checkin_schedule_time: "00:10", + model_failure_cooldown_minutes: "10", +}; + +export const initialTokenForm: TokenForm = { + name: "", + quota_total: "", + status: "active", + expires_at: "", + allowed_channels: [], }; diff --git a/apps/ui/src/core/types.ts b/apps/ui/src/core/types.ts index 5c56672..61ad85a 100644 --- a/apps/ui/src/core/types.ts +++ b/apps/ui/src/core/types.ts @@ -41,6 +41,8 @@ export type Token = { quota_total: number | null; quota_used: number; status: string; + allowed_channels?: string[] | null; + expires_at?: string | null; created_at?: string | null; updated_at?: string | null; }; @@ -66,6 +68,20 @@ export type UsageLog = { created_at: string; }; +export type UsageQuery = { + channel: string; + token: string; + model: string; + status: string; +}; + +export type UsageResponse = { + logs: UsageLog[]; + total: number; + limit: number; + offset: number; +}; + export type DashboardData = { summary: { total_requests: number; @@ -84,6 +100,7 @@ export type Settings = { session_ttl_hours: number; admin_password_set?: boolean; checkin_schedule_time?: string; + model_failure_cooldown_minutes?: number; }; export type ModelChannel = { @@ -142,6 +159,15 @@ export type SettingsForm = { session_ttl_hours: string; admin_password: string; checkin_schedule_time: string; + model_failure_cooldown_minutes: string; +}; + +export type TokenForm = { + name: string; + quota_total: string; + status: string; + expires_at: string; + allowed_channels: string[]; }; export type CheckinResultItem = { @@ -158,3 +184,11 @@ export type CheckinSummary = { failed: number; skipped: number; }; + +export type NoticeTone = "success" | "warning" | "error" | "info"; + +export type NoticeMessage = { + tone: NoticeTone; + message: string; + id: number; +}; diff --git a/apps/ui/src/core/utils.ts b/apps/ui/src/core/utils.ts index eb32591..82bb899 100644 --- a/apps/ui/src/core/utils.ts +++ b/apps/ui/src/core/utils.ts @@ -26,6 +26,87 @@ export const formatDateTime = (value?: string | null) => { )}`; }; +const formatInTimeZone = ( + value: string | null | undefined, + timeZone: string, + parts: Array<"year" | "month" | "day" | "hour" | "minute" | "second">, + separator: { date: string }, +): string => { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "-"; + } + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const dateParts = formatter.formatToParts(date); + const pick = (type: string) => + dateParts.find((part) => part.type === type)?.value ?? "00"; + const dateText = `${pick("year")}-${pick("month")}-${pick("day")}`; + const timeText = parts.includes("second") + ? `${pick("hour")}:${pick("minute")}:${pick("second")}` + : `${pick("hour")}:${pick("minute")}`; + return `${dateText}${separator.date}${timeText}`.trim(); +}; + +export const formatChinaDateTime = (value?: string | null) => + formatInTimeZone( + value, + "Asia/Shanghai", + ["year", "month", "day", "hour", "minute", "second"], + { date: " " }, + ); + +export const toChinaDateTimeInput = (value?: string | null) => { + if (!value) { + return ""; + } + const formatted = formatInTimeZone( + value, + "Asia/Shanghai", + ["year", "month", "day", "hour", "minute"], + { date: "T" }, + ); + return formatted === "-" ? "" : formatted; +}; + +export const toChinaIsoFromInput = (value?: string | null) => { + if (!value) { + return null; + } + const raw = value.trim(); + if (!raw) { + return null; + } + const [datePart, timePart] = raw.split("T"); + if (!datePart || !timePart) { + return null; + } + const [year, month, day] = datePart.split("-").map(Number); + const [hour, minute] = timePart.split(":").map(Number); + if ( + Number.isNaN(year) || + Number.isNaN(month) || + Number.isNaN(day) || + Number.isNaN(hour) || + Number.isNaN(minute) + ) { + return null; + } + const utcMillis = Date.UTC(year, month - 1, day, hour - 8, minute); + return new Date(utcMillis).toISOString(); +}; + /** * Toggles channel or token status between active and disabled. * @@ -79,63 +160,20 @@ export const buildPageItems = (current: number, total: number): PageItem[] => { }); }; -const truncateText = (value: string | null | undefined, max = 80) => { - if (!value) { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed; -}; - -const describeError = (status?: number | null, code?: string | null) => { - const normalized = (code ?? "").toLowerCase(); - if (status === 401 || status === 403) { - return "鉴权失败"; - } - if (status === 429 || normalized.includes("rate")) { - return "触发限流"; - } - if ( - normalized.includes("insufficient") || - normalized.includes("quota") || - normalized.includes("balance") - ) { - return "余额不足"; - } - if (normalized.includes("model")) { - return "模型不可用"; - } - return null; -}; - export type UsageStatusDetail = { label: string; - message: string | null; tone: "success" | "error"; }; export const buildUsageStatusDetail = (log: UsageLog): UsageStatusDetail => { - if (log.status === "ok") { - return { label: "成功", message: null, tone: "success" }; - } - const parts: string[] = []; - if (log.upstream_status !== null && log.upstream_status !== undefined) { - parts.push(String(log.upstream_status)); - } - if (log.error_code) { - parts.push(log.error_code); - } - const hint = describeError(log.upstream_status, log.error_code ?? null); - if (hint) { - parts.push(hint); - } - const suffix = parts.length > 0 ? ` (${parts.join(" / ")})` : ""; + const statusCode = log.upstream_status ?? null; + const isOk = log.status === "ok"; + const label = + statusCode !== null && statusCode !== undefined + ? String(statusCode) + : "-"; return { - label: `失败${suffix}`, - message: truncateText(log.error_message), - tone: "error", + label, + tone: isOk ? "success" : "error", }; }; diff --git a/apps/ui/src/features/AppLayout.tsx b/apps/ui/src/features/AppLayout.tsx index f290a5f..b5b77ed 100644 --- a/apps/ui/src/features/AppLayout.tsx +++ b/apps/ui/src/features/AppLayout.tsx @@ -1,11 +1,12 @@ -import type { TabId, TabItem } from "../core/types"; +import type { NoticeMessage, TabId, TabItem } from "../core/types"; type AppLayoutProps = { tabs: TabItem[]; activeTab: TabId; activeLabel: string; token: string | null; - notice: string; + notice: NoticeMessage | null; + onDismissNotice: () => void; onTabChange: (tabId: TabId) => void; onLogout: () => void; children?: unknown; @@ -26,10 +27,23 @@ export const AppLayout = ({ activeLabel, token, notice, + onDismissNotice, onTabChange, onLogout, children, }: AppLayoutProps) => { + const noticeToneStyles: Record = { + success: "border-emerald-200 bg-emerald-50 text-emerald-800", + warning: "border-amber-200 bg-amber-50 text-amber-800", + error: "border-rose-200 bg-rose-50 text-rose-700", + info: "border-sky-200 bg-sky-50 text-sky-800", + }; + const noticeToneLabel: Record = { + success: "成功", + warning: "提示", + error: "错误", + info: "信息", + }; const closeMobileNav = () => { const toggle = document.querySelector("#app-nav-toggle"); if (toggle) { @@ -153,8 +167,26 @@ export const AppLayout = ({
{notice && ( -
- {notice} +
+
+
+ + {noticeToneLabel[notice.tone]} + +
+ {notice.message} +
+
+ +
)} {children} diff --git a/apps/ui/src/features/DashboardView.tsx b/apps/ui/src/features/DashboardView.tsx index 519fb41..7f1f37c 100644 --- a/apps/ui/src/features/DashboardView.tsx +++ b/apps/ui/src/features/DashboardView.tsx @@ -2,6 +2,8 @@ import type { DashboardData } from "../core/types"; type DashboardViewProps = { dashboard: DashboardData | null; + onRefresh: () => void; + isRefreshing: boolean; }; /** @@ -13,23 +15,83 @@ type DashboardViewProps = { * Returns: * Dashboard JSX element. */ -export const DashboardView = ({ dashboard }: DashboardViewProps) => { +export const DashboardView = ({ + dashboard, + onRefresh, + isRefreshing, +}: DashboardViewProps) => { if (!dashboard) { return (
- 暂无数据 +
+
+

+ 数据面板 +

+

+ 查看请求量、消耗与性能趋势。 +

+
+ +
+
+ 暂无数据,请先产生调用或刷新面板。 +
+ +
+
); } + const totalRequests = dashboard.summary.total_requests; + const totalErrors = dashboard.summary.total_errors; const errorRate = dashboard.summary.total_requests ? Math.round( (dashboard.summary.total_errors / dashboard.summary.total_requests) * 100, ) : 0; + const successRate = totalRequests + ? Math.max(0, Math.round(((totalRequests - totalErrors) / totalRequests) * 100)) + : 0; + const avgTokensPerRequest = totalRequests + ? Math.round(dashboard.summary.total_tokens / totalRequests) + : 0; return ( -
-
+
+
+
+

+ 数据面板 +

+

+ 快速掌握请求表现、性能与消耗概况。 +

+
+ +
+
总请求 @@ -54,11 +116,24 @@ export const DashboardView = ({ dashboard }: DashboardViewProps) => {
- 错误率 + 成功率 -
{errorRate}%
+
+ {successRate}% +
- 平均延迟 {Math.round(dashboard.summary.avg_latency)}ms + 错误率 {errorRate}% + +
+
+ + 单次消耗 + +
+ {avgTokensPerRequest} +
+ + 平均 {Math.round(dashboard.summary.avg_latency)}ms 延迟
diff --git a/apps/ui/src/features/LoginView.tsx b/apps/ui/src/features/LoginView.tsx index 8675de6..33c690e 100644 --- a/apps/ui/src/features/LoginView.tsx +++ b/apps/ui/src/features/LoginView.tsx @@ -1,5 +1,8 @@ +import type { NoticeMessage } from "../core/types"; + type LoginViewProps = { - notice: string; + notice: NoticeMessage | null; + isSubmitting: boolean; onSubmit: (event: Event) => void; }; @@ -12,39 +15,52 @@ type LoginViewProps = { * Returns: * Login JSX element. */ -export const LoginView = ({ notice, onSubmit }: LoginViewProps) => ( -
-

- api-workers -

-

请输入管理员密码登录管理台。

-
-
-
-); + {isSubmitting ? "登录中..." : "登录"} + + +

按回车键可快速提交。

+ {notice && ( +
+ {notice.message} +
+ )} +
+ ); +}; diff --git a/apps/ui/src/features/ModelsView.tsx b/apps/ui/src/features/ModelsView.tsx index 18b0cae..5cdb29e 100644 --- a/apps/ui/src/features/ModelsView.tsx +++ b/apps/ui/src/features/ModelsView.tsx @@ -13,41 +13,62 @@ type ModelsViewProps = { * Returns: * Models JSX element. */ -export const ModelsView = ({ models }: ModelsViewProps) => ( -
-
-

- 模型广场 -

- - {models.length} 个模型 - +export const ModelsView = ({ models }: ModelsViewProps) => { + const channelCount = new Set( + models.flatMap((model) => model.channels.map((channel) => channel.id)), + ).size; + return ( +
+
+
+

+ 模型广场 +

+

+ 当前聚合的模型与所属渠道清单。 +

+
+
+ + {models.length} 个模型 + + + {channelCount} 个渠道 + +
+
+ {models.length === 0 ? ( +
+ 暂无模型,请先在站点管理配置可用渠道。 +
+ ) : ( +
+ + + + + + + + + {models.map((model) => ( + + + + + ))} + +
+ 模型 + + 渠道 +
+ {model.id} + + {model.channels.map((channel) => channel.name).join(" / ")} +
+
+ )}
-
- - - - - - - - - {models.map((model) => ( - - - - - ))} - -
- 模型 - - 渠道 -
- {model.id} - - {model.channels.map((channel) => channel.name).join(" / ")} -
-
-
-); + ); +}; diff --git a/apps/ui/src/features/SettingsView.tsx b/apps/ui/src/features/SettingsView.tsx index 7a8baff..6909094 100644 --- a/apps/ui/src/features/SettingsView.tsx +++ b/apps/ui/src/features/SettingsView.tsx @@ -3,6 +3,7 @@ import type { SettingsForm } from "../core/types"; type SettingsViewProps = { settingsForm: SettingsForm; adminPasswordSet: boolean; + isSaving: boolean; onSubmit: (event: Event) => void; onFormChange: (patch: Partial) => void; }; @@ -19,10 +20,11 @@ type SettingsViewProps = { export const SettingsView = ({ settingsForm, adminPasswordSet, + isSaving, onSubmit, onFormChange, }: SettingsViewProps) => ( -
+

系统设置 @@ -94,6 +96,31 @@ export const SettingsView = ({ }} />

+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + model_failure_cooldown_minutes: target?.value ?? "", + }); + }} + /> +

+ 同一模型失败后在该时间内跳过对应渠道。 +

+
diff --git a/apps/ui/src/features/SitesView.tsx b/apps/ui/src/features/SitesView.tsx index 787064e..4453992 100644 --- a/apps/ui/src/features/SitesView.tsx +++ b/apps/ui/src/features/SitesView.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "hono/jsx/dom"; import { getSiteCheckinLabel, getSiteTypeLabel, @@ -24,13 +25,14 @@ type SitesViewProps = { lastRun: string | null; siteSearch: string; siteSort: SiteSortState; + isActionPending: (key: string) => boolean; onCreate: () => void; onCloseModal: () => void; onEdit: (site: Site) => void; onSubmit: (event: Event) => void; onTest: (id: string) => void; onToggle: (id: string, status: string) => void; - onDelete: (id: string) => void; + onDelete: (site: Site) => void; onPageChange: (next: number) => void; onPageSizeChange: (next: number) => void; onSearchChange: (next: string) => void; @@ -64,6 +66,7 @@ export const SitesView = ({ lastRun, siteSearch, siteSort, + isActionPending, onCreate, onCloseModal, onEdit, @@ -82,6 +85,10 @@ export const SitesView = ({ const isEditing = Boolean(editingSite); const pageItems = buildPageItems(sitePage, siteTotalPages); const today = getBeijingDateString(); + const isSubmitting = isActionPending("site:submit"); + const isTestingAll = isActionPending("site:testAll"); + const isCheckinAll = isActionPending("site:checkinAll"); + const [localSearch, setLocalSearch] = useState(siteSearch); const isOfficialType = siteForm.site_type === "openai" || siteForm.site_type === "anthropic" || @@ -130,8 +137,32 @@ export const SitesView = ({ } return siteSort.direction === "asc" ? "▲" : "▼"; }; + useEffect(() => { + if (!isSiteModalOpen) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseModal(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isSiteModalOpen, onCloseModal]); + useEffect(() => { + setLocalSearch(siteSearch); + }, [siteSearch]); + useEffect(() => { + const timer = window.setTimeout(() => { + if (localSearch !== siteSearch) { + onSearchChange(localSearch); + } + }, 300); + return () => window.clearTimeout(timer); + }, [localSearch, onSearchChange, siteSearch]); return ( -
+
@@ -152,18 +183,20 @@ export const SitesView = ({
)}
) : ( pagedSites.map((site) => { @@ -218,6 +260,13 @@ export const SitesView = ({ site.system_token && site.system_userid, ); const callTokenCount = site.call_tokens?.length ?? 0; + const testPending = isActionPending(`site:test:${site.id}`); + const togglePending = isActionPending( + `site:toggle:${site.id}`, + ); + const deletePending = isActionPending( + `site:delete:${site.id}`, + ); return (
onTest(site.id)} > - 连通测试 + {testPending ? "测试中..." : "连通测试"}
@@ -346,13 +402,27 @@ export const SitesView = ({
{pagedSites.length === 0 ? (
- 暂无站点,请先创建。 +

暂无站点,请先创建。

+
) : (
{pagedSites.map((site) => { const isActive = site.status === "active"; const callTokenCount = site.call_tokens?.length ?? 0; + const testPending = isActionPending(`site:test:${site.id}`); + const togglePending = isActionPending( + `site:toggle:${site.id}`, + ); + const deletePending = isActionPending( + `site:delete:${site.id}`, + ); return (
onTest(site.id)} > - 连通测试 + {testPending ? "测试中..." : "连通测试"}
@@ -510,11 +587,23 @@ export const SitesView = ({ )}
{isSiteModalOpen && ( -
-
+
+