fix:优化ui
This commit is contained in:
1 parent
7220682a60
commit
af87b09a48
44 files changed
+5103
-1993
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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` 模块
|
||||
|
||||
@@ -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` 表
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 仅聚合启用状态的渠道
|
||||
- 使用“模型广场测试结果”作为可用模型来源
|
||||
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
|
||||
- 管理台模型广场支持搜索、渠道筛选与分页展示
|
||||
|
||||
## 依赖关系
|
||||
- `channels` 表
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)} />
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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))}
|
||||
>
|
||||
<
|
||||
</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))}
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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)} />
|
||||
);
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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)} />
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)} />;
|
||||
@@ -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)} />;
|
||||
@@ -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)} />
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export const cx = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(" ");
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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="关闭导航"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+1011
-920
File diff suppressed because it is too large.
Load diff
+382
-345
@@ -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))}
|
||||
>
|
||||
<
|
||||
</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))
|
||||
}
|
||||
>
|
||||
>
|
||||
</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
@@ -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))}
|
||||
>
|
||||
<
|
||||
</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))}
|
||||
>
|
||||
>
|
||||
</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
@@ -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;
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user