fix:优化ui

This commit is contained in:
lsy committed 2026-03-17 19:23:08 +08:00
1 parent 7220682a60
commit af87b09a48
44 files changed
+5103 -1993

No files matched your search

+14
View File
@@ -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)
+14 -5
View File
@@ -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` 模块
+5 -2
View File
@@ -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` 表
+1
View File
@@ -12,6 +12,7 @@
- 仅聚合启用状态的渠道
- 使用“模型广场测试结果”作为可用模型来源
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
- 管理台模型广场支持搜索、渠道筛选与分页展示
## 依赖关系
- `channels` 表
+2 -2
View File
@@ -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` 表
+340 -90
View File
@@ -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<DashboardQuery["preset"], number> = {
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<NoticeMessage[]>([]);
const [data, setData] = useState<AdminData>(initialData);
const [dashboardQuery, setDashboardQuery] = useState<DashboardQuery>(() => {
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<DashboardQuery["preset"]> = [
"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<SettingsForm>(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<SiteSortState>({
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<Token | null>(null);
const [tokenForm, setTokenForm] = useState<TokenForm>(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<UsageQuery>(initialUsageQuery);
@@ -283,10 +430,17 @@ const App = () => {
[token, updateToken],
);
const loadDashboard = useCallback(async () => {
const dashboard = await apiFetch<DashboardData>("/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<DashboardData>(
`/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<DashboardQuery>) => {
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<UsageResponse>(
`/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 (
<DashboardView
dashboard={data.dashboard}
isRefreshing={isActionPending(buildActionKey("dashboard:refresh"))}
isRefreshing={
isActionPending(buildActionKey("dashboard:refresh")) ||
isActionPending(buildActionKey("dashboard:filter"))
}
query={dashboardQuery}
channels={data.sites}
tokens={data.tokens}
onQueryChange={handleDashboardQueryChange}
onApply={handleDashboardApply}
onRefresh={handleDashboardRefresh}
/>
);
@@ -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 && (
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={closeConfirm}
/>
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
<div
aria-labelledby="confirm-title"
aria-modal="true"
class="app-card w-full max-w-md p-6"
role="dialog"
>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="app-title text-lg" id="confirm-title">
{confirmState.title}
</h3>
<p class="mt-2 text-sm text-[color:var(--app-ink-muted)]">
{confirmState.message}
</p>
</div>
<button
class="app-button app-focus"
type="button"
onClick={closeConfirm}
>
关闭
</button>
<Dialog open={Boolean(confirmState)} onClose={closeConfirm}>
<DialogContent
aria-labelledby="confirm-title"
aria-modal="true"
class="max-w-md"
>
<DialogHeader>
<div>
<DialogTitle id="confirm-title">
{confirmState.title}
</DialogTitle>
<DialogDescription>{confirmState.message}</DialogDescription>
</div>
<div class="mt-6 flex flex-wrap items-center justify-end gap-2">
<button
class="app-button app-focus"
type="button"
onClick={closeConfirm}
>
取消
</button>
<button
class={`app-button app-focus ${
confirmState.tone === "error"
? "app-button-danger"
: "app-button-primary"
}`}
type="button"
disabled={confirmPending}
onClick={handleConfirm}
>
{confirmPending
? "处理中..."
: (confirmState.confirmLabel ?? "确认")}
</button>
</div>
</div>
</div>
</div>
<Button size="sm" type="button" onClick={closeConfirm}>
关闭
</Button>
</DialogHeader>
<DialogFooter>
<Button size="sm" type="button" onClick={closeConfirm}>
取消
</Button>
<Button
size="sm"
variant={confirmState.tone === "error" ? "danger" : "primary"}
type="button"
disabled={confirmPending}
onClick={handleConfirm}
>
{confirmPending
? "处理中..."
: (confirmState.confirmLabel ?? "确认")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
+124
View File
@@ -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<number | null>(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 (
<div class={cx("relative", className)}>
{points.length === 0 ? (
<div class="flex h-full items-center justify-center text-sm text-[color:var(--app-ink-muted)]">
暂无趋势数据
</div>
) : (
<div class="relative w-full" style={`height:${height}px`}>
<svg
viewBox="0 0 100 100"
class="h-full w-full"
role="img"
aria-label={`${valueLabel}趋势`}
onMouseLeave={() => setActiveIndex(null)}
onMouseMove={handleMouseMove}
>
<defs>
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(10,132,255,0.35)" />
<stop offset="100%" stopColor="rgba(10,132,255,0.02)" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#areaGradient)" />
<path
d={linePath}
fill="none"
stroke="rgba(10,132,255,0.9)"
strokeWidth="1.8"
/>
{activePoint ? (
<circle
cx={activePoint.x}
cy={activePoint.y}
r="2.5"
fill="#0a84ff"
/>
) : null}
</svg>
{activePoint ? (
<div
class="pointer-events-none absolute top-2 rounded-xl border border-white/80 bg-white/90 px-3 py-2 text-xs text-[color:var(--app-ink)] shadow-[0_12px_30px_rgba(15,23,42,0.12)]"
style={`left: ${activePoint.x}%; transform: translateX(-50%);`}
>
<div class="text-[11px] text-[color:var(--app-ink-muted)]">
{activePoint.label}
</div>
<div class="mt-1 font-semibold">
{valueLabel}: {activePoint.value}
</div>
{activePoint.secondary !== undefined ? (
<div class="text-[11px] text-[color:var(--app-ink-muted)]">
{secondaryLabel}: {activePoint.secondary}
</div>
) : null}
</div>
) : null}
</div>
)}
</div>
);
};
+20
View File
@@ -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<NonNullable<BadgeProps["variant"]>, 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 <span {...props} class={cx(variantStyles[variant], className)} />;
};
+42
View File
@@ -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<ButtonVariant, string> = {
default: "",
primary: "app-button-primary",
ghost: "app-button-ghost",
danger: "app-button-danger",
};
const sizeStyles: Record<ButtonSize, string> = {
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 (
<button
{...props}
class={cx(
"app-button app-focus",
variantStyles[variant],
sizeStyles[size],
className,
)}
/>
);
};
+60
View File
@@ -0,0 +1,60 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type CardProps = JSX.IntrinsicElements["div"] & {
variant?: "default" | "compact";
};
export const Card = ({
variant = "default",
class: className,
...props
}: CardProps) => {
const baseClass =
variant === "compact" ? "app-card app-card--compact" : "app-card";
return <div {...props} class={cx(baseClass, className)} />;
};
export const CardHeader = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div
{...props}
class={cx("mb-4 flex items-start justify-between gap-3", className)}
/>
);
export const CardTitle = ({
class: className,
...props
}: JSX.IntrinsicElements["h3"]) => (
<h3 {...props} class={cx("app-title text-lg", className)} />
);
export const CardDescription = ({
class: className,
...props
}: JSX.IntrinsicElements["p"]) => (
<p
{...props}
class={cx("text-sm text-[color:var(--app-ink-muted)]", className)}
/>
);
export const CardContent = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("space-y-4", className)} />
);
export const CardFooter = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div
{...props}
class={cx("mt-4 flex items-center justify-end gap-2", className)}
/>
);
+22
View File
@@ -0,0 +1,22 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type ChipProps = JSX.IntrinsicElements["span"] & {
variant?: "default" | "accent" | "success" | "muted" | "danger";
};
const variantStyles: Record<NonNullable<ChipProps["variant"]>, string> = {
default: "app-chip",
accent: "app-chip app-chip--accent",
success: "app-chip app-chip--success",
muted: "app-chip app-chip--muted",
danger: "app-chip app-chip--danger",
};
export const Chip = ({
variant = "default",
class: className,
...props
}: ChipProps) => {
return <span {...props} class={cx(variantStyles[variant], className)} />;
};
+112
View File
@@ -0,0 +1,112 @@
import type { JSX } from "hono/jsx";
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import { Button } from "./button";
import { Popover, PopoverContent } from "./popover";
import { cx } from "./utils";
export type ColumnOption = {
id: string;
label: string;
locked?: boolean;
description?: string;
};
export type ColumnPickerProps = {
label?: string;
columns: ColumnOption[];
value: string[];
onChange: (next: string[]) => void;
class?: JSX.ClassValue;
};
export const ColumnPicker = ({
label = "自定义列",
columns,
value,
onChange,
class: className,
}: ColumnPickerProps) => {
const [open, setOpen] = useState(false);
const instanceId = useMemo(
() => `column-${Math.random().toString(36).slice(2)}`,
[],
);
const popoverEvent = "app:popover-open";
const visibleSet = useMemo(() => new Set(value), [value]);
const toggleColumn = (column: ColumnOption) => {
if (column.locked) {
return;
}
const next = new Set(visibleSet);
if (next.has(column.id)) {
next.delete(column.id);
} else {
next.add(column.id);
}
const ordered = columns.map((item) => item.id).filter((id) => next.has(id));
onChange(ordered);
};
useEffect(() => {
const handlePopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail !== instanceId) {
setOpen(false);
}
};
window.addEventListener(popoverEvent, handlePopoverOpen);
return () => {
window.removeEventListener(popoverEvent, handlePopoverOpen);
};
}, [instanceId, popoverEvent]);
return (
<div class={cx("relative", className)}>
<Button
class="h-8 px-3 text-[11px]"
size="sm"
type="button"
onClick={(event) => {
event.stopPropagation();
if (!open) {
window.dispatchEvent(
new CustomEvent<string>(popoverEvent, { detail: instanceId }),
);
}
setOpen((prev) => !prev);
}}
>
{label}
</Button>
<Popover open={open}>
<PopoverContent class="right-0 w-64 p-3 app-popover-content--spaced">
<div class="text-xs font-semibold uppercase tracking-widest text-[color:var(--app-ink-muted)]">
显示列
</div>
<div class="mt-2 space-y-2 text-xs text-[color:var(--app-ink-muted)]">
{columns.map((column) => (
<label class="flex items-start gap-2" key={column.id}>
<input
checked={visibleSet.has(column.id)}
class="mt-0.5 h-3.5 w-3.5 rounded border-[color:var(--app-border)] text-[color:var(--app-ink)] focus:ring-[color:var(--app-accent-soft)]"
disabled={column.locked}
type="checkbox"
onChange={() => toggleColumn(column)}
/>
<span>
<span class="text-[color:var(--app-ink)]">
{column.label}
</span>
{column.description ? (
<span class="mt-1 block text-[11px] text-[color:var(--app-ink-muted)]">
{column.description}
</span>
) : null}
</span>
</label>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
};
+75
View File
@@ -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 (
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={onClose}
/>
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
{children}
</div>
</div>
);
};
export const DialogContent = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div
{...props}
class={cx("app-card w-full max-w-xl p-6", className)}
role="dialog"
/>
);
export const DialogHeader = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div
{...props}
class={cx("flex items-start justify-between gap-4", className)}
/>
);
export const DialogTitle = ({
class: className,
...props
}: JSX.IntrinsicElements["h3"]) => (
<h3 {...props} class={cx("app-title text-lg", className)} />
);
export const DialogDescription = ({
class: className,
...props
}: JSX.IntrinsicElements["p"]) => (
<p
{...props}
class={cx("mt-2 text-xs text-[color:var(--app-ink-muted)]", className)}
/>
);
export const DialogFooter = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div
{...props}
class={cx("mt-6 flex flex-wrap items-center justify-end gap-2", className)}
/>
);
@@ -0,0 +1,38 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type DropdownMenuProps = JSX.IntrinsicElements["div"] & {
open: boolean;
};
export const DropdownMenu = ({
open,
class: className,
...props
}: DropdownMenuProps) => {
if (!open) {
return null;
}
return <div {...props} class={cx("app-dropdown", className)} />;
};
export const DropdownMenuContent = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("app-dropdown-content", className)} />
);
export const DropdownMenuItem = ({
class: className,
...props
}: JSX.IntrinsicElements["button"]) => (
<button {...props} class={cx("app-dropdown-item", className)} type="button" />
);
export const DropdownMenuSeparator = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("app-dropdown-separator", className)} />
);
+46
View File
@@ -0,0 +1,46 @@
export { AreaChart } from "./area-chart";
export { Badge } from "./badge";
export { Button } from "./button";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./card";
export { Chip } from "./chip";
export { ColumnPicker } from "./column-picker";
export {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./dialog";
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "./dropdown-menu";
export { Input } from "./input";
export { MultiSelect } from "./multi-select";
export { Pagination } from "./pagination";
export { Popover, PopoverContent } from "./popover";
export { Select } from "./select";
export { Skeleton } from "./skeleton";
export { Switch } from "./switch";
export {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./table";
export { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
export { Toast, ToastProgress, ToastTitle, ToastViewport } from "./toast";
export { Tooltip } from "./tooltip";
export { cx } from "./utils";
+16
View File
@@ -0,0 +1,16 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type InputProps = JSX.IntrinsicElements["input"] & {
variant?: "default" | "pill";
};
export const Input = ({
variant = "default",
class: className,
...props
}: InputProps) => {
const variantClass =
variant === "pill" ? "app-input app-input--pill" : "app-input";
return <input {...props} class={cx(variantClass, "app-focus", className)} />;
};
+213
View File
@@ -0,0 +1,213 @@
import type { JSX } from "hono/jsx";
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import { Input } from "./input";
import { PopoverContent } from "./popover";
import { cx } from "./utils";
export type MultiSelectOption = {
value: string;
label: string;
};
export type MultiSelectProps = {
options: MultiSelectOption[];
value: string[];
onChange: (next: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
class?: JSX.ClassValue;
};
export const MultiSelect = ({
options,
value,
onChange,
placeholder = "请选择",
searchPlaceholder = "搜索",
emptyLabel = "暂无选项",
class: className,
}: MultiSelectProps) => {
const [open, setOpen] = useState(false);
const instanceId = useMemo(
() => `multi-${Math.random().toString(36).slice(2)}`,
[],
);
const popoverEvent = "app:popover-open";
const [search, setSearch] = useState("");
const valueSet = useMemo(() => new Set(value), [value]);
const selectedLabels = useMemo(
() =>
options
.filter((option) => valueSet.has(option.value))
.map((option) => option.label),
[options, valueSet],
);
const summary =
selectedLabels.length === 0
? placeholder
: selectedLabels.length <= 2
? selectedLabels.join(", ")
: `${selectedLabels.slice(0, 2).join(", ")} +${
selectedLabels.length - 2
}`;
const filteredOptions = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter((option) => {
const label = option.label.toLowerCase();
const valueText = option.value.toLowerCase();
return label.includes(keyword) || valueText.includes(keyword);
});
}, [options, search]);
const toggleValue = (option: MultiSelectOption) => {
const next = new Set(valueSet);
if (next.has(option.value)) {
next.delete(option.value);
} else {
next.add(option.value);
}
const ordered = options
.map((item) => item.value)
.filter((item) => next.has(item));
onChange(ordered);
};
const clearAll = () => onChange([]);
useEffect(() => {
const handleGlobalOpen = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail !== instanceId) {
setOpen(false);
}
};
window.addEventListener(popoverEvent, handleGlobalOpen);
return () => {
window.removeEventListener(popoverEvent, handleGlobalOpen);
};
}, [instanceId, popoverEvent]);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
if (target.closest(".app-multi-select-root")) {
return;
}
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div
class={cx(
"app-multi-select-root",
open && "app-multi-select-root--open",
className,
)}
>
<button
aria-expanded={open}
class={cx("app-multi-select app-focus", open && "app-multi-select--open")}
type="button"
onClick={(event) => {
event.stopPropagation();
if (!open) {
window.dispatchEvent(
new CustomEvent<string>(popoverEvent, {
detail: instanceId,
}),
);
}
setOpen((prev) => !prev);
}}
>
<span
class={cx(
"app-multi-select__value",
selectedLabels.length === 0 && "app-multi-select__placeholder",
)}
>
{summary}
</span>
{selectedLabels.length > 0 && (
<span class="app-multi-select__count">{selectedLabels.length}</span>
)}
<span aria-hidden="true" class="app-multi-select__chevron">
▾
</span>
</button>
{open ? (
<PopoverContent
class="app-popover-content--full p-3"
onPointerDown={(event) => event.stopPropagation()}
>
<Input
class="h-8 text-xs"
placeholder={searchPlaceholder}
value={search}
onInput={(event) =>
setSearch((event.currentTarget as HTMLInputElement).value)
}
/>
<div class="mt-2 flex items-center justify-between text-[11px] text-[color:var(--app-ink-muted)]">
<span>已选 {selectedLabels.length}</span>
{selectedLabels.length > 0 ? (
<button
class="text-[11px] font-semibold text-[color:var(--app-ink-muted)] transition-colors hover:text-[color:var(--app-ink)]"
type="button"
onClick={clearAll}
>
清空
</button>
) : null}
</div>
<div class="mt-2 max-h-48 space-y-1 overflow-auto">
{filteredOptions.length === 0 ? (
<div class="px-2 py-3 text-xs text-[color:var(--app-ink-muted)]">
{emptyLabel}
</div>
) : (
filteredOptions.map((option) => {
const selected = valueSet.has(option.value);
return (
<button
class={cx(
"app-multi-option",
selected && "app-multi-option--selected",
)}
key={option.value}
type="button"
onClick={() => toggleValue(option)}
>
<span class="truncate">{option.label}</span>
{selected ? (
<span class="app-multi-option__check">✓</span>
) : null}
</button>
);
})
)}
</div>
</PopoverContent>
) : null}
</div>
);
};
+69
View File
@@ -0,0 +1,69 @@
import type { JSX } from "hono/jsx";
import { Button } from "./button";
import { cx } from "./utils";
export type PaginationItem = number | "ellipsis";
export type PaginationProps = {
page: number;
totalPages: number;
items: PaginationItem[];
onPageChange: (next: number) => void;
disabled?: boolean;
class?: JSX.ClassValue;
};
export const Pagination = ({
page,
totalPages,
items,
onPageChange,
disabled,
class: className,
}: PaginationProps) => {
return (
<div class={cx("app-pagination", className)}>
<Button
class="h-8 w-8 text-xs"
size="sm"
type="button"
disabled={disabled || page <= 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
&lt;
</Button>
{items.map((item, index) =>
item === "ellipsis" ? (
<span
class="px-2 text-xs text-[color:var(--app-ink-muted)]"
key={`e-${index}`}
>
...
</span>
) : (
<Button
class={`h-8 min-w-8 px-3 text-xs ${
item === page ? "app-button-primary" : ""
}`}
size="sm"
type="button"
key={item}
disabled={disabled}
onClick={() => onPageChange(item)}
>
{item}
</Button>
),
)}
<Button
class="h-8 w-8 text-xs"
size="sm"
type="button"
disabled={disabled || page >= totalPages}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
>
&gt;
</Button>
</div>
);
};
+20
View File
@@ -0,0 +1,20 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type PopoverProps = JSX.IntrinsicElements["div"] & {
open: boolean;
};
export const Popover = ({ open, class: className, ...props }: PopoverProps) => {
if (!open) {
return null;
}
return <div {...props} class={cx("app-popover", className)} />;
};
export const PopoverContent = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("app-popover-content", className)} />
);
+16
View File
@@ -0,0 +1,16 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type SelectProps = JSX.IntrinsicElements["select"] & {
variant?: "default" | "pill";
};
export const Select = ({
variant = "default",
class: className,
...props
}: SelectProps) => {
const variantClass =
variant === "pill" ? "app-input app-input--pill" : "app-input";
return <select {...props} class={cx(variantClass, "app-focus", className)} />;
};
+9
View File
@@ -0,0 +1,9 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export const Skeleton = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("app-skeleton", className)} />
);
+35
View File
@@ -0,0 +1,35 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type SwitchProps = Omit<JSX.IntrinsicElements["button"], "role"> & {
checked?: boolean;
onToggle?: (next: boolean) => void;
};
export const Switch = ({
checked = false,
onToggle,
class: className,
onClick,
...props
}: SwitchProps) => {
return (
<button
{...props}
type={props.type ?? "button"}
role="switch"
aria-checked={checked}
class={cx(
"app-switch app-focus",
checked && "app-switch--checked",
className,
)}
onClick={(event) => {
onClick?.(event);
onToggle?.(!checked);
}}
>
<span class="app-switch__thumb" />
</button>
);
};
+38
View File
@@ -0,0 +1,38 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export const Table = ({
class: className,
...props
}: JSX.IntrinsicElements["table"]) => (
<table {...props} class={cx("app-table", className)} />
);
export const TableHeader = ({
class: className,
...props
}: JSX.IntrinsicElements["thead"]) => (
<thead {...props} class={cx(className)} />
);
export const TableBody = ({
class: className,
...props
}: JSX.IntrinsicElements["tbody"]) => (
<tbody {...props} class={cx(className)} />
);
export const TableRow = ({
class: className,
...props
}: JSX.IntrinsicElements["tr"]) => <tr {...props} class={cx(className)} />;
export const TableHead = ({
class: className,
...props
}: JSX.IntrinsicElements["th"]) => <th {...props} class={cx(className)} />;
export const TableCell = ({
class: className,
...props
}: JSX.IntrinsicElements["td"]) => <td {...props} class={cx(className)} />;
+40
View File
@@ -0,0 +1,40 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export const Tabs = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("flex flex-col gap-3", className)} />
);
export const TabsList = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("flex flex-wrap items-center gap-2", className)} />
);
export type TabsTriggerProps = JSX.IntrinsicElements["button"] & {
active?: boolean;
};
export const TabsTrigger = ({
active,
class: className,
...props
}: TabsTriggerProps) => (
<button
{...props}
class={cx(
"app-button app-focus h-8 px-3 text-xs",
active ? "app-button-primary" : "",
className,
)}
/>
);
export const TabsContent = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => <div {...props} class={cx(className)} />;
+36
View File
@@ -0,0 +1,36 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export const ToastViewport = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("app-toast", className)} />
);
export const Toast = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div {...props} class={cx("app-toast-card", className)} />
);
export const ToastTitle = ({
class: className,
...props
}: JSX.IntrinsicElements["div"]) => (
<div
{...props}
class={cx(
"mt-1 text-sm font-semibold text-[color:var(--app-ink)]",
className,
)}
/>
);
export const ToastProgress = ({
class: className,
...props
}: JSX.IntrinsicElements["span"]) => (
<span {...props} class={cx("app-toast-progress", className)} />
);
+18
View File
@@ -0,0 +1,18 @@
import type { JSX } from "hono/jsx";
import { cx } from "./utils";
export type TooltipProps = JSX.IntrinsicElements["span"] & {
content: string;
};
export const Tooltip = ({
content,
class: className,
...props
}: TooltipProps) => (
<span
{...props}
class={cx("app-tooltip", className)}
data-tooltip={content}
/>
);
+2
View File
@@ -0,0 +1,2 @@
export const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(" ");
+11
View File
@@ -1,5 +1,6 @@
import type {
AdminData,
DashboardQuery,
SettingsForm,
SiteForm,
TabItem,
@@ -52,6 +53,16 @@ export const initialSettingsForm: SettingsForm = {
model_failure_cooldown_minutes: "10",
};
export const initialDashboardQuery: DashboardQuery = {
preset: "all",
interval: "month",
from: "",
to: "",
channel_ids: [],
token_ids: [],
model: "",
};
export const initialTokenForm: TokenForm = {
name: "",
quota_total: "",
+26 -5
View File
@@ -69,10 +69,12 @@ export type UsageLog = {
};
export type UsageQuery = {
channel: string;
token: string;
model: string;
status: string;
channel_ids: string[];
token_ids: string[];
models: string[];
statuses: string[];
from: string;
to: string;
};
export type UsageResponse = {
@@ -89,12 +91,31 @@ export type DashboardData = {
avg_latency: number;
total_errors: number;
};
byDay: Array<{ day: string; requests: number; tokens: number }>;
interval: "day" | "week" | "month";
trend: Array<{ bucket: string; requests: number; tokens: number }>;
byModel: Array<{ model: string; requests: number; tokens: number }>;
byChannel: Array<{ channel_name: string; requests: number; tokens: number }>;
byToken: Array<{ token_name: string; requests: number; tokens: number }>;
};
export type DashboardRangePreset =
| "all"
| "7d"
| "30d"
| "90d"
| "1y"
| "custom";
export type DashboardQuery = {
preset: DashboardRangePreset;
interval: "day" | "week" | "month";
from: string;
to: string;
channel_ids: string[];
token_ids: string[];
model: string;
};
export type Settings = {
log_retention_days: number;
session_ttl_hours: number;
+66 -6
View File
@@ -162,16 +162,76 @@ export const buildPageItems = (current: number, total: number): PageItem[] => {
export type UsageStatusDetail = {
label: string;
tone: "success" | "error";
};
export const buildUsageStatusDetail = (log: UsageLog): UsageStatusDetail => {
const statusCode = log.upstream_status ?? null;
const isOk = log.status === "ok";
const label =
statusCode !== null && statusCode !== undefined ? String(statusCode) : "-";
if (statusCode !== null && statusCode !== undefined) {
return {
label: String(statusCode),
};
}
return {
label,
tone: isOk ? "success" : "error",
label: "未知",
};
};
export const loadColumnPrefs = (
storageKey: string,
defaults: string[],
): string[] => {
if (typeof window === "undefined") {
return defaults;
}
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return defaults;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return defaults;
}
const set = new Set(parsed.filter((item) => typeof item === "string"));
const ordered = defaults.filter((id) => set.has(id));
return ordered.length > 0 ? ordered : defaults;
} catch (_error) {
return defaults;
}
};
export const persistColumnPrefs = (storageKey: string, next: string[]) => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(storageKey, JSON.stringify(next));
};
export const loadPageSizePref = (
storageKey: string,
fallback: number,
): number => {
if (typeof window === "undefined") {
return fallback;
}
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return fallback;
}
const value = Number(raw);
if (Number.isNaN(value) || value <= 0) {
return fallback;
}
return value;
} catch (_error) {
return fallback;
}
};
export const persistPageSizePref = (storageKey: string, value: number) => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(storageKey, String(value));
};
+45 -40
View File
@@ -1,3 +1,12 @@
import {
Badge,
Button,
Chip,
Toast,
ToastProgress,
ToastTitle,
ToastViewport,
} from "../components/ui";
import type { NoticeMessage, TabId, TabItem } from "../core/types";
type AppLayoutProps = {
@@ -62,10 +71,11 @@ export const AppLayout = ({
<input class="peer hidden" id="app-nav-toggle" type="checkbox" />
<header class="app-bar flex items-center justify-between px-4 py-4 lg:hidden">
<div class="flex items-center gap-3">
<button
<Button
aria-controls="app-nav-toggle"
aria-label="打开导航"
class="app-button app-focus inline-flex h-10 items-center gap-2 px-3 text-xs"
class="inline-flex h-10 items-center gap-2 px-3 text-xs"
size="sm"
type="button"
onClick={toggleMobileNav}
>
@@ -94,7 +104,7 @@ export const AppLayout = ({
/>
</svg>
菜单
</button>
</Button>
<div class="flex flex-col">
<span class="text-sm font-semibold text-[color:var(--app-ink)]">
api-workers
@@ -105,16 +115,18 @@ export const AppLayout = ({
</div>
</div>
<div class="flex items-center gap-2">
<span class="app-badge text-[10px] uppercase tracking-widest">
<Badge class="text-[10px] uppercase tracking-widest" variant="muted">
{token ? "已登录" : "未登录"}
</span>
<button
class="app-button app-button-ghost app-focus h-9 px-3 text-xs"
</Badge>
<Button
class="h-9 px-3 text-xs"
size="sm"
variant="ghost"
type="button"
onClick={onLogout}
>
退出
</button>
</Button>
</div>
</header>
<aside class="app-sidebar fixed inset-y-0 left-0 z-40 flex w-72 -translate-x-full flex-col overflow-y-auto px-5 py-8 shadow-xl transition-transform duration-300 ease-in-out peer-checked:translate-x-0 lg:sticky lg:top-0 lg:z-auto lg:h-screen lg:w-auto lg:translate-x-0 lg:shadow-none">
@@ -140,57 +152,50 @@ export const AppLayout = ({
</button>
))}
</nav>
<div class="mt-auto hidden w-full items-center justify-between gap-2 rounded-2xl border border-[color:var(--app-border)] bg-white/70 px-3 py-2 text-xs text-[color:var(--app-ink-muted)] lg:flex">
<span>{token ? "已登录" : "未登录"}</span>
<Button
class="h-8 px-3 text-[11px]"
size="sm"
variant="ghost"
type="button"
onClick={onLogout}
>
退出
</Button>
</div>
</aside>
<main class="px-4 pt-5 pb-16 sm:px-10 sm:pt-8">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="app-title text-2xl">{activeLabel}</h1>
<p class="text-sm text-[color:var(--app-ink-muted)]">
集中管理渠道、模型、令牌与使用情况。
</p>
</div>
<div class="hidden items-center gap-3 lg:flex">
<span class="app-badge text-xs">{token ? "已登录" : "未登录"}</span>
<button
class="app-button app-button-ghost app-focus h-11 px-4 text-sm"
type="button"
onClick={onLogout}
>
退出
</button>
</div>
</div>
{children}
</main>
{notices.length > 0 && (
<output aria-live="polite" class="app-toast">
<ToastViewport aria-live="polite">
{notices.map((notice) => (
<div
class={`app-toast-card ${noticeToneStyles[notice.tone]}`}
<Toast
class={noticeToneStyles[notice.tone]}
key={notice.id}
style={`--toast-duration: ${notice.durationMs ?? 4500}ms`}
>
<div class="flex items-start justify-between gap-3">
<div>
<span class="app-chip text-[10px]">
<Chip class="text-[10px]">
{noticeToneLabel[notice.tone]}
</span>
<div class="mt-1 text-sm font-semibold text-[color:var(--app-ink)]">
{notice.message}
</div>
</Chip>
<ToastTitle>{notice.message}</ToastTitle>
</div>
<button
class="app-button app-focus h-8 px-3 text-[11px]"
<Button
class="h-8 px-3 text-[11px]"
size="sm"
type="button"
onClick={() => onDismissNotice(notice.id)}
>
关闭
</button>
</Button>
</div>
<span aria-hidden="true" class="app-toast-progress" />
</div>
<ToastProgress aria-hidden="true" />
</Toast>
))}
</output>
</ToastViewport>
)}
<button
aria-label="关闭导航"
+804 -139
View File
@@ -1,9 +1,44 @@
import type { DashboardData } from "../core/types";
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import {
AreaChart,
Button,
Card,
Chip,
ColumnPicker,
Input,
MultiSelect,
Popover,
PopoverContent,
Select,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../components/ui";
import { cx } from "../components/ui/utils";
import type { DashboardData, DashboardQuery, Site, Token } from "../core/types";
import {
getBeijingDateString,
loadColumnPrefs,
persistColumnPrefs,
} from "../core/utils";
type DashboardViewProps = {
dashboard: DashboardData | null;
onRefresh: () => void;
isRefreshing: boolean;
query: DashboardQuery;
channels: Site[];
tokens: Token[];
onQueryChange: (patch: Partial<DashboardQuery>) => void;
onApply: (next?: DashboardQuery) => void;
};
/**
@@ -19,7 +54,256 @@ export const DashboardView = ({
dashboard,
onRefresh,
isRefreshing,
query,
channels,
tokens,
onQueryChange,
onApply,
}: DashboardViewProps) => {
const [activeRankTab, setActiveRankTab] = useState<
"model" | "channel" | "token"
>(() => {
if (typeof window === "undefined") {
return "model";
}
const stored = window.localStorage.getItem("dashboard:rankTab");
if (stored === "model" || stored === "channel" || stored === "token") {
return stored;
}
return "model";
});
const [filterOpen, setFilterOpen] = useState(false);
const filterRootClass = "app-dashboard-filter";
const [intervalOpen, setIntervalOpen] = useState(false);
const intervalRootClass = "app-dashboard-interval";
const popoverEvent = "app:popover-open";
const filterPopoverId = "dashboard-filter";
const intervalPopoverId = "dashboard-interval";
const [draftFilters, setDraftFilters] = useState(() => ({
channel_ids: query.channel_ids,
token_ids: query.token_ids,
model: query.model,
}));
const trendColumnDefaults = ["bucket", "requests", "tokens"];
const rankColumnDefaults = ["name", "requests", "tokens"];
const [trendColumns, setTrendColumns] = useState(() =>
loadColumnPrefs("columns:dashboard:trend", trendColumnDefaults),
);
const [rankColumns, setRankColumns] = useState(() =>
loadColumnPrefs("columns:dashboard:rank", rankColumnDefaults),
);
const trendColumnSet = useMemo(() => new Set(trendColumns), [trendColumns]);
const rankColumnSet = useMemo(() => new Set(rankColumns), [rankColumns]);
const trendColumnsConfig = [
{ id: "bucket", label: "日期", locked: true },
{ id: "requests", label: "请求" },
{ id: "tokens", label: "Tokens" },
];
const rankColumnsConfig = [
{ id: "name", label: "名称", locked: true },
{ id: "requests", label: "请求" },
{ id: "tokens", label: "Tokens" },
];
const intervalLabel =
query.interval === "week"
? "周"
: query.interval === "month"
? "月"
: "日";
const hasAdvancedFilters = Boolean(
query.channel_ids.length > 0 || query.token_ids.length > 0 || query.model,
);
const channelOptions = useMemo(
() =>
channels.map((channel) => ({
value: channel.id,
label: channel.name || channel.id,
})),
[channels],
);
const tokenOptions = useMemo(
() =>
tokens.map((token) => ({
value: token.id,
label: token.name || token.id,
})),
[tokens],
);
const chartData = useMemo(
() =>
(dashboard?.trend ?? []).map((row) => ({
label: row.bucket,
value: row.requests,
secondary: row.tokens,
})),
[dashboard?.trend],
);
const rankingData = useMemo(() => {
if (!dashboard) {
return [];
}
if (activeRankTab === "channel") {
return dashboard.byChannel.map((row) => ({
name: row.channel_name ?? "-",
requests: row.requests,
tokens: row.tokens,
}));
}
if (activeRankTab === "token") {
return dashboard.byToken.map((row) => ({
name: row.token_name ?? "-",
requests: row.requests,
tokens: row.tokens,
}));
}
return dashboard.byModel.map((row) => ({
name: row.model ?? "-",
requests: row.requests,
tokens: row.tokens,
}));
}, [activeRankTab, dashboard]);
const updateTrendColumns = (next: string[]) => {
setTrendColumns(next);
persistColumnPrefs("columns:dashboard:trend", next);
};
const updateRankColumns = (next: string[]) => {
setRankColumns(next);
persistColumnPrefs("columns:dashboard:rank", next);
};
const setPreset = (preset: DashboardQuery["preset"]) => {
if (preset === "all") {
onQueryChange({ preset, from: "", to: "" });
return;
}
if (preset === "custom") {
const today = getBeijingDateString();
onQueryChange({
preset,
from: query.from || today,
to: query.to || today,
});
return;
}
onQueryChange({ preset });
};
const handleApply = () => {
const nextQuery = {
...query,
channel_ids: draftFilters.channel_ids,
token_ids: draftFilters.token_ids,
model: draftFilters.model,
};
onQueryChange(nextQuery);
setFilterOpen(false);
onApply(nextQuery);
};
const handleIntervalApply = (nextInterval: DashboardQuery["interval"]) => {
const nextQuery = { ...query, interval: nextInterval };
onQueryChange(nextQuery);
onApply(nextQuery);
};
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem("dashboard:rankTab", activeRankTab);
}, [activeRankTab]);
useEffect(() => {
setDraftFilters({
channel_ids: query.channel_ids,
token_ids: query.token_ids,
model: query.model,
});
}, [query.channel_ids, query.model, query.token_ids]);
useEffect(() => {
if (!filterOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
if (target.closest(`.${filterRootClass}`)) {
return;
}
setFilterOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setFilterOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [filterOpen, filterRootClass]);
useEffect(() => {
const handlePopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail !== filterPopoverId) {
setFilterOpen(false);
}
};
window.addEventListener(popoverEvent, handlePopoverOpen);
return () => {
window.removeEventListener(popoverEvent, handlePopoverOpen);
};
}, [filterPopoverId, popoverEvent]);
useEffect(() => {
if (filterOpen && intervalOpen) {
setIntervalOpen(false);
}
}, [filterOpen, intervalOpen]);
useEffect(() => {
if (!intervalOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
if (target.closest(`.${intervalRootClass}`)) {
return;
}
setIntervalOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setIntervalOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [intervalOpen, intervalRootClass]);
useEffect(() => {
const handlePopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail !== intervalPopoverId) {
setIntervalOpen(false);
}
};
window.addEventListener(popoverEvent, handlePopoverOpen);
return () => {
window.removeEventListener(popoverEvent, handlePopoverOpen);
};
}, [intervalPopoverId, popoverEvent]);
useEffect(() => {
if (intervalOpen && filterOpen) {
setFilterOpen(false);
}
}, [filterOpen, intervalOpen]);
if (!dashboard) {
return (
<div class="animate-fade-up space-y-5">
@@ -28,34 +312,176 @@ export const DashboardView = ({
<h3 class="app-title">数据面板</h3>
<p class="app-subtitle">查看请求量、消耗与性能趋势。</p>
</div>
<button
class="app-button app-focus"
type="button"
disabled={isRefreshing}
onClick={onRefresh}
>
<Button type="button" disabled={isRefreshing} onClick={onRefresh}>
{isRefreshing ? "刷新中..." : "刷新"}
</button>
</Button>
</div>
<div class="app-card mt-6 text-center">
<div class="app-chip app-chip--accent">空状态</div>
<p class="mt-3 text-sm text-[color:var(--app-ink-muted)]">
暂无数据,请先产生调用或刷新面板。
</p>
<div class="mt-4 flex justify-center">
<button
class="app-button app-button-primary app-focus"
type="button"
onClick={onRefresh}
disabled={isRefreshing}
>
立即刷新
</button>
<Card
variant="compact"
class="app-layer-raised mt-4 flex flex-wrap items-center gap-2 p-3"
>
<div class="flex flex-wrap items-center gap-2">
{[
{ value: "all", label: "全部" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
{ value: "90d", label: "近 90 天" },
{ value: "1y", label: "近一年" },
{ value: "custom", label: "自定义" },
].map((preset) => (
<Button
class="h-8 px-3 text-[11px]"
key={preset.value}
size="sm"
type="button"
variant={query.preset === preset.value ? "primary" : "ghost"}
onClick={() =>
setPreset(preset.value as DashboardQuery["preset"])
}
>
{preset.label}
</Button>
))}
</div>
</div>
{query.preset === "custom" && (
<div class="flex flex-wrap items-center gap-2">
<Input
class="h-8 w-36 text-xs"
type="date"
value={query.from}
onInput={(event) =>
onQueryChange({
from: (event.currentTarget as HTMLInputElement).value,
})
}
/>
<Input
class="h-8 w-36 text-xs"
type="date"
value={query.to}
onInput={(event) =>
onQueryChange({
to: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
)}
<div class="flex flex-wrap items-center gap-2">
<div class={cx("relative", filterRootClass)}>
<Button
class="h-8 px-3 text-[11px]"
size="sm"
variant="ghost"
type="button"
onClick={(event) => {
event.stopPropagation();
setIntervalOpen(false);
if (!filterOpen) {
window.dispatchEvent(
new CustomEvent<string>(popoverEvent, {
detail: filterPopoverId,
}),
);
}
setFilterOpen((prev) => !prev);
}}
>
更多筛选
</Button>
<Popover open={filterOpen}>
<PopoverContent class="right-0 w-72 p-3 app-popover-content--spaced">
<div class="grid gap-2">
<MultiSelect
class="w-full"
options={channelOptions}
value={draftFilters.channel_ids}
placeholder="选择渠道"
searchPlaceholder="搜索渠道"
emptyLabel="暂无匹配渠道"
onChange={(next) =>
setDraftFilters((prev) => ({
...prev,
channel_ids: next,
}))
}
/>
<MultiSelect
class="w-full"
options={tokenOptions}
value={draftFilters.token_ids}
placeholder="选择令牌"
searchPlaceholder="搜索令牌"
emptyLabel="暂无匹配令牌"
onChange={(next) =>
setDraftFilters((prev) => ({
...prev,
token_ids: next,
}))
}
/>
<Input
class="h-8 text-xs"
placeholder="模型关键词"
value={draftFilters.model}
onInput={(event) =>
setDraftFilters((prev) => ({
...prev,
model: (event.currentTarget as HTMLInputElement).value,
}))
}
/>
</div>
</PopoverContent>
</Popover>
</div>
{hasAdvancedFilters ? (
<Chip variant="accent">已筛选</Chip>
) : null}
<Button
class="h-8 px-4 text-[11px]"
size="sm"
variant="primary"
type="button"
disabled={isRefreshing}
onClick={handleApply}
>
应用筛选
</Button>
</div>
</Card>
{isRefreshing ? (
<div class="app-grid app-grid--kpi">
{Array.from({ length: 4 }).map((_, index) => (
<Card variant="compact" key={`kpi-skeleton-${index}`}>
<Skeleton class="h-4 w-20" />
<Skeleton class="mt-3 h-7 w-24" />
<Skeleton class="mt-2 h-3 w-16" />
</Card>
))}
</div>
) : (
<Card class="mt-6 text-center">
<Chip variant="accent">空状态</Chip>
<p class="mt-3 text-sm text-[color:var(--app-ink-muted)]">
暂无数据,请先产生调用或刷新面板。
</p>
<div class="mt-4 flex justify-center">
<Button
variant="primary"
type="button"
onClick={onRefresh}
disabled={isRefreshing}
>
立即刷新
</Button>
</div>
</Card>
)}
</div>
);
}
const totalRequests = dashboard.summary.total_requests;
const totalErrors = dashboard.summary.total_errors;
const errorRate = dashboard.summary.total_requests
@@ -80,142 +506,381 @@ export const DashboardView = ({
<h3 class="app-title">数据面板</h3>
<p class="app-subtitle">快速掌握请求表现、性能与消耗概况。</p>
</div>
<button
class="app-button app-focus"
type="button"
disabled={isRefreshing}
onClick={onRefresh}
>
<Button type="button" disabled={isRefreshing} onClick={onRefresh}>
{isRefreshing ? "刷新中..." : "刷新"}
</button>
</Button>
</div>
<Card
variant="compact"
class="app-layer-raised flex flex-wrap items-center gap-2 p-3"
>
<div class="flex flex-wrap items-center gap-2">
{[
{ value: "all", label: "全部" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
{ value: "90d", label: "近 90 天" },
{ value: "1y", label: "近一年" },
{ value: "custom", label: "自定义" },
].map((preset) => (
<Button
class="h-8 px-3 text-[11px]"
key={preset.value}
size="sm"
type="button"
variant={query.preset === preset.value ? "primary" : "ghost"}
onClick={() =>
setPreset(preset.value as DashboardQuery["preset"])
}
>
{preset.label}
</Button>
))}
</div>
{query.preset === "custom" && (
<div class="flex flex-wrap items-center gap-2">
<Input
class="h-8 w-36 text-xs"
type="date"
value={query.from}
onInput={(event) =>
onQueryChange({
from: (event.currentTarget as HTMLInputElement).value,
})
}
/>
<Input
class="h-8 w-36 text-xs"
type="date"
value={query.to}
onInput={(event) =>
onQueryChange({
to: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
)}
<div class="flex flex-wrap items-center gap-2">
<div class={cx("relative", filterRootClass)}>
<Button
class="h-8 px-3 text-[11px]"
size="sm"
variant="ghost"
type="button"
onClick={(event) => {
event.stopPropagation();
setIntervalOpen(false);
if (!filterOpen) {
window.dispatchEvent(
new CustomEvent<string>(popoverEvent, {
detail: filterPopoverId,
}),
);
}
setFilterOpen((prev) => !prev);
}}
>
更多筛选
</Button>
<Popover open={filterOpen}>
<PopoverContent class="right-0 w-72 p-3 app-popover-content--spaced">
<div class="grid gap-2">
<MultiSelect
class="w-full"
options={channelOptions}
value={draftFilters.channel_ids}
placeholder="选择渠道"
searchPlaceholder="搜索渠道"
emptyLabel="暂无匹配渠道"
onChange={(next) =>
setDraftFilters((prev) => ({
...prev,
channel_ids: next,
}))
}
/>
<MultiSelect
class="w-full"
options={tokenOptions}
value={draftFilters.token_ids}
placeholder="选择令牌"
searchPlaceholder="搜索令牌"
emptyLabel="暂无匹配令牌"
onChange={(next) =>
setDraftFilters((prev) => ({
...prev,
token_ids: next,
}))
}
/>
<Input
class="h-8 text-xs"
placeholder="模型关键词"
value={draftFilters.model}
onInput={(event) =>
setDraftFilters((prev) => ({
...prev,
model: (event.currentTarget as HTMLInputElement).value,
}))
}
/>
</div>
</PopoverContent>
</Popover>
</div>
{hasAdvancedFilters ? <Chip variant="accent">已筛选</Chip> : null}
<Button
class="h-8 px-4 text-[11px]"
size="sm"
variant="primary"
type="button"
disabled={isRefreshing}
onClick={handleApply}
>
应用筛选
</Button>
</div>
</Card>
<div class="app-grid app-grid--kpi">
<div class="app-card app-card--compact">
<span class="app-chip">总请求</span>
<Card variant="compact">
<Chip>总请求</Chip>
<div class="app-kpi-value">{dashboard.summary.total_requests}</div>
<span class="app-kpi-meta">最近窗口</span>
</div>
<div class="app-card app-card--compact">
<span class="app-chip">总 Tokens</span>
</Card>
<Card variant="compact">
<Chip>总 Tokens</Chip>
<div class="app-kpi-value">{dashboard.summary.total_tokens}</div>
<span class="app-kpi-meta">累计消耗</span>
</div>
<div class="app-card app-card--compact">
<span class="app-chip">成功率</span>
</Card>
<Card variant="compact">
<Chip>成功率</Chip>
<div class="app-kpi-value">{successRate}%</div>
<span class="app-kpi-meta">错误率 {errorRate}%</span>
</div>
<div class="app-card app-card--compact">
<span class="app-chip">单次消耗</span>
</Card>
<Card variant="compact">
<Chip>单次消耗</Chip>
<div class="app-kpi-value">{avgTokensPerRequest}</div>
<span class="app-kpi-meta">
平均 {Math.round(dashboard.summary.avg_latency)}ms 延迟
</span>
</div>
</Card>
</div>
<div class="app-grid app-grid--split">
<div class="app-card">
<Card class="flex h-[420px] flex-col sm:h-[520px]">
<div class="mb-4 flex items-center justify-between">
<h3 class="app-title">按日趋势</h3>
<div>
<h3 class="app-title">请求趋势</h3>
<p class="app-subtitle">按统计颗粒观察请求量与 Tokens 变化。</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class={cx("relative", intervalRootClass)}>
<Button
class="h-8 px-3 text-[11px]"
size="sm"
type="button"
onClick={(event) => {
event.stopPropagation();
setFilterOpen(false);
if (!intervalOpen) {
window.dispatchEvent(
new CustomEvent<string>(popoverEvent, {
detail: intervalPopoverId,
}),
);
}
setIntervalOpen((prev) => !prev);
}}
>
统计颗粒{" "}
<span class="ml-1 text-[10px] text-[color:var(--app-ink-muted)]">
{intervalLabel}
</span>
</Button>
<Popover open={intervalOpen}>
<PopoverContent class="right-0 w-28 p-2 app-popover-content--spaced app-popover-content--tight">
{[
{ value: "day", label: "按日" },
{ value: "week", label: "按周" },
{ value: "month", label: "按月" },
].map((option) => (
<button
class={cx(
"app-dropdown-item app-dropdown-item--right",
query.interval === option.value &&
"app-dropdown-item--active",
)}
key={option.value}
type="button"
onClick={() => {
setIntervalOpen(false);
handleIntervalApply(
option.value as DashboardQuery["interval"],
);
}}
>
<span class="text-xs font-semibold">{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
<ColumnPicker
columns={trendColumnsConfig.map((column) =>
column.id === "bucket"
? { ...column, label: intervalLabel }
: column,
)}
value={trendColumns}
onChange={updateTrendColumns}
/>
</div>
</div>
<div class="overflow-x-auto">
<table class="app-table">
<thead>
<tr>
<th>日期</th>
<th>请求</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{dashboard.byDay.map((row) => (
<tr key={row.day}>
<td>{row.day}</td>
<td>{row.requests}</td>
<td>{row.tokens}</td>
</tr>
))}
</tbody>
</table>
<div class="mt-2 flex min-h-0 flex-1 flex-col gap-4">
<div>
<AreaChart
data={chartData}
valueLabel="请求"
secondaryLabel="Tokens"
/>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{trendColumnSet.has("bucket") && (
<TableHead>{intervalLabel}</TableHead>
)}
{trendColumnSet.has("requests") && (
<TableHead>请求</TableHead>
)}
{trendColumnSet.has("tokens") && (
<TableHead>Tokens</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{(dashboard.trend ?? []).length === 0 ? (
<TableRow>
<TableCell
class="px-3 py-6 text-center text-sm text-[color:var(--app-ink-muted)]"
colSpan={3}
>
暂无趋势数据
</TableCell>
</TableRow>
) : (
dashboard.trend.map((row) => (
<TableRow key={row.bucket}>
{trendColumnSet.has("bucket") && (
<TableCell>{row.bucket}</TableCell>
)}
{trendColumnSet.has("requests") && (
<TableCell>{row.requests}</TableCell>
)}
{trendColumnSet.has("tokens") && (
<TableCell>{row.tokens}</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<div class="app-card">
</Card>
<Card class="flex h-[420px] flex-col sm:h-[520px]">
<div class="mb-4 flex items-center justify-between">
<h3 class="app-title">模型排行</h3>
<div>
<h3 class="app-title">排行</h3>
<p class="app-subtitle">模型、渠道与令牌的 Top 表现。</p>
</div>
<ColumnPicker
columns={rankColumnsConfig}
value={rankColumns}
onChange={updateRankColumns}
/>
</div>
<div class="overflow-x-auto">
<table class="app-table">
<thead>
<tr>
<th>模型</th>
<th>请求</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{dashboard.byModel.map((row) => (
<tr key={row.model}>
<td>{row.model ?? "-"}</td>
<td>{row.requests}</td>
<td>{row.tokens}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div class="app-grid app-grid--split">
<div class="app-card">
<div class="mb-4 flex items-center justify-between">
<h3 class="app-title">渠道贡献</h3>
</div>
<div class="overflow-x-auto">
<table class="app-table">
<thead>
<tr>
<th>渠道</th>
<th>请求</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{dashboard.byChannel.map((row) => (
<tr key={row.channel_name ?? "unknown"}>
<td>{row.channel_name ?? "-"}</td>
<td>{row.requests}</td>
<td>{row.tokens}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="app-card">
<div class="mb-4 flex items-center justify-between">
<h3 class="app-title">令牌贡献</h3>
</div>
<div class="overflow-x-auto">
<table class="app-table">
<thead>
<tr>
<th>令牌</th>
<th>请求</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{dashboard.byToken.map((row) => (
<tr key={row.token_name ?? "unknown"}>
<td>{row.token_name ?? "-"}</td>
<td>{row.requests}</td>
<td>{row.tokens}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Tabs class="min-h-0 flex-1">
<TabsList class="grid grid-cols-3 gap-2">
<TabsTrigger
active={activeRankTab === "model"}
class="w-full"
type="button"
onClick={() => setActiveRankTab("model")}
>
模型排行
</TabsTrigger>
<TabsTrigger
active={activeRankTab === "channel"}
class="w-full"
type="button"
onClick={() => setActiveRankTab("channel")}
>
渠道排行
</TabsTrigger>
<TabsTrigger
active={activeRankTab === "token"}
class="w-full"
type="button"
onClick={() => setActiveRankTab("token")}
>
令牌排行
</TabsTrigger>
</TabsList>
<TabsContent class="min-h-0 flex-1">
<div class="h-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
{rankColumnSet.has("name") && (
<TableHead>
{activeRankTab === "model"
? "模型"
: activeRankTab === "channel"
? "渠道"
: "令牌"}
</TableHead>
)}
{rankColumnSet.has("requests") && (
<TableHead>请求</TableHead>
)}
{rankColumnSet.has("tokens") && (
<TableHead>Tokens</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{rankingData.length === 0 ? (
<TableRow>
<TableCell
class="px-3 py-6 text-center text-sm text-[color:var(--app-ink-muted)]"
colSpan={3}
>
暂无排行数据
</TableCell>
</TableRow>
) : (
rankingData.map((row) => (
<TableRow key={`${activeRankTab}-${row.name}`}>
{rankColumnSet.has("name") && (
<TableCell>{row.name}</TableCell>
)}
{rankColumnSet.has("requests") && (
<TableCell>{row.requests}</TableCell>
)}
{rankColumnSet.has("tokens") && (
<TableCell>{row.tokens}</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</Card>
</div>
</div>
);
+54 -50
View File
@@ -1,9 +1,10 @@
import { Button, Card, Input } from "../components/ui";
import type { NoticeMessage } from "../core/types";
type LoginViewProps = {
notice: NoticeMessage | null;
isSubmitting: boolean;
onSubmit: (event: Event) => void;
notice: NoticeMessage | null;
isSubmitting: boolean;
onSubmit: (event: Event) => void;
};
/**
@@ -16,52 +17,55 @@ type LoginViewProps = {
* Login JSX element.
*/
export const LoginView = ({
notice,
isSubmitting,
onSubmit,
notice,
isSubmitting,
onSubmit,
}: LoginViewProps) => {
const toneStyles: Record<NoticeMessage["tone"], string> = {
success: "app-notice app-notice--success",
warning: "app-notice app-notice--warning",
error: "app-notice app-notice--error",
info: "app-notice app-notice--info",
};
return (
<div class="app-card animate-fade-up mx-auto mt-24 max-w-md p-8">
<h1 class="app-title mb-2 text-2xl">api-workers</h1>
<p class="text-sm text-[color:var(--app-ink-muted)]">
请输入管理员密码登录管理台。
</p>
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="password"
>
管理员密码
</label>
<input
class="app-input app-focus"
id="password"
name="password"
type="password"
required
/>
</div>
<button
class="app-button app-button-primary app-focus h-11 px-4 text-sm"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? "登录中..." : "登录"}
</button>
</form>
<p class="mt-3 text-xs text-[color:var(--app-ink-muted)]">
按回车键可快速提交。
</p>
{notice && (
<div class={`mt-4 ${toneStyles[notice.tone]}`}>{notice.message}</div>
)}
</div>
);
const toneStyles: Record<NoticeMessage["tone"], string> = {
success: "app-notice app-notice--success",
warning: "app-notice app-notice--warning",
error: "app-notice app-notice--error",
info: "app-notice app-notice--info",
};
return (
<Card class="animate-fade-up mx-auto mt-24 max-w-md p-8">
<h1 class="app-title mb-2 text-2xl">api-workers</h1>
<p class="text-sm text-[color:var(--app-ink-muted)]">
请输入管理员密码登录管理台。
</p>
<form
class="mt-6 grid gap-4"
onSubmit={onSubmit}
>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="password"
>
管理员密码
</label>
<Input
id="password"
name="password"
type="password"
required
/>
</div>
<Button
variant="primary"
size="lg"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? "登录中..." : "登录"}
</Button>
</form>
<p class="mt-3 text-xs text-[color:var(--app-ink-muted)]">
按回车键可快速提交。
</p>
{notice && (
<div class={`mt-4 ${toneStyles[notice.tone]}`}>{notice.message}</div>
)}
</Card>
);
};
+216 -26
View File
@@ -1,4 +1,26 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import {
Card,
Chip,
ColumnPicker,
MultiSelect,
Pagination,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../components/ui";
import type { ModelItem } from "../core/types";
import {
buildPageItems,
loadColumnPrefs,
loadPageSizePref,
persistColumnPrefs,
persistPageSizePref,
} from "../core/utils";
type ModelsViewProps = {
models: ModelItem[];
@@ -17,42 +39,210 @@ export const ModelsView = ({ models }: ModelsViewProps) => {
const channelCount = new Set(
models.flatMap((model) => model.channels.map((channel) => channel.id)),
).size;
const modelColumns = [
{ id: "model", label: "模型", locked: true },
{ id: "channels", label: "渠道" },
];
const [visibleColumns, setVisibleColumns] = useState(() =>
loadColumnPrefs(
"columns:models",
modelColumns.map((column) => column.id),
),
);
const visibleColumnSet = useMemo(
() => new Set(visibleColumns),
[visibleColumns],
);
const updateColumns = (next: string[]) => {
setVisibleColumns(next);
persistColumnPrefs("columns:models", next);
};
const [modelFilters, setModelFilters] = useState<string[]>([]);
const [channelFilters, setChannelFilters] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
loadPageSizePref("pageSize:models", 15),
);
const pageSizeOptions = [15, 30, 50];
const modelOptions = useMemo(
() =>
models.map((model) => ({
value: model.id,
label: model.id,
})),
[models],
);
const channelOptions = useMemo(() => {
const map = new Map<string, string>();
for (const model of models) {
for (const channel of model.channels) {
const label = channel.name || channel.id;
if (!map.has(channel.id)) {
map.set(channel.id, label);
}
}
}
return Array.from(map.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [models]);
const filteredModels = useMemo(() => {
const modelSet =
modelFilters.length > 0 ? new Set(modelFilters) : null;
const channelSet =
channelFilters.length > 0 ? new Set(channelFilters) : null;
return models.filter((model) => {
const matchesModel = modelSet ? modelSet.has(model.id) : true;
const matchesChannel = channelSet
? model.channels.some((channel) => channelSet.has(channel.id))
: true;
return matchesModel && matchesChannel;
});
}, [channelFilters, modelFilters, models]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(filteredModels.length / pageSize)),
[filteredModels.length, pageSize],
);
const pageItems = useMemo(
() => buildPageItems(page, totalPages),
[page, totalPages],
);
const pagedModels = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredModels.slice(start, start + pageSize);
}, [filteredModels, page, pageSize]);
useEffect(() => {
setPage(1);
}, [channelFilters, modelFilters, pageSize]);
return (
<div class="app-card animate-fade-up p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="animate-fade-up space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="app-title text-lg">模型广场</h3>
<p class="app-subtitle">当前聚合的模型与所属渠道清单。</p>
</div>
<div class="flex items-center gap-2 text-xs text-[color:var(--app-ink-muted)]">
<span class="app-chip">{models.length} 个模型</span>
<span class="app-chip">{channelCount} 个渠道</span>
<div class="flex flex-wrap items-center gap-2 text-xs text-[color:var(--app-ink-muted)]">
<Chip>{models.length} 个模型</Chip>
<Chip>{channelCount} 个渠道</Chip>
<ColumnPicker
columns={modelColumns}
value={visibleColumns}
onChange={updateColumns}
/>
</div>
</div>
{models.length === 0 ? (
<div class="app-card text-center text-sm text-[color:var(--app-ink-muted)]">
暂无模型,请先在站点管理配置可用渠道。
<Card variant="compact" class="app-layer-raised space-y-3 p-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
模型
</label>
<MultiSelect
class="w-full"
options={modelOptions}
value={modelFilters}
placeholder="选择模型"
searchPlaceholder="搜索模型"
emptyLabel="暂无匹配模型"
onChange={setModelFilters}
/>
</div>
<div>
<label class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
渠道
</label>
<MultiSelect
class="w-full"
options={channelOptions}
value={channelFilters}
placeholder="选择渠道"
searchPlaceholder="搜索渠道"
emptyLabel="暂无匹配渠道"
onChange={setChannelFilters}
/>
</div>
</div>
</Card>
{models.length === 0 ? (
<Card class="text-center text-sm text-[color:var(--app-ink-muted)]">
暂无模型,请先在站点管理配置可用渠道。
</Card>
) : (
<div class="overflow-x-auto">
<table class="app-table min-w-105 w-full text-xs sm:text-sm">
<thead>
<tr>
<th>模型</th>
<th>渠道</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.id}>
<td>{model.id}</td>
<td>
{model.channels.map((channel) => channel.name).join(" / ")}
</td>
</tr>
<div class="app-surface overflow-x-auto">
<Table class="min-w-105 w-full text-xs sm:text-sm">
<TableHeader>
<TableRow>
{visibleColumnSet.has("model") && <TableHead>模型</TableHead>}
{visibleColumnSet.has("channels") && (
<TableHead>渠道</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{pagedModels.length === 0 ? (
<TableRow>
<TableCell
class="px-3 py-6 text-center text-sm text-[color:var(--app-ink-muted)]"
colSpan={visibleColumns.length}
>
暂无匹配模型
</TableCell>
</TableRow>
) : (
pagedModels.map((model) => (
<TableRow key={model.id}>
{visibleColumnSet.has("model") && (
<TableCell>{model.id}</TableCell>
)}
{visibleColumnSet.has("channels") && (
<TableCell>
{model.channels
.map((channel) => channel.name)
.join(" / ")}
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
{models.length > 0 && (
<div class="flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-[color:var(--app-ink-muted)]">
共 {filteredModels.length} 条 · {totalPages} 页
</span>
<Pagination
page={page}
totalPages={totalPages}
items={pageItems}
onPageChange={setPage}
/>
</div>
<label class="app-page-size" for="model-page-size">
<span>每页条数</span>
<Select
variant="pill"
class="w-auto text-xs app-page-size__select"
id="model-page-size"
value={String(pageSize)}
onChange={(event) => {
const next = Number(
(event.currentTarget as HTMLSelectElement).value,
);
persistPageSizePref("pageSize:models", next);
setPageSize(next);
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={String(size)}>
{size}
</option>
))}
</tbody>
</table>
</Select>
</label>
</div>
)}
</div>
+20 -26
View File
@@ -1,3 +1,4 @@
import { Button, Card, Input } from "../components/ui";
import type { RuntimeProxyConfig, SettingsForm } from "../core/types";
type SettingsViewProps = {
@@ -60,20 +61,20 @@ export const SettingsView = ({
];
return (
<div class="app-card animate-fade-up p-5">
<div class="mb-4 flex items-center justify-between">
<div class="animate-fade-up space-y-4">
<div class="flex items-center justify-between">
<h3 class="app-title text-lg">系统设置</h3>
</div>
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
<Card class="p-5">
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="retention"
>
日志保留天数
</label>
<input
class="app-input app-focus"
<Input
id="retention"
name="log_retention_days"
type="number"
@@ -94,8 +95,7 @@ export const SettingsView = ({
>
会话时长(小时)
</label>
<input
class="app-input app-focus"
<Input
id="session-ttl"
name="session_ttl_hours"
type="number"
@@ -116,8 +116,7 @@ export const SettingsView = ({
>
签到时间(中国时间)
</label>
<input
class="app-input app-focus"
<Input
id="checkin-schedule-time"
name="checkin_schedule_time"
type="time"
@@ -137,8 +136,7 @@ export const SettingsView = ({
>
失败冷却(分钟)
</label>
<input
class="app-input app-focus"
<Input
id="failure-cooldown"
name="model_failure_cooldown_minutes"
type="number"
@@ -162,8 +160,7 @@ export const SettingsView = ({
>
管理员密码
</label>
<input
class="app-input app-focus"
<Input
id="admin-password"
name="admin_password"
type="password"
@@ -185,16 +182,13 @@ export const SettingsView = ({
</p>
</div>
<div class="flex items-end lg:col-span-2">
<button
class="app-button app-button-primary app-focus h-11 px-4 text-sm"
type="submit"
disabled={isSaving}
>
<Button variant="primary" size="lg" type="submit" disabled={isSaving}>
{isSaving ? "保存中..." : "保存设置"}
</button>
</Button>
</div>
</form>
<div class="mt-6 border-t border-white/70 pt-5">
</form>
</Card>
<Card class="p-5">
<h4 class="app-title text-base">运行时配置(只读)</h4>
<p class="mt-2 text-xs text-[color:var(--app-ink-muted)]">
需要调整请在 Cloudflare Dashboard 或部署环境中修改以下环境变量,
@@ -202,7 +196,7 @@ export const SettingsView = ({
</p>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
{runtimeItems.map((item) => (
<div class="rounded-xl bg-white/70 px-3 py-3 text-sm">
<div class="rounded-xl border border-white/60 bg-white/70 px-3 py-3 text-sm">
<div class="text-sm font-semibold text-[color:var(--app-ink)]">
{item.label}
</div>
@@ -215,7 +209,7 @@ export const SettingsView = ({
</div>
))}
</div>
</div>
</Card>
</div>
);
};
File diff suppressed because it is too large. Load diff
+382 -345
View File
@@ -1,9 +1,26 @@
import { useEffect } from "hono/jsx/dom";
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import {
Button,
Card,
Chip,
ColumnPicker,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Pagination,
Select,
} from "../components/ui";
import type { Site, Token, TokenForm } from "../core/types";
import {
buildPageItems,
formatChinaDateTime,
formatDateTime,
loadColumnPrefs,
persistColumnPrefs,
} from "../core/utils";
type TokensViewProps = {
@@ -30,6 +47,16 @@ type TokensViewProps = {
};
const pageSizeOptions = [10, 20, 50];
const tokenColumnOptions = [
{ id: "name", label: "名称", width: "minmax(0,1.2fr)", locked: true },
{ id: "status", label: "状态", width: "minmax(0,0.6fr)" },
{ id: "quota", label: "已用/额度", width: "minmax(0,0.9fr)" },
{ id: "prefix", label: "前缀", width: "minmax(0,0.6fr)" },
{ id: "created", label: "创建时间", width: "minmax(0,1fr)" },
{ id: "expires", label: "过期时间", width: "minmax(0,1fr)" },
{ id: "channels", label: "渠道限制", width: "minmax(0,0.7fr)" },
{ id: "actions", label: "操作", width: "minmax(0,1.3fr)", locked: true },
];
/**
* Renders the tokens management view.
@@ -70,6 +97,29 @@ export const TokensView = ({
? "更新令牌名称、额度、状态与过期时间。"
: "创建后会自动复制令牌,请妥善保存。";
const submitLabel = isEditing ? "保存修改" : "生成令牌";
const [visibleColumns, setVisibleColumns] = useState(() =>
loadColumnPrefs(
"columns:tokens",
tokenColumnOptions.map((column) => column.id),
),
);
const visibleColumnSet = useMemo(
() => new Set(visibleColumns),
[visibleColumns],
);
const updateVisibleColumns = (next: string[]) => {
setVisibleColumns(next);
persistColumnPrefs("columns:tokens", next);
};
const tokenGridTemplate = useMemo(
() =>
tokenColumnOptions
.filter((column) => visibleColumnSet.has(column.id))
.map((column) => column.width)
.join(" "),
[visibleColumnSet],
);
const displayPages = tokenTotal === 0 ? 0 : tokenTotalPages;
const selectedChannels = new Set(tokenForm.allowed_channels);
const toggleChannel = (channelId: string) => {
const next = new Set(selectedChannels);
@@ -97,35 +147,44 @@ export const TokensView = ({
}, [isTokenModalOpen, onCloseModal]);
return (
<div class="space-y-5">
<div class="app-card animate-fade-up p-5">
<div class="animate-fade-up space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="app-title text-lg">令牌列表</h3>
<p class="app-subtitle">统一管理令牌状态、额度与操作入口。</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="app-button app-button-primary app-focus h-9 px-4 text-xs"
<ColumnPicker
columns={tokenColumnOptions}
value={visibleColumns}
onChange={updateVisibleColumns}
/>
<Button
class="h-9 px-4 text-xs"
size="sm"
variant="primary"
type="button"
onClick={onCreate}
>
新增令牌
</button>
</Button>
</div>
</div>
<div class="mt-4">
<div>
<div class="space-y-3 md:hidden">
{pagedTokens.length === 0 ? (
<div class="app-card text-center text-sm text-[color:var(--app-ink-muted)]">
<Card class="text-center text-sm text-[color:var(--app-ink-muted)]">
<p>暂无令牌,请先创建。</p>
<button
class="app-button app-button-primary app-focus mt-4 h-9 px-4 text-xs"
<Button
class="mt-4 h-9 px-4 text-xs"
size="sm"
variant="primary"
type="button"
onClick={onCreate}
>
生成令牌
</button>
</div>
</Button>
</Card>
) : (
pagedTokens.map((tokenItem) => {
const isActive = tokenItem.status === "active";
@@ -139,7 +198,7 @@ export const TokensView = ({
`token:delete:${tokenItem.id}`,
);
return (
<div class="app-card p-4" key={tokenItem.id}>
<Card class="p-4" key={tokenItem.id}>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[color:var(--app-ink)]">
@@ -149,37 +208,36 @@ export const TokensView = ({
前缀 {tokenItem.key_prefix ?? "-"}
</p>
</div>
<span
class={`app-chip text-[10px] uppercase tracking-widest ${
isActive ? "app-chip--success" : "app-chip--muted"
}`}
<Chip
class="text-[10px] uppercase tracking-widest"
variant={isActive ? "success" : "muted"}
>
{isActive ? "启用" : "禁用"}
</span>
</Chip>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-[color:var(--app-ink-muted)]">
<div class="app-card app-card--compact">
<Card variant="compact">
<p>已用/额度</p>
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
{tokenItem.quota_used} /{" "}
{tokenItem.quota_total ?? "∞"}
</p>
</div>
<div class="app-card app-card--compact">
</Card>
<Card variant="compact">
<p>创建时间</p>
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
{formatDateTime(tokenItem.created_at)}
</p>
</div>
<div class="app-card app-card--compact">
</Card>
<Card variant="compact">
<p>过期时间</p>
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
{tokenItem.expires_at
? formatChinaDateTime(tokenItem.expires_at)
: "永不过期"}
</p>
</div>
<div class="app-card app-card--compact">
</Card>
<Card variant="compact">
<p>渠道限制</p>
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
{tokenItem.allowed_channels &&
@@ -187,67 +245,77 @@ export const TokensView = ({
? `${tokenItem.allowed_channels.length} 个`
: "全开"}
</p>
</div>
</Card>
</div>
<div class="mt-3 grid grid-cols-2 gap-2">
<button
class="app-button app-focus col-span-2 h-9 w-full px-3 text-xs"
<Button
class="col-span-2 h-9 w-full px-3 text-xs"
size="sm"
type="button"
onClick={() => onEdit(tokenItem)}
>
编辑
</button>
<button
class="app-button app-focus h-9 w-full px-3 text-xs"
</Button>
<Button
class="h-9 w-full px-3 text-xs"
size="sm"
type="button"
disabled={revealPending}
onClick={() => onReveal(tokenItem.id)}
>
{revealPending ? "查看中..." : "查看"}
</button>
<button
class="app-button app-focus h-9 w-full px-3 text-xs"
</Button>
<Button
class="h-9 w-full px-3 text-xs"
size="sm"
type="button"
disabled={togglePending}
onClick={() => onToggle(tokenItem.id, tokenItem.status)}
>
{togglePending ? "处理中..." : "切换"}
</button>
<button
class="app-button app-button-ghost app-focus col-span-2 h-9 w-full px-3 text-xs"
</Button>
<Button
class="col-span-2 h-9 w-full px-3 text-xs"
size="sm"
variant="ghost"
type="button"
disabled={deletePending}
onClick={() => onDelete(tokenItem)}
>
{deletePending ? "删除中..." : "删除"}
</button>
</Button>
</div>
</div>
</Card>
);
})
)}
</div>
<div class="app-surface hidden overflow-hidden md:block">
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.3fr)] gap-3 bg-white/60 px-4 py-3 text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
<div>名称</div>
<div>状态</div>
<div>已用/额度</div>
<div>前缀</div>
<div>创建时间</div>
<div>过期时间</div>
<div>渠道限制</div>
<div>操作</div>
<div
class="grid gap-3 bg-white/60 px-4 py-3 text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
style={`grid-template-columns: ${tokenGridTemplate};`}
>
{visibleColumnSet.has("name") && <div>名称</div>}
{visibleColumnSet.has("status") && <div>状态</div>}
{visibleColumnSet.has("quota") && <div>已用/额度</div>}
{visibleColumnSet.has("prefix") && <div>前缀</div>}
{visibleColumnSet.has("created") && <div>创建时间</div>}
{visibleColumnSet.has("expires") && <div>过期时间</div>}
{visibleColumnSet.has("channels") && <div>渠道限制</div>}
{visibleColumnSet.has("actions") && <div>操作</div>}
</div>
{pagedTokens.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-[color:var(--app-ink-muted)]">
<p>暂无令牌,请先创建。</p>
<button
class="app-button app-button-primary app-focus mt-4 h-9 px-4 text-xs"
<Button
class="mt-4 h-9 px-4 text-xs"
size="sm"
variant="primary"
type="button"
onClick={onCreate}
>
生成令牌
</button>
</Button>
</div>
) : (
<div class="divide-y divide-white/60">
@@ -264,78 +332,100 @@ export const TokensView = ({
);
return (
<div
class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.3fr)] items-center gap-3 px-4 py-4 text-sm"
class="grid items-center gap-3 px-4 py-4 text-sm"
key={tokenItem.id}
style={`grid-template-columns: ${tokenGridTemplate};`}
>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-[color:var(--app-ink)]">
{tokenItem.name}
</span>
</div>
<div>
<span
class={`app-chip text-xs ${
isActive ? "app-chip--success" : "app-chip--muted"
}`}
>
{isActive ? "启用" : "禁用"}
</span>
</div>
<div class="text-sm font-semibold text-[color:var(--app-ink)]">
{tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"}
</div>
<div class="text-sm text-[color:var(--app-ink)]">
{tokenItem.key_prefix ?? "-"}
</div>
<div class="text-sm text-[color:var(--app-ink)]">
{formatDateTime(tokenItem.created_at)}
</div>
<div class="text-sm text-[color:var(--app-ink)]">
{tokenItem.expires_at
? formatChinaDateTime(tokenItem.expires_at)
: "永不过期"}
</div>
<div class="text-sm text-[color:var(--app-ink)]">
{tokenItem.allowed_channels &&
tokenItem.allowed_channels.length > 0
? `${tokenItem.allowed_channels.length} 个`
: "全开"}
</div>
<div class="flex flex-wrap gap-2">
<button
class="app-button app-focus h-9 px-3 text-xs"
type="button"
onClick={() => onEdit(tokenItem)}
>
编辑
</button>
<button
class="app-button app-focus h-9 px-3 text-xs"
type="button"
disabled={revealPending}
onClick={() => onReveal(tokenItem.id)}
>
{revealPending ? "查看中..." : "查看"}
</button>
<button
class="app-button app-focus h-9 px-3 text-xs"
type="button"
disabled={togglePending}
onClick={() =>
onToggle(tokenItem.id, tokenItem.status)
}
>
{togglePending ? "处理中..." : "切换"}
</button>
<button
class="app-button app-button-ghost app-focus h-9 px-3 text-xs"
type="button"
disabled={deletePending}
onClick={() => onDelete(tokenItem)}
>
{deletePending ? "删除中..." : "删除"}
</button>
</div>
{visibleColumnSet.has("name") && (
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-[color:var(--app-ink)]">
{tokenItem.name}
</span>
</div>
)}
{visibleColumnSet.has("status") && (
<div>
<Chip
variant={isActive ? "success" : "muted"}
class="text-xs"
>
{isActive ? "启用" : "禁用"}
</Chip>
</div>
)}
{visibleColumnSet.has("quota") && (
<div class="text-sm font-semibold text-[color:var(--app-ink)]">
{tokenItem.quota_used} /{" "}
{tokenItem.quota_total ?? "∞"}
</div>
)}
{visibleColumnSet.has("prefix") && (
<div class="text-sm text-[color:var(--app-ink)]">
{tokenItem.key_prefix ?? "-"}
</div>
)}
{visibleColumnSet.has("created") && (
<div class="text-sm text-[color:var(--app-ink)]">
{formatDateTime(tokenItem.created_at)}
</div>
)}
{visibleColumnSet.has("expires") && (
<div class="text-sm text-[color:var(--app-ink)]">
{tokenItem.expires_at
? formatChinaDateTime(tokenItem.expires_at)
: "永不过期"}
</div>
)}
{visibleColumnSet.has("channels") && (
<div class="text-sm text-[color:var(--app-ink)]">
{tokenItem.allowed_channels &&
tokenItem.allowed_channels.length > 0
? `${tokenItem.allowed_channels.length} 个`
: "全开"}
</div>
)}
{visibleColumnSet.has("actions") && (
<div class="flex flex-wrap gap-2">
<Button
class="h-9 px-3 text-xs"
size="sm"
type="button"
onClick={() => onEdit(tokenItem)}
>
编辑
</Button>
<Button
class="h-9 px-3 text-xs"
size="sm"
type="button"
disabled={revealPending}
onClick={() => onReveal(tokenItem.id)}
>
{revealPending ? "查看中..." : "查看"}
</Button>
<Button
class="h-9 px-3 text-xs"
size="sm"
type="button"
disabled={togglePending}
onClick={() =>
onToggle(tokenItem.id, tokenItem.status)
}
>
{togglePending ? "处理中..." : "切换"}
</Button>
<Button
class="h-9 px-3 text-xs"
size="sm"
variant="ghost"
type="button"
disabled={deletePending}
onClick={() => onDelete(tokenItem)}
>
{deletePending ? "删除中..." : "删除"}
</Button>
</div>
)}
</div>
);
})}
@@ -343,56 +433,25 @@ export const TokensView = ({
)}
</div>
</div>
<div class="mt-4 flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-[color:var(--app-ink-muted)]">
共 {tokenTotal} 条 · {tokenTotalPages} 页
共 {tokenTotal} 条 · {displayPages} 页
</span>
<button
class="app-button app-focus h-8 w-8 text-xs"
type="button"
disabled={tokenPage <= 1}
onClick={() => onPageChange(Math.max(1, tokenPage - 1))}
>
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span
class="px-2 text-xs text-[color:var(--app-ink-muted)]"
key={`e-${index}`}
>
...
</span>
) : (
<button
class={`app-button app-focus h-8 min-w-8 px-3 text-xs ${
item === tokenPage ? "app-button-primary" : ""
}`}
type="button"
key={item}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
<button
class="app-button app-focus h-8 w-8 text-xs"
type="button"
disabled={tokenPage >= tokenTotalPages}
onClick={() =>
onPageChange(Math.min(tokenTotalPages, tokenPage + 1))
}
>
&gt;
</button>
<Pagination
page={tokenPage}
totalPages={tokenTotalPages}
items={pageItems}
onPageChange={onPageChange}
/>
</div>
<label class="app-chip app-chip--muted flex items-center gap-2 px-3 py-1 text-xs">
每页条数
<select
class="app-input app-input--pill app-focus w-auto text-xs"
value={tokenPageSize}
<label class="app-page-size" for="token-page-size">
<span>每页条数</span>
<Select
variant="pill"
class="w-auto text-xs app-page-size__select"
id="token-page-size"
value={String(tokenPageSize)}
onChange={(event) => {
onPageSizeChange(
Number((event.currentTarget as HTMLSelectElement).value),
@@ -400,199 +459,177 @@ export const TokensView = ({
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
<option key={size} value={String(size)}>
{size}
</option>
))}
</select>
</Select>
</label>
</div>
</div>
{isTokenModalOpen && (
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={onCloseModal}
/>
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
<div
aria-labelledby="token-modal-title"
aria-modal="true"
class="app-card w-full max-w-xl p-6"
role="dialog"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="app-title mb-1 text-lg" id="token-modal-title">
{modalTitle}
</h3>
<p class="text-xs text-[color:var(--app-ink-muted)]">
{modalDescription}
</p>
</div>
<button
class="app-button app-focus h-9 px-3 text-xs"
type="button"
onClick={onCloseModal}
>
关闭
</button>
<Dialog open={isTokenModalOpen} onClose={onCloseModal}>
<DialogContent
aria-labelledby="token-modal-title"
aria-modal="true"
class="max-w-xl"
>
<DialogHeader>
<div>
<DialogTitle id="token-modal-title">{modalTitle}</DialogTitle>
<DialogDescription>{modalDescription}</DialogDescription>
</div>
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-name"
>
名称
</label>
<input
class="app-input app-focus"
id="token-name"
name="name"
required
value={tokenForm.name}
onInput={(event) =>
onFormChange({
name: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-quota"
>
额度(可选)
</label>
<input
class="app-input app-focus"
id="token-quota"
name="quota_total"
type="number"
min="0"
placeholder="留空表示无限"
value={tokenForm.quota_total}
onInput={(event) =>
onFormChange({
quota_total: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-status"
>
状态
</label>
<select
class="app-input app-focus"
id="token-status"
name="status"
value={tokenForm.status}
onChange={(event) =>
onFormChange({
status: (event.currentTarget as HTMLSelectElement)
.value,
})
}
>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-expires"
>
过期时间(北京时间)
</label>
<input
class="app-input app-focus"
id="token-expires"
name="expires_at"
type="datetime-local"
step="60"
placeholder="留空表示不过期"
value={tokenForm.expires_at}
onInput={(event) =>
onFormChange({
expires_at: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
留空表示不过期。
</p>
</div>
<div>
<div class="flex items-center justify-between">
<div class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
允许渠道
</div>
<button
class="text-xs font-semibold text-[color:var(--app-ink-muted)] transition-all duration-200 ease-in-out hover:text-[color:var(--app-ink)]"
type="button"
onClick={clearChannels}
>
全开
</button>
<Button size="sm" type="button" onClick={onCloseModal}>
关闭
</Button>
</DialogHeader>
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-name"
>
名称
</label>
<Input
id="token-name"
name="name"
required
value={tokenForm.name}
onInput={(event) =>
onFormChange({
name: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-quota"
>
额度(可选)
</label>
<Input
id="token-quota"
name="quota_total"
type="number"
min="0"
placeholder="留空表示无限"
value={tokenForm.quota_total}
onInput={(event) =>
onFormChange({
quota_total: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-status"
>
状态
</label>
<Select
id="token-status"
name="status"
value={tokenForm.status}
onChange={(event) =>
onFormChange({
status: (event.currentTarget as HTMLSelectElement).value,
})
}
>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</Select>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="token-expires"
>
过期时间(北京时间)
</label>
<Input
id="token-expires"
name="expires_at"
type="datetime-local"
step="60"
placeholder="留空表示不过期"
value={tokenForm.expires_at}
onInput={(event) =>
onFormChange({
expires_at: (event.currentTarget as HTMLInputElement)
.value,
})
}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
留空表示不过期。
</p>
</div>
<div>
<div class="flex items-center justify-between">
<div class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
允许渠道
</div>
<p class="text-xs text-[color:var(--app-ink-muted)]">
未选择表示全开。
</p>
<div class="app-card app-card--compact mt-2 max-h-36 space-y-2 overflow-auto text-xs text-[color:var(--app-ink-muted)]">
{sites.length === 0 ? (
<p class="text-[color:var(--app-ink-muted)]">
暂无渠道,请先创建。
</p>
) : (
sites.map((site) => (
<label class="flex items-center gap-2" key={site.id}>
<input
class="h-3 w-3 rounded border-[color:var(--app-border)] text-[color:var(--app-ink)] focus:ring-[color:var(--app-accent-soft)]"
type="checkbox"
checked={selectedChannels.has(site.id)}
onChange={() => toggleChannel(site.id)}
/>
<span class="truncate">{site.name ?? site.id}</span>
</label>
))
)}
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<button
class="app-button app-focus h-10 px-4 text-xs"
class="text-xs font-semibold text-[color:var(--app-ink-muted)] transition-all duration-200 ease-in-out hover:text-[color:var(--app-ink)]"
type="button"
onClick={onCloseModal}
onClick={clearChannels}
>
取消
</button>
<button
class="app-button app-button-primary app-focus h-10 px-5 text-xs"
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? isEditing
? "保存中..."
: "生成中..."
: submitLabel}
全开
</button>
</div>
</form>
</div>
</div>
</div>
<p class="text-xs text-[color:var(--app-ink-muted)]">
未选择表示全开。
</p>
<Card
variant="compact"
class="mt-2 max-h-36 space-y-2 overflow-auto text-xs text-[color:var(--app-ink-muted)]"
>
{sites.length === 0 ? (
<p class="text-[color:var(--app-ink-muted)]">
暂无渠道,请先创建。
</p>
) : (
sites.map((site) => (
<label class="flex items-center gap-2" key={site.id}>
<input
class="h-3 w-3 rounded border-[color:var(--app-border)] text-[color:var(--app-ink)] focus:ring-[color:var(--app-accent-soft)]"
type="checkbox"
checked={selectedChannels.has(site.id)}
onChange={() => toggleChannel(site.id)}
/>
<span class="truncate">{site.name ?? site.id}</span>
</label>
))
)}
</Card>
</div>
<DialogFooter>
<Button size="sm" type="button" onClick={onCloseModal}>
取消
</Button>
<Button
size="sm"
variant="primary"
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? isEditing
? "保存中..."
: "生成中..."
: submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
+460 -274
View File
@@ -1,9 +1,34 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import type { UsageLog, UsageQuery } from "../core/types";
import {
Button,
Card,
Chip,
ColumnPicker,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
MultiSelect,
Pagination,
Select,
Skeleton,
Tooltip,
} from "../components/ui";
import type {
ModelItem,
Site,
Token,
UsageLog,
UsageQuery,
} from "../core/types";
import {
buildPageItems,
buildUsageStatusDetail,
formatDateTime,
loadColumnPrefs,
persistColumnPrefs,
} from "../core/utils";
type UsageViewProps = {
@@ -13,6 +38,9 @@ type UsageViewProps = {
pageSize: number;
filters: UsageQuery;
isRefreshing: boolean;
sites: Site[];
tokens: Token[];
models: ModelItem[];
onRefresh: () => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
@@ -59,6 +87,9 @@ export const UsageView = ({
pageSize,
filters,
isRefreshing,
sites,
tokens,
models,
onRefresh,
onPageChange,
onPageSizeChange,
@@ -67,27 +98,92 @@ export const UsageView = ({
onClear,
}: UsageViewProps) => {
const [activeErrorLog, setActiveErrorLog] = useState<UsageLog | null>(null);
const usageColumns = [
{ id: "time", label: "时间", locked: true },
{ id: "model", label: "模型" },
{ id: "channel", label: "渠道" },
{ id: "token", label: "令牌" },
{ id: "prompt_tokens", label: "输入 Tokens" },
{ id: "completion_tokens", label: "输出 Tokens" },
{ id: "latency", label: "用时 (s)" },
{ id: "first_token", label: "首 token 延迟 (s)" },
{ id: "stream", label: "流式" },
{ id: "reasoning", label: "推理强度" },
{ id: "status", label: "状态码" },
];
const [visibleColumns, setVisibleColumns] = useState(() =>
loadColumnPrefs(
"columns:usage",
usageColumns.map((column) => column.id),
),
);
const visibleColumnSet = useMemo(
() => new Set(visibleColumns),
[visibleColumns],
);
const visibleColumnCount = visibleColumns.length;
const updateVisibleColumns = (next: string[]) => {
setVisibleColumns(next);
persistColumnPrefs("columns:usage", next);
};
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize],
);
const displayPages = total === 0 ? 0 : totalPages;
const pageItems = useMemo(
() => buildPageItems(page, totalPages),
[page, totalPages],
);
const closeErrorModal = () => setActiveErrorLog(null);
const hasFilters =
filters.channel.trim() ||
filters.token.trim() ||
filters.model.trim() ||
filters.status.trim();
const handleSearchKey = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
onSearch();
filters.channel_ids.length > 0 ||
filters.token_ids.length > 0 ||
filters.models.length > 0 ||
filters.statuses.length > 0 ||
filters.from.trim() ||
filters.to.trim();
const showSkeleton = isRefreshing && usage.length === 0;
const channelOptions = useMemo(
() =>
sites.map((site) => ({
value: site.id,
label: site.name ?? site.id,
})),
[sites],
);
const tokenOptions = useMemo(
() =>
tokens.map((token) => ({
value: token.id,
label: token.name || token.id,
})),
[tokens],
);
const modelOptions = useMemo(
() =>
models.map((model) => ({
value: model.id,
label: model.id,
})),
[models],
);
const statusOptions = useMemo(() => {
const codes = new Set<string>();
for (const log of usage) {
if (log.upstream_status !== null && log.upstream_status !== undefined) {
codes.add(String(log.upstream_status));
}
}
};
for (const value of filters.statuses) {
if (/^\d+$/.test(value)) {
codes.add(value);
}
}
return Array.from(codes)
.sort((a, b) => Number(a) - Number(b))
.map((value) => ({ value, label: value }));
}, [filters.statuses, usage]);
useEffect(() => {
if (!activeErrorLog) {
@@ -106,197 +202,310 @@ export const UsageView = ({
}, [activeErrorLog]);
return (
<div class="space-y-4">
<div class="app-card animate-fade-up p-5">
<div class="space-y-5">
<div class="animate-fade-up space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="app-title text-lg">使用日志</h3>
<p class="app-subtitle">追踪每次调用的令牌与关键性能指标。</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="app-button app-focus h-9 px-4 text-xs"
<Button
class="h-9 px-4 text-xs"
size="sm"
type="button"
disabled={isRefreshing}
onClick={onRefresh}
>
{isRefreshing ? "刷新中..." : "刷新"}
</button>
</Button>
</div>
</div>
<div class="app-card app-card--compact mt-4 flex flex-wrap items-center gap-2 p-3">
<label class="app-chip app-chip--muted app-filter-group">
<span class="app-filter-label">渠道</span>
<input
class="app-filter-input"
placeholder="渠道名称"
value={filters.channel}
onInput={(event) =>
onFiltersChange({
channel: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
<Card variant="compact" class="app-layer-raised space-y-3 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs font-semibold uppercase tracking-widest text-[color:var(--app-ink-muted)]">
筛选模块
</div>
<ColumnPicker
columns={usageColumns}
value={visibleColumns}
onChange={updateVisibleColumns}
/>
</label>
<label class="app-chip app-chip--muted app-filter-group">
<span class="app-filter-label">令牌</span>
<input
class="app-filter-input"
placeholder="令牌名称"
value={filters.token}
onInput={(event) =>
onFiltersChange({
token: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<label class="app-chip app-chip--muted app-filter-group">
<span class="app-filter-label">模型</span>
<input
class="app-filter-input"
placeholder="模型名称"
value={filters.model}
onInput={(event) =>
onFiltersChange({
model: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<label class="app-chip app-chip--muted app-filter-group">
<span class="app-filter-label">状态码</span>
<input
class="app-filter-input app-filter-input--tight"
placeholder="200/503"
inputMode="numeric"
value={filters.status}
onInput={(event) =>
onFiltersChange({
status: (event.currentTarget as HTMLInputElement).value,
})
}
onKeyDown={handleSearchKey}
/>
</label>
<div class="flex flex-wrap items-center gap-2">
<button
class="app-button app-button-primary app-focus h-8 px-4 text-[11px]"
type="button"
disabled={isRefreshing}
onClick={onSearch}
>
搜索
</button>
<button
class="app-button app-button-ghost app-focus h-8 px-4 text-[11px]"
type="button"
disabled={isRefreshing || !hasFilters}
onClick={onClear}
>
清空
</button>
</div>
</div>
<div class="app-surface mt-4 overflow-hidden">
<div class="h-[420px] overflow-auto sm:h-[520px]">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="usage-from"
>
开始日期
</label>
<Input
id="usage-from"
type="date"
value={filters.from}
onInput={(event) =>
onFiltersChange({
from: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="usage-to"
>
结束日期
</label>
<Input
id="usage-to"
type="date"
value={filters.to}
onInput={(event) =>
onFiltersChange({
to: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
渠道
</label>
<MultiSelect
class="w-full"
options={channelOptions}
value={filters.channel_ids}
placeholder="选择渠道"
searchPlaceholder="搜索渠道"
emptyLabel="暂无匹配渠道"
onChange={(next) => onFiltersChange({ channel_ids: next })}
/>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
令牌
</label>
<MultiSelect
class="w-full"
options={tokenOptions}
value={filters.token_ids}
placeholder="选择令牌"
searchPlaceholder="搜索令牌"
emptyLabel="暂无匹配令牌"
onChange={(next) => onFiltersChange({ token_ids: next })}
/>
</div>
<div>
<label class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
模型
</label>
<MultiSelect
class="w-full"
options={modelOptions}
value={filters.models}
placeholder="选择模型"
searchPlaceholder="搜索模型"
emptyLabel="暂无匹配模型"
onChange={(next) => onFiltersChange({ models: next })}
/>
</div>
<div>
<label class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
状态
</label>
<MultiSelect
class="w-full"
options={statusOptions}
value={filters.statuses}
placeholder="选择状态"
searchPlaceholder="搜索状态"
emptyLabel="暂无匹配状态"
onChange={(next) => onFiltersChange({ statuses: next })}
/>
</div>
<div class="flex items-end gap-2 sm:col-span-2 lg:col-span-3">
<Button
class="h-9 px-4 text-[11px]"
size="sm"
variant="primary"
type="button"
disabled={isRefreshing}
onClick={onSearch}
>
搜索
</Button>
<Button
class="h-9 px-4 text-[11px]"
size="sm"
variant="ghost"
type="button"
disabled={isRefreshing || !hasFilters}
onClick={onClear}
>
清空
</Button>
</div>
</div>
</Card>
<div class="app-surface overflow-hidden">
<div class="h-[360px] overflow-auto sm:h-[440px]">
<table class="app-table min-w-[960px] w-full text-xs sm:text-sm">
<thead>
<tr>
<th class="sticky top-0 bg-slate-100/90">时间</th>
<th class="sticky top-0 bg-slate-100/90">模型</th>
<th class="sticky top-0 bg-slate-100/90">渠道</th>
<th class="sticky top-0 bg-slate-100/90">令牌</th>
<th class="sticky top-0 bg-slate-100/90">输入 Tokens</th>
<th class="sticky top-0 bg-slate-100/90">输出 Tokens</th>
<th class="sticky top-0 bg-slate-100/90">用时 (s)</th>
<th class="sticky top-0 bg-slate-100/90">首 token 延迟 (s)</th>
<th class="sticky top-0 bg-slate-100/90">流式</th>
<th class="sticky top-0 bg-slate-100/90">推理强度</th>
<th class="sticky top-0 bg-slate-100/90">状态码</th>
{visibleColumnSet.has("time") && (
<th class="sticky top-0 bg-slate-100/90">时间</th>
)}
{visibleColumnSet.has("model") && (
<th class="sticky top-0 bg-slate-100/90">模型</th>
)}
{visibleColumnSet.has("channel") && (
<th class="sticky top-0 bg-slate-100/90">渠道</th>
)}
{visibleColumnSet.has("token") && (
<th class="sticky top-0 bg-slate-100/90">令牌</th>
)}
{visibleColumnSet.has("prompt_tokens") && (
<th class="sticky top-0 bg-slate-100/90">输入 Tokens</th>
)}
{visibleColumnSet.has("completion_tokens") && (
<th class="sticky top-0 bg-slate-100/90">输出 Tokens</th>
)}
{visibleColumnSet.has("latency") && (
<th class="sticky top-0 bg-slate-100/90">用时 (s)</th>
)}
{visibleColumnSet.has("first_token") && (
<th class="sticky top-0 bg-slate-100/90">
<Tooltip content="首个 token 返回的等待时间。">
<span>首 token 延迟 (s)</span>
</Tooltip>
</th>
)}
{visibleColumnSet.has("stream") && (
<th class="sticky top-0 bg-slate-100/90">流式</th>
)}
{visibleColumnSet.has("reasoning") && (
<th class="sticky top-0 bg-slate-100/90">
<Tooltip content="模型推理强度等级。">
<span>推理强度</span>
</Tooltip>
</th>
)}
{visibleColumnSet.has("status") && (
<th class="sticky top-0 bg-slate-100/90">状态码</th>
)}
</tr>
</thead>
<tbody>
{usage.length === 0 ? (
{showSkeleton ? (
Array.from({ length: 6 }).map((_, rowIndex) => (
<tr key={`skeleton-${rowIndex}`}>
{Array.from({ length: visibleColumnCount }).map(
(_, cellIndex) => (
<td class="px-3 py-2.5" key={`cell-${cellIndex}`}>
<Skeleton class="h-3 w-full" />
</td>
),
)}
</tr>
))
) : usage.length === 0 ? (
<tr>
<td
class="px-3 py-10 text-center text-sm text-[color:var(--app-ink-muted)]"
colSpan={11}
colSpan={visibleColumnCount}
>
<div class="flex flex-col items-center gap-3">
<span>暂无日志,先完成一次调用吧。</span>
<button
class="app-button app-button-primary app-focus h-8 px-4 text-[11px]"
<Button
class="h-8 px-4 text-[11px]"
size="sm"
variant="primary"
type="button"
onClick={onRefresh}
disabled={isRefreshing}
>
立即刷新
</button>
</Button>
</div>
</td>
</tr>
) : (
usage.map((log) => {
const statusDetail = buildUsageStatusDetail(log);
const hasDetail = Boolean(
log.error_message || log.error_code,
);
return (
<tr key={log.id}>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatDateTime(log.created_at)}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.model ?? "-"}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.channel_name ?? log.channel_id ?? "-"}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.token_name ?? log.token_id ?? "-"}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatTokens(log.prompt_tokens)}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatTokens(log.completion_tokens)}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatSeconds(log.latency_ms)}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatSeconds(log.first_token_latency_ms)}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatStream(log.stream)}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.reasoning_effort ?? "-"}
</td>
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
<span
class={`app-chip text-[10px] ${
statusDetail.tone === "success"
? "app-chip--success"
: "app-chip--muted"
}`}
>
{statusDetail.label}
</span>
{hasDetail ? (
{visibleColumnSet.has("time") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatDateTime(log.created_at)}
</td>
)}
{visibleColumnSet.has("model") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.model ?? "-"}
</td>
)}
{visibleColumnSet.has("channel") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.channel_name ?? log.channel_id ?? "-"}
</td>
)}
{visibleColumnSet.has("token") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.token_name ?? log.token_id ?? "-"}
</td>
)}
{visibleColumnSet.has("prompt_tokens") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatTokens(log.prompt_tokens)}
</td>
)}
{visibleColumnSet.has("completion_tokens") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatTokens(log.completion_tokens)}
</td>
)}
{visibleColumnSet.has("latency") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatSeconds(log.latency_ms)}
</td>
)}
{visibleColumnSet.has("first_token") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatSeconds(log.first_token_latency_ms)}
</td>
)}
{visibleColumnSet.has("stream") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{formatStream(log.stream)}
</td>
)}
{visibleColumnSet.has("reasoning") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
{log.reasoning_effort ?? "-"}
</td>
)}
{visibleColumnSet.has("status") && (
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
<button
class="app-button app-button-ghost app-focus mt-2 h-7 px-2 text-[10px]"
class="app-focus inline-flex items-center border-0 bg-transparent p-0"
type="button"
onClick={() => setActiveErrorLog(log)}
>
查看详情
<Chip
class="text-[10px]"
variant={
log.upstream_status === 200 ? "success" : "danger"
}
>
{statusDetail.label}
</Chip>
</button>
) : null}
</td>
</td>
)}
</tr>
);
})
@@ -305,55 +514,26 @@ export const UsageView = ({
</table>
</div>
</div>
<div class="mt-4 flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-[color:var(--app-ink-muted)]">
共 {totalPages} 页 · {total} 条
共 {displayPages} 页 · {total} 条
</span>
<button
class="app-button app-focus h-8 w-8 text-xs"
type="button"
disabled={page <= 1 || isRefreshing}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span
class="px-2 text-xs text-[color:var(--app-ink-muted)]"
key={`e-${index}`}
>
...
</span>
) : (
<button
class={`app-button app-focus h-8 min-w-8 px-3 text-xs ${
item === page ? "app-button-primary" : ""
}`}
type="button"
key={item}
disabled={isRefreshing}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
<button
class="app-button app-focus h-8 w-8 text-xs"
type="button"
disabled={page >= totalPages || isRefreshing}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
>
&gt;
</button>
<Pagination
page={page}
totalPages={totalPages}
items={pageItems}
onPageChange={onPageChange}
disabled={isRefreshing}
/>
</div>
<label class="app-chip app-chip--muted flex items-center gap-2 px-3 py-1 text-xs">
每页条数
<select
class="app-input app-input--pill app-focus w-auto text-xs"
value={pageSize}
<label class="app-page-size" for="usage-page-size">
<span>每页条数</span>
<Select
variant="pill"
class="w-auto text-xs app-page-size__select"
id="usage-page-size"
value={String(pageSize)}
disabled={isRefreshing}
onChange={(event) =>
onPageSizeChange(
@@ -362,89 +542,95 @@ export const UsageView = ({
}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
<option key={size} value={String(size)}>
{size}
</option>
))}
</select>
</Select>
</label>
</div>
</div>
{activeErrorLog ? (
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={closeErrorModal}
/>
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
<div
aria-labelledby="usage-error-title"
aria-modal="true"
class="app-card w-full max-w-2xl p-6"
role="dialog"
>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="app-title text-lg" id="usage-error-title">
错误详情
</h3>
<p class="mt-2 text-xs text-[color:var(--app-ink-muted)]">
状态码 {activeErrorLog.upstream_status ?? "-"}
<Dialog open={Boolean(activeErrorLog)} onClose={closeErrorModal}>
<DialogContent
aria-labelledby="usage-error-title"
aria-modal="true"
class="max-w-2xl"
>
<DialogHeader>
<div>
<DialogTitle id="usage-error-title">错误详情</DialogTitle>
<DialogDescription>
状态码{" "}
{activeErrorLog.upstream_status !== null &&
activeErrorLog.upstream_status !== undefined
? activeErrorLog.upstream_status
: "未知"}
</DialogDescription>
{activeErrorLog.error_code ? (
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
错误码: {activeErrorLog.error_code}
</p>
{activeErrorLog.error_code ? (
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
错误码: {activeErrorLog.error_code}
</p>
) : null}
</div>
<button
class="app-button app-focus h-8 px-3 text-xs"
type="button"
onClick={closeErrorModal}
>
关闭
</button>
) : null}
</div>
<div class="app-card app-card--compact mt-4 text-xs text-[color:var(--app-ink)]">
<div class="grid gap-2">
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">时间</span>
<span>{formatDateTime(activeErrorLog.created_at)}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">模型</span>
<span>{activeErrorLog.model ?? "-"}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">渠道</span>
<span>
{activeErrorLog.channel_name ??
activeErrorLog.channel_id ??
"-"}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">令牌</span>
<span>
{activeErrorLog.token_name ??
activeErrorLog.token_id ??
"-"}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">耗时</span>
<span>{formatSeconds(activeErrorLog.latency_ms)}</span>
</div>
<Button size="sm" type="button" onClick={closeErrorModal}>
关闭
</Button>
</DialogHeader>
<Card
variant="compact"
class="mt-4 text-xs text-[color:var(--app-ink)]"
>
<div class="grid gap-2">
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">时间</span>
<span>{formatDateTime(activeErrorLog.created_at)}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">模型</span>
<span>{activeErrorLog.model ?? "-"}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">渠道</span>
<span>
{activeErrorLog.channel_name ??
activeErrorLog.channel_id ??
"-"}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">令牌</span>
<span>
{activeErrorLog.token_name ??
activeErrorLog.token_id ??
"-"}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-[color:var(--app-ink-muted)]">耗时</span>
<span>{formatSeconds(activeErrorLog.latency_ms)}</span>
</div>
<p class="mt-3 text-[11px] text-[color:var(--app-ink-muted)]">
错误片段已省略,请结合状态码与错误码排查。
</p>
</div>
</div>
</div>
</div>
</Card>
{activeErrorLog.error_message ? (
<Card
variant="compact"
class="mt-3 text-xs text-[color:var(--app-ink)]"
>
<div class="text-[11px] font-semibold uppercase tracking-widest text-[color:var(--app-ink-muted)]">
错误摘要
</div>
<pre class="mt-2 h-40 overflow-auto whitespace-pre-wrap break-words text-[color:var(--app-ink)]">
{activeErrorLog.error_message}
</pre>
</Card>
) : (
<p class="mt-3 text-[11px] text-[color:var(--app-ink-muted)]">
暂无错误摘要,请结合状态码与错误码排查。
</p>
)}
</DialogContent>
</Dialog>
) : null}
</div>
);
+410 -50
View File
@@ -1,25 +1,23 @@
@import "tailwindcss";
:root {
color-scheme: light;
--app-bg: #f4f5f7;
--app-surface: rgba(255, 255, 255, 0.78);
--app-bg: #f5f7fb;
--app-surface: rgba(255, 255, 255, 0.88);
--app-surface-strong: #ffffff;
--app-ink: #1d1d1f;
--app-ink-muted: #6e6e73;
--app-border: rgba(210, 210, 215, 0.7);
--app-ink: #0f172a;
--app-ink-muted: #55627a;
--app-border: rgba(148, 163, 184, 0.45);
--app-accent: #0a84ff;
--app-accent-strong: #0071e3;
--app-accent-soft: rgba(10, 132, 255, 0.16);
--app-success: #34c759;
--app-warning: #ff9f0a;
--app-danger: #ff453a;
--app-info: #0a84ff;
--app-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
--app-shadow: 0 20px 48px rgba(15, 23, 42, 0.12);
--app-radius: 22px;
--app-accent-strong: #0066ff;
--app-accent-soft: rgba(10, 132, 255, 0.18);
--app-success: #22c55e;
--app-warning: #f59e0b;
--app-danger: #ef4444;
--app-info: #38bdf8;
--app-shadow-soft: 0 16px 40px rgba(15, 23, 42, 0.08);
--app-shadow: 0 30px 70px rgba(15, 23, 42, 0.16);
--app-radius: 20px;
--app-radius-sm: 14px;
--app-radius-xs: 10px;
--app-gap: 20px;
--app-blur: 18px;
}
@@ -47,15 +45,11 @@ body {
background:
radial-gradient(
circle at 12% 12%,
rgba(255, 255, 255, 0.9),
transparent 45%
rgba(10, 132, 255, 0.18),
transparent 42%
),
radial-gradient(
circle at 88% 8%,
rgba(180, 190, 210, 0.25),
transparent 40%
),
linear-gradient(180deg, #f7f8fa 0%, #f4f5f7 50%, #eef0f4 100%);
radial-gradient(circle at 88% 8%, rgba(56, 189, 248, 0.14), transparent 44%),
linear-gradient(180deg, #f8fafc 0%, #f1f5fb 55%, #eef2f8 100%);
}
.app-background::after {
@@ -117,7 +111,7 @@ body {
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(29, 29, 31, 0.06);
background: rgba(12, 10, 9, 0.06);
color: var(--app-ink-muted);
}
@@ -127,12 +121,17 @@ body {
}
.app-chip--success {
background: rgba(52, 199, 89, 0.14);
color: #1f7a3a;
background: rgba(22, 163, 74, 0.14);
color: #166534;
}
.app-chip--danger {
background: rgba(239, 68, 68, 0.16);
color: #991b1b;
}
.app-chip--muted {
background: rgba(29, 29, 31, 0.08);
background: rgba(12, 10, 9, 0.08);
color: var(--app-ink-muted);
}
@@ -166,7 +165,7 @@ body {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.app-grid--split {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
}
}
@@ -175,19 +174,19 @@ body {
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
box-shadow: var(--app-shadow-soft);
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
backdrop-filter: blur(var(--app-blur)) saturate(1.1);
}
.app-bar {
background: var(--app-surface);
border-bottom: 1px solid var(--app-border);
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
backdrop-filter: blur(var(--app-blur)) saturate(1.1);
}
.app-sidebar {
background: var(--app-surface);
border-right: 1px solid var(--app-border);
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
backdrop-filter: blur(var(--app-blur)) saturate(1.1);
}
.app-card {
@@ -196,7 +195,7 @@ body {
border-radius: var(--app-radius);
padding: 20px;
box-shadow: var(--app-shadow-soft);
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
backdrop-filter: blur(var(--app-blur)) saturate(1.1);
transition:
transform 180ms ease-out,
box-shadow 180ms ease-out;
@@ -234,7 +233,7 @@ body {
font-size: 12px;
font-weight: 600;
color: var(--app-ink);
background: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.86);
cursor: pointer;
transition:
transform 180ms ease-out,
@@ -246,7 +245,7 @@ body {
.app-button:hover {
transform: translateY(-1px);
box-shadow: var(--app-shadow-soft);
border-color: rgba(10, 132, 255, 0.35);
border-color: rgba(10, 132, 255, 0.4);
}
.app-button:disabled {
@@ -258,7 +257,7 @@ body {
.app-button-primary {
background: var(--app-accent);
color: #fff;
color: #ffffff;
border-color: transparent;
}
@@ -273,7 +272,7 @@ body {
}
.app-button-danger:hover {
background: #ff3b30;
background: #b91c1c;
}
.app-button-ghost {
@@ -284,7 +283,7 @@ body {
width: 100%;
border-radius: var(--app-radius-sm);
border: 1px solid var(--app-border);
background: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.86);
padding: 10px 12px;
font-size: 14px;
color: var(--app-ink);
@@ -299,6 +298,72 @@ body {
padding: 6px 10px;
}
.app-layer-raised {
position: relative;
z-index: 200;
}
.app-multi-select-root {
position: relative;
width: 100%;
}
.app-multi-select-root--open {
z-index: 240;
}
.app-multi-select {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
border-radius: var(--app-radius-sm);
border: 1px solid var(--app-border);
background: rgba(255, 255, 255, 0.86);
padding: 8px 10px;
font-size: 12px;
color: var(--app-ink);
cursor: pointer;
transition:
border-color 180ms ease-out,
box-shadow 180ms ease-out,
background-color 180ms ease-out;
}
.app-multi-select--open {
border-color: var(--app-accent);
box-shadow: 0 0 0 3px var(--app-accent-soft);
}
.app-multi-select__value {
flex: 1;
text-align: left;
}
.app-multi-select__placeholder {
color: var(--app-ink-muted);
}
.app-multi-select__count {
min-width: 20px;
height: 20px;
border-radius: 999px;
background: var(--app-accent-soft);
color: var(--app-accent-strong);
font-size: 11px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
}
.app-multi-select__chevron {
font-size: 12px;
color: var(--app-ink-muted);
}
.app-input:focus {
border-color: var(--app-accent);
box-shadow: 0 0 0 3px var(--app-accent-soft);
@@ -328,6 +393,83 @@ body {
color: var(--app-ink-muted);
}
.app-search {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
border: 1px solid var(--app-border);
background: rgba(255, 255, 255, 0.9);
padding: 6px 12px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.app-search__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--app-ink-muted);
}
.app-search__icon svg {
width: 18px;
height: 18px;
}
.app-search__input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
color: var(--app-ink);
outline: none;
}
.app-search__input::placeholder {
color: var(--app-ink-muted);
}
.app-page-size {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
border: 1px solid var(--app-border);
padding: 6px 10px;
font-size: 11px;
color: var(--app-ink-muted);
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.app-page-size span {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 10px;
}
.app-page-size__select {
border-color: transparent;
background: rgba(255, 255, 255, 0.95);
padding: 4px 28px 4px 10px;
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.08);
height: 30px;
min-width: 86px;
text-align: right;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--app-ink-muted) 50%),
linear-gradient(135deg, var(--app-ink-muted) 50%, transparent 50%);
background-position:
calc(100% - 14px) 52%,
calc(100% - 9px) 52%;
background-size: 5px 5px;
background-repeat: no-repeat;
}
.app-table {
width: 100%;
border-collapse: collapse;
@@ -349,7 +491,7 @@ body {
}
.app-table tr:hover {
background: rgba(10, 132, 255, 0.05);
background: rgba(10, 132, 255, 0.08);
}
.app-focus:focus-visible {
@@ -378,7 +520,7 @@ body {
.app-nav-button--active {
color: var(--app-ink);
background: rgba(10, 132, 255, 0.12);
background: rgba(10, 132, 255, 0.14);
}
.app-badge {
@@ -386,7 +528,17 @@ body {
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
background: rgba(29, 29, 31, 0.08);
background: rgba(12, 10, 9, 0.08);
color: var(--app-ink-muted);
}
.app-badge--accent {
background: var(--app-accent-soft);
color: var(--app-accent-strong);
}
.app-badge--muted {
background: rgba(12, 10, 9, 0.06);
color: var(--app-ink-muted);
}
@@ -399,26 +551,26 @@ body {
.app-notice--success {
border-color: rgba(52, 199, 89, 0.3);
background: rgba(52, 199, 89, 0.12);
color: #1f7a3a;
background: rgba(22, 163, 74, 0.12);
color: #166534;
}
.app-notice--warning {
border-color: rgba(255, 159, 10, 0.3);
background: rgba(255, 159, 10, 0.12);
color: #8a5200;
background: rgba(245, 158, 11, 0.12);
color: #92400e;
}
.app-notice--error {
border-color: rgba(255, 69, 58, 0.3);
background: rgba(255, 69, 58, 0.12);
color: #8a1d1a;
border-color: rgba(220, 38, 38, 0.3);
background: rgba(220, 38, 38, 0.12);
color: #991b1b;
}
.app-notice--info {
border-color: rgba(10, 132, 255, 0.3);
background: rgba(10, 132, 255, 0.12);
color: #0b4aa6;
background: rgba(56, 189, 248, 0.14);
color: #0c4a6e;
}
.app-toast {
@@ -451,6 +603,214 @@ body {
animation: toastProgress var(--toast-duration, 4500ms) linear forwards;
}
.app-switch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
width: 44px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--app-border);
background: rgba(148, 163, 184, 0.3);
padding: 2px;
transition:
background-color 180ms ease-out,
border-color 180ms ease-out;
}
.app-switch__thumb {
width: 18px;
height: 18px;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.15);
transition: transform 180ms ease-out;
}
.app-switch--checked {
background: var(--app-accent);
border-color: transparent;
}
.app-switch--checked .app-switch__thumb {
transform: translateX(20px);
}
.app-skeleton {
position: relative;
overflow: hidden;
border-radius: var(--app-radius-sm);
background: rgba(148, 163, 184, 0.18);
}
.app-skeleton::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6),
transparent
);
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.app-tooltip {
position: relative;
display: inline-flex;
align-items: center;
}
.app-tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
opacity: 0;
pointer-events: none;
white-space: nowrap;
background: rgba(15, 23, 42, 0.9);
color: #ffffff;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
transition:
opacity 180ms ease-out,
transform 180ms ease-out;
}
.app-tooltip:hover::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.app-popover,
.app-dropdown {
position: relative;
display: inline-flex;
}
.app-popover-content,
.app-dropdown-content {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 240;
min-width: 220px;
border-radius: var(--app-radius-sm);
border: 1px solid var(--app-border);
background: var(--app-surface-strong);
box-shadow: var(--app-shadow-soft);
padding: 8px;
}
.app-popover-content--full {
left: 0;
right: auto;
width: 100%;
min-width: 100%;
max-width: 100%;
}
.app-popover-content--tight {
min-width: 0;
width: auto;
}
.app-popover-content--spaced {
top: calc(100% + 14px);
}
.app-interval-popover {
min-width: 136px;
width: 136px;
max-width: 136px;
}
.app-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
font-size: 12px;
color: var(--app-ink);
transition: background-color 160ms ease-out;
}
.app-dropdown-item:hover {
background: rgba(10, 132, 255, 0.08);
}
.app-dropdown-item--active {
background: var(--app-accent-soft);
color: var(--app-accent-strong);
}
.app-dropdown-item--stack {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.app-dropdown-item--right {
justify-content: flex-end;
text-align: right;
width: 100%;
}
.app-multi-option {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
font-size: 12px;
color: var(--app-ink);
transition: background-color 160ms ease-out;
}
.app-multi-option:hover {
background: rgba(10, 132, 255, 0.08);
}
.app-multi-option--selected {
background: rgba(10, 132, 255, 0.12);
}
.app-multi-option__check {
font-size: 12px;
color: var(--app-accent-strong);
}
.app-dropdown-separator {
height: 1px;
margin: 6px 0;
background: rgba(148, 163, 184, 0.35);
}
.app-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-up {
animation: none;
+42 -6
View File
@@ -6,14 +6,36 @@ const dashboard = new Hono<AppEnv>();
function buildDateFilters(query: Record<string, string>) {
let sql = " WHERE 1=1";
const params: Array<string> = [];
const channelIds = (query.channel_ids ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const tokenIds = (query.token_ids ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (query.from) {
sql += " AND created_at >= ?";
sql += " AND usage_logs.created_at >= ?";
params.push(query.from);
}
if (query.to) {
sql += " AND created_at <= ?";
sql += " AND usage_logs.created_at <= ?";
params.push(query.to);
}
if (query.model) {
sql += " AND usage_logs.model LIKE ? COLLATE NOCASE";
params.push(`%${query.model}%`);
}
if (channelIds.length > 0) {
sql += ` AND usage_logs.channel_id IN (${channelIds
.map(() => "?")
.join(",")})`;
params.push(...channelIds);
}
if (tokenIds.length > 0) {
sql += ` AND usage_logs.token_id IN (${tokenIds.map(() => "?").join(",")})`;
params.push(...tokenIds);
}
return { sql, params };
}
@@ -22,7 +44,20 @@ function buildDateFilters(query: Record<string, string>) {
*/
dashboard.get("/", async (c) => {
const query = c.req.query();
const interval =
query.interval === "week" || query.interval === "month"
? query.interval
: "day";
const rawLimit = Number(query.limit ?? 30);
const normalizedLimit = Number.isNaN(rawLimit) ? 30 : Math.floor(rawLimit);
const limit = Math.min(Math.max(normalizedLimit, 1), 366);
const { sql, params } = buildDateFilters(query);
const bucketExpression =
interval === "week"
? "strftime('%Y-W%W', created_at)"
: interval === "month"
? "substr(created_at, 1, 7)"
: "substr(created_at, 1, 10)";
const summary = await c.env.DB.prepare(
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`,
@@ -30,10 +65,10 @@ dashboard.get("/", async (c) => {
.bind(...params)
.first();
const byDay = await c.env.DB.prepare(
`SELECT substr(created_at, 1, 10) as day, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY day ORDER BY day DESC LIMIT 30`,
const trend = await c.env.DB.prepare(
`SELECT ${bucketExpression} as bucket, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY bucket ORDER BY bucket ASC LIMIT ?`,
)
.bind(...params)
.bind(...params, limit)
.all();
const byModel = await c.env.DB.prepare(
@@ -61,7 +96,8 @@ dashboard.get("/", async (c) => {
avg_latency: 0,
total_errors: 0,
},
byDay: byDay.results ?? [],
trend: trend.results ?? [],
interval,
byModel: byModel.results ?? [],
byChannel: byChannel.results ?? [],
byToken: byToken.results ?? [],
+13 -1
View File
@@ -11,7 +11,7 @@ import {
listChannels,
updateChannel,
} from "../services/channel-repo";
import { runCheckinAll } from "../services/checkin-runner";
import { runCheckinAll, runCheckinSingle } from "../services/checkin-runner";
import {
buildSiteMetadata,
parseSiteMetadata,
@@ -432,4 +432,16 @@ sites.post("/checkin-all", async (c) => {
});
});
sites.post("/:id/checkin", async (c) => {
const id = c.req.param("id");
const result = await runCheckinSingle(c.env.DB, id, new Date());
if (!result) {
return jsonError(c, 404, "site_not_found", "site_not_found");
}
return c.json({
result: result.result,
runs_at: result.runsAt,
});
});
export default sites;
+61
View File
@@ -36,6 +36,67 @@ usage.get("/", async (c) => {
filters.push("usage_logs.token_id = ?");
params.push(query.token_id);
}
if (query.channel_ids) {
const channelIds = String(query.channel_ids)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (channelIds.length > 0) {
const placeholders = channelIds.map(() => "?").join(", ");
filters.push(`usage_logs.channel_id IN (${placeholders})`);
params.push(...channelIds);
}
}
if (query.token_ids) {
const tokenIds = String(query.token_ids)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (tokenIds.length > 0) {
const placeholders = tokenIds.map(() => "?").join(", ");
filters.push(`usage_logs.token_id IN (${placeholders})`);
params.push(...tokenIds);
}
}
if (query.models) {
const models = String(query.models)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (models.length > 0) {
const placeholders = models.map(() => "?").join(", ");
filters.push(`usage_logs.model IN (${placeholders})`);
params.push(...models);
}
}
if (query.statuses) {
const statuses = String(query.statuses)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (statuses.length > 0) {
const numericStatuses = statuses
.map((item) => Number(item))
.filter((value) => !Number.isNaN(value));
const textStatuses = statuses.filter((item) =>
Number.isNaN(Number(item)),
);
const statusFilters: string[] = [];
if (numericStatuses.length > 0) {
const placeholders = numericStatuses.map(() => "?").join(", ");
statusFilters.push(`usage_logs.upstream_status IN (${placeholders})`);
params.push(...numericStatuses);
}
if (textStatuses.length > 0) {
const placeholders = textStatuses.map(() => "?").join(", ");
statusFilters.push(`usage_logs.status IN (${placeholders})`);
params.push(...textStatuses);
}
if (statusFilters.length > 0) {
filters.push(`(${statusFilters.join(" OR ")})`);
}
}
}
if (query.channel) {
const channel = String(query.channel).trim();
if (channel) {
+52 -1
View File
@@ -1,6 +1,10 @@
import type { D1Database } from "@cloudflare/workers-types";
import { beijingDateString } from "../utils/time";
import { listChannels, updateChannelCheckinResult } from "./channel-repo";
import {
getChannelById,
listChannels,
updateChannelCheckinResult,
} from "./channel-repo";
import {
type CheckinResultItem,
type CheckinSummary,
@@ -67,3 +71,50 @@ export async function runCheckinAll(
runsAt: now.toISOString(),
};
}
export async function runCheckinSingle(
db: D1Database,
channelId: string,
now: Date = new Date(),
): Promise<{ result: CheckinResultItem; runsAt: string } | null> {
const channel = await getChannelById(db, channelId);
if (!channel) {
return null;
}
const today = beijingDateString(now);
const alreadyChecked =
channel.last_checkin_date === today &&
(channel.last_checkin_status === "success" ||
channel.last_checkin_status === "skipped");
if (alreadyChecked) {
return {
result: {
id: channel.id,
name: channel.name,
status: "skipped",
message: channel.last_checkin_message ?? "今日已签到",
checkin_date: channel.last_checkin_date ?? today,
},
runsAt: now.toISOString(),
};
}
const result = await runCheckin({
id: channel.id,
name: channel.name,
base_url: String(channel.base_url),
checkin_url: channel.checkin_url ?? null,
system_token: channel.system_token ?? null,
system_userid: channel.system_userid ?? null,
});
const checkinDate = result.checkin_date ?? today;
await updateChannelCheckinResult(db, channel.id, {
last_checkin_date: checkinDate,
last_checkin_status: result.status,
last_checkin_message: result.message,
last_checkin_at: now.toISOString(),
});
return {
result: { ...result, checkin_date: checkinDate },
runsAt: now.toISOString(),
};
}
+3 -5
View File
@@ -16,13 +16,12 @@ const buildLog = (partial: Partial<{ status: string; upstream_status: number | n
});
describe("usage status detail", () => {
it("renders success label", () => {
it("renders unknown when no status code", () => {
const detail = buildUsageStatusDetail(buildLog({ status: "ok" }) as any);
expect(detail.label).toBe("-");
expect(detail.tone).toBe("success");
expect(detail.label).toBe("未知");
});
it("renders failure with status code", () => {
it("renders status code only", () => {
const detail = buildUsageStatusDetail(
buildLog({
status: "error",
@@ -32,6 +31,5 @@ describe("usage status detail", () => {
}) as any,
);
expect(detail.label).toBe("403");
expect(detail.tone).toBe("error");
});
});