feat: 升级管理台与路由能力
- 管理台体验升级与日志分页/搜索/状态码展示 - 令牌过期与允许渠道控制,站点新增/修改自动测试 - 调用令牌按模型匹配选择,移除模型 TTL 并优化冷却逻辑 - 新增迁移与文档同步
This commit is contained in:
1 parent
8af53f34f0
commit
f6f98491b3
41 files changed
+2476
-578
No files matched your search
@@ -29,6 +29,9 @@
|
||||
- **[usage/proxy]**: 使用日志记录上游失败详情并在日志状态列展示
|
||||
- 方案: [202603141815_usage-log-status](archive/2026-03/202603141815_usage-log-status/)
|
||||
- 文件: apps/worker/migrations/0005_add_usage_error_fields.sql, apps/worker/src/db/schema.sql, apps/worker/src/services/usage.ts, apps/worker/src/routes/proxy.ts, apps/ui/src/core/types.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
|
||||
- **[admin-ui/usage]**: 状态列改为“状态码 + 错误码”,成功展示 `200 OK`
|
||||
- 方案: [202603141925_usage-status-label](archive/2026-03/202603141925_usage-status-label/)
|
||||
- 文件: apps/worker/src/routes/proxy.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
|
||||
- **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总)
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
|
||||
@@ -168,6 +171,62 @@
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/routes/proxy.ts
|
||||
|
||||
### 快速修改
|
||||
|
||||
- **[admin-ui]**: 修复模型广场渲染语法错误 — by lsy
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: apps/ui/src/features/ModelsView.tsx:16
|
||||
- **[admin-ui]**: 站点搜索加入防抖与使用日志状态码/摘要展示优化 — by lsy
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: apps/ui/src/features/SitesView.tsx, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx
|
||||
- **[admin-ui]**: 提交操作增加即时防重复锁,避免连点重复创建 — by lsy
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: apps/ui/src/App.tsx
|
||||
|
||||
## [0.9.4] - 2026-03-15
|
||||
|
||||
### 变更
|
||||
- **[usage/admin-ui]**: 使用日志改为服务端分页并支持渠道/令牌/模型搜索 — by lsy
|
||||
- 方案: [202603150145_usage-log-pagination-and-channel-disable](archive/2026-03/202603150145_usage-log-pagination-and-channel-disable/)
|
||||
- 决策: usage-log-pagination-and-channel-disable#D001(使用 offset+limit+total 分页协议)
|
||||
- **[channels]**: 连通性测试不再覆盖已禁用渠道状态 — by lsy
|
||||
- 方案: [202603150145_usage-log-pagination-and-channel-disable](archive/2026-03/202603150145_usage-log-pagination-and-channel-disable/)
|
||||
|
||||
## [0.9.2] - 2026-03-14
|
||||
|
||||
### 变更
|
||||
- **[usage/proxy]**: 状态列仅展示日志状态码并支持错误详情查看,冷却改为窗口内连续失败触发 — by lsy
|
||||
- 方案: [202603142337_usage-status-cooldown-tuning](archive/2026-03/202603142337_usage-status-cooldown-tuning/)
|
||||
- 决策: usage-status-cooldown-tuning#D001(连续失败阈值冷却)
|
||||
|
||||
## [0.9.1] - 2026-03-14
|
||||
|
||||
### 变更
|
||||
- **[proxy/models/settings/admin-ui]**: 移除模型能力 TTL,路由不再兜底并支持失败冷却配置 — by lsy
|
||||
- 方案: [202603142241_remove-model-ttl-fix-routing](archive/2026-03/202603142241_remove-model-ttl-fix-routing/)
|
||||
- 决策: remove-model-ttl-fix-routing#D001(移除模型能力 TTL)
|
||||
|
||||
## [0.9.2] - 2026-03-15
|
||||
|
||||
### 变更
|
||||
- **[proxy/usage/admin-ui]**: 冷却仅对上游失败触发,使用日志列表仅展示状态码 — by lsy
|
||||
- 方案: [202603150017_usage-status-cooldown-migration](archive/2026-03/202603150017_usage-status-cooldown-migration/)
|
||||
- 决策: usage-status-cooldown-migration#D001(冷却仅针对上游/网络失败)
|
||||
|
||||
## [0.9.3] - 2026-03-15
|
||||
|
||||
### 变更
|
||||
- **[proxy/usage]**: 所有调用写入日志,失败计数仅在成功时清零 — by lsy
|
||||
- 方案: [202603150017_usage-status-cooldown-migration](archive/2026-03/202603150017_usage-status-cooldown-migration/)
|
||||
- 决策: usage-status-cooldown-migration#D001(冷却仅针对上游/网络失败)
|
||||
|
||||
## [0.9.0] - 2026-03-14
|
||||
|
||||
### 新增
|
||||
- **[admin-ui]**: 管理台体验升级(通知分级、确认弹窗、空状态 CTA、视觉主题与动效) — by lsy
|
||||
- 方案: [202603142024_admin-ui-ux-upgrade](archive/2026-03/202603142024_admin-ui-ux-upgrade/)
|
||||
- 决策: admin-ui-ux-upgrade#D001(统一通知与确认对话管理)
|
||||
|
||||
## [0.8.4] - 2026-02-25
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -24,16 +24,25 @@
|
||||
- openai/Anthropic/gemini 站点支持 base_url 为空时自动填充官方地址
|
||||
- done-hub 站点仅使用 base_url,不再维护多地址输入
|
||||
- 站点连通测试提示令牌成功/失败统计
|
||||
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
||||
- 使用日志状态列展示详细失败原因(状态码/错误码/摘要)
|
||||
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
|
||||
- 站点新增/修改完成后自动触发连通测试并提示结果
|
||||
- 使用日志支持服务端分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
||||
- 使用日志支持按渠道/令牌/模型/状态码搜索并与分页联动
|
||||
- 使用日志状态列仅展示状态码,错误详情弹窗仅展示元信息(不展示错误片段)
|
||||
- 令牌管理采用列表视图(默认每页 10 条),生成/编辑使用弹窗表单,支持过期时间(北京时间)与允许渠道(默认全开)并默认复制,列表展示创建时间、前缀、过期时间与渠道限制摘要
|
||||
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
||||
- 系统设置增加定时签到开关与时间(中国时间)
|
||||
- 系统设置提供模型失败冷却分钟数配置
|
||||
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
|
||||
- Token 仅本地保存,不回显
|
||||
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
|
||||
- 渠道/令牌列表在移动端以卡片形式展示,操作区改为网格按钮排列
|
||||
- 数据面板、模型广场、使用日志表格在移动端支持横向滚动与字号收紧
|
||||
- 全局通知支持成功/提示/错误/信息分级展示,可关闭并自动消退
|
||||
- 删除类操作必须经过确认弹窗
|
||||
- 主要异步操作提供 loading 与按钮禁用态
|
||||
- 弹窗支持遮罩/ESC 关闭并自动聚焦首字段
|
||||
- 空状态提供清晰 CTA 引导下一步
|
||||
- 全局字体通过外部字体源加载,背景包含暖调渐变与轻纹理
|
||||
|
||||
## 依赖关系
|
||||
- `auth` / `channels` / `models` / `tokens` / `usage` / `dashboard` / `settings` 模块
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- 创建渠道时可传入自定义 `id`,未提供则自动生成
|
||||
- 连通性测试会对调用令牌逐个调用 `/v1/models` 并汇总模型
|
||||
- 至少一枚令牌测试成功时更新 `models_json` 并返回成功/失败统计
|
||||
- 连通性测试不会覆盖已禁用渠道的状态(保持 `disabled`)
|
||||
- New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json`
|
||||
- New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新
|
||||
- New API 分组列表从 `group_name` 字段解析,空时返回 `default`
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
## 行为规范
|
||||
- 仅聚合启用状态的渠道
|
||||
- 使用“模型广场测试结果”作为可用模型来源
|
||||
- 模型结果带 TTL(默认 2 小时,来自 settings),过期后回退到渠道声明模型列表(避免模型广场清空)
|
||||
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
## 行为规范
|
||||
- 基于令牌鉴权
|
||||
- 按渠道权重随机排序
|
||||
- 调用令牌按顺序选择,未配置时回退 `channels.api_key`
|
||||
- 调用令牌按模型列表匹配选择,未配置或无列表时回退 `channels.api_key`
|
||||
- 调用令牌模型列表来自渠道连通测试写回
|
||||
- done-hub 仅使用 `base_url`,不再按多地址切换
|
||||
- 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段
|
||||
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
|
||||
@@ -26,8 +27,12 @@
|
||||
- 允许 `metadata_json.endpoint_overrides` 覆盖 chat/embedding/image 上游地址(可用 `{model}` 占位)
|
||||
- 允许 `metadata_json.header_override` 与 `metadata_json.query_override` 注入额外上游请求头/查询参数
|
||||
- Anthropic 默认注入 `anthropic-version=2023-06-01`,可通过 `header_override` 覆盖
|
||||
- 路由时优先使用模型能力表(TTL 内测试成功的模型),无能力记录时回退渠道声明模型列表
|
||||
- 上游失败/超时会记录到能力表并进入冷却期,冷却期内跳过对应渠道/模型
|
||||
- 路由时优先使用模型能力表记录(测试成功的模型),无能力记录时回退渠道声明模型列表
|
||||
- 当模型在任意渠道中都不被支持时返回 `no_available_channels`,不回退到全部渠道
|
||||
- 上游失败/超时会记录到能力表并进入冷却期(需在窗口内连续失败达到阈值),冷却期内跳过对应渠道/模型
|
||||
- 失败计数仅在成功响应时清零
|
||||
- 仅 429/5xx/408/timeout/exception 计入冷却,4xx 请求错误不触发冷却
|
||||
- 映射模型场景失败时同时记录下游模型错误,确保冷却窗口生效
|
||||
- 上游成功响应会刷新能力表,清空错误并延长可用窗口
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
- 维护系统级配置(如日志保留天数)
|
||||
- 为使用日志与面板提供统一配置读取
|
||||
- 维护定时签到执行时间
|
||||
- 维护模型能力 TTL(模型广场与路由过滤窗口)
|
||||
- 维护模型失败冷却时间(运行时失败的渠道/模型冷却窗口)
|
||||
|
||||
## 接口定义
|
||||
@@ -17,7 +16,6 @@
|
||||
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
|
||||
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
|
||||
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
|
||||
- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期
|
||||
- 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -15,8 +15,13 @@
|
||||
## 行为规范
|
||||
- 令牌可通过 reveal 接口再次查看
|
||||
- 默认允许所有渠道
|
||||
- 可选限制允许的渠道列表(`allowed_channels`),为空或未设置表示全开
|
||||
- 额度消耗由代理调用记录
|
||||
- 管理台列表展示创建时间与 key_prefix 前缀
|
||||
- `expires_at` 为可选字段,留空表示永不过期
|
||||
- 管理台以北京时间输入过期时间,服务端统一转为 UTC 存储
|
||||
- 令牌过期后直接拒绝请求(`token_expired`),不自动更改状态
|
||||
- 管理台支持编辑名称/额度/状态/过期时间/允许渠道,新增令牌默认复制到剪贴板
|
||||
- 管理台列表展示创建时间与 key_prefix 前缀、过期时间
|
||||
|
||||
## 依赖关系
|
||||
- `tokens` 表
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
- `GET /api/usage` 使用日志查询
|
||||
|
||||
## 行为规范
|
||||
- 代理调用统一写入 `usage_logs`
|
||||
- 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回)
|
||||
- 保留天数可配置,查询时触发清理
|
||||
- 查询结果附带渠道与令牌名称
|
||||
- `GET /api/usage` 支持 `offset/limit` 分页并返回 `total`
|
||||
- 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤)
|
||||
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
|
||||
- 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段
|
||||
- usage_logs 记录上游失败详情(status/code/message),用于日志状态展示
|
||||
- usage_logs 记录上游失败详情(status/code/message),日志列表仅展示状态码,详情弹窗仅展示元信息(不展示错误片段)
|
||||
|
||||
## 依赖关系
|
||||
- `usage_logs` 表
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="theme-color" content="#1C1917" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>api-workers</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+873
-217
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,10 @@
|
||||
import type { AdminData, SettingsForm, SiteForm, TabItem } from "./types";
|
||||
import type {
|
||||
AdminData,
|
||||
SettingsForm,
|
||||
SiteForm,
|
||||
TabItem,
|
||||
TokenForm,
|
||||
} from "./types";
|
||||
|
||||
export const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
@@ -43,4 +49,13 @@ export const initialSettingsForm: SettingsForm = {
|
||||
session_ttl_hours: "12",
|
||||
admin_password: "",
|
||||
checkin_schedule_time: "00:10",
|
||||
model_failure_cooldown_minutes: "10",
|
||||
};
|
||||
|
||||
export const initialTokenForm: TokenForm = {
|
||||
name: "",
|
||||
quota_total: "",
|
||||
status: "active",
|
||||
expires_at: "",
|
||||
allowed_channels: [],
|
||||
};
|
||||
@@ -41,6 +41,8 @@ export type Token = {
|
||||
quota_total: number | null;
|
||||
quota_used: number;
|
||||
status: string;
|
||||
allowed_channels?: string[] | null;
|
||||
expires_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
@@ -66,6 +68,20 @@ export type UsageLog = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type UsageQuery = {
|
||||
channel: string;
|
||||
token: string;
|
||||
model: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UsageResponse = {
|
||||
logs: UsageLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type DashboardData = {
|
||||
summary: {
|
||||
total_requests: number;
|
||||
@@ -84,6 +100,7 @@ export type Settings = {
|
||||
session_ttl_hours: number;
|
||||
admin_password_set?: boolean;
|
||||
checkin_schedule_time?: string;
|
||||
model_failure_cooldown_minutes?: number;
|
||||
};
|
||||
|
||||
export type ModelChannel = {
|
||||
@@ -142,6 +159,15 @@ export type SettingsForm = {
|
||||
session_ttl_hours: string;
|
||||
admin_password: string;
|
||||
checkin_schedule_time: string;
|
||||
model_failure_cooldown_minutes: string;
|
||||
};
|
||||
|
||||
export type TokenForm = {
|
||||
name: string;
|
||||
quota_total: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
allowed_channels: string[];
|
||||
};
|
||||
|
||||
export type CheckinResultItem = {
|
||||
@@ -158,3 +184,11 @@ export type CheckinSummary = {
|
||||
failed: number;
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
export type NoticeTone = "success" | "warning" | "error" | "info";
|
||||
|
||||
export type NoticeMessage = {
|
||||
tone: NoticeTone;
|
||||
message: string;
|
||||
id: number;
|
||||
};
|
||||
+89
-51
@@ -26,6 +26,87 @@ export const formatDateTime = (value?: string | null) => {
|
||||
)}`;
|
||||
};
|
||||
|
||||
const formatInTimeZone = (
|
||||
value: string | null | undefined,
|
||||
timeZone: string,
|
||||
parts: Array<"year" | "month" | "day" | "hour" | "minute" | "second">,
|
||||
separator: { date: string },
|
||||
): string => {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "-";
|
||||
}
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const dateParts = formatter.formatToParts(date);
|
||||
const pick = (type: string) =>
|
||||
dateParts.find((part) => part.type === type)?.value ?? "00";
|
||||
const dateText = `${pick("year")}-${pick("month")}-${pick("day")}`;
|
||||
const timeText = parts.includes("second")
|
||||
? `${pick("hour")}:${pick("minute")}:${pick("second")}`
|
||||
: `${pick("hour")}:${pick("minute")}`;
|
||||
return `${dateText}${separator.date}${timeText}`.trim();
|
||||
};
|
||||
|
||||
export const formatChinaDateTime = (value?: string | null) =>
|
||||
formatInTimeZone(
|
||||
value,
|
||||
"Asia/Shanghai",
|
||||
["year", "month", "day", "hour", "minute", "second"],
|
||||
{ date: " " },
|
||||
);
|
||||
|
||||
export const toChinaDateTimeInput = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const formatted = formatInTimeZone(
|
||||
value,
|
||||
"Asia/Shanghai",
|
||||
["year", "month", "day", "hour", "minute"],
|
||||
{ date: "T" },
|
||||
);
|
||||
return formatted === "-" ? "" : formatted;
|
||||
};
|
||||
|
||||
export const toChinaIsoFromInput = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const [datePart, timePart] = raw.split("T");
|
||||
if (!datePart || !timePart) {
|
||||
return null;
|
||||
}
|
||||
const [year, month, day] = datePart.split("-").map(Number);
|
||||
const [hour, minute] = timePart.split(":").map(Number);
|
||||
if (
|
||||
Number.isNaN(year) ||
|
||||
Number.isNaN(month) ||
|
||||
Number.isNaN(day) ||
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const utcMillis = Date.UTC(year, month - 1, day, hour - 8, minute);
|
||||
return new Date(utcMillis).toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles channel or token status between active and disabled.
|
||||
*
|
||||
@@ -79,63 +160,20 @@ export const buildPageItems = (current: number, total: number): PageItem[] => {
|
||||
});
|
||||
};
|
||||
|
||||
const truncateText = (value: string | null | undefined, max = 80) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
|
||||
};
|
||||
|
||||
const describeError = (status?: number | null, code?: string | null) => {
|
||||
const normalized = (code ?? "").toLowerCase();
|
||||
if (status === 401 || status === 403) {
|
||||
return "鉴权失败";
|
||||
}
|
||||
if (status === 429 || normalized.includes("rate")) {
|
||||
return "触发限流";
|
||||
}
|
||||
if (
|
||||
normalized.includes("insufficient") ||
|
||||
normalized.includes("quota") ||
|
||||
normalized.includes("balance")
|
||||
) {
|
||||
return "余额不足";
|
||||
}
|
||||
if (normalized.includes("model")) {
|
||||
return "模型不可用";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export type UsageStatusDetail = {
|
||||
label: string;
|
||||
message: string | null;
|
||||
tone: "success" | "error";
|
||||
};
|
||||
|
||||
export const buildUsageStatusDetail = (log: UsageLog): UsageStatusDetail => {
|
||||
if (log.status === "ok") {
|
||||
return { label: "成功", message: null, tone: "success" };
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (log.upstream_status !== null && log.upstream_status !== undefined) {
|
||||
parts.push(String(log.upstream_status));
|
||||
}
|
||||
if (log.error_code) {
|
||||
parts.push(log.error_code);
|
||||
}
|
||||
const hint = describeError(log.upstream_status, log.error_code ?? null);
|
||||
if (hint) {
|
||||
parts.push(hint);
|
||||
}
|
||||
const suffix = parts.length > 0 ? ` (${parts.join(" / ")})` : "";
|
||||
const statusCode = log.upstream_status ?? null;
|
||||
const isOk = log.status === "ok";
|
||||
const label =
|
||||
statusCode !== null && statusCode !== undefined
|
||||
? String(statusCode)
|
||||
: "-";
|
||||
return {
|
||||
label: `失败${suffix}`,
|
||||
message: truncateText(log.error_message),
|
||||
tone: "error",
|
||||
label,
|
||||
tone: isOk ? "success" : "error",
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { TabId, TabItem } from "../core/types";
|
||||
import type { NoticeMessage, TabId, TabItem } from "../core/types";
|
||||
|
||||
type AppLayoutProps = {
|
||||
tabs: TabItem[];
|
||||
activeTab: TabId;
|
||||
activeLabel: string;
|
||||
token: string | null;
|
||||
notice: string;
|
||||
notice: NoticeMessage | null;
|
||||
onDismissNotice: () => void;
|
||||
onTabChange: (tabId: TabId) => void;
|
||||
onLogout: () => void;
|
||||
children?: unknown;
|
||||
@@ -26,10 +27,23 @@ export const AppLayout = ({
|
||||
activeLabel,
|
||||
token,
|
||||
notice,
|
||||
onDismissNotice,
|
||||
onTabChange,
|
||||
onLogout,
|
||||
children,
|
||||
}: AppLayoutProps) => {
|
||||
const noticeToneStyles: Record<NoticeMessage["tone"], string> = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
error: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-800",
|
||||
};
|
||||
const noticeToneLabel: Record<NoticeMessage["tone"], string> = {
|
||||
success: "成功",
|
||||
warning: "提示",
|
||||
error: "错误",
|
||||
info: "信息",
|
||||
};
|
||||
const closeMobileNav = () => {
|
||||
const toggle = document.querySelector<HTMLInputElement>("#app-nav-toggle");
|
||||
if (toggle) {
|
||||
@@ -153,8 +167,26 @@ export const AppLayout = ({
|
||||
</div>
|
||||
</div>
|
||||
{notice && (
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{notice}
|
||||
<div
|
||||
class={`animate-fade-up mt-4 rounded-xl border px-4 py-3 text-sm ${noticeToneStyles[notice.tone]}`}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-stone-500">
|
||||
{noticeToneLabel[notice.tone]}
|
||||
</span>
|
||||
<div class="mt-1 text-sm font-semibold text-stone-900">
|
||||
{notice.message}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-white px-3 text-[11px] font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onDismissNotice}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { DashboardData } from "../core/types";
|
||||
|
||||
type DashboardViewProps = {
|
||||
dashboard: DashboardData | null;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -13,23 +15,83 @@ type DashboardViewProps = {
|
||||
* Returns:
|
||||
* Dashboard JSX element.
|
||||
*/
|
||||
export const DashboardView = ({ dashboard }: DashboardViewProps) => {
|
||||
export const DashboardView = ({
|
||||
dashboard,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: DashboardViewProps) => {
|
||||
if (!dashboard) {
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
暂无数据
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
数据面板
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
查看请求量、消耗与性能趋势。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-6 rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
|
||||
暂无数据,请先产生调用或刷新面板。
|
||||
<div class="mt-4 flex justify-center">
|
||||
<button
|
||||
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const totalRequests = dashboard.summary.total_requests;
|
||||
const totalErrors = dashboard.summary.total_errors;
|
||||
const errorRate = dashboard.summary.total_requests
|
||||
? Math.round(
|
||||
(dashboard.summary.total_errors / dashboard.summary.total_requests) *
|
||||
100,
|
||||
)
|
||||
: 0;
|
||||
const successRate = totalRequests
|
||||
? Math.max(0, Math.round(((totalRequests - totalErrors) / totalRequests) * 100))
|
||||
: 0;
|
||||
const avgTokensPerRequest = totalRequests
|
||||
? Math.round(dashboard.summary.total_tokens / totalRequests)
|
||||
: 0;
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div class="animate-fade-up space-y-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
数据面板
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
快速掌握请求表现、性能与消耗概况。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-4">
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
总请求
|
||||
@@ -54,11 +116,24 @@ export const DashboardView = ({ dashboard }: DashboardViewProps) => {
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
错误率
|
||||
成功率
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">{errorRate}%</div>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{successRate}%
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
平均延迟 {Math.round(dashboard.summary.avg_latency)}ms
|
||||
错误率 {errorRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
单次消耗
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{avgTokensPerRequest}
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
平均 {Math.round(dashboard.summary.avg_latency)}ms 延迟
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { NoticeMessage } from "../core/types";
|
||||
|
||||
type LoginViewProps = {
|
||||
notice: string;
|
||||
notice: NoticeMessage | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (event: Event) => void;
|
||||
};
|
||||
|
||||
@@ -12,39 +15,52 @@ type LoginViewProps = {
|
||||
* Returns:
|
||||
* Login JSX element.
|
||||
*/
|
||||
export const LoginView = ({ notice, onSubmit }: LoginViewProps) => (
|
||||
<div class="mx-auto mt-24 max-w-md rounded-2xl border border-stone-200 bg-white p-8 shadow-lg">
|
||||
<h1 class="mb-2 font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
|
||||
api-workers
|
||||
</h1>
|
||||
<p class="text-sm text-stone-500">请输入管理员密码登录管理台。</p>
|
||||
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="password"
|
||||
export const LoginView = ({ notice, isSubmitting, onSubmit }: LoginViewProps) => {
|
||||
const toneStyles: Record<NoticeMessage["tone"], string> = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
error: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-800",
|
||||
};
|
||||
return (
|
||||
<div class="animate-fade-up mx-auto mt-24 max-w-md rounded-2xl border border-stone-200 bg-white p-8 shadow-lg">
|
||||
<h1 class="mb-2 font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
|
||||
api-workers
|
||||
</h1>
|
||||
<p class="text-sm text-stone-500">请输入管理员密码登录管理台。</p>
|
||||
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="password"
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
{notice && (
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{isSubmitting ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-3 text-xs text-stone-500">按回车键可快速提交。</p>
|
||||
{notice && (
|
||||
<div
|
||||
class={`mt-4 rounded-xl border px-4 py-3 text-sm ${toneStyles[notice.tone]}`}
|
||||
>
|
||||
{notice.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,41 +13,62 @@ type ModelsViewProps = {
|
||||
* Returns:
|
||||
* Models JSX element.
|
||||
*/
|
||||
export const ModelsView = ({ models }: ModelsViewProps) => (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
模型广场
|
||||
</h3>
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
{models.length} 个模型
|
||||
</span>
|
||||
export const ModelsView = ({ models }: ModelsViewProps) => {
|
||||
const channelCount = new Set(
|
||||
models.flatMap((model) => model.channels.map((channel) => channel.id)),
|
||||
).size;
|
||||
return (
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
模型广场
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
当前聚合的模型与所属渠道清单。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-stone-500">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1">
|
||||
{models.length} 个模型
|
||||
</span>
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1">
|
||||
{channelCount} 个渠道
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{models.length === 0 ? (
|
||||
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
|
||||
暂无模型,请先在站点管理配置可用渠道。
|
||||
</div>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-105 w-full border-collapse text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
渠道
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr class="hover:bg-stone-50" key={model.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{model.id}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{model.channels.map((channel) => channel.name).join(" / ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-105 w-full border-collapse text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
渠道
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr class="hover:bg-stone-50" key={model.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{model.id}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{model.channels.map((channel) => channel.name).join(" / ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import type { SettingsForm } from "../core/types";
|
||||
type SettingsViewProps = {
|
||||
settingsForm: SettingsForm;
|
||||
adminPasswordSet: boolean;
|
||||
isSaving: boolean;
|
||||
onSubmit: (event: Event) => void;
|
||||
onFormChange: (patch: Partial<SettingsForm>) => void;
|
||||
};
|
||||
@@ -19,10 +20,11 @@ type SettingsViewProps = {
|
||||
export const SettingsView = ({
|
||||
settingsForm,
|
||||
adminPasswordSet,
|
||||
isSaving,
|
||||
onSubmit,
|
||||
onFormChange,
|
||||
}: SettingsViewProps) => (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
系统设置
|
||||
@@ -94,6 +96,31 @@ export const SettingsView = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="failure-cooldown"
|
||||
>
|
||||
失败冷却(分钟)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="failure-cooldown"
|
||||
name="model_failure_cooldown_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settingsForm.model_failure_cooldown_minutes}
|
||||
onInput={(event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | null;
|
||||
onFormChange({
|
||||
model_failure_cooldown_minutes: target?.value ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-stone-500">
|
||||
同一模型失败后在该时间内跳过对应渠道。
|
||||
</p>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
@@ -127,8 +154,9 @@ export const SettingsView = ({
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
>
|
||||
保存设置
|
||||
{isSaving ? "保存中..." : "保存设置"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "hono/jsx/dom";
|
||||
import {
|
||||
getSiteCheckinLabel,
|
||||
getSiteTypeLabel,
|
||||
@@ -24,13 +25,14 @@ type SitesViewProps = {
|
||||
lastRun: string | null;
|
||||
siteSearch: string;
|
||||
siteSort: SiteSortState;
|
||||
isActionPending: (key: string) => boolean;
|
||||
onCreate: () => void;
|
||||
onCloseModal: () => void;
|
||||
onEdit: (site: Site) => void;
|
||||
onSubmit: (event: Event) => void;
|
||||
onTest: (id: string) => void;
|
||||
onToggle: (id: string, status: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDelete: (site: Site) => void;
|
||||
onPageChange: (next: number) => void;
|
||||
onPageSizeChange: (next: number) => void;
|
||||
onSearchChange: (next: string) => void;
|
||||
@@ -64,6 +66,7 @@ export const SitesView = ({
|
||||
lastRun,
|
||||
siteSearch,
|
||||
siteSort,
|
||||
isActionPending,
|
||||
onCreate,
|
||||
onCloseModal,
|
||||
onEdit,
|
||||
@@ -82,6 +85,10 @@ export const SitesView = ({
|
||||
const isEditing = Boolean(editingSite);
|
||||
const pageItems = buildPageItems(sitePage, siteTotalPages);
|
||||
const today = getBeijingDateString();
|
||||
const isSubmitting = isActionPending("site:submit");
|
||||
const isTestingAll = isActionPending("site:testAll");
|
||||
const isCheckinAll = isActionPending("site:checkinAll");
|
||||
const [localSearch, setLocalSearch] = useState(siteSearch);
|
||||
const isOfficialType =
|
||||
siteForm.site_type === "openai" ||
|
||||
siteForm.site_type === "anthropic" ||
|
||||
@@ -130,8 +137,32 @@ export const SitesView = ({
|
||||
}
|
||||
return siteSort.direction === "asc" ? "▲" : "▼";
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isSiteModalOpen) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCloseModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSiteModalOpen, onCloseModal]);
|
||||
useEffect(() => {
|
||||
setLocalSearch(siteSearch);
|
||||
}, [siteSearch]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (localSearch !== siteSearch) {
|
||||
onSearchChange(localSearch);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [localSearch, onSearchChange, siteSearch]);
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
<div class="animate-fade-up space-y-5">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -152,18 +183,20 @@ export const SitesView = ({
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isCheckinAll}
|
||||
onClick={onRunAll}
|
||||
>
|
||||
一键签到
|
||||
{isCheckinAll ? "签到中..." : "一键签到"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isTestingAll}
|
||||
onClick={onTestAll}
|
||||
>
|
||||
一键测试
|
||||
{isTestingAll ? "测试中..." : "一键测试"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
@@ -180,9 +213,11 @@ export const SitesView = ({
|
||||
<input
|
||||
class="w-full bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="站点名称或 URL"
|
||||
value={siteSearch}
|
||||
value={localSearch}
|
||||
onInput={(event) =>
|
||||
onSearchChange((event.currentTarget as HTMLInputElement).value)
|
||||
setLocalSearch(
|
||||
(event.currentTarget as HTMLInputElement).value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
@@ -206,8 +241,15 @@ export const SitesView = ({
|
||||
<div class="mt-4">
|
||||
<div class="space-y-3 md:hidden">
|
||||
{pagedSites.length === 0 ? (
|
||||
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
|
||||
暂无站点,请先创建。
|
||||
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-10 text-center text-sm text-stone-500">
|
||||
<p>暂无站点,请先创建。</p>
|
||||
<button
|
||||
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
新增站点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
pagedSites.map((site) => {
|
||||
@@ -218,6 +260,13 @@ export const SitesView = ({
|
||||
site.system_token && site.system_userid,
|
||||
);
|
||||
const callTokenCount = site.call_tokens?.length ?? 0;
|
||||
const testPending = isActionPending(`site:test:${site.id}`);
|
||||
const togglePending = isActionPending(
|
||||
`site:toggle:${site.id}`,
|
||||
);
|
||||
const deletePending = isActionPending(
|
||||
`site:delete:${site.id}`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
class={`rounded-xl border p-4 shadow-sm ${
|
||||
@@ -297,16 +346,22 @@ export const SitesView = ({
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={testPending}
|
||||
onClick={() => onTest(site.id)}
|
||||
>
|
||||
连通测试
|
||||
{testPending ? "测试中..." : "连通测试"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={togglePending}
|
||||
onClick={() => onToggle(site.id, site.status)}
|
||||
>
|
||||
{isActive ? "禁用" : "启用"}
|
||||
{togglePending
|
||||
? "处理中..."
|
||||
: isActive
|
||||
? "禁用"
|
||||
: "启用"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@@ -318,9 +373,10 @@ export const SitesView = ({
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onDelete(site.id)}
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(site)}
|
||||
>
|
||||
删除
|
||||
{deletePending ? "删除中..." : "删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,13 +402,27 @@ export const SitesView = ({
|
||||
</div>
|
||||
{pagedSites.length === 0 ? (
|
||||
<div class="px-4 py-10 text-center text-sm text-stone-500">
|
||||
暂无站点,请先创建。
|
||||
<p>暂无站点,请先创建。</p>
|
||||
<button
|
||||
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
新增站点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div class="divide-y divide-stone-100">
|
||||
{pagedSites.map((site) => {
|
||||
const isActive = site.status === "active";
|
||||
const callTokenCount = site.call_tokens?.length ?? 0;
|
||||
const testPending = isActionPending(`site:test:${site.id}`);
|
||||
const togglePending = isActionPending(
|
||||
`site:toggle:${site.id}`,
|
||||
);
|
||||
const deletePending = isActionPending(
|
||||
`site:delete:${site.id}`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
class={`grid grid-cols-[minmax(0,1.4fr)_minmax(0,0.6fr)_minmax(0,0.6fr)_minmax(0,0.5fr)_minmax(0,0.6fr)_minmax(0,0.6fr)_minmax(0,0.8fr)_minmax(0,1.4fr)] items-center gap-3 px-4 py-4 text-sm ${
|
||||
@@ -407,16 +477,22 @@ export const SitesView = ({
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={testPending}
|
||||
onClick={() => onTest(site.id)}
|
||||
>
|
||||
连通测试
|
||||
{testPending ? "测试中..." : "连通测试"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={togglePending}
|
||||
onClick={() => onToggle(site.id, site.status)}
|
||||
>
|
||||
{isActive ? "禁用" : "启用"}
|
||||
{togglePending
|
||||
? "处理中..."
|
||||
: isActive
|
||||
? "禁用"
|
||||
: "启用"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@@ -428,9 +504,10 @@ export const SitesView = ({
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onDelete(site.id)}
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(site)}
|
||||
>
|
||||
删除
|
||||
{deletePending ? "删除中..." : "删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,11 +587,23 @@ export const SitesView = ({
|
||||
)}
|
||||
</div>
|
||||
{isSiteModalOpen && (
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
|
||||
<div class="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="site-modal-title"
|
||||
aria-modal="true"
|
||||
class="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
<h3
|
||||
class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
|
||||
id="site-modal-title"
|
||||
>
|
||||
{isEditing ? "编辑站点" : "新增站点"}
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
@@ -549,6 +638,7 @@ export const SitesView = ({
|
||||
name="name"
|
||||
value={siteForm.name}
|
||||
required
|
||||
autoFocus
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
name: (event.currentTarget as HTMLInputElement).value,
|
||||
@@ -835,8 +925,13 @@ export const SitesView = ({
|
||||
<button
|
||||
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isEditing ? "保存修改" : "创建站点"}
|
||||
{isSubmitting
|
||||
? "保存中..."
|
||||
: isEditing
|
||||
? "保存修改"
|
||||
: "创建站点"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Token } from "../core/types";
|
||||
import { buildPageItems, formatDateTime } from "../core/utils";
|
||||
import { useEffect } from "hono/jsx/dom";
|
||||
import type { Site, Token, TokenForm } from "../core/types";
|
||||
import {
|
||||
buildPageItems,
|
||||
formatChinaDateTime,
|
||||
formatDateTime,
|
||||
} from "../core/utils";
|
||||
|
||||
type TokensViewProps = {
|
||||
pagedTokens: Token[];
|
||||
@@ -8,14 +13,20 @@ type TokensViewProps = {
|
||||
tokenTotal: number;
|
||||
tokenTotalPages: number;
|
||||
isTokenModalOpen: boolean;
|
||||
isActionPending: (key: string) => boolean;
|
||||
sites: Site[];
|
||||
tokenForm: TokenForm;
|
||||
editingToken: Token | null;
|
||||
onCreate: () => void;
|
||||
onCloseModal: () => void;
|
||||
onPageChange: (next: number) => void;
|
||||
onPageSizeChange: (next: number) => void;
|
||||
onSubmit: (event: Event) => void;
|
||||
onFormChange: (patch: Partial<TokenForm>) => void;
|
||||
onEdit: (token: Token) => void;
|
||||
onReveal: (id: string) => void;
|
||||
onToggle: (id: string, status: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDelete: (token: Token) => void;
|
||||
};
|
||||
|
||||
const pageSizeOptions = [10, 20, 50];
|
||||
@@ -36,18 +47,56 @@ export const TokensView = ({
|
||||
tokenTotal,
|
||||
tokenTotalPages,
|
||||
isTokenModalOpen,
|
||||
isActionPending,
|
||||
sites,
|
||||
tokenForm,
|
||||
editingToken,
|
||||
onCreate,
|
||||
onCloseModal,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onSubmit,
|
||||
onFormChange,
|
||||
onEdit,
|
||||
onReveal,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: TokensViewProps) => {
|
||||
const pageItems = buildPageItems(tokenPage, tokenTotalPages);
|
||||
const isSubmitting = isActionPending("token:submit");
|
||||
const isEditing = Boolean(editingToken);
|
||||
const modalTitle = isEditing ? "编辑令牌" : "生成令牌";
|
||||
const modalDescription = isEditing
|
||||
? "更新令牌名称、额度、状态与过期时间。"
|
||||
: "创建后会自动复制令牌,请妥善保存。";
|
||||
const submitLabel = isEditing ? "保存修改" : "生成令牌";
|
||||
const selectedChannels = new Set(tokenForm.allowed_channels);
|
||||
const toggleChannel = (channelId: string) => {
|
||||
const next = new Set(selectedChannels);
|
||||
if (next.has(channelId)) {
|
||||
next.delete(channelId);
|
||||
} else {
|
||||
next.add(channelId);
|
||||
}
|
||||
onFormChange({ allowed_channels: Array.from(next) });
|
||||
};
|
||||
const clearChannels = () => onFormChange({ allowed_channels: [] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTokenModalOpen) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCloseModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isTokenModalOpen, onCloseModal]);
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
<div class="animate-fade-up space-y-5">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -71,12 +120,28 @@ export const TokensView = ({
|
||||
<div class="mt-4">
|
||||
<div class="space-y-3 md:hidden">
|
||||
{pagedTokens.length === 0 ? (
|
||||
<div class="rounded-xl border border-stone-200 bg-white px-4 py-10 text-center text-sm text-stone-500">
|
||||
暂无令牌,请先创建。
|
||||
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-10 text-center text-sm text-stone-500">
|
||||
<p>暂无令牌,请先创建。</p>
|
||||
<button
|
||||
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
生成令牌
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
pagedTokens.map((tokenItem) => {
|
||||
const isActive = tokenItem.status === "active";
|
||||
const revealPending = isActionPending(
|
||||
`token:reveal:${tokenItem.id}`,
|
||||
);
|
||||
const togglePending = isActionPending(
|
||||
`token:toggle:${tokenItem.id}`,
|
||||
);
|
||||
const deletePending = isActionPending(
|
||||
`token:delete:${tokenItem.id}`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
class="rounded-xl border border-stone-200 bg-white p-4 shadow-sm"
|
||||
@@ -115,28 +180,55 @@ export const TokensView = ({
|
||||
{formatDateTime(tokenItem.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<p>过期时间</p>
|
||||
<p class="mt-1 font-semibold text-stone-700">
|
||||
{tokenItem.expires_at
|
||||
? formatChinaDateTime(tokenItem.expires_at)
|
||||
: "永不过期"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<p>渠道限制</p>
|
||||
<p class="mt-1 font-semibold text-stone-700">
|
||||
{tokenItem.allowed_channels &&
|
||||
tokenItem.allowed_channels.length > 0
|
||||
? `${tokenItem.allowed_channels.length} 个`
|
||||
: "全开"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="col-span-2 h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={() => onEdit(tokenItem)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={revealPending}
|
||||
onClick={() => onReveal(tokenItem.id)}
|
||||
>
|
||||
查看
|
||||
{revealPending ? "查看中..." : "查看"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={togglePending}
|
||||
onClick={() => onToggle(tokenItem.id, tokenItem.status)}
|
||||
>
|
||||
切换
|
||||
{togglePending ? "处理中..." : "切换"}
|
||||
</button>
|
||||
<button
|
||||
class="col-span-2 h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onDelete(tokenItem.id)}
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(tokenItem)}
|
||||
>
|
||||
删除
|
||||
{deletePending ? "删除中..." : "删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,25 +237,43 @@ export const TokensView = ({
|
||||
)}
|
||||
</div>
|
||||
<div class="hidden overflow-hidden rounded-xl border border-stone-200 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,1.2fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
|
||||
<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-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
|
||||
<div>名称</div>
|
||||
<div>状态</div>
|
||||
<div>已用/额度</div>
|
||||
<div>前缀</div>
|
||||
<div>创建时间</div>
|
||||
<div>过期时间</div>
|
||||
<div>渠道限制</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
{pagedTokens.length === 0 ? (
|
||||
<div class="px-4 py-10 text-center text-sm text-stone-500">
|
||||
暂无令牌,请先创建。
|
||||
<p>暂无令牌,请先创建。</p>
|
||||
<button
|
||||
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
生成令牌
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div class="divide-y divide-stone-100">
|
||||
{pagedTokens.map((tokenItem) => {
|
||||
const isActive = tokenItem.status === "active";
|
||||
const revealPending = isActionPending(
|
||||
`token:reveal:${tokenItem.id}`,
|
||||
);
|
||||
const togglePending = isActionPending(
|
||||
`token:toggle:${tokenItem.id}`,
|
||||
);
|
||||
const deletePending = isActionPending(
|
||||
`token:delete:${tokenItem.id}`,
|
||||
);
|
||||
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,1.2fr)] items-center gap-3 px-4 py-4 text-sm"
|
||||
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"
|
||||
key={tokenItem.id}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
@@ -191,29 +301,50 @@ export const TokensView = ({
|
||||
<div class="text-sm text-stone-700">
|
||||
{formatDateTime(tokenItem.created_at)}
|
||||
</div>
|
||||
<div class="text-sm text-stone-700">
|
||||
{tokenItem.expires_at
|
||||
? formatChinaDateTime(tokenItem.expires_at)
|
||||
: "永不过期"}
|
||||
</div>
|
||||
<div class="text-sm text-stone-700">
|
||||
{tokenItem.allowed_channels &&
|
||||
tokenItem.allowed_channels.length > 0
|
||||
? `${tokenItem.allowed_channels.length} 个`
|
||||
: "全开"}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={() => onEdit(tokenItem)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={revealPending}
|
||||
onClick={() => onReveal(tokenItem.id)}
|
||||
>
|
||||
查看
|
||||
{revealPending ? "查看中..." : "查看"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={togglePending}
|
||||
onClick={() =>
|
||||
onToggle(tokenItem.id, tokenItem.status)
|
||||
}
|
||||
>
|
||||
切换
|
||||
{togglePending ? "处理中..." : "切换"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onDelete(tokenItem.id)}
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(tokenItem)}
|
||||
>
|
||||
删除
|
||||
{deletePending ? "删除中..." : "删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,15 +419,27 @@ export const TokensView = ({
|
||||
</div>
|
||||
</div>
|
||||
{isTokenModalOpen && (
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
|
||||
<div class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="token-modal-title"
|
||||
aria-modal="true"
|
||||
class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
生成令牌
|
||||
<h3
|
||||
class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
|
||||
id="token-modal-title"
|
||||
>
|
||||
{modalTitle}
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
创建后可在列表中查看与管理。
|
||||
{modalDescription}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -320,6 +463,13 @@ export const TokensView = ({
|
||||
id="token-name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
value={tokenForm.name}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
name: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -336,8 +486,106 @@ export const TokensView = ({
|
||||
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-stone-500"
|
||||
for="token-status"
|
||||
>
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
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-stone-500"
|
||||
for="token-expires"
|
||||
>
|
||||
过期时间(北京时间)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
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-stone-400">
|
||||
留空表示不过期。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500">
|
||||
允许渠道
|
||||
</label>
|
||||
<button
|
||||
class="text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:text-stone-900"
|
||||
type="button"
|
||||
onClick={clearChannels}
|
||||
>
|
||||
全开
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-stone-400">
|
||||
未选择表示全开。
|
||||
</p>
|
||||
<div class="mt-2 max-h-36 space-y-2 overflow-auto rounded-lg border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
|
||||
{sites.length === 0 ? (
|
||||
<p class="text-stone-400">暂无渠道,请先创建。</p>
|
||||
) : (
|
||||
sites.map((site) => (
|
||||
<label
|
||||
class="flex items-center gap-2"
|
||||
key={site.id}
|
||||
>
|
||||
<input
|
||||
class="h-3 w-3 rounded border-stone-300 text-stone-900 focus:ring-amber-300"
|
||||
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="h-10 rounded-full border border-stone-200 bg-stone-50 px-4 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
@@ -349,8 +597,13 @@ export const TokensView = ({
|
||||
<button
|
||||
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
生成令牌
|
||||
{isSubmitting
|
||||
? isEditing
|
||||
? "保存中..."
|
||||
: "生成中..."
|
||||
: submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "hono/jsx/dom";
|
||||
import type { UsageLog } from "../core/types";
|
||||
import type { UsageLog, UsageQuery } from "../core/types";
|
||||
import {
|
||||
buildPageItems,
|
||||
buildUsageStatusDetail,
|
||||
@@ -8,7 +8,17 @@ import {
|
||||
|
||||
type UsageViewProps = {
|
||||
usage: UsageLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
filters: UsageQuery;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onPageChange: (next: number) => void;
|
||||
onPageSizeChange: (next: number) => void;
|
||||
onFiltersChange: (patch: Partial<UsageQuery>) => void;
|
||||
onSearch: () => void;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
const pageSizeOptions = [50, 100, 200];
|
||||
@@ -42,34 +52,61 @@ const formatStream = (value: boolean | number | null | undefined) => {
|
||||
* Returns:
|
||||
* Usage JSX element.
|
||||
*/
|
||||
export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [page, setPage] = useState(1);
|
||||
const total = usage.length;
|
||||
export const UsageView = ({
|
||||
usage,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onFiltersChange,
|
||||
onSearch,
|
||||
onClear,
|
||||
}: UsageViewProps) => {
|
||||
const [activeErrorLog, setActiveErrorLog] = useState<UsageLog | null>(null);
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(total / pageSize)),
|
||||
[total, pageSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage((prev) => Math.min(prev, totalPages));
|
||||
}, [totalPages]);
|
||||
|
||||
const pagedUsage = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return usage.slice(start, start + pageSize);
|
||||
}, [usage, page, pageSize]);
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeErrorLog) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeErrorModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [activeErrorLog]);
|
||||
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
@@ -83,9 +120,87 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
刷新
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 p-3">
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
渠道
|
||||
<input
|
||||
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="渠道名称"
|
||||
value={filters.channel}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
channel: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
令牌
|
||||
<input
|
||||
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="令牌名称"
|
||||
value={filters.token}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
token: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
模型
|
||||
<input
|
||||
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="模型名称"
|
||||
value={filters.model}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
model: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
状态码
|
||||
<input
|
||||
class="w-28 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
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="h-8 rounded-full bg-stone-900 px-4 text-[11px] font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onSearch}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-white px-4 text-[11px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing || !hasFilters}
|
||||
onClick={onClear}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,10 +228,10 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
输出 Tokens
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
用时
|
||||
用时 (s)
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
首 token 延迟
|
||||
首 token 延迟 (s)
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
流式
|
||||
@@ -125,23 +240,36 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
推理强度
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
状态
|
||||
状态码
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedUsage.length === 0 ? (
|
||||
{usage.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
class="px-3 py-10 text-center text-sm text-stone-500"
|
||||
colSpan={11}
|
||||
>
|
||||
暂无日志。
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span>暂无日志,先完成一次调用吧。</span>
|
||||
<button
|
||||
class="h-8 rounded-full bg-stone-900 px-4 text-[11px] font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pagedUsage.map((log) => {
|
||||
usage.map((log) => {
|
||||
const statusDetail = buildUsageStatusDetail(log);
|
||||
const hasDetail = Boolean(
|
||||
log.error_message || log.error_code,
|
||||
);
|
||||
return (
|
||||
<tr class="hover:bg-stone-50" key={log.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
@@ -184,10 +312,14 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
>
|
||||
{statusDetail.label}
|
||||
</div>
|
||||
{statusDetail.message ? (
|
||||
<div class="mt-1 text-[11px] text-stone-500">
|
||||
{statusDetail.message}
|
||||
</div>
|
||||
{hasDetail ? (
|
||||
<button
|
||||
class="mt-2 rounded-full border border-stone-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
|
||||
type="button"
|
||||
onClick={() => setActiveErrorLog(log)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -200,12 +332,14 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-stone-500">共 {totalPages} 页</span>
|
||||
<span class="text-xs text-stone-500">
|
||||
共 {totalPages} 页 · {total} 条
|
||||
</span>
|
||||
<button
|
||||
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || isRefreshing}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@@ -223,7 +357,8 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
}`}
|
||||
type="button"
|
||||
key={item}
|
||||
onClick={() => setPage(item)}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
@@ -232,8 +367,8 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
<button
|
||||
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || isRefreshing}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
@@ -243,11 +378,12 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
<select
|
||||
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
value={pageSize}
|
||||
onChange={(event) => {
|
||||
setPageSize(
|
||||
disabled={isRefreshing}
|
||||
onChange={(event) =>
|
||||
onPageSizeChange(
|
||||
Number((event.currentTarget as HTMLSelectElement).value),
|
||||
);
|
||||
}}
|
||||
)
|
||||
}
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
@@ -257,6 +393,81 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{activeErrorLog ? (
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/50 px-4 py-8"
|
||||
onClick={closeErrorModal}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="usage-error-title"
|
||||
aria-modal="true"
|
||||
class="w-full max-w-2xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3
|
||||
class="font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
|
||||
id="usage-error-title"
|
||||
>
|
||||
错误详情
|
||||
</h3>
|
||||
<p class="mt-2 text-xs text-stone-500">
|
||||
状态码 {activeErrorLog.upstream_status ?? "-"}
|
||||
</p>
|
||||
{activeErrorLog.error_code ? (
|
||||
<p class="mt-1 text-xs text-stone-500">
|
||||
错误码: {activeErrorLog.error_code}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={closeErrorModal}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-700">
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">时间</span>
|
||||
<span>{formatDateTime(activeErrorLog.created_at)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">模型</span>
|
||||
<span>{activeErrorLog.model ?? "-"}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">渠道</span>
|
||||
<span>
|
||||
{activeErrorLog.channel_name ??
|
||||
activeErrorLog.channel_id ??
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">令牌</span>
|
||||
<span>
|
||||
{activeErrorLog.token_name ??
|
||||
activeErrorLog.token_id ??
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">耗时</span>
|
||||
<span>{formatSeconds(activeErrorLog.latency_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] text-stone-400">
|
||||
错误片段已省略,请结合状态码与错误码排查。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--app-ink: #1c1917;
|
||||
--app-ink-muted: #78716c;
|
||||
--app-amber: #f59e0b;
|
||||
--app-amber-soft: #fde68a;
|
||||
--app-bg: #f8f5f0;
|
||||
--app-surface: #ffffff;
|
||||
--app-border: rgba(28, 25, 23, 0.12);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--app-bg);
|
||||
}
|
||||
|
||||
.app-background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 8% 12%, rgba(245, 158, 11, 0.16), transparent 45%),
|
||||
radial-gradient(circle at 90% 8%, rgba(120, 113, 108, 0.18), transparent 40%),
|
||||
linear-gradient(180deg, #fff7ed 0%, #f8f5f0 38%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.app-background::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(12, 10, 9, 0.06) 0.6px, transparent 0);
|
||||
background-size: 3px 3px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeUp 0.35s ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channel_model_capabilities ADD COLUMN last_err_count INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tokens ADD COLUMN expires_at TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channel_call_tokens ADD COLUMN models_json TEXT;
|
||||
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS channel_call_tokens (
|
||||
channel_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
models_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -46,6 +47,7 @@ CREATE TABLE IF NOT EXISTS tokens (
|
||||
quota_used INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
allowed_channels TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -85,3 +87,21 @@ CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_model_capabilities (
|
||||
channel_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
last_ok_at INTEGER NOT NULL,
|
||||
last_err_at INTEGER,
|
||||
last_err_code TEXT,
|
||||
last_err_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (channel_id, model)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_model
|
||||
ON channel_model_capabilities (model);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_channel
|
||||
ON channel_model_capabilities (channel_id);
|
||||
@@ -12,6 +12,7 @@ export type TokenRecord = {
|
||||
quota_used: number;
|
||||
status: string;
|
||||
allowed_channels: string | null;
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const record = await c.env.DB.prepare(
|
||||
"SELECT id, name, quota_total, quota_used, status, allowed_channels FROM tokens WHERE key_hash = ?",
|
||||
"SELECT id, name, quota_total, quota_used, status, allowed_channels, expires_at FROM tokens WHERE key_hash = ?",
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first<TokenRecord>();
|
||||
@@ -34,6 +35,13 @@ export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
return jsonError(c, 401, "invalid_token", "invalid_token");
|
||||
}
|
||||
|
||||
if (record.expires_at) {
|
||||
const parsed = Date.parse(record.expires_at);
|
||||
if (!Number.isNaN(parsed) && Date.now() >= parsed) {
|
||||
return jsonError(c, 403, "token_expired", "token_expired");
|
||||
}
|
||||
}
|
||||
|
||||
if (record.status !== "active") {
|
||||
return jsonError(c, 403, "token_disabled", "token_disabled");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { listCallTokens } from "../services/channel-call-token-repo";
|
||||
import {
|
||||
listCallTokens,
|
||||
updateCallTokenModels,
|
||||
} from "../services/channel-call-token-repo";
|
||||
import { modelsToJson } from "../services/channel-models";
|
||||
import {
|
||||
channelExists,
|
||||
@@ -197,6 +200,14 @@ channels.post("/:id/test", async (c) => {
|
||||
return jsonError(c, 502, "channel_unreachable", "channel_unreachable");
|
||||
}
|
||||
|
||||
const tokenIdSet = new Set(callTokenRows.map((row) => row.id));
|
||||
for (const item of summary.items) {
|
||||
if (!item.ok || !item.tokenId || !tokenIdSet.has(item.tokenId)) {
|
||||
continue;
|
||||
}
|
||||
await updateCallTokenModels(c.env.DB, item.tokenId, item.models, nowIso());
|
||||
}
|
||||
|
||||
const models = summary.models.map((id) => ({ id }));
|
||||
await updateChannelTestResult(c.env.DB, id, {
|
||||
ok: true,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { listModelEntriesWithFallback } from "../services/channel-model-capabilities";
|
||||
import { listActiveChannels } from "../services/channel-repo";
|
||||
import { getModelCapabilityTtlHours } from "../services/settings";
|
||||
|
||||
const models = new Hono<AppEnv>();
|
||||
|
||||
@@ -11,8 +10,6 @@ const models = new Hono<AppEnv>();
|
||||
*/
|
||||
models.get("/", async (c) => {
|
||||
const channels = await listActiveChannels(c.env.DB);
|
||||
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
|
||||
const entries = await listModelEntriesWithFallback(
|
||||
c.env.DB,
|
||||
channels.map((channel) => ({
|
||||
@@ -20,7 +17,6 @@ models.get("/", async (c) => {
|
||||
name: channel.name,
|
||||
models_json: channel.models_json,
|
||||
})),
|
||||
ttlSeconds,
|
||||
);
|
||||
|
||||
const map = new Map<
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
listModelsByChannelWithFallback,
|
||||
} from "../services/channel-model-capabilities";
|
||||
import { listActiveChannels } from "../services/channel-repo";
|
||||
import { getModelCapabilityTtlHours } from "../services/settings";
|
||||
import { newApiSuccess } from "../utils/newapi-response";
|
||||
|
||||
const users = new Hono<AppEnv>({ strict: false });
|
||||
@@ -13,8 +12,6 @@ users.use("*", newApiAuth);
|
||||
|
||||
users.get("/models", async (c) => {
|
||||
const channels = await listActiveChannels(c.env.DB);
|
||||
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
|
||||
const map = await listModelsByChannelWithFallback(
|
||||
c.env.DB,
|
||||
channels.map((channel) => ({
|
||||
@@ -22,7 +19,6 @@ users.get("/models", async (c) => {
|
||||
name: channel.name,
|
||||
models_json: channel.models_json,
|
||||
})),
|
||||
ttlSeconds,
|
||||
);
|
||||
const modelSet = new Set<string>();
|
||||
for (const models of map.values()) {
|
||||
|
||||
+186
-35
@@ -3,7 +3,6 @@ import type { AppEnv } from "../env";
|
||||
import { type TokenRecord, tokenAuth } from "../middleware/tokenAuth";
|
||||
import {
|
||||
type CallTokenItem,
|
||||
selectCallToken,
|
||||
} from "../services/call-token-selector";
|
||||
import { listCallTokens } from "../services/channel-call-token-repo";
|
||||
import {
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
upsertChannelModelCapabilities,
|
||||
} from "../services/channel-model-capabilities";
|
||||
import {
|
||||
getModelCapabilityTtlHours,
|
||||
getModelFailureCooldownMinutes,
|
||||
} from "../services/settings";
|
||||
import {
|
||||
@@ -80,7 +78,9 @@ type ErrorDetails = {
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
function truncateMessage(value: string | null, max = 240): string | null {
|
||||
const FAILURE_COUNT_THRESHOLD = 2;
|
||||
|
||||
function normalizeMessage(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ function truncateMessage(value: string | null, max = 240): string | null {
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function extractErrorDetails(
|
||||
@@ -114,12 +114,28 @@ async function extractErrorDetails(
|
||||
: null;
|
||||
return {
|
||||
errorCode,
|
||||
errorMessage: truncateMessage(errorMessage),
|
||||
errorMessage: normalizeMessage(errorMessage),
|
||||
};
|
||||
}
|
||||
}
|
||||
const text = await response.clone().text().catch(() => "");
|
||||
return { errorCode: null, errorMessage: truncateMessage(text) };
|
||||
return { errorCode: null, errorMessage: normalizeMessage(text) };
|
||||
}
|
||||
|
||||
export function shouldCooldown(
|
||||
upstreamStatus: number | null,
|
||||
errorCode: string | null,
|
||||
): boolean {
|
||||
if (errorCode === "timeout" || errorCode === "exception") {
|
||||
return true;
|
||||
}
|
||||
if (upstreamStatus === 429 || upstreamStatus === 408) {
|
||||
return true;
|
||||
}
|
||||
if (upstreamStatus !== null && upstreamStatus >= 500) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function channelSupportsModel(
|
||||
@@ -134,37 +150,46 @@ function channelSupportsModel(
|
||||
const declaredModels = extractModels(channel).map((entry) => entry.id);
|
||||
const metadata = parseChannelMetadata(channel.metadata_json);
|
||||
const mapped = resolveMappedModel(metadata.model_mapping, model);
|
||||
const hasExplicitMapping = hasExplicitModelMapping(metadata, model);
|
||||
const declaredAllows =
|
||||
declaredModels.length === 0
|
||||
? true
|
||||
: (mapped ? declaredModels.includes(mapped) : false) ||
|
||||
declaredModels.includes(model);
|
||||
if (!declaredAllows) {
|
||||
declaredModels.length > 0
|
||||
? (mapped ? declaredModels.includes(mapped) : false) ||
|
||||
declaredModels.includes(model)
|
||||
: null;
|
||||
const verifiedAllows =
|
||||
verified && verified.size > 0
|
||||
? (mapped ? verified.has(mapped) : false) || verified.has(model)
|
||||
: null;
|
||||
if (hasExplicitMapping) {
|
||||
if (verified && verified.size > 0) {
|
||||
return Boolean(verifiedAllows);
|
||||
}
|
||||
if (declaredModels.length > 0) {
|
||||
return Boolean(declaredAllows);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (declaredModels.length > 0 && !declaredAllows) {
|
||||
return false;
|
||||
}
|
||||
if (verified && verified.size > 0) {
|
||||
if (mapped && verified.has(mapped)) {
|
||||
return true;
|
||||
}
|
||||
return verified.has(model);
|
||||
return Boolean(verifiedAllows);
|
||||
}
|
||||
return true;
|
||||
if (declaredModels.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function selectCandidateChannels(
|
||||
allowedChannels: ChannelRecord[],
|
||||
downstreamModel: string | null,
|
||||
verifiedModelsByChannel: Map<string, Set<string>>,
|
||||
verifiedModelsByChannel: Map<string, Set<string>> = new Map(),
|
||||
): ChannelRecord[] {
|
||||
const modelChannels = allowedChannels.filter((channel) =>
|
||||
channelSupportsModel(channel, downstreamModel, verifiedModelsByChannel),
|
||||
);
|
||||
if (modelChannels.length > 0) {
|
||||
return modelChannels;
|
||||
}
|
||||
// Fallback to all allowed channels. Downstream protocol should not restrict
|
||||
// upstream channel selection.
|
||||
return allowedChannels;
|
||||
return modelChannels;
|
||||
}
|
||||
|
||||
function hasExplicitModelMapping(
|
||||
@@ -184,7 +209,7 @@ export function resolveUpstreamModelForChannel(
|
||||
channel: ChannelRecord,
|
||||
metadata: ChannelMetadata,
|
||||
downstreamModel: string | null,
|
||||
verifiedModelsByChannel: Map<string, Set<string>>,
|
||||
verifiedModelsByChannel: Map<string, Set<string>> = new Map(),
|
||||
): { model: string | null; autoMapped: boolean } {
|
||||
const mapped = resolveMappedModel(metadata.model_mapping, downstreamModel);
|
||||
if (!downstreamModel || hasExplicitModelMapping(metadata, downstreamModel)) {
|
||||
@@ -219,6 +244,36 @@ function filterAllowedChannels(
|
||||
return channels.filter((channel) => allowedSet.has(channel.id));
|
||||
}
|
||||
|
||||
const normalizeTokenModels = (raw?: string | null): string[] | null => {
|
||||
const parsed = safeJsonParse<string[] | null>(raw ?? null, null);
|
||||
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed.map((item) => String(item));
|
||||
};
|
||||
|
||||
const selectTokenForModel = (
|
||||
tokens: CallTokenItem[],
|
||||
model: string | null,
|
||||
): { token: CallTokenItem | null; hasModelList: boolean } => {
|
||||
if (tokens.length === 0) {
|
||||
return { token: null, hasModelList: false };
|
||||
}
|
||||
if (!model) {
|
||||
return { token: tokens[0], hasModelList: false };
|
||||
}
|
||||
const tokensWithModels = tokens.map((token) => ({
|
||||
token,
|
||||
models: normalizeTokenModels(token.models_json),
|
||||
}));
|
||||
const hasModelList = tokensWithModels.some((entry) => entry.models);
|
||||
if (!hasModelList) {
|
||||
return { token: tokens[0], hasModelList: false };
|
||||
}
|
||||
const match = tokensWithModels.find((entry) => entry.models?.includes(model));
|
||||
return { token: match?.token ?? null, hasModelList };
|
||||
};
|
||||
|
||||
function resolveChannelBaseUrl(channel: ChannelRecord): string {
|
||||
return normalizeBaseUrl(channel.base_url);
|
||||
}
|
||||
@@ -297,6 +352,7 @@ async function fetchWithTimeout(
|
||||
*/
|
||||
proxy.all("/*", tokenAuth, async (c) => {
|
||||
const tokenRecord = c.get("tokenRecord") as TokenRecord;
|
||||
const requestStart = Date.now();
|
||||
let requestText = await c.req.text();
|
||||
const parsedBody = requestText
|
||||
? safeJsonParse<Record<string, unknown> | null>(requestText, null)
|
||||
@@ -361,6 +417,33 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
);
|
||||
}
|
||||
|
||||
const recordEarlyUsage = (options: {
|
||||
status: number;
|
||||
code: string;
|
||||
message?: string | null;
|
||||
}) => {
|
||||
const latencyMs = Date.now() - requestStart;
|
||||
const errorMessage = options.message ?? options.code;
|
||||
scheduleDbWrite(
|
||||
c,
|
||||
recordUsage(c.env.DB, {
|
||||
tokenId: tokenRecord.id,
|
||||
channelId: null,
|
||||
model: downstreamModel,
|
||||
requestPath: c.req.path,
|
||||
totalTokens: 0,
|
||||
latencyMs,
|
||||
firstTokenLatencyMs: isStream ? null : latencyMs,
|
||||
stream: isStream,
|
||||
reasoningEffort,
|
||||
status: "error",
|
||||
upstreamStatus: options.status,
|
||||
errorCode: options.code,
|
||||
errorMessage,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const channelResult = await c.env.DB.prepare(
|
||||
"SELECT * FROM channels WHERE status = ?",
|
||||
)
|
||||
@@ -377,18 +460,16 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
channel_id: row.channel_id,
|
||||
name: row.name,
|
||||
api_key: row.api_key,
|
||||
models_json: row.models_json ?? null,
|
||||
};
|
||||
const list = callTokenMap.get(row.channel_id) ?? [];
|
||||
list.push(entry);
|
||||
callTokenMap.set(row.channel_id, list);
|
||||
}
|
||||
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
|
||||
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
|
||||
const verifiedModelsByChannel = await listVerifiedModelsByChannel(
|
||||
c.env.DB,
|
||||
allowedChannels.map((channel) => channel.id),
|
||||
ttlSeconds,
|
||||
);
|
||||
let candidates = selectCandidateChannels(
|
||||
allowedChannels,
|
||||
@@ -403,6 +484,7 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
candidates.map((channel) => channel.id),
|
||||
downstreamModel,
|
||||
cooldownSeconds,
|
||||
FAILURE_COUNT_THRESHOLD,
|
||||
);
|
||||
if (coolingChannels.size > 0) {
|
||||
candidates = candidates.filter(
|
||||
@@ -415,6 +497,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
cooldown_minutes: cooldownMinutes,
|
||||
blocked_channels: coolingChannels.size,
|
||||
});
|
||||
recordEarlyUsage({
|
||||
status: 503,
|
||||
code: "upstream_cooldown",
|
||||
message: "upstream_cooldown",
|
||||
});
|
||||
return jsonError(
|
||||
c,
|
||||
503,
|
||||
@@ -434,6 +521,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
});
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
recordEarlyUsage({
|
||||
status: 503,
|
||||
code: "no_available_channels",
|
||||
message: "no_available_channels",
|
||||
});
|
||||
return jsonError(c, 503, "no_available_channels", "no_available_channels");
|
||||
}
|
||||
const targetPath = c.req.path;
|
||||
@@ -451,7 +543,6 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
let lastChannel: ChannelRecord | null = null;
|
||||
let lastRequestPath = targetPath;
|
||||
let lastErrorDetails: ErrorDetails | null = null;
|
||||
const start = Date.now();
|
||||
let selectedChannel: ChannelRecord | null = null;
|
||||
let selectedUpstreamProvider: ProviderType | null = null;
|
||||
let selectedUpstreamModel: string | null = null;
|
||||
@@ -476,8 +567,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
}
|
||||
const baseUrl = resolveChannelBaseUrl(channel);
|
||||
const tokens = callTokenMap.get(channel.id) ?? [];
|
||||
const selectedToken = tokens.length > 0 ? selectCallToken(tokens) : null;
|
||||
const apiKey = selectedToken?.api_key ?? channel.api_key;
|
||||
const selection = selectTokenForModel(tokens, recordModel);
|
||||
if (!selection.token && selection.hasModelList && recordModel) {
|
||||
continue;
|
||||
}
|
||||
const apiKey = selection.token?.api_key ?? channel.api_key;
|
||||
const headers = buildUpstreamHeaders(
|
||||
new Headers(c.req.header()),
|
||||
upstreamProvider,
|
||||
@@ -588,6 +682,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
} | null = null;
|
||||
if (endpointType === "chat" || endpointType === "responses") {
|
||||
if (!normalizedChat) {
|
||||
recordEarlyUsage({
|
||||
status: 400,
|
||||
code: "invalid_body",
|
||||
message: "invalid_body",
|
||||
});
|
||||
return jsonError(c, 400, "invalid_body", "invalid_body");
|
||||
}
|
||||
const request = buildUpstreamChatRequest(
|
||||
@@ -607,6 +706,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
};
|
||||
} else if (endpointType === "embeddings") {
|
||||
if (!normalizedEmbedding) {
|
||||
recordEarlyUsage({
|
||||
status: 400,
|
||||
code: "invalid_body",
|
||||
message: "invalid_body",
|
||||
});
|
||||
return jsonError(c, 400, "invalid_body", "invalid_body");
|
||||
}
|
||||
const request = buildUpstreamEmbeddingRequest(
|
||||
@@ -624,6 +728,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
};
|
||||
} else if (endpointType === "images") {
|
||||
if (!normalizedImage) {
|
||||
recordEarlyUsage({
|
||||
status: 400,
|
||||
code: "invalid_body",
|
||||
message: "invalid_body",
|
||||
});
|
||||
return jsonError(c, 400, "invalid_body", "invalid_body");
|
||||
}
|
||||
const request = buildUpstreamImageRequest(
|
||||
@@ -718,7 +827,11 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
errorCode: errorInfo.errorCode,
|
||||
errorMessage: errorInfo.errorMessage,
|
||||
};
|
||||
if (recordModel) {
|
||||
const cooldownEligible = shouldCooldown(
|
||||
response.status,
|
||||
errorInfo.errorCode,
|
||||
);
|
||||
if (recordModel && cooldownSeconds > 0 && cooldownEligible) {
|
||||
scheduleDbWrite(
|
||||
c,
|
||||
recordChannelModelError(
|
||||
@@ -730,6 +843,23 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
downstreamModel &&
|
||||
downstreamModel !== recordModel &&
|
||||
cooldownSeconds > 0 &&
|
||||
cooldownEligible
|
||||
) {
|
||||
scheduleDbWrite(
|
||||
c,
|
||||
recordChannelModelError(
|
||||
c.env.DB,
|
||||
channel.id,
|
||||
downstreamModel,
|
||||
String(response.status),
|
||||
nowSeconds,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error instanceof Error &&
|
||||
@@ -747,11 +877,15 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
lastErrorDetails = {
|
||||
upstreamStatus: null,
|
||||
errorCode: isTimeout ? "timeout" : "exception",
|
||||
errorMessage: truncateMessage(
|
||||
errorMessage: normalizeMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
};
|
||||
if (recordModel) {
|
||||
const cooldownEligible = shouldCooldown(
|
||||
null,
|
||||
isTimeout ? "timeout" : "exception",
|
||||
);
|
||||
if (recordModel && cooldownSeconds > 0 && cooldownEligible) {
|
||||
scheduleDbWrite(
|
||||
c,
|
||||
recordChannelModelError(
|
||||
@@ -763,11 +897,28 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
downstreamModel &&
|
||||
downstreamModel !== recordModel &&
|
||||
cooldownSeconds > 0 &&
|
||||
cooldownEligible
|
||||
) {
|
||||
scheduleDbWrite(
|
||||
c,
|
||||
recordChannelModelError(
|
||||
c.env.DB,
|
||||
channel.id,
|
||||
downstreamModel,
|
||||
isTimeout ? "timeout" : "exception",
|
||||
nowSeconds,
|
||||
),
|
||||
);
|
||||
}
|
||||
lastResponse = null;
|
||||
}
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
const latencyMs = Date.now() - requestStart;
|
||||
if (!selectedChannel && lastResponse && !lastResponse.ok) {
|
||||
console.warn("[proxy:upstream_exhausted]", {
|
||||
path: targetPath,
|
||||
@@ -838,7 +989,7 @@ proxy.all("/*", tokenAuth, async (c) => {
|
||||
reasoningEffort,
|
||||
status: lastResponse.ok ? "ok" : "error",
|
||||
upstreamStatus: lastResponse.ok
|
||||
? null
|
||||
? lastResponse.status
|
||||
: errorDetails?.upstreamStatus ?? lastResponse.status,
|
||||
errorCode: lastResponse.ok ? null : errorDetails?.errorCode ?? null,
|
||||
errorMessage: lastResponse.ok
|
||||
|
||||
@@ -8,14 +8,12 @@ import {
|
||||
getCheckinScheduleTime,
|
||||
getRetentionDays,
|
||||
getSessionTtlHours,
|
||||
getModelCapabilityTtlHours,
|
||||
getModelFailureCooldownMinutes,
|
||||
isAdminPasswordSet,
|
||||
setAdminPasswordHash,
|
||||
setCheckinScheduleTime,
|
||||
setRetentionDays,
|
||||
setSessionTtlHours,
|
||||
setModelCapabilityTtlHours,
|
||||
setModelFailureCooldownMinutes,
|
||||
} from "../services/settings";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
@@ -31,7 +29,6 @@ settings.get("/", async (c) => {
|
||||
const sessionTtlHours = await getSessionTtlHours(c.env.DB);
|
||||
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
|
||||
const checkinScheduleTime = await getCheckinScheduleTime(c.env.DB);
|
||||
const modelCapabilityTtlHours = await getModelCapabilityTtlHours(c.env.DB);
|
||||
const modelFailureCooldownMinutes = await getModelFailureCooldownMinutes(
|
||||
c.env.DB,
|
||||
);
|
||||
@@ -40,7 +37,6 @@ settings.get("/", async (c) => {
|
||||
session_ttl_hours: sessionTtlHours,
|
||||
admin_password_set: adminPasswordSet,
|
||||
checkin_schedule_time: checkinScheduleTime,
|
||||
model_capability_ttl_hours: modelCapabilityTtlHours,
|
||||
model_failure_cooldown_minutes: modelFailureCooldownMinutes,
|
||||
});
|
||||
});
|
||||
@@ -86,20 +82,6 @@ settings.put("/", async (c) => {
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (body.model_capability_ttl_hours !== undefined) {
|
||||
const hours = Number(body.model_capability_ttl_hours);
|
||||
if (Number.isNaN(hours) || hours < 1) {
|
||||
return jsonError(
|
||||
c,
|
||||
400,
|
||||
"invalid_model_capability_ttl_hours",
|
||||
"invalid_model_capability_ttl_hours",
|
||||
);
|
||||
}
|
||||
await setModelCapabilityTtlHours(c.env.DB, hours);
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (body.model_failure_cooldown_minutes !== undefined) {
|
||||
const minutes = Number(body.model_failure_cooldown_minutes);
|
||||
if (Number.isNaN(minutes) || minutes < 1) {
|
||||
|
||||
@@ -16,6 +16,43 @@ type TokenRow = {
|
||||
status: string;
|
||||
allowed_channels: string | null;
|
||||
token_plain?: string | null;
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
const normalizeAllowedChannels = (
|
||||
raw: string | null,
|
||||
): string[] | null => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = safeJsonParse<string[] | null>(raw, null);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed.map((item) => String(item));
|
||||
};
|
||||
|
||||
const normalizeExpiresAt = (
|
||||
value: unknown,
|
||||
): { value: string | null; valid: boolean } => {
|
||||
if (value === undefined) {
|
||||
return { value: null, valid: true };
|
||||
}
|
||||
if (value === null) {
|
||||
return { value: null, valid: true };
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return { value: null, valid: false };
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return { value: null, valid: true };
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return { value: null, valid: false };
|
||||
}
|
||||
return { value: new Date(parsed).toISOString(), valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,9 +60,13 @@ type TokenRow = {
|
||||
*/
|
||||
tokens.get("/", async (c) => {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, created_at, updated_at FROM tokens ORDER BY created_at DESC",
|
||||
"SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, expires_at, created_at, updated_at FROM tokens ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ tokens: result.results ?? [] });
|
||||
const tokens = (result.results ?? []).map((row) => ({
|
||||
...row,
|
||||
allowed_channels: normalizeAllowedChannels(row.allowed_channels ?? null),
|
||||
}));
|
||||
return c.json({ tokens });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -46,9 +87,13 @@ tokens.post("/", async (c) => {
|
||||
body.quota_total === null || body.quota_total === undefined
|
||||
? null
|
||||
: Number(body.quota_total);
|
||||
const expiresAt = normalizeExpiresAt(body.expires_at);
|
||||
if (!expiresAt.valid) {
|
||||
return jsonError(c, 400, "invalid_expires_at", "invalid_expires_at");
|
||||
}
|
||||
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO tokens (id, name, key_hash, key_prefix, token_plain, quota_total, quota_used, status, allowed_channels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO tokens (id, name, key_hash, key_prefix, token_plain, quota_total, quota_used, status, allowed_channels, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
@@ -60,6 +105,7 @@ tokens.post("/", async (c) => {
|
||||
0,
|
||||
body.status ?? "active",
|
||||
JSON.stringify(body.allowed_channels ?? null),
|
||||
expiresAt.value,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
@@ -97,9 +143,17 @@ tokens.patch("/:id", async (c) => {
|
||||
body.quota_used === null || body.quota_used === undefined
|
||||
? existing.quota_used
|
||||
: Number(body.quota_used);
|
||||
let expiresAtUpdate: string | null | undefined = existing.expires_at ?? null;
|
||||
if (Object.hasOwn(body, "expires_at")) {
|
||||
const normalized = normalizeExpiresAt(body.expires_at);
|
||||
if (!normalized.valid) {
|
||||
return jsonError(c, 400, "invalid_expires_at", "invalid_expires_at");
|
||||
}
|
||||
expiresAtUpdate = normalized.value;
|
||||
}
|
||||
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, updated_at = ? WHERE id = ?",
|
||||
"UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, expires_at = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(
|
||||
body.name ?? existing.name,
|
||||
@@ -107,6 +161,7 @@ tokens.patch("/:id", async (c) => {
|
||||
Number.isNaN(quotaUsedUpdate) ? existing.quota_used : quotaUsedUpdate,
|
||||
body.status ?? existing.status,
|
||||
JSON.stringify(body.allowed_channels ?? existingAllowed ?? null),
|
||||
expiresAtUpdate,
|
||||
nowIso(),
|
||||
id,
|
||||
)
|
||||
|
||||
@@ -10,42 +10,92 @@ const usage = new Hono<AppEnv>();
|
||||
*/
|
||||
usage.get("/", async (c) => {
|
||||
const query = c.req.query();
|
||||
const filters: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
let sql =
|
||||
"SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id WHERE 1=1";
|
||||
|
||||
if (query.from) {
|
||||
sql += " AND usage_logs.created_at >= ?";
|
||||
filters.push("usage_logs.created_at >= ?");
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to) {
|
||||
sql += " AND usage_logs.created_at <= ?";
|
||||
filters.push("usage_logs.created_at <= ?");
|
||||
params.push(query.to);
|
||||
}
|
||||
if (query.model) {
|
||||
sql += " AND usage_logs.model = ?";
|
||||
params.push(query.model);
|
||||
const model = String(query.model).trim();
|
||||
if (model) {
|
||||
filters.push("usage_logs.model LIKE ? COLLATE NOCASE");
|
||||
params.push(`%${model}%`);
|
||||
}
|
||||
}
|
||||
if (query.channel_id) {
|
||||
sql += " AND usage_logs.channel_id = ?";
|
||||
filters.push("usage_logs.channel_id = ?");
|
||||
params.push(query.channel_id);
|
||||
}
|
||||
if (query.token_id) {
|
||||
sql += " AND usage_logs.token_id = ?";
|
||||
filters.push("usage_logs.token_id = ?");
|
||||
params.push(query.token_id);
|
||||
}
|
||||
if (query.channel) {
|
||||
const channel = String(query.channel).trim();
|
||||
if (channel) {
|
||||
filters.push("channels.name LIKE ? COLLATE NOCASE");
|
||||
params.push(`%${channel}%`);
|
||||
}
|
||||
}
|
||||
if (query.token) {
|
||||
const token = String(query.token).trim();
|
||||
if (token) {
|
||||
filters.push("tokens.name LIKE ? COLLATE NOCASE");
|
||||
params.push(`%${token}%`);
|
||||
}
|
||||
}
|
||||
if (query.status) {
|
||||
const rawStatus = String(query.status).trim();
|
||||
if (rawStatus) {
|
||||
const numericStatus = Number(rawStatus);
|
||||
if (Number.isNaN(numericStatus)) {
|
||||
filters.push("usage_logs.status LIKE ? COLLATE NOCASE");
|
||||
params.push(`%${rawStatus}%`);
|
||||
} else {
|
||||
filters.push("usage_logs.upstream_status = ?");
|
||||
params.push(numericStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit ?? 50), 200);
|
||||
sql += " ORDER BY usage_logs.created_at DESC LIMIT ?";
|
||||
params.push(Number.isNaN(limit) ? 50 : limit);
|
||||
const rawLimit = Number(query.limit ?? 50);
|
||||
const normalizedLimit = Number.isNaN(rawLimit) ? 50 : Math.floor(rawLimit);
|
||||
const limit = Math.min(Math.max(normalizedLimit, 1), 200);
|
||||
const rawOffset = Number(query.offset ?? 0);
|
||||
const normalizedOffset = Number.isNaN(rawOffset)
|
||||
? 0
|
||||
: Math.max(0, Math.floor(rawOffset));
|
||||
|
||||
const whereSql = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
||||
const baseSql =
|
||||
"FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id";
|
||||
|
||||
const retention = await getRetentionDays(c.env.DB);
|
||||
await pruneUsageLogs(c.env.DB, retention);
|
||||
|
||||
const result = await c.env.DB.prepare(sql)
|
||||
const countRow = await c.env.DB.prepare(
|
||||
`SELECT COUNT(*) as total ${baseSql} ${whereSql}`,
|
||||
)
|
||||
.bind(...params)
|
||||
.first<{ total: number }>();
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
|
||||
const listSql = `SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name ${baseSql} ${whereSql} ORDER BY usage_logs.created_at DESC LIMIT ? OFFSET ?`;
|
||||
const result = await c.env.DB.prepare(listSql)
|
||||
.bind(...params, limit, normalizedOffset)
|
||||
.all();
|
||||
return c.json({ logs: result.results ?? [] });
|
||||
return c.json({
|
||||
logs: result.results ?? [],
|
||||
total,
|
||||
limit,
|
||||
offset: normalizedOffset,
|
||||
});
|
||||
});
|
||||
|
||||
export default usage;
|
||||
@@ -3,6 +3,7 @@ export type CallTokenItem = {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
api_key: string;
|
||||
models_json?: string | null;
|
||||
};
|
||||
|
||||
export const selectCallToken = (
|
||||
|
||||
@@ -79,3 +79,18 @@ export async function replaceCallTokensForChannel(
|
||||
await insertCallToken(db, token);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCallTokenModels(
|
||||
db: D1Database,
|
||||
tokenId: string,
|
||||
models: string[],
|
||||
updatedAt: string,
|
||||
): Promise<void> {
|
||||
const modelsJson = models.length > 0 ? JSON.stringify(models) : null;
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE channel_call_tokens SET models_json = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(modelsJson, updatedAt, tokenId)
|
||||
.run();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export type ChannelCallTokenRow = {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
api_key: string;
|
||||
models_json?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
@@ -3,17 +3,15 @@ import { nowIso } from "../utils/time";
|
||||
import { extractModelIds } from "./channel-models";
|
||||
import type { ModelEntry } from "./channel-models";
|
||||
|
||||
export const DEFAULT_MODEL_CAPABILITY_TTL_SEC = 2 * 60 * 60;
|
||||
|
||||
export type CapabilityRow = {
|
||||
channel_id: string;
|
||||
model: string;
|
||||
last_ok_at: number | null;
|
||||
last_err_count?: number | null;
|
||||
};
|
||||
|
||||
export function buildCapabilityMap(
|
||||
rows: CapabilityRow[],
|
||||
cutoff: number,
|
||||
): Map<string, Set<string>> {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const row of rows) {
|
||||
@@ -21,7 +19,7 @@ export function buildCapabilityMap(
|
||||
continue;
|
||||
}
|
||||
const lastOk = Number(row.last_ok_at ?? 0);
|
||||
if (!lastOk || lastOk < cutoff) {
|
||||
if (!lastOk || lastOk <= 0) {
|
||||
continue;
|
||||
}
|
||||
const set = map.get(row.channel_id) ?? new Set<string>();
|
||||
@@ -34,30 +32,27 @@ export function buildCapabilityMap(
|
||||
export async function listVerifiedModelsByChannel(
|
||||
db: D1Database,
|
||||
channelIds: string[],
|
||||
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
if (channelIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const cutoff = Math.floor(Date.now() / 1000) - ttlSeconds;
|
||||
const placeholders = channelIds.map(() => "?").join(", ");
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT channel_id, model, last_ok_at FROM channel_model_capabilities WHERE channel_id IN (${placeholders}) AND last_ok_at >= ?`,
|
||||
`SELECT channel_id, model, last_ok_at FROM channel_model_capabilities WHERE channel_id IN (${placeholders}) AND last_ok_at > 0`,
|
||||
)
|
||||
.bind(...channelIds, cutoff)
|
||||
.bind(...channelIds)
|
||||
.all<CapabilityRow>();
|
||||
return buildCapabilityMap(rows.results ?? [], cutoff);
|
||||
return buildCapabilityMap(rows.results ?? []);
|
||||
}
|
||||
|
||||
export async function listVerifiedModelEntries(
|
||||
db: D1Database,
|
||||
channels: Array<{ id: string; name: string }>,
|
||||
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
|
||||
): Promise<ModelEntry[]> {
|
||||
const ids = channels.map((channel) => channel.id);
|
||||
const nameMap = new Map(channels.map((channel) => [channel.id, channel.name]));
|
||||
const map = await listVerifiedModelsByChannel(db, ids, ttlSeconds);
|
||||
const map = await listVerifiedModelsByChannel(db, ids);
|
||||
const entries: ModelEntry[] = [];
|
||||
for (const [channelId, models] of map.entries()) {
|
||||
const channelName = nameMap.get(channelId) ?? channelId;
|
||||
@@ -71,10 +66,9 @@ export async function listVerifiedModelEntries(
|
||||
export async function listModelsByChannelWithFallback(
|
||||
db: D1Database,
|
||||
channels: Array<{ id: string; name: string; models_json?: string | null }>,
|
||||
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
const ids = channels.map((channel) => channel.id);
|
||||
const verified = await listVerifiedModelsByChannel(db, ids, ttlSeconds);
|
||||
const verified = await listVerifiedModelsByChannel(db, ids);
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const channel of channels) {
|
||||
const verifiedModels = verified.get(channel.id);
|
||||
@@ -93,9 +87,8 @@ export async function listModelsByChannelWithFallback(
|
||||
export async function listModelEntriesWithFallback(
|
||||
db: D1Database,
|
||||
channels: Array<{ id: string; name: string; models_json?: string | null }>,
|
||||
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
|
||||
): Promise<ModelEntry[]> {
|
||||
const map = await listModelsByChannelWithFallback(db, channels, ttlSeconds);
|
||||
const map = await listModelsByChannelWithFallback(db, channels);
|
||||
const entries: ModelEntry[] = [];
|
||||
for (const channel of channels) {
|
||||
const models = map.get(channel.id);
|
||||
@@ -114,6 +107,7 @@ export async function listCoolingDownChannelsForModel(
|
||||
channelIds: string[],
|
||||
model: string | null,
|
||||
cooldownSeconds: number,
|
||||
minErrorCount: number = 1,
|
||||
): Promise<Set<string>> {
|
||||
if (!model || channelIds.length === 0 || cooldownSeconds <= 0) {
|
||||
return new Set();
|
||||
@@ -123,15 +117,21 @@ export async function listCoolingDownChannelsForModel(
|
||||
const placeholders = channelIds.map(() => "?").join(", ");
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT channel_id, last_err_at, last_ok_at FROM channel_model_capabilities WHERE model = ? AND channel_id IN (${placeholders}) AND last_err_at IS NOT NULL AND last_err_at >= ?`,
|
||||
`SELECT channel_id, last_err_at, last_ok_at, last_err_count FROM channel_model_capabilities WHERE model = ? AND channel_id IN (${placeholders}) AND last_err_at IS NOT NULL AND last_err_at >= ?`,
|
||||
)
|
||||
.bind(model, ...channelIds, cutoff)
|
||||
.all<{ channel_id: string; last_err_at: number | null; last_ok_at: number | null }>();
|
||||
.all<{
|
||||
channel_id: string;
|
||||
last_err_at: number | null;
|
||||
last_ok_at: number | null;
|
||||
last_err_count?: number | null;
|
||||
}>();
|
||||
const blocked = new Set<string>();
|
||||
for (const row of rows.results ?? []) {
|
||||
const lastErr = Number(row.last_err_at ?? 0);
|
||||
const lastOk = Number(row.last_ok_at ?? 0);
|
||||
if (lastErr && lastErr >= lastOk) {
|
||||
const errCount = Number(row.last_err_count ?? 0);
|
||||
if (lastErr && lastErr >= lastOk && errCount >= minErrorCount) {
|
||||
blocked.add(row.channel_id);
|
||||
}
|
||||
}
|
||||
@@ -151,9 +151,16 @@ export async function recordChannelModelError(
|
||||
const timestamp = nowIso();
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, created_at, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_err_at = excluded.last_err_at, last_err_code = excluded.last_err_code, updated_at = excluded.updated_at",
|
||||
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, last_err_count, created_at, updated_at) VALUES (?, ?, 0, ?, ?, 1, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_err_at = excluded.last_err_at, last_err_code = excluded.last_err_code, last_err_count = COALESCE(channel_model_capabilities.last_err_count, 0) + 1, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(
|
||||
channelId,
|
||||
model,
|
||||
nowSeconds,
|
||||
errorCode,
|
||||
timestamp,
|
||||
timestamp,
|
||||
)
|
||||
.bind(channelId, model, nowSeconds, errorCode, timestamp, timestamp)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -168,7 +175,7 @@ export async function upsertChannelModelCapabilities(
|
||||
}
|
||||
const timestamp = nowIso();
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_ok_at = excluded.last_ok_at, last_err_at = NULL, last_err_code = NULL, updated_at = excluded.updated_at",
|
||||
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, last_err_count, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, 0, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_ok_at = excluded.last_ok_at, last_err_at = NULL, last_err_code = NULL, last_err_count = 0, updated_at = excluded.updated_at",
|
||||
);
|
||||
const statements = models.map((model) =>
|
||||
stmt.bind(channelId, model, nowSeconds, timestamp, timestamp),
|
||||
|
||||
@@ -144,7 +144,13 @@ export async function updateChannelTestResult(
|
||||
},
|
||||
): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const status = result.ok ? "active" : "error";
|
||||
const current = await db
|
||||
.prepare("SELECT status FROM channels WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ status: string }>();
|
||||
const currentStatus = current?.status ?? "active";
|
||||
const status =
|
||||
currentStatus === "disabled" ? "disabled" : result.ok ? "active" : "error";
|
||||
const modelsJson =
|
||||
result.modelsJson ??
|
||||
(result.models ? modelsToJson(result.models) : undefined);
|
||||
|
||||
@@ -4,13 +4,11 @@ import { nowIso } from "../utils/time";
|
||||
const DEFAULT_LOG_RETENTION_DAYS = 30;
|
||||
const DEFAULT_SESSION_TTL_HOURS = 12;
|
||||
const DEFAULT_CHECKIN_SCHEDULE_TIME = "00:10";
|
||||
const DEFAULT_MODEL_CAPABILITY_TTL_HOURS = 2;
|
||||
const DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES = 10;
|
||||
const RETENTION_KEY = "log_retention_days";
|
||||
const SESSION_TTL_KEY = "session_ttl_hours";
|
||||
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
|
||||
const CHECKIN_SCHEDULE_TIME_KEY = "checkin_schedule_time";
|
||||
const MODEL_CAPABILITY_TTL_KEY = "model_capability_ttl_hours";
|
||||
const MODEL_FAILURE_COOLDOWN_KEY = "model_failure_cooldown_minutes";
|
||||
|
||||
async function readSetting(
|
||||
@@ -130,21 +128,6 @@ export async function setCheckinScheduleTime(
|
||||
await upsertSetting(db, CHECKIN_SCHEDULE_TIME_KEY, time);
|
||||
}
|
||||
|
||||
export async function getModelCapabilityTtlHours(
|
||||
db: D1Database,
|
||||
): Promise<number> {
|
||||
const value = await readSetting(db, MODEL_CAPABILITY_TTL_KEY);
|
||||
return parsePositiveNumber(value, DEFAULT_MODEL_CAPABILITY_TTL_HOURS);
|
||||
}
|
||||
|
||||
export async function setModelCapabilityTtlHours(
|
||||
db: D1Database,
|
||||
hours: number,
|
||||
): Promise<void> {
|
||||
const value = Math.max(1, Math.floor(hours)).toString();
|
||||
await upsertSetting(db, MODEL_CAPABILITY_TTL_KEY, value);
|
||||
}
|
||||
|
||||
export async function getModelFailureCooldownMinutes(
|
||||
db: D1Database,
|
||||
): Promise<number> {
|
||||
|
||||
@@ -18,11 +18,11 @@ const buildLog = (partial: Partial<{ status: string; upstream_status: number | n
|
||||
describe("usage status detail", () => {
|
||||
it("renders success label", () => {
|
||||
const detail = buildUsageStatusDetail(buildLog({ status: "ok" }) as any);
|
||||
expect(detail.label).toBe("成功");
|
||||
expect(detail.label).toBe("-");
|
||||
expect(detail.tone).toBe("success");
|
||||
});
|
||||
|
||||
it("renders failure with status and code", () => {
|
||||
it("renders failure with status code", () => {
|
||||
const detail = buildUsageStatusDetail(
|
||||
buildLog({
|
||||
status: "error",
|
||||
@@ -31,9 +31,7 @@ describe("usage status detail", () => {
|
||||
error_message: "Invalid API key",
|
||||
}) as any,
|
||||
);
|
||||
expect(detail.label).toContain("失败");
|
||||
expect(detail.label).toContain("403");
|
||||
expect(detail.label).toContain("authentication_error");
|
||||
expect(detail.message).toBe("Invalid API key");
|
||||
expect(detail.label).toBe("403");
|
||||
expect(detail.tone).toBe("error");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user