From af87b09a48ac7ca660c97588aa46236af740da9a Mon Sep 17 00:00:00 2001 From: lsy Date: Tue, 17 Mar 2026 19:23:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BC=98=E5=8C=96ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .helloagents/CHANGELOG.md | 14 + .helloagents/modules/admin-ui.md | 19 +- .helloagents/modules/dashboard.md | 7 +- .helloagents/modules/models.md | 1 + .helloagents/modules/usage.md | 4 +- apps/ui/src/App.tsx | 430 ++++- apps/ui/src/components/ui/area-chart.tsx | 124 ++ apps/ui/src/components/ui/badge.tsx | 20 + apps/ui/src/components/ui/button.tsx | 42 + apps/ui/src/components/ui/card.tsx | 60 + apps/ui/src/components/ui/chip.tsx | 22 + apps/ui/src/components/ui/column-picker.tsx | 112 ++ apps/ui/src/components/ui/dialog.tsx | 75 + apps/ui/src/components/ui/dropdown-menu.tsx | 38 + apps/ui/src/components/ui/index.ts | 46 + apps/ui/src/components/ui/input.tsx | 16 + apps/ui/src/components/ui/multi-select.tsx | 213 ++ apps/ui/src/components/ui/pagination.tsx | 69 + apps/ui/src/components/ui/popover.tsx | 20 + apps/ui/src/components/ui/select.tsx | 16 + apps/ui/src/components/ui/skeleton.tsx | 9 + apps/ui/src/components/ui/switch.tsx | 35 + apps/ui/src/components/ui/table.tsx | 38 + apps/ui/src/components/ui/tabs.tsx | 40 + apps/ui/src/components/ui/toast.tsx | 36 + apps/ui/src/components/ui/tooltip.tsx | 18 + apps/ui/src/components/ui/utils.ts | 2 + apps/ui/src/core/constants.ts | 11 + apps/ui/src/core/types.ts | 31 +- apps/ui/src/core/utils.ts | 72 +- apps/ui/src/features/AppLayout.tsx | 85 +- apps/ui/src/features/DashboardView.tsx | 943 +++++++-- apps/ui/src/features/LoginView.tsx | 104 +- apps/ui/src/features/ModelsView.tsx | 242 ++- apps/ui/src/features/SettingsView.tsx | 46 +- apps/ui/src/features/SitesView.tsx | 1931 ++++++++++--------- apps/ui/src/features/TokensView.tsx | 727 +++---- apps/ui/src/features/UsageView.tsx | 734 ++++--- apps/ui/src/styles.css | 460 ++++- apps/worker/src/routes/dashboard.ts | 48 +- apps/worker/src/routes/sites.ts | 14 +- apps/worker/src/routes/usage.ts | 61 + apps/worker/src/services/checkin-runner.ts | 53 +- tests/ui/usage-status.test.ts | 8 +- 44 files changed, 5103 insertions(+), 1993 deletions(-) create mode 100644 apps/ui/src/components/ui/area-chart.tsx create mode 100644 apps/ui/src/components/ui/badge.tsx create mode 100644 apps/ui/src/components/ui/button.tsx create mode 100644 apps/ui/src/components/ui/card.tsx create mode 100644 apps/ui/src/components/ui/chip.tsx create mode 100644 apps/ui/src/components/ui/column-picker.tsx create mode 100644 apps/ui/src/components/ui/dialog.tsx create mode 100644 apps/ui/src/components/ui/dropdown-menu.tsx create mode 100644 apps/ui/src/components/ui/index.ts create mode 100644 apps/ui/src/components/ui/input.tsx create mode 100644 apps/ui/src/components/ui/multi-select.tsx create mode 100644 apps/ui/src/components/ui/pagination.tsx create mode 100644 apps/ui/src/components/ui/popover.tsx create mode 100644 apps/ui/src/components/ui/select.tsx create mode 100644 apps/ui/src/components/ui/skeleton.tsx create mode 100644 apps/ui/src/components/ui/switch.tsx create mode 100644 apps/ui/src/components/ui/table.tsx create mode 100644 apps/ui/src/components/ui/tabs.tsx create mode 100644 apps/ui/src/components/ui/toast.tsx create mode 100644 apps/ui/src/components/ui/tooltip.tsx create mode 100644 apps/ui/src/components/ui/utils.ts diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 4e283d8..5d7c9ea 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -11,6 +11,16 @@ - **[deploy/proxy/settings]**: 调整 stream usage 默认值并避免部署覆盖云端变量 — by lsy - 方案: [202603170025_runtime-defaults-no-override](archive/2026-03/202603170025_runtime-defaults-no-override/) +- **[admin-ui/usage]**: 使用日志渠道筛选改为搜索多选,分页条数与列偏好本地记忆 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) +- **[admin-ui/usage]**: 状态展示改为仅显示上游状态码并在详情弹窗展示完整错误摘要 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) +- **[admin-ui/usage]**: 修复使用日志多选下拉的宽度与定位 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) +- **[admin-ui]**: 页面布局拆分为标题/筛选/数据区,数据面板筛选条更紧凑 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) +- **[usage]**: 使用日志筛选支持渠道/令牌/模型/状态多选查询 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) - **[deploy-workflow]**: 本地部署脚本改为纯本地流程(构建 + 本地迁移) — by lsy - 方案: [202603162008_local-deploy-local-only](archive/2026-03/202603162008_local-deploy-local-only/) - **[settings/admin-ui]**: 设置接口返回运行时配置,管理台只读展示并指引环境变量配置 @@ -197,6 +207,10 @@ - **[deploy-workflow]**: 队列检查改为 Node 解析并容错非 JSON 输出 — by lsy - 方案: [202603162220_deploy-queue-json-parse](archive/2026-03/202603162220_deploy-queue-json-parse/) - 决策: deploy-queue-json-parse#D001(队列列表解析方式选择) +- **[dashboard]**: 修复数据面板筛选 SQL 歧义导致的 500 错误 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) +- **[admin-ui/sites]**: 修复站点管理筛选区域 JSX 结构错误导致构建失败 — by lsy + - 方案: [202603170949_shadcn-unified-ui](archive/2026-03/202603170949_shadcn-unified-ui/) - **[admin-ui]**: Toast 通知固定右上并带进度条,弹窗改为全屏遮罩,筛选标题横排 — by lsy - 方案: [202603160049_modal-toast-search-fixes](archive/2026-03/202603160049_modal-toast-search-fixes/) - 决策: modal-toast-search-fixes#D001(通知统一为右上 Toast) diff --git a/.helloagents/modules/admin-ui.md b/.helloagents/modules/admin-ui.md index ed03682..1364ff8 100644 --- a/.helloagents/modules/admin-ui.md +++ b/.helloagents/modules/admin-ui.md @@ -15,7 +15,11 @@ - 入口文件为 `apps/ui/src/App.tsx`(`main.tsx` 已移除) - 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块 - 样式基于 Tailwind v4(结合全局主题变量) -- Dashboard 样板页引入 `apple-demo` 主题类(Apple 风格、bento grid) +- 数据面板提供 Area Chart 趋势图与时间范围筛选(含“全部”、近 7/30/90 天、近一年、自定义) +- 统计颗粒(按日/周/月)放在趋势图标题区,与自定义列并排 +- 数据面板时间范围与统计颗粒本地记忆,优先使用上次选择 +- 数据面板筛选条紧凑化,渠道/令牌/模型收纳为高级筛选(渠道/令牌为搜索多选) +- 数据面板排行模块使用等分 Tabs 切换(模型/渠道/令牌) - 前端状态集中在单页 - 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作 - 站点管理支持名称/URL 搜索与列头排序,搜索/排序后再分页 @@ -27,14 +31,19 @@ - 站点连通测试提示令牌成功/失败统计 - 站点新增/修改完成后自动触发连通测试并提示结果 - 使用日志支持服务端分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度 -- 使用日志支持按渠道/令牌/模型/状态码搜索并与分页联动 -- 使用日志状态列仅展示状态码,错误详情弹窗仅展示元信息(不展示错误片段) +- 使用日志支持按日期/渠道/令牌/模型/状态码筛选并与分页联动(渠道/令牌/模型/状态均为搜索多选) +- 使用日志状态列仅展示上游状态码,缺失时显示“未知”,不做 OK/Error 语义化 +- 错误详情弹窗展示元信息并提供完整错误摘要 - 令牌管理采用列表视图(默认每页 10 条),生成/编辑使用弹窗表单,支持过期时间(北京时间)与允许渠道(默认全开)并默认复制,列表展示创建时间、前缀、过期时间与渠道限制摘要 - 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`) - 系统设置增加定时签到开关与时间(中国时间) - 系统设置提供模型失败冷却分钟数配置 - 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示在部署环境中修改环境变量 -- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择 +- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数(无数据时显示 0 页) +- 每页条数选择与列显示偏好本地持久化 +- 页面布局拆分为“标题区 + 筛选区 + 数据区”,避免单一大卡片堆叠 +- 数据表支持列自定义(本地持久化) +- 模型广场支持搜索、渠道筛选与本地分页 - Token 仅本地保存,不回显 - 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定 - 渠道/令牌列表在移动端以卡片形式展示,操作区改为网格按钮排列 @@ -44,7 +53,7 @@ - 主要异步操作提供 loading 与按钮禁用态 - 弹窗支持全屏遮罩/ESC 关闭,不强制自动聚焦首字段 - 空状态提供清晰 CTA 引导下一步 -- 全局字体通过外部字体源加载,背景包含暖调渐变与轻纹理 +- 全局字体使用系统字体栈,背景包含冷色光晕与轻纹理 ## 依赖关系 - `auth` / `channels` / `models` / `tokens` / `usage` / `dashboard` / `settings` 模块 diff --git a/.helloagents/modules/dashboard.md b/.helloagents/modules/dashboard.md index 32b7cff..9c9333a 100644 --- a/.helloagents/modules/dashboard.md +++ b/.helloagents/modules/dashboard.md @@ -9,8 +9,11 @@ ## 行为规范 - 以 `usage_logs` 为统计来源 -- 默认返回最近 30 条趋势数据 -- 前端 DashboardView 使用 `apple-demo` 样板主题(Apple 风格) +- `GET /api/dashboard` 支持 `from/to` 时间区间与 `interval=day|week|month` 聚合 +- 趋势数据返回 `trend` + `interval` 字段,默认最近 30 个区间 +- 可选筛选:`channel_ids` / `token_ids` / `model` +- 管理台默认展示全量时间范围,并在趋势卡片内提供统计颗粒(按日/周/月) +- 统计颗粒与时间范围在前端本地存储并优先复用 ## 依赖关系 - `usage_logs` 表 diff --git a/.helloagents/modules/models.md b/.helloagents/modules/models.md index cce144f..a6eca4c 100644 --- a/.helloagents/modules/models.md +++ b/.helloagents/modules/models.md @@ -12,6 +12,7 @@ - 仅聚合启用状态的渠道 - 使用“模型广场测试结果”作为可用模型来源 - 模型归一化支持字符串数组与对象数组(对象取 `id` 字段) +- 管理台模型广场支持搜索、渠道筛选与分页展示 ## 依赖关系 - `channels` 表 diff --git a/.helloagents/modules/usage.md b/.helloagents/modules/usage.md index d072850..0823ac9 100644 --- a/.helloagents/modules/usage.md +++ b/.helloagents/modules/usage.md @@ -15,10 +15,10 @@ - 保留天数可配置,查询时触发清理 - 查询结果附带渠道与令牌名称 - `GET /api/usage` 支持 `offset/limit` 分页并返回 `total` -- 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤) +- 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤,新增 `channel_ids`/`token_ids`/`models`/`statuses` 多选过滤) - usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度 - 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段 -- usage_logs 记录上游失败详情(status/code/message),日志列表仅展示状态码,详情弹窗仅展示元信息(不展示错误片段) +- usage_logs 记录上游失败详情(status/code/message),日志列表仅展示上游状态码,缺失时显示“未知”,详情弹窗包含元信息与错误摘要 ## 依赖关系 - `usage_logs` 表 diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 1e04f0e..8b13dee 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -7,8 +7,18 @@ import { useRef, useState, } from "hono/jsx/dom"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./components/ui"; import { createApiFetch } from "./core/api"; import { + initialDashboardQuery, initialData, initialSettingsForm, initialSiteForm, @@ -26,6 +36,7 @@ import type { AdminData, CheckinSummary, DashboardData, + DashboardQuery, NoticeMessage, NoticeTone, Settings, @@ -40,6 +51,9 @@ import type { UsageResponse, } from "./core/types"; import { + getBeijingDateString, + loadPageSizePref, + persistPageSizePref, toChinaDateTimeInput, toChinaIsoFromInput, toggleStatus, @@ -102,10 +116,99 @@ const buildActionKey = (scope: string, id?: string) => id ? `${scope}:${id}` : scope; const initialUsageQuery: UsageQuery = { - channel: "", - token: "", - model: "", - status: "", + channel_ids: [], + token_ids: [], + models: [], + statuses: [], + from: "", + to: "", +}; + +const dashboardPresetDays: Record = { + all: 0, + "7d": 7, + "30d": 30, + "90d": 90, + "1y": 365, + custom: 30, +}; + +const resolveDashboardRange = (query: DashboardQuery) => { + const today = new Date(); + if (query.preset === "all") { + return { from: "", to: "", days: 0 }; + } + if (query.preset !== "custom") { + const days = dashboardPresetDays[query.preset]; + const fromDate = new Date(today); + fromDate.setDate(today.getDate() - (days - 1)); + return { + from: getBeijingDateString(fromDate), + to: getBeijingDateString(today), + days, + }; + } + const fromValue = query.from || getBeijingDateString(today); + const toValue = query.to || getBeijingDateString(today); + const fromDate = new Date(fromValue); + const toDate = new Date(toValue); + const diffDays = + Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime()) + ? 1 + : Math.max( + 1, + Math.ceil((toDate.getTime() - fromDate.getTime()) / 86400000) + 1, + ); + return { from: fromValue, to: toValue, days: diffDays }; +}; + +const buildDashboardParams = (query: DashboardQuery) => { + const interval = query.interval; + if (query.preset === "all") { + const params = new URLSearchParams(); + params.set("interval", interval); + params.set("limit", "366"); + const channelIds = query.channel_ids.filter(Boolean); + const tokenIds = query.token_ids.filter(Boolean); + if (channelIds.length > 0) { + params.set("channel_ids", channelIds.join(",")); + } + if (tokenIds.length > 0) { + params.set("token_ids", tokenIds.join(",")); + } + if (query.model) { + params.set("model", query.model); + } + return { params, range: { from: "", to: "" } }; + } + const { from, to, days } = resolveDashboardRange(query); + const limit = + interval === "day" + ? days + : interval === "week" + ? Math.ceil(days / 7) + : Math.ceil(days / 30); + const params = new URLSearchParams(); + params.set("interval", interval); + params.set("limit", String(limit)); + if (from) { + params.set("from", `${from} 00:00:00`); + } + if (to) { + params.set("to", `${to} 23:59:59`); + } + const channelIds = query.channel_ids.filter(Boolean); + const tokenIds = query.token_ids.filter(Boolean); + if (channelIds.length > 0) { + params.set("channel_ids", channelIds.join(",")); + } + if (tokenIds.length > 0) { + params.set("token_ids", tokenIds.join(",")); + } + if (query.model) { + params.set("model", query.model); + } + return { params, range: { from, to } }; }; /** @@ -128,21 +231,65 @@ const App = () => { const [loading, setLoading] = useState(false); const [notices, setNotices] = useState([]); const [data, setData] = useState(initialData); + const [dashboardQuery, setDashboardQuery] = useState(() => { + if (typeof window === "undefined") { + return initialDashboardQuery; + } + const storedPreset = window.localStorage.getItem("dashboard:preset"); + const storedInterval = window.localStorage.getItem("dashboard:interval"); + const storedFrom = window.localStorage.getItem("dashboard:from") ?? ""; + const storedTo = window.localStorage.getItem("dashboard:to") ?? ""; + const allowedPresets: Array = [ + "all", + "7d", + "30d", + "90d", + "1y", + "custom", + ]; + const preset = allowedPresets.includes( + storedPreset as DashboardQuery["preset"], + ) + ? (storedPreset as DashboardQuery["preset"]) + : initialDashboardQuery.preset; + const interval = + storedInterval === "day" || + storedInterval === "week" || + storedInterval === "month" + ? (storedInterval as DashboardQuery["interval"]) + : initialDashboardQuery.interval; + if (preset === "custom") { + return { + ...initialDashboardQuery, + preset, + interval, + from: storedFrom, + to: storedTo, + }; + } + return { ...initialDashboardQuery, preset, interval, from: "", to: "" }; + }); const [settingsForm, setSettingsForm] = useState(initialSettingsForm); const [sitePage, setSitePage] = useState(1); - const [sitePageSize, setSitePageSize] = useState(10); + const [sitePageSize, setSitePageSize] = useState(() => + loadPageSizePref("pageSize:sites", 10), + ); const [siteSearch, setSiteSearch] = useState(""); const [siteSort, setSiteSort] = useState({ key: "name", direction: "asc", }); const [tokenPage, setTokenPage] = useState(1); - const [tokenPageSize, setTokenPageSize] = useState(10); + const [tokenPageSize, setTokenPageSize] = useState(() => + loadPageSizePref("pageSize:tokens", 10), + ); const [editingToken, setEditingToken] = useState(null); const [tokenForm, setTokenForm] = useState(initialTokenForm); const [usagePage, setUsagePage] = useState(1); - const [usagePageSize, setUsagePageSize] = useState(50); + const [usagePageSize, setUsagePageSize] = useState(() => + loadPageSizePref("pageSize:usage", 50), + ); const [usageTotal, setUsageTotal] = useState(0); const [usageFilters, setUsageFilters] = useState(initialUsageQuery); @@ -283,10 +430,17 @@ const App = () => { [token, updateToken], ); - const loadDashboard = useCallback(async () => { - const dashboard = await apiFetch("/api/dashboard"); - setData((prev) => ({ ...prev, dashboard })); - }, [apiFetch]); + const loadDashboard = useCallback( + async (override?: DashboardQuery) => { + const query = override ?? dashboardQuery; + const { params } = buildDashboardParams(query); + const dashboard = await apiFetch( + `/api/dashboard?${params.toString()}`, + ); + setData((prev) => ({ ...prev, dashboard })); + }, + [apiFetch, dashboardQuery], + ); const handleDashboardRefresh = useCallback(async () => { const actionKey = buildActionKey("dashboard:refresh"); @@ -304,6 +458,54 @@ const App = () => { } }, [endAction, isActionPending, loadDashboard, pushNotice, startAction]); + const handleDashboardQueryChange = useCallback( + (patch: Partial) => { + setDashboardQuery((prev) => { + const next = { ...prev, ...patch }; + if (typeof window !== "undefined") { + window.localStorage.setItem("dashboard:preset", next.preset); + window.localStorage.setItem("dashboard:interval", next.interval); + if (next.preset === "custom") { + window.localStorage.setItem("dashboard:from", next.from); + window.localStorage.setItem("dashboard:to", next.to); + } else { + window.localStorage.removeItem("dashboard:from"); + window.localStorage.removeItem("dashboard:to"); + } + } + return next; + }); + }, + [], + ); + + const handleDashboardApply = useCallback( + async (override?: DashboardQuery) => { + const actionKey = buildActionKey("dashboard:filter"); + if (isActionPending(actionKey)) { + return; + } + const nextQuery = override ?? dashboardQuery; + startAction(actionKey); + try { + await loadDashboard(nextQuery); + pushNotice("success", "筛选已更新"); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, + [ + dashboardQuery, + endAction, + isActionPending, + loadDashboard, + pushNotice, + startAction, + ], + ); + const loadSites = useCallback(async () => { const result = await apiFetch<{ sites: Site[]; @@ -342,21 +544,29 @@ const App = () => { 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); + const channelIds = query.channel_ids.filter(Boolean); + const tokenIds = query.token_ids.filter(Boolean); + const models = query.models.filter(Boolean); + const statuses = query.statuses.filter(Boolean); + const from = query.from.trim(); + const to = query.to.trim(); + if (from) { + params.set("from", `${from} 00:00:00`); } - if (token) { - params.set("token", token); + if (to) { + params.set("to", `${to} 23:59:59`); } - if (model) { - params.set("model", model); + if (channelIds.length > 0) { + params.set("channel_ids", channelIds.join(",")); } - if (status) { - params.set("status", status); + if (tokenIds.length > 0) { + params.set("token_ids", tokenIds.join(",")); + } + if (models.length > 0) { + params.set("models", models.join(",")); + } + if (statuses.length > 0) { + params.set("statuses", statuses.join(",")); } const result = await apiFetch( `/api/usage?${params.toString()}`, @@ -378,7 +588,7 @@ const App = () => { dismissNotice(); try { if (tabId === "dashboard") { - await loadDashboard(); + await Promise.all([loadDashboard(), loadSites(), loadTokens()]); } if (tabId === "channels") { await loadSites(); @@ -390,7 +600,12 @@ const App = () => { await Promise.all([loadTokens(), loadSites()]); } if (tabId === "usage") { - await loadUsage(); + await Promise.all([ + loadUsage(), + loadSites(), + loadTokens(), + loadModels(), + ]); } if (tabId === "settings") { await loadSettings(); @@ -518,6 +733,7 @@ const App = () => { }, []); const handleSitePageSizeChange = useCallback((next: number) => { + persistPageSizePref("pageSize:sites", next); setSitePageSize(next); setSitePage(1); }, []); @@ -535,6 +751,7 @@ const App = () => { }, []); const handleTokenPageSizeChange = useCallback((next: number) => { + persistPageSizePref("pageSize:tokens", next); setTokenPageSize(next); setTokenPage(1); }, []); @@ -568,6 +785,7 @@ const App = () => { return; } startAction(actionKey); + persistPageSizePref("pageSize:usage", next); setUsagePageSize(next); setUsagePage(1); try { @@ -591,10 +809,12 @@ const App = () => { return; } const nextQuery = { - channel: usageFilters.channel.trim(), - token: usageFilters.token.trim(), - model: usageFilters.model.trim(), - status: usageFilters.status.trim(), + channel_ids: usageFilters.channel_ids.filter(Boolean), + token_ids: usageFilters.token_ids.filter(Boolean), + models: usageFilters.models.filter(Boolean), + statuses: usageFilters.statuses.filter((value) => /^\d+$/.test(value)), + from: usageFilters.from.trim(), + to: usageFilters.to.trim(), }; startAction(actionKey); setUsageQuery(nextQuery); @@ -613,10 +833,12 @@ const App = () => { loadUsage, pushNotice, startAction, - usageFilters.channel, - usageFilters.model, - usageFilters.status, - usageFilters.token, + usageFilters.channel_ids, + usageFilters.from, + usageFilters.models, + usageFilters.statuses, + usageFilters.token_ids, + usageFilters.to, ]); const handleUsageClear = useCallback(async () => { @@ -1244,6 +1466,44 @@ const App = () => { [endAction, isActionPending, loadTokens, pushNotice, startAction, apiFetch], ); + const handleCheckinRunSite = useCallback( + async (site: Site) => { + const actionKey = buildActionKey("site:checkin", site.id); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + try { + const result = await apiFetch<{ + result: { + id: string; + name: string; + status: "success" | "failed" | "skipped"; + message: string; + checkin_date?: string | null; + }; + runs_at: string; + }>(`/api/sites/${site.id}/checkin`, { method: "POST" }); + await loadSites(); + const tone = + result.result.status === "failed" + ? "warning" + : result.result.status === "skipped" + ? "info" + : "success"; + pushNotice( + tone, + `${site.name || "站点"}:${result.result.message || "签到完成"}`, + ); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, + [endAction, isActionPending, loadSites, pushNotice, startAction, apiFetch], + ); + const handleCheckinRunAll = useCallback(async () => { const actionKey = buildActionKey("site:checkinAll"); if (isActionPending(actionKey)) { @@ -1376,7 +1636,15 @@ const App = () => { return ( ); @@ -1402,6 +1670,7 @@ const App = () => { onEdit={startSiteEdit} onSubmit={handleSiteSubmit} onTest={handleSiteTest} + onCheckin={handleCheckinRunSite} onToggle={handleSiteToggle} onDelete={requestSiteDelete} onPageChange={handleSitePageChange} @@ -1455,6 +1724,9 @@ const App = () => { isActionPending(buildActionKey("usage:refresh")) || isActionPending(buildActionKey("usage:load")) } + sites={data.sites} + tokens={data.tokens} + models={data.models} onRefresh={handleUsageRefresh} onPageChange={handleUsagePageChange} onPageSizeChange={handleUsagePageSizeChange} @@ -1503,63 +1775,41 @@ const App = () => { /> )} {confirmState && ( -
- + + + +
+ + {confirmState.title} + + {confirmState.message}
-
- - -
-
- - + + + + + + + + )} ); diff --git a/apps/ui/src/components/ui/area-chart.tsx b/apps/ui/src/components/ui/area-chart.tsx new file mode 100644 index 0000000..7973a67 --- /dev/null +++ b/apps/ui/src/components/ui/area-chart.tsx @@ -0,0 +1,124 @@ +import type { JSX } from "hono/jsx"; +import { useMemo, useState } from "hono/jsx/dom"; +import { cx } from "./utils"; + +export type AreaChartPoint = { + label: string; + value: number; + secondary?: number; +}; + +export type AreaChartProps = { + data: AreaChartPoint[]; + height?: number; + valueLabel?: string; + secondaryLabel?: string; + class?: JSX.ClassValue; +}; + +export const AreaChart = ({ + data, + height = 180, + valueLabel = "请求", + secondaryLabel = "Tokens", + class: className, +}: AreaChartProps) => { + const [activeIndex, setActiveIndex] = useState(null); + const points = useMemo(() => { + if (data.length === 0) { + return []; + } + const maxValue = Math.max(...data.map((item) => item.value), 1); + return data.map((item, index) => { + const x = data.length === 1 ? 50 : (index / (data.length - 1)) * 100; + const y = 100 - (item.value / maxValue) * 100; + return { ...item, x, y }; + }); + }, [data]); + const linePath = useMemo(() => { + if (points.length === 0) { + return ""; + } + return points + .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`) + .join(" "); + }, [points]); + const areaPath = linePath ? `${linePath} L 100 100 L 0 100 Z` : ""; + const activePoint = + activeIndex !== null && points[activeIndex] ? points[activeIndex] : null; + + const handleMouseMove = (event: MouseEvent) => { + if (points.length === 0) { + return; + } + const target = event.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const ratio = rect.width ? (event.clientX - rect.left) / rect.width : 0; + const index = Math.min( + points.length - 1, + Math.max(0, Math.round(ratio * (points.length - 1))), + ); + setActiveIndex(index); + }; + + return ( +
+ {points.length === 0 ? ( +
+ 暂无趋势数据 +
+ ) : ( +
+ setActiveIndex(null)} + onMouseMove={handleMouseMove} + > + + + + + + + + + {activePoint ? ( + + ) : null} + + {activePoint ? ( +
+
+ {activePoint.label} +
+
+ {valueLabel}: {activePoint.value} +
+ {activePoint.secondary !== undefined ? ( +
+ {secondaryLabel}: {activePoint.secondary} +
+ ) : null} +
+ ) : null} +
+ )} +
+ ); +}; diff --git a/apps/ui/src/components/ui/badge.tsx b/apps/ui/src/components/ui/badge.tsx new file mode 100644 index 0000000..d7c80a8 --- /dev/null +++ b/apps/ui/src/components/ui/badge.tsx @@ -0,0 +1,20 @@ +import type { JSX } from "hono/jsx"; +import { cx } from "./utils"; + +export type BadgeProps = JSX.IntrinsicElements["span"] & { + variant?: "default" | "accent" | "muted"; +}; + +const variantStyles: Record, string> = { + default: "app-badge", + accent: "app-badge app-badge--accent", + muted: "app-badge app-badge--muted", +}; + +export const Badge = ({ + variant = "default", + class: className, + ...props +}: BadgeProps) => { + return ; +}; diff --git a/apps/ui/src/components/ui/button.tsx b/apps/ui/src/components/ui/button.tsx new file mode 100644 index 0000000..f537be1 --- /dev/null +++ b/apps/ui/src/components/ui/button.tsx @@ -0,0 +1,42 @@ +import type { JSX } from "hono/jsx"; +import { cx } from "./utils"; + +type ButtonVariant = "default" | "primary" | "ghost" | "danger"; +type ButtonSize = "sm" | "md" | "lg"; + +export type ButtonProps = JSX.IntrinsicElements["button"] & { + variant?: ButtonVariant; + size?: ButtonSize; +}; + +const variantStyles: Record = { + default: "", + primary: "app-button-primary", + ghost: "app-button-ghost", + danger: "app-button-danger", +}; + +const sizeStyles: Record = { + sm: "h-9 px-3 text-xs", + md: "h-10 px-4 text-sm", + lg: "h-11 px-5 text-sm", +}; + +export const Button = ({ + variant = "default", + size = "md", + class: className, + ...props +}: ButtonProps) => { + return ( + + + +
+ 显示列 +
+
+ {columns.map((column) => ( + + ))} +
+
+
+ + ); +}; diff --git a/apps/ui/src/components/ui/dialog.tsx b/apps/ui/src/components/ui/dialog.tsx new file mode 100644 index 0000000..4820d61 --- /dev/null +++ b/apps/ui/src/components/ui/dialog.tsx @@ -0,0 +1,75 @@ +import type { JSX } from "hono/jsx"; +import { cx } from "./utils"; + +export type DialogProps = { + open: boolean; + onClose?: () => void; + children?: unknown; +}; + +export const Dialog = ({ open, onClose, children }: DialogProps) => { + if (!open) { + return null; + } + return ( +
+
+ ); +}; + +export const DialogContent = ({ + class: className, + ...props +}: JSX.IntrinsicElements["div"]) => ( +