refactor(app): 管理台模块化并整理 worker API 类型

- 拆分管理台入口为 App/feature/core 结构并更新样式/配置
- 规范 worker 路由/中间件/服务类型与 JSON 解析
- 同步工具链与知识库记录
This commit is contained in:
lsy committed 2026-02-16 00:48:49 +08:00
1 parent dd3fe3033b
commit 3bcc9ebb99
63 files changed
+4496 -2647

No files matched your search

+16 -11
View File
@@ -24,13 +24,15 @@ bun run dev:worker
环境变量(`apps/worker/wrangler.toml` 或 `wrangler secret put`):
- `ADMIN_PASSWORD` 管理员密码
- `SESSION_TTL_HOURS` 管理会话时长(小时)
- `LOG_RETENTION_DAYS` 日志保留天数
- `CORS_ORIGIN` 允许的管理台来源
- `PROXY_RETRY_ROUNDS` 代理失败轮询次数(默认 2)
- `PROXY_RETRY_DELAY_MS` 轮询间隔(毫秒,默认 200)
- `NEW_API_ADMIN_TOKEN` New API 兼容接口的管理员令牌
系统设置(管理台 → 系统设置):
- 日志保留天数(默认 30)
- 会话时长(小时,默认 12)
- 管理员密码(首次登录自动初始化,可在系统设置中修改)
### 3) Admin UI
@@ -56,7 +58,7 @@ bun run fix
为了支持 all-api-hub 等插件同步渠道,Worker 提供 New API 风格的渠道管理接口:
- 路径前缀:`/api/channel`
- 认证方式:`Authorization: Bearer {NEW_API_ADMIN_TOKEN}`(或管理台登录 token)
- 认证方式:`Authorization: Bearer {管理员密码}`(或管理台登录 token)
- 可选请求头:`New-Api-User: 1`
支持的常用接口:
@@ -79,6 +81,14 @@ bun run fix
bun run --filter new-api-lite-worker db:migrate
```
## 忘记管理员密码
管理员密码存储为哈希,可通过删除设置记录来重置,下一次登录会用输入的密码重新初始化:
```bash
bunx wrangler d1 execute DB --command "DELETE FROM settings WHERE key = 'admin_password_hash';"
```
## 生产部署
### 1) 准备 Cloudflare 资源
@@ -89,12 +99,7 @@ bun run --filter new-api-lite-worker db:migrate
bunx wrangler d1 create new_api_lite
```
2. 设置 Worker 变量与密钥(推荐用 secret):
```bash
bunx wrangler secret put ADMIN_PASSWORD
bunx wrangler secret put NEW_API_ADMIN_TOKEN
```
2. 设置 Worker 变量与密钥(如 CORS_ORIGIN):
其他变量可继续放在 `apps/worker/wrangler.toml` 的 `[vars]` 中,`CORS_ORIGIN` 需指向管理台域名。
+1 -1
View File
@@ -12,7 +12,7 @@
<div id="app"></div>
<script
type="module"
src="/src/main.ts"
src="/src/App.tsx"
></script>
</body>
</html>
+18 -12
View File
@@ -1,14 +1,20 @@
{
"name": "new-api-lite-admin",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.4.5",
"vite": "^5.2.0"
}
"name": "new-api-lite-admin",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.4.5",
"vite": "^5.2.0"
},
"dependencies": {
"hono": "^4.11.9"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+566
View File
@@ -0,0 +1,566 @@
import "./styles.css";
import {
render,
useCallback,
useEffect,
useMemo,
useState,
} from "hono/jsx/dom";
import { createApiFetch } from "./core/api";
import {
initialChannelForm,
initialData,
initialSettingsForm,
tabs,
} from "./core/constants";
import type {
AdminData,
Channel,
ChannelForm,
DashboardData,
Settings,
SettingsForm,
TabId,
Token,
UsageLog,
} from "./core/types";
import { toggleStatus } from "./core/utils";
import { AppLayout } from "./features/AppLayout";
import { ChannelsView } from "./features/ChannelsView";
import { DashboardView } from "./features/DashboardView";
import { LoginView } from "./features/LoginView";
import { ModelsView } from "./features/ModelsView";
import { SettingsView } from "./features/SettingsView";
import { TokensView } from "./features/TokensView";
import { UsageView } from "./features/UsageView";
const root = document.querySelector<HTMLDivElement>("#app");
if (!root) {
throw new Error("Missing #app root");
}
/**
* Renders the admin console application.
*
* Returns:
* Root application JSX element.
*/
const App = () => {
const [token, setToken] = useState<string | null>(() =>
localStorage.getItem("admin_token"),
);
const [activeTab, setActiveTab] = useState<TabId>("dashboard");
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [data, setData] = useState<AdminData>(initialData);
const [settingsForm, setSettingsForm] =
useState<SettingsForm>(initialSettingsForm);
const [channelPage, setChannelPage] = useState(1);
const [channelPageSize, setChannelPageSize] = useState(10);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [channelForm, setChannelForm] = useState<ChannelForm>(() => ({
...initialChannelForm,
}));
const [isChannelModalOpen, setChannelModalOpen] = useState(false);
const updateToken = useCallback((next: string | null) => {
setToken(next);
if (next) {
localStorage.setItem("admin_token", next);
} else {
localStorage.removeItem("admin_token");
}
}, []);
const apiFetch = useMemo(
() => createApiFetch(token, () => updateToken(null)),
[token, updateToken],
);
const loadDashboard = useCallback(async () => {
const dashboard = await apiFetch<DashboardData>("/api/dashboard");
setData((prev) => ({ ...prev, dashboard }));
}, [apiFetch]);
const loadChannels = useCallback(async () => {
const result = await apiFetch<{ channels: Channel[] }>("/api/channels");
setData((prev) => ({ ...prev, channels: result.channels }));
}, [apiFetch]);
const loadModels = useCallback(async () => {
const result = await apiFetch<{
models: Array<{
id: string;
channels: Array<{ id: string; name: string }>;
}>;
}>("/api/models");
setData((prev) => ({ ...prev, models: result.models }));
}, [apiFetch]);
const loadTokens = useCallback(async () => {
const result = await apiFetch<{ tokens: Token[] }>("/api/tokens");
setData((prev) => ({ ...prev, tokens: result.tokens }));
}, [apiFetch]);
const loadUsage = useCallback(async () => {
const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=50");
setData((prev) => ({ ...prev, usage: result.logs }));
}, [apiFetch]);
const loadSettings = useCallback(async () => {
const settings = await apiFetch<Settings>("/api/settings");
setData((prev) => ({ ...prev, settings }));
}, [apiFetch]);
const loadTab = useCallback(
async (tabId: TabId) => {
setLoading(true);
setNotice("");
try {
if (tabId === "dashboard") {
await loadDashboard();
}
if (tabId === "channels") {
await loadChannels();
}
if (tabId === "models") {
await loadModels();
}
if (tabId === "tokens") {
await loadTokens();
}
if (tabId === "usage") {
await loadUsage();
}
if (tabId === "settings") {
await loadSettings();
}
} catch (error) {
setNotice((error as Error).message);
} finally {
setLoading(false);
}
},
[
loadChannels,
loadDashboard,
loadModels,
loadSettings,
loadTokens,
loadUsage,
],
);
useEffect(() => {
if (token) {
loadTab(activeTab);
}
}, [token, activeTab, loadTab]);
useEffect(() => {
if (!data.settings) {
return;
}
setSettingsForm({
log_retention_days: String(data.settings.log_retention_days ?? 30),
session_ttl_hours: String(data.settings.session_ttl_hours ?? 12),
admin_password: "",
});
}, [data.settings]);
const handleLogin = useCallback(
async (event: Event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const password = String(formData.get("password") ?? "");
try {
const result = await apiFetch<{ token: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
});
updateToken(result.token);
setNotice("");
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, updateToken],
);
const handleLogout = useCallback(async () => {
await apiFetch("/api/auth/logout", { method: "POST" }).catch(() => null);
updateToken(null);
}, [apiFetch, updateToken]);
const handleChannelFormChange = useCallback((patch: Partial<ChannelForm>) => {
setChannelForm((prev) => ({ ...prev, ...patch }));
}, []);
const handleSettingsFormChange = useCallback(
(patch: Partial<SettingsForm>) => {
setSettingsForm((prev) => ({ ...prev, ...patch }));
},
[],
);
const handleChannelPageChange = useCallback((next: number) => {
setChannelPage(next);
}, []);
const handleChannelPageSizeChange = useCallback((next: number) => {
setChannelPageSize(next);
setChannelPage(1);
}, []);
const handleTabChange = useCallback((tabId: TabId) => {
setActiveTab(tabId);
}, []);
const closeChannelModal = useCallback(() => {
setEditingChannel(null);
setChannelForm({ ...initialChannelForm });
setChannelModalOpen(false);
}, []);
const openChannelCreate = useCallback(() => {
setEditingChannel(null);
setChannelForm({ ...initialChannelForm });
setChannelModalOpen(true);
setNotice("");
}, []);
const startChannelEdit = useCallback((channel: Channel) => {
setEditingChannel(channel);
setChannelForm({
name: channel.name ?? "",
base_url: channel.base_url ?? "",
api_key: channel.api_key ?? "",
weight: channel.weight ?? 1,
});
setChannelModalOpen(true);
setNotice("");
}, []);
const handleChannelSubmit = useCallback(
async (event: Event) => {
event.preventDefault();
const channelName = channelForm.name.trim();
const normalizedName = channelName.toLowerCase();
const nameExists = data.channels.some(
(channel) =>
channel.name.trim().toLowerCase() === normalizedName &&
channel.id !== editingChannel?.id,
);
if (nameExists) {
setNotice("渠道名称已存在,请使用其他名称");
return;
}
try {
const body = {
name: channelName,
base_url: channelForm.base_url.trim(),
api_key: channelForm.api_key.trim(),
weight: Number(channelForm.weight),
};
if (editingChannel) {
await apiFetch(`/api/channels/${editingChannel.id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
setNotice("渠道已更新");
} else {
await apiFetch("/api/channels", {
method: "POST",
body: JSON.stringify(body),
});
setNotice("渠道已创建");
}
closeChannelModal();
await loadChannels();
} catch (error) {
setNotice((error as Error).message);
}
},
[
apiFetch,
channelForm,
closeChannelModal,
data.channels,
editingChannel,
loadChannels,
],
);
const handleTokenSubmit = useCallback(
async (event: Event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries()) as Record<
string,
FormDataEntryValue
>;
try {
const result = await apiFetch<{ token: string }>("/api/tokens", {
method: "POST",
body: JSON.stringify({
name: payload.name,
quota_total: payload.quota_total
? Number(payload.quota_total)
: null,
}),
});
setNotice(`新令牌: ${result.token}`);
form.reset();
await loadTokens();
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadTokens],
);
const handleSettingsSubmit = useCallback(
async (event: Event) => {
event.preventDefault();
const retention = Number(settingsForm.log_retention_days);
const sessionTtlHours = Number(settingsForm.session_ttl_hours);
const payload: Record<string, number | string> = {
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
};
const password = settingsForm.admin_password.trim();
if (password) {
payload.admin_password = password;
}
try {
await apiFetch("/api/settings", {
method: "PUT",
body: JSON.stringify(payload),
});
await loadSettings();
setSettingsForm((prev) => ({ ...prev, admin_password: "" }));
setNotice("设置已更新");
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadSettings, settingsForm],
);
const handleChannelTest = useCallback(
async (id: string) => {
try {
const result = await apiFetch<{ models: Array<{ id: string }> }>(
`/api/channels/${id}/test`,
{
method: "POST",
},
);
await loadChannels();
setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`);
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadChannels],
);
const handleChannelDelete = useCallback(
async (id: string) => {
try {
await apiFetch(`/api/channels/${id}`, { method: "DELETE" });
await loadChannels();
setNotice("渠道已删除");
if (editingChannel?.id === id) {
closeChannelModal();
}
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, closeChannelModal, editingChannel, loadChannels],
);
const handleChannelToggle = useCallback(
async (id: string, status: string) => {
try {
const next = toggleStatus(status);
await apiFetch(`/api/channels/${id}`, {
method: "PATCH",
body: JSON.stringify({ status: next }),
});
await loadChannels();
setNotice(`渠道已${next === "active" ? "启用" : "停用"}`);
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadChannels],
);
const handleTokenDelete = useCallback(
async (id: string) => {
try {
await apiFetch(`/api/tokens/${id}`, { method: "DELETE" });
await loadTokens();
setNotice("令牌已删除");
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadTokens],
);
const handleTokenReveal = useCallback(
async (id: string) => {
try {
const result = await apiFetch<{ token: string | null }>(
`/api/tokens/${id}/reveal`,
);
setNotice(result.token ? `令牌: ${result.token}` : "未找到令牌");
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch],
);
const handleTokenToggle = useCallback(
async (id: string, status: string) => {
try {
const next = toggleStatus(status);
await apiFetch(`/api/tokens/${id}`, {
method: "PATCH",
body: JSON.stringify({ status: next }),
});
await loadTokens();
setNotice(`令牌已${next === "active" ? "启用" : "停用"}`);
} catch (error) {
setNotice((error as Error).message);
}
},
[apiFetch, loadTokens],
);
const handleUsageRefresh = useCallback(async () => {
try {
await loadUsage();
setNotice("日志已刷新");
} catch (error) {
setNotice((error as Error).message);
}
}, [loadUsage]);
const channelTotal = data.channels.length;
const channelTotalPages = useMemo(
() => Math.max(1, Math.ceil(channelTotal / channelPageSize)),
[channelTotal, channelPageSize],
);
const pagedChannels = useMemo(() => {
const start = (channelPage - 1) * channelPageSize;
return data.channels.slice(start, start + channelPageSize);
}, [channelPage, channelPageSize, data.channels]);
useEffect(() => {
setChannelPage((prev) => Math.min(prev, channelTotalPages));
}, [channelTotalPages]);
const activeLabel = useMemo(
() => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台",
[activeTab],
);
const renderContent = () => {
if (loading) {
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
加载中...
</div>
);
}
if (activeTab === "dashboard") {
return <DashboardView dashboard={data.dashboard} />;
}
if (activeTab === "channels") {
return (
<ChannelsView
channelForm={channelForm}
channelPage={channelPage}
channelPageSize={channelPageSize}
channelTotal={channelTotal}
channelTotalPages={channelTotalPages}
pagedChannels={pagedChannels}
editingChannel={editingChannel}
isChannelModalOpen={isChannelModalOpen}
onCreate={openChannelCreate}
onCloseModal={closeChannelModal}
onEdit={startChannelEdit}
onSubmit={handleChannelSubmit}
onTest={handleChannelTest}
onToggle={handleChannelToggle}
onDelete={handleChannelDelete}
onPageChange={handleChannelPageChange}
onPageSizeChange={handleChannelPageSizeChange}
onFormChange={handleChannelFormChange}
/>
);
}
if (activeTab === "models") {
return <ModelsView models={data.models} />;
}
if (activeTab === "tokens") {
return (
<TokensView
tokens={data.tokens}
onSubmit={handleTokenSubmit}
onReveal={handleTokenReveal}
onToggle={handleTokenToggle}
onDelete={handleTokenDelete}
/>
);
}
if (activeTab === "usage") {
return <UsageView usage={data.usage} onRefresh={handleUsageRefresh} />;
}
if (activeTab === "settings") {
return (
<SettingsView
settingsForm={settingsForm}
adminPasswordSet={data.settings?.admin_password_set ?? false}
onSubmit={handleSettingsSubmit}
onFormChange={handleSettingsFormChange}
/>
);
}
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
未知模块
</div>
);
};
return (
<div class="min-h-screen bg-linear-to-b from-white via-stone-50 to-stone-100 font-['IBM_Plex_Sans'] text-stone-900 antialiased">
{token ? (
<AppLayout
tabs={tabs}
activeTab={activeTab}
activeLabel={activeLabel}
token={token}
notice={notice}
onTabChange={handleTabChange}
onLogout={handleLogout}
>
{renderContent()}
</AppLayout>
) : (
<LoginView notice={notice} onSubmit={handleLogin} />
)}
</div>
);
};
render(<App />, root);
+40
View File
@@ -0,0 +1,40 @@
import { apiBase } from "./constants";
export type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
/**
* Creates a typed API fetcher bound to the current auth token.
*
* Args:
* token: Bearer token string or null.
* onUnauthorized: Callback invoked on 401 responses.
*
* Returns:
* A fetch function that wraps API calls with auth headers and errors.
*/
export const createApiFetch = (
token: string | null,
onUnauthorized: () => void,
): ApiFetch => {
return async <T>(path: string, options: RequestInit = {}): Promise<T> => {
const headers = new Headers(options.headers ?? {});
headers.set("Content-Type", "application/json");
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${apiBase}${path}`, {
...options,
headers,
});
if (!response.ok) {
if (response.status === 401) {
onUnauthorized();
}
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(payload?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
};
};
+34
View File
@@ -0,0 +1,34 @@
import type { AdminData, ChannelForm, SettingsForm, TabItem } from "./types";
export const apiBase = import.meta.env.VITE_API_BASE ?? "";
export const tabs: TabItem[] = [
{ id: "dashboard", label: "数据面板" },
{ id: "channels", label: "渠道管理" },
{ id: "models", label: "模型广场" },
{ id: "tokens", label: "令牌管理" },
{ id: "usage", label: "使用日志" },
{ id: "settings", label: "系统设置" },
];
export const initialData: AdminData = {
channels: [],
tokens: [],
models: [],
usage: [],
dashboard: null,
settings: null,
};
export const initialChannelForm: ChannelForm = {
name: "",
base_url: "",
api_key: "",
weight: 1,
};
export const initialSettingsForm: SettingsForm = {
log_retention_days: "30",
session_ttl_hours: "12",
admin_password: "",
};
+95
View File
@@ -0,0 +1,95 @@
export type Channel = {
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
models_json?: string;
};
export type Token = {
id: string;
name: string;
key_prefix: string;
quota_total: number | null;
quota_used: number;
status: string;
};
export type UsageLog = {
id: string;
model: string;
channel_id: string;
channel_name?: string | null;
token_id: string;
token_name?: string | null;
total_tokens: number;
latency_ms: number;
status: string;
created_at: string;
};
export type DashboardData = {
summary: {
total_requests: number;
total_tokens: number;
avg_latency: number;
total_errors: number;
};
byDay: Array<{ day: string; requests: number; tokens: number }>;
byModel: Array<{ model: string; requests: number; tokens: number }>;
byChannel: Array<{ channel_name: string; requests: number; tokens: number }>;
byToken: Array<{ token_name: string; requests: number; tokens: number }>;
};
export type Settings = {
log_retention_days: number;
session_ttl_hours: number;
admin_password_set?: boolean;
};
export type ModelChannel = {
id: string;
name: string;
};
export type ModelItem = {
id: string;
channels: ModelChannel[];
};
export type AdminData = {
channels: Channel[];
tokens: Token[];
models: ModelItem[];
usage: UsageLog[];
dashboard: DashboardData | null;
settings: Settings | null;
};
export type TabId =
| "dashboard"
| "channels"
| "models"
| "tokens"
| "usage"
| "settings";
export type TabItem = {
id: TabId;
label: string;
};
export type ChannelForm = {
name: string;
base_url: string;
api_key: string;
weight: number;
};
export type SettingsForm = {
log_retention_days: string;
session_ttl_hours: string;
admin_password: string;
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Formats a datetime string for display.
*
* Args:
* value: ISO datetime string or nullable value.
*
* Returns:
* A human-friendly datetime string or "-".
*/
export const formatDateTime = (value?: string | null) => {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
};
/**
* Toggles channel or token status between active and disabled.
*
* Args:
* value: Current status value.
*
* Returns:
* Next status value.
*/
export const toggleStatus = (value: string) =>
value === "active" ? "disabled" : "active";
+90
View File
@@ -0,0 +1,90 @@
import type { TabId, TabItem } from "../core/types";
type AppLayoutProps = {
tabs: TabItem[];
activeTab: TabId;
activeLabel: string;
token: string | null;
notice: string;
onTabChange: (tabId: TabId) => void;
onLogout: () => void;
children?: unknown;
};
/**
* Renders the admin app layout.
*
* Args:
* props: App layout props.
*
* Returns:
* App shell JSX element.
*/
export const AppLayout = ({
tabs,
activeTab,
activeLabel,
token,
notice,
onTabChange,
onLogout,
children,
}: AppLayoutProps) => (
<div class="grid min-h-screen grid-cols-1 lg:grid-cols-[260px_1fr]">
<aside class="border-b border-stone-200 bg-white px-5 py-8 lg:sticky lg:top-0 lg:h-screen lg:border-b-0 lg:border-r">
<div class="mb-8 flex flex-col gap-1.5">
<h2 class="font-['Space_Grotesk'] text-lg font-semibold tracking-tight text-stone-900">
api-workers
</h2>
<span class="text-xs uppercase tracking-widest text-stone-500">
console
</span>
</div>
<nav class="flex flex-col gap-2.5">
{tabs.map((tab) => (
<button
class={`flex h-11 w-full items-center rounded-xl px-3.5 py-2.5 text-left text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
activeTab === tab.id
? "bg-stone-100 text-stone-900"
: "text-stone-500 hover:bg-stone-100 hover:text-stone-900"
}`}
type="button"
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</aside>
<main class="px-6 pt-6 pb-16 sm:px-10 sm:pt-8">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
{activeLabel}
</h1>
<p class="text-sm text-stone-500">
集中管理渠道、模型、令牌与使用情况。
</p>
</div>
<div class="flex items-center gap-3">
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
{token ? "已登录" : "未登录"}
</span>
<button
class="h-11 rounded-lg border border-stone-200 bg-transparent px-4 py-2.5 text-sm 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={onLogout}
>
退出
</button>
</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>
)}
{children}
</main>
</div>
);
+332
View File
@@ -0,0 +1,332 @@
import type { Channel, ChannelForm } from "../core/types";
type ChannelsViewProps = {
channelForm: ChannelForm;
channelPage: number;
channelPageSize: number;
channelTotal: number;
channelTotalPages: number;
pagedChannels: Channel[];
editingChannel: Channel | null;
isChannelModalOpen: boolean;
onCreate: () => void;
onCloseModal: () => void;
onEdit: (channel: Channel) => void;
onSubmit: (event: Event) => void;
onTest: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onFormChange: (patch: Partial<ChannelForm>) => void;
};
/**
* Renders the channels management view.
*
* Args:
* props: Channels view props.
*
* Returns:
* Channels JSX element.
*/
export const ChannelsView = ({
channelForm,
channelPage,
channelPageSize,
channelTotal,
channelTotalPages,
pagedChannels,
editingChannel,
isChannelModalOpen,
onCreate,
onCloseModal,
onEdit,
onSubmit,
onTest,
onToggle,
onDelete,
onPageChange,
onPageSizeChange,
onFormChange,
}: ChannelsViewProps) => {
const isEditing = Boolean(editingChannel);
return (
<div class="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>
<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 flex-wrap items-center gap-2">
<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={onCreate}
>
新增渠道
</button>
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
{channelTotal} 条
</span>
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-500">
每页
<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={channelPageSize}
onChange={(event) => {
onPageSizeChange(
Number((event.currentTarget as HTMLSelectElement).value),
);
}}
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</label>
</div>
</div>
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200">
<div class="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] 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>
{pagedChannels.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无渠道,请先创建。
</div>
) : (
<div class="divide-y divide-stone-100">
{pagedChannels.map((channel) => {
const isActive = channel.status === "active";
return (
<div
class={`grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] items-center gap-3 px-4 py-4 text-sm ${
editingChannel?.id === channel.id
? "bg-amber-50/60"
: "bg-white"
}`}
key={channel.id}
>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-stone-900">
{channel.name}
</span>
<span
class="truncate text-xs text-stone-500"
title={channel.base_url}
>
{channel.base_url}
</span>
</div>
<div>
<span
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${
isActive
? "border-emerald-100 bg-emerald-50 text-emerald-600"
: "border-stone-200 bg-stone-100 text-stone-500"
}`}
>
{isActive ? "启用" : "禁用"}
</span>
</div>
<div class="text-sm font-semibold text-stone-700">
{channel.weight}
</div>
<div class="flex flex-wrap gap-2">
<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"
onClick={() => onTest(channel.id)}
>
连通测试
</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"
onClick={() => onToggle(channel.id, channel.status)}
>
{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"
type="button"
onClick={() => onEdit(channel)}
>
编辑
</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(channel.id)}
>
删除
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-stone-500">
<div>
第 {channelPage} / {channelTotalPages} 页 · 共 {channelTotal} 条
</div>
<div class="flex items-center gap-2">
<button
class="h-9 rounded-full border border-stone-200 bg-white px-4 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={channelPage <= 1}
onClick={() => onPageChange(Math.max(1, channelPage - 1))}
>
上一页
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-4 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={channelPage >= channelTotalPages}
onClick={() =>
onPageChange(Math.min(channelTotalPages, channelPage + 1))
}
>
下一页
</button>
</div>
</div>
</div>
{isChannelModalOpen && (
<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="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
{isEditing ? "编辑渠道" : "新增渠道"}
</h3>
<p class="text-xs text-stone-500">
{isEditing
? `正在编辑:${editingChannel?.name ?? ""}`
: "填写渠道信息并保存。"}
</p>
</div>
<button
class="h-9 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-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={onCloseModal}
>
关闭
</button>
</div>
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="channel-name"
>
名称
</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="channel-name"
name="name"
value={channelForm.name}
required
onInput={(event) =>
onFormChange({
name: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="channel-base"
>
Base URL
</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="channel-base"
name="base_url"
placeholder="https://api.openai.com"
value={channelForm.base_url}
required
onInput={(event) =>
onFormChange({
base_url: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="channel-key"
>
API Key
</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="channel-key"
name="api_key"
value={channelForm.api_key}
required
onInput={(event) =>
onFormChange({
api_key: (event.currentTarget as HTMLInputElement).value,
})
}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="channel-weight"
>
权重
</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="channel-weight"
name="weight"
type="number"
min="1"
value={channelForm.weight}
onInput={(event) =>
onFormChange({
weight: Number(
(event.currentTarget as HTMLInputElement).value || 0,
),
})
}
/>
</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"
type="button"
onClick={onCloseModal}
>
取消
</button>
<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"
>
{isEditing ? "保存修改" : "创建渠道"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+222
View File
@@ -0,0 +1,222 @@
import type { DashboardData } from "../core/types";
type DashboardViewProps = {
dashboard: DashboardData | null;
};
/**
* Renders the dashboard summary and charts.
*
* Args:
* props: Dashboard view props.
*
* Returns:
* Dashboard JSX element.
*/
export const DashboardView = ({ dashboard }: DashboardViewProps) => {
if (!dashboard) {
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
暂无数据
</div>
);
}
const errorRate = dashboard.summary.total_requests
? Math.round(
(dashboard.summary.total_errors / dashboard.summary.total_requests) *
100,
)
: 0;
return (
<div class="space-y-5">
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
<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">
{dashboard.summary.total_requests}
</div>
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
最近窗口
</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">
总 Tokens
</span>
<div class="text-2xl font-semibold text-stone-900">
{dashboard.summary.total_tokens}
</div>
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
累计消耗
</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">{errorRate}%</div>
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
平均延迟 {Math.round(dashboard.summary.avg_latency)}ms
</span>
</div>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
<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>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
日期
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
请求
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
Tokens
</th>
</tr>
</thead>
<tbody>
{dashboard.byDay.map((row) => (
<tr class="hover:bg-stone-50" key={row.day}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.day}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.requests}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.tokens}
</td>
</tr>
))}
</tbody>
</table>
</div>
<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>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
模型
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
请求
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
Tokens
</th>
</tr>
</thead>
<tbody>
{dashboard.byModel.map((row) => (
<tr class="hover:bg-stone-50" key={row.model}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.model ?? "-"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.requests}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.tokens}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
<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>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
渠道
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
请求
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
Tokens
</th>
</tr>
</thead>
<tbody>
{dashboard.byChannel.map((row) => (
<tr
class="hover:bg-stone-50"
key={row.channel_name ?? "unknown"}
>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.channel_name ?? "-"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.requests}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.tokens}
</td>
</tr>
))}
</tbody>
</table>
</div>
<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>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
令牌
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
请求
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
Tokens
</th>
</tr>
</thead>
<tbody>
{dashboard.byToken.map((row) => (
<tr class="hover:bg-stone-50" key={row.token_name ?? "unknown"}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.token_name ?? "-"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.requests}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{row.tokens}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
type LoginViewProps = {
notice: string;
onSubmit: (event: Event) => void;
};
/**
* Renders the admin login view.
*
* Args:
* props: Login view props.
*
* 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"
>
管理员密码
</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>
);
+51
View File
@@ -0,0 +1,51 @@
import type { ModelItem } from "../core/types";
type ModelsViewProps = {
models: ModelItem[];
};
/**
* Renders the models view.
*
* Args:
* props: Models view props.
*
* 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>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
模型
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
渠道
</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-sm text-stone-700">
{model.id}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{model.channels.map((channel) => channel.name).join(" / ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
+115
View File
@@ -0,0 +1,115 @@
import type { SettingsForm } from "../core/types";
type SettingsViewProps = {
settingsForm: SettingsForm;
adminPasswordSet: boolean;
onSubmit: (event: Event) => void;
onFormChange: (patch: Partial<SettingsForm>) => void;
};
/**
* Renders the settings view.
*
* Args:
* props: Settings view props.
*
* Returns:
* Settings JSX element.
*/
export const SettingsView = ({
settingsForm,
adminPasswordSet,
onSubmit,
onFormChange,
}: SettingsViewProps) => (
<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>
</div>
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="retention"
>
日志保留天数
</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="retention"
name="log_retention_days"
type="number"
min="1"
value={settingsForm.log_retention_days}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
log_retention_days: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="session-ttl"
>
会话时长(小时)
</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="session-ttl"
name="session_ttl_hours"
type="number"
min="1"
value={settingsForm.session_ttl_hours}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
session_ttl_hours: target?.value ?? "",
});
}}
/>
</div>
<div class="lg:col-span-2">
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="admin-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="admin-password"
name="admin_password"
type="password"
placeholder={
adminPasswordSet
? "已设置,留空则不修改"
: "未设置,保存后即为登录密码"
}
value={settingsForm.admin_password}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
admin_password: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-stone-500">
密码状态:{adminPasswordSet ? "已设置" : "未设置"}
</p>
</div>
<div class="flex items-end lg:col-span-2">
<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>
</div>
</form>
</div>
);
+139
View File
@@ -0,0 +1,139 @@
import type { Token } from "../core/types";
type TokensViewProps = {
tokens: Token[];
onSubmit: (event: Event) => void;
onReveal: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
};
/**
* Renders the tokens management view.
*
* Args:
* props: Tokens view props.
*
* Returns:
* Tokens JSX element.
*/
export const TokensView = ({
tokens,
onSubmit,
onReveal,
onToggle,
onDelete,
}: TokensViewProps) => (
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
<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>
</div>
<form class="grid gap-3.5" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="token-name"
>
名称
</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-name"
name="name"
required
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
for="token-quota"
>
额度(可选)
</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-quota"
name="quota_total"
type="number"
min="0"
placeholder="留空表示无限"
/>
</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>
</div>
<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>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
名称
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
状态
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
已用/额度
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
操作
</th>
</tr>
</thead>
<tbody>
{tokens.map((tokenItem) => (
<tr class="hover:bg-stone-50" key={tokenItem.id}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{tokenItem.name}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{tokenItem.status}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
<div class="flex flex-wrap gap-2">
<button
class="h-11 rounded-lg border border-stone-200 bg-stone-100 px-4 py-2.5 text-sm 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"
onClick={() => onReveal(tokenItem.id)}
>
查看
</button>
<button
class="h-11 rounded-lg border border-stone-200 bg-stone-100 px-4 py-2.5 text-sm 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"
onClick={() => onToggle(tokenItem.id, tokenItem.status)}
>
切换
</button>
<button
class="h-11 rounded-lg border border-stone-200 bg-transparent px-4 py-2.5 text-sm 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)}
>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
+81
View File
@@ -0,0 +1,81 @@
import type { UsageLog } from "../core/types";
import { formatDateTime } from "../core/utils";
type UsageViewProps = {
usage: UsageLog[];
onRefresh: () => void;
};
/**
* Renders the usage logs view.
*
* Args:
* props: Usage view props.
*
* Returns:
* Usage JSX element.
*/
export const UsageView = ({ usage, onRefresh }: UsageViewProps) => (
<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>
<button
class="h-11 rounded-lg border border-stone-200 bg-stone-100 px-4 py-2.5 text-sm 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"
onClick={onRefresh}
>
刷新
</button>
</div>
<table class="w-full border-collapse text-sm">
<thead>
<tr>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
时间
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
模型
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
渠道
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
Tokens
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
延迟
</th>
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
状态
</th>
</tr>
</thead>
<tbody>
{usage.map((log) => (
<tr class="hover:bg-stone-50" key={log.id}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{formatDateTime(log.created_at)}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{log.model ?? "-"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{log.channel_name ?? log.channel_id ?? "-"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{log.total_tokens ?? 0}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{log.latency_ms ?? 0} ms
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{log.status}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
-718
View File
@@ -1,718 +0,0 @@
import './styles.css';
type Channel = {
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
rate_limit: number;
models_json?: string;
};
type Token = {
id: string;
name: string;
key_prefix: string;
quota_total: number | null;
quota_used: number;
status: string;
};
type UsageLog = {
id: string;
model: string;
channel_id: string;
channel_name?: string | null;
token_id: string;
token_name?: string | null;
total_tokens: number;
latency_ms: number;
status: string;
created_at: string;
};
type DashboardData = {
summary: {
total_requests: number;
total_tokens: number;
avg_latency: number;
total_errors: number;
};
byDay: Array<{ day: string; requests: number; tokens: number }>;
byModel: Array<{ model: string; requests: number; tokens: number }>;
byChannel: Array<{ channel_name: string; requests: number; tokens: number }>;
byToken: Array<{ token_name: string; requests: number; tokens: number }>;
};
type Settings = {
log_retention_days: number;
};
const root = document.querySelector<HTMLDivElement>('#app');
const apiBase = import.meta.env.VITE_API_BASE ?? '';
const tabs = [
{ id: 'dashboard', label: '数据面板' },
{ id: 'channels', label: '渠道管理' },
{ id: 'models', label: '模型广场' },
{ id: 'tokens', label: '令牌管理' },
{ id: 'usage', label: '使用日志' },
{ id: 'settings', label: '系统设置' }
];
const state = {
token: localStorage.getItem('admin_token'),
activeTab: 'dashboard',
loading: false,
notice: '',
data: {
channels: [] as Channel[],
tokens: [] as Token[],
models: [] as Array<{ id: string; channels: Array<{ id: string; name: string }> }>,
usage: [] as UsageLog[],
dashboard: null as DashboardData | null,
settings: null as Settings | null
}
};
if (!root) {
throw new Error('Missing #app root');
}
function setNotice(message: string) {
state.notice = message;
render();
}
function setToken(token: string | null) {
state.token = token;
if (token) {
localStorage.setItem('admin_token', token);
} else {
localStorage.removeItem('admin_token');
}
render();
}
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers ?? {});
headers.set('Content-Type', 'application/json');
if (state.token) {
headers.set('Authorization', `Bearer ${state.token}`);
}
const response = await fetch(`${apiBase}${path}`, { ...options, headers });
if (!response.ok) {
if (response.status === 401) {
setToken(null);
}
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}
async function loadDashboard() {
state.data.dashboard = await apiFetch<DashboardData>('/api/dashboard');
}
async function loadChannels() {
const result = await apiFetch<{ channels: Channel[] }>('/api/channels');
state.data.channels = result.channels;
}
async function loadModels() {
const result = await apiFetch<{ models: Array<{ id: string; channels: Array<{ id: string; name: string }> }> }>('/api/models');
state.data.models = result.models;
}
async function loadTokens() {
const result = await apiFetch<{ tokens: Token[] }>('/api/tokens');
state.data.tokens = result.tokens;
}
async function loadUsage() {
const result = await apiFetch<{ logs: UsageLog[] }>('/api/usage?limit=50');
state.data.usage = result.logs;
}
async function loadSettings() {
state.data.settings = await apiFetch<Settings>('/api/settings');
}
async function loadTab(tabId: string) {
state.loading = true;
state.notice = '';
render();
try {
if (tabId === 'dashboard') {
await loadDashboard();
}
if (tabId === 'channels') {
await loadChannels();
}
if (tabId === 'models') {
await loadModels();
}
if (tabId === 'tokens') {
await loadTokens();
}
if (tabId === 'usage') {
await loadUsage();
}
if (tabId === 'settings') {
await loadSettings();
}
} catch (error) {
setNotice((error as Error).message);
} finally {
state.loading = false;
render();
}
}
function renderLogin() {
return `
<div class="login-card">
<h1>api-workers</h1>
<p>请输入管理员密码登录管理台。</p>
<form id="login-form" class="form-grid">
<div>
<label for="password">管理员密码</label>
<input id="password" name="password" type="password" required />
</div>
<button class="button" type="submit">登录</button>
</form>
${state.notice ? `<div class="notice">${state.notice}</div>` : ''}
</div>
`;
}
function renderSidebar() {
return `
<aside class="sidebar">
<div class="brand">
<h2>api-workers</h2>
<span>console</span>
</div>
<nav class="nav-list">
${tabs
.map(
(tab) => `
<button class="nav-item ${state.activeTab === tab.id ? 'active' : ''}" data-tab="${tab.id}" type="button">
${tab.label}
</button>
`
)
.join('')}
</nav>
</aside>
`;
}
function renderDashboard() {
const data = state.data.dashboard;
if (!data) {
return '<div class="card">暂无数据</div>';
}
const errorRate = data.summary.total_requests
? Math.round((data.summary.total_errors / data.summary.total_requests) * 100)
: 0;
return `
<div class="grid three">
<div class="card kpi">
<span class="badge">总请求</span>
<div class="value">${data.summary.total_requests}</div>
<span class="mono">最近窗口</span>
</div>
<div class="card kpi">
<span class="badge">总 Tokens</span>
<div class="value">${data.summary.total_tokens}</div>
<span class="mono">累计消耗</span>
</div>
<div class="card kpi">
<span class="badge">错误率</span>
<div class="value">${errorRate}%</div>
<span class="mono">平均延迟 ${Math.round(data.summary.avg_latency)}ms</span>
</div>
</div>
<div class="grid two" style="margin-top: 20px;">
<div class="card">
<div class="section-header">
<h3>按日趋势</h3>
</div>
<table class="table">
<thead><tr><th>日期</th><th>请求</th><th>Tokens</th></tr></thead>
<tbody>
${data.byDay
.map((row) => `<tr><td>${row.day}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
.join('')}
</tbody>
</table>
</div>
<div class="card">
<div class="section-header">
<h3>模型排行</h3>
</div>
<table class="table">
<thead><tr><th>模型</th><th>请求</th><th>Tokens</th></tr></thead>
<tbody>
${data.byModel
.map((row) => `<tr><td>${row.model ?? '-'}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
.join('')}
</tbody>
</table>
</div>
</div>
<div class="grid two" style="margin-top: 20px;">
<div class="card">
<div class="section-header">
<h3>渠道贡献</h3>
</div>
<table class="table">
<thead><tr><th>渠道</th><th>请求</th><th>Tokens</th></tr></thead>
<tbody>
${data.byChannel
.map((row) => `<tr><td>${row.channel_name ?? '-'}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
.join('')}
</tbody>
</table>
</div>
<div class="card">
<div class="section-header">
<h3>令牌贡献</h3>
</div>
<table class="table">
<thead><tr><th>令牌</th><th>请求</th><th>Tokens</th></tr></thead>
<tbody>
${data.byToken
.map((row) => `<tr><td>${row.token_name ?? '-'}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
.join('')}
</tbody>
</table>
</div>
</div>
`;
}
function renderChannels() {
return `
<div class="grid two">
<div class="card">
<div class="section-header">
<h3>新增渠道</h3>
</div>
<form id="channel-form" class="form-grid">
<div>
<label for="channel-id">渠道 ID(可选)</label>
<input id="channel-id" name="id" placeholder="例如 ch_openai" />
</div>
<div>
<label for="channel-name">名称</label>
<input id="channel-name" name="name" required />
</div>
<div>
<label for="channel-base">Base URL</label>
<input id="channel-base" name="base_url" placeholder="https://api.openai.com" required />
</div>
<div>
<label for="channel-key">API Key</label>
<input id="channel-key" name="api_key" required />
</div>
<div class="form-grid two">
<div>
<label for="channel-weight">权重</label>
<input id="channel-weight" name="weight" type="number" min="1" value="1" />
</div>
<div>
<label for="channel-rate">限流</label>
<input id="channel-rate" name="rate_limit" type="number" min="0" value="0" />
</div>
</div>
<button class="button" type="submit">创建渠道</button>
</form>
</div>
<div class="card">
<div class="section-header">
<h3>渠道列表</h3>
</div>
<table class="table">
<thead>
<tr><th>ID</th><th>名称</th><th>状态</th><th>权重</th><th>操作</th></tr>
</thead>
<tbody>
${state.data.channels
.map(
(channel) => `
<tr>
<td class="mono">${channel.id}</td>
<td>${channel.name}</td>
<td>${channel.status}</td>
<td>${channel.weight}</td>
<td>
<button class="button secondary" type="button" data-action="channel-test" data-id="${channel.id}">连通测试</button>
<button class="button ghost" type="button" data-action="channel-toggle" data-id="${channel.id}" data-status="${channel.status}">切换状态</button>
<button class="button ghost" type="button" data-action="channel-delete" data-id="${channel.id}">删除</button>
</td>
</tr>
`
)
.join('')}
</tbody>
</table>
</div>
</div>
`;
}
function renderModels() {
return `
<div class="card">
<div class="section-header">
<h3>模型广场</h3>
<span class="badge">${state.data.models.length} 个模型</span>
</div>
<table class="table">
<thead><tr><th>模型</th><th>渠道</th></tr></thead>
<tbody>
${state.data.models
.map(
(model) => `
<tr>
<td>${model.id}</td>
<td>${model.channels.map((channel) => channel.name).join(' / ')}</td>
</tr>
`
)
.join('')}
</tbody>
</table>
</div>
`;
}
function renderTokens() {
return `
<div class="grid two">
<div class="card">
<div class="section-header">
<h3>生成令牌</h3>
</div>
<form id="token-form" class="form-grid">
<div>
<label for="token-name">名称</label>
<input id="token-name" name="name" required />
</div>
<div>
<label for="token-quota">额度(可选)</label>
<input id="token-quota" name="quota_total" type="number" min="0" placeholder="留空表示无限" />
</div>
<button class="button" type="submit">生成令牌</button>
</form>
</div>
<div class="card">
<div class="section-header">
<h3>令牌列表</h3>
</div>
<table class="table">
<thead>
<tr><th>名称</th><th>状态</th><th>已用/额度</th><th>操作</th></tr>
</thead>
<tbody>
${state.data.tokens
.map(
(token) => `
<tr>
<td>${token.name}</td>
<td>${token.status}</td>
<td>${token.quota_used} / ${token.quota_total ?? '∞'}</td>
<td>
<button class="button secondary" type="button" data-action="token-reveal" data-id="${token.id}">查看</button>
<button class="button secondary" type="button" data-action="token-toggle" data-id="${token.id}" data-status="${token.status}">切换</button>
<button class="button ghost" type="button" data-action="token-delete" data-id="${token.id}">删除</button>
</td>
</tr>
`
)
.join('')}
</tbody>
</table>
</div>
</div>
`;
}
function renderUsage() {
return `
<div class="card">
<div class="section-header">
<h3>使用日志</h3>
<button class="button secondary" type="button" data-action="usage-refresh">刷新</button>
</div>
<table class="table">
<thead><tr><th>时间</th><th>模型</th><th>渠道</th><th>Tokens</th><th>延迟</th><th>状态</th></tr></thead>
<tbody>
${state.data.usage
.map(
(log) => `
<tr>
<td>${log.created_at?.slice(0, 19).replace('T', ' ')}</td>
<td>${log.model ?? '-'}</td>
<td>${log.channel_name ?? log.channel_id ?? '-'}</td>
<td>${log.total_tokens ?? 0}</td>
<td>${log.latency_ms ?? 0} ms</td>
<td>${log.status}</td>
</tr>
`
)
.join('')}
</tbody>
</table>
</div>
`;
}
function renderSettings() {
const retention = state.data.settings?.log_retention_days ?? 30;
return `
<div class="card">
<div class="section-header">
<h3>系统设置</h3>
</div>
<form id="settings-form" class="form-grid two">
<div>
<label for="retention">日志保留天数</label>
<input id="retention" name="log_retention_days" type="number" min="1" value="${retention}" />
</div>
<div style="display: flex; align-items: flex-end;">
<button class="button" type="submit">保存设置</button>
</div>
</form>
</div>
`;
}
function renderContent() {
if (state.loading) {
return '<div class="card">加载中...</div>';
}
if (state.activeTab === 'dashboard') {
return renderDashboard();
}
if (state.activeTab === 'channels') {
return renderChannels();
}
if (state.activeTab === 'models') {
return renderModels();
}
if (state.activeTab === 'tokens') {
return renderTokens();
}
if (state.activeTab === 'usage') {
return renderUsage();
}
if (state.activeTab === 'settings') {
return renderSettings();
}
return '<div class="card">未知模块</div>';
}
function renderApp() {
return `
<div class="app-shell">
${renderSidebar()}
<main class="main">
<div class="top-bar">
<div>
<h1>${tabs.find((tab) => tab.id === state.activeTab)?.label ?? '管理台'}</h1>
<p>集中管理渠道、模型、令牌与使用情况。</p>
</div>
<div class="top-bar-actions">
<span class="badge">${state.token ? '已登录' : '未登录'}</span>
<button class="button ghost" type="button" data-action="logout">退出</button>
</div>
</div>
${state.notice ? `<div class="notice">${state.notice}</div>` : ''}
${renderContent()}
</main>
</div>
`;
}
function render() {
root!.innerHTML = state.token ? renderApp() : renderLogin();
bindEvents();
}
function bindEvents() {
const loginForm = document.querySelector<HTMLFormElement>('#login-form');
if (loginForm) {
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(loginForm);
const password = String(formData.get('password') ?? '');
try {
const result = await apiFetch<{ token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password })
});
setToken(result.token);
state.notice = '';
await loadTab(state.activeTab);
} catch (error) {
setNotice((error as Error).message);
}
});
}
const channelForm = document.querySelector<HTMLFormElement>('#channel-form');
if (channelForm) {
channelForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(channelForm);
const payload = Object.fromEntries(formData.entries());
const channelId = String(payload.id ?? '').trim();
try {
const body = {
...payload,
weight: Number(payload.weight ?? 1),
rate_limit: Number(payload.rate_limit ?? 0)
} as Record<string, unknown>;
if (channelId) {
body.id = channelId;
} else {
delete body.id;
}
await apiFetch('/api/channels', {
method: 'POST',
body: JSON.stringify(body)
});
channelForm.reset();
await loadChannels();
render();
} catch (error) {
setNotice((error as Error).message);
}
});
}
const tokenForm = document.querySelector<HTMLFormElement>('#token-form');
if (tokenForm) {
tokenForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(tokenForm);
const payload = Object.fromEntries(formData.entries());
try {
const result = await apiFetch<{ token: string }>('/api/tokens', {
method: 'POST',
body: JSON.stringify({
name: payload.name,
quota_total: payload.quota_total ? Number(payload.quota_total) : null
})
});
setNotice(`新令牌: ${result.token}`);
tokenForm.reset();
await loadTokens();
render();
} catch (error) {
setNotice((error as Error).message);
}
});
}
const settingsForm = document.querySelector<HTMLFormElement>('#settings-form');
if (settingsForm) {
settingsForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(settingsForm);
const value = Number(formData.get('log_retention_days'));
try {
await apiFetch('/api/settings', {
method: 'PUT',
body: JSON.stringify({ log_retention_days: value })
});
await loadSettings();
setNotice('设置已更新');
} catch (error) {
setNotice((error as Error).message);
}
});
}
root!.querySelectorAll('[data-tab]').forEach((item) => {
item.addEventListener('click', () => {
const tab = (item as HTMLElement).dataset.tab ?? 'dashboard';
state.activeTab = tab;
loadTab(tab);
});
});
root!.querySelectorAll('[data-action]').forEach((item) => {
item.addEventListener('click', async () => {
const action = (item as HTMLElement).dataset.action ?? '';
const id = (item as HTMLElement).dataset.id ?? '';
try {
if (action === 'logout') {
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => null);
setToken(null);
return;
}
if (action === 'channel-test') {
const result = await apiFetch<{ models: Array<{ id: string }> }>(`/api/channels/${id}/test`, { method: 'POST' });
await loadChannels();
setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`);
render();
}
if (action === 'channel-delete') {
await apiFetch(`/api/channels/${id}`, { method: 'DELETE' });
await loadChannels();
setNotice('渠道已删除');
render();
}
if (action === 'channel-toggle') {
const status = (item as HTMLElement).dataset.status === 'active' ? 'disabled' : 'active';
await apiFetch(`/api/channels/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status })
});
await loadChannels();
setNotice(`渠道已${status === 'active' ? '启用' : '停用'}`);
render();
}
if (action === 'token-delete') {
await apiFetch(`/api/tokens/${id}`, { method: 'DELETE' });
await loadTokens();
setNotice('令牌已删除');
render();
}
if (action === 'token-reveal') {
const result = await apiFetch<{ token: string | null }>(`/api/tokens/${id}/reveal`);
setNotice(result.token ? `令牌: ${result.token}` : '未找到令牌');
}
if (action === 'token-toggle') {
const status = (item as HTMLElement).dataset.status === 'active' ? 'disabled' : 'active';
await apiFetch(`/api/tokens/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status })
});
await loadTokens();
setNotice(`令牌已${status === 'active' ? '启用' : '停用'}`);
render();
}
if (action === 'usage-refresh') {
await loadUsage();
setNotice('日志已刷新');
render();
}
} catch (error) {
setNotice((error as Error).message);
}
});
});
}
render();
if (state.token) {
loadTab(state.activeTab);
}
+1 -353
View File
@@ -1,353 +1 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap');
:root {
--bg: #f6f4ef;
--surface: #ffffff;
--surface-alt: #f0ede7;
--text: #1b1b19;
--muted: #6b6760;
--border: #e2ddd3;
--accent: #d4af37;
--accent-dark: #b9972a;
--shadow: 0 12px 30px rgba(27, 27, 25, 0.08);
--radius: 16px;
--radius-sm: 10px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'IBM Plex Sans', sans-serif;
background: radial-gradient(circle at top, #ffffff 0%, #f3efe8 40%, #efeae2 100%);
color: var(--text);
min-height: 100vh;
}
h1,
h2,
h3,
h4 {
font-family: 'Space Grotesk', sans-serif;
margin: 0 0 0.75rem 0;
letter-spacing: -0.02em;
}
p {
margin: 0 0 1rem 0;
color: var(--muted);
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font-family: inherit;
}
#app {
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 32px 20px;
background: var(--surface);
border-right: 1px solid var(--border);
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 32px;
}
.brand span {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.24em;
color: var(--muted);
}
.nav-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.nav-item {
display: flex;
align-items: center;
padding: 10px 14px;
border-radius: 12px;
border: none;
background: transparent;
text-align: left;
width: 100%;
min-height: 44px;
color: var(--muted);
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
}
.nav-item.active {
background: var(--surface-alt);
color: var(--text);
}
.nav-item:hover {
background: var(--surface-alt);
color: var(--text);
}
.main {
padding: 32px 40px 60px;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.top-bar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.badge {
padding: 4px 10px;
border-radius: 999px;
background: var(--surface-alt);
font-size: 12px;
color: var(--muted);
}
.grid {
display: grid;
gap: 20px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card {
background: var(--surface);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.card h3 {
margin-bottom: 12px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.button {
background: var(--text);
color: #fff;
border: none;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
min-height: 44px;
}
.button.secondary {
background: var(--surface-alt);
color: var(--text);
border: 1px solid var(--border);
}
.button.ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(27, 27, 25, 0.12);
}
.button:focus-visible,
.nav-item:focus-visible {
outline: 2px solid rgba(212, 175, 55, 0.6);
outline-offset: 2px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th,
.table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.table th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.table tr:hover {
background: #faf8f3;
}
.form-grid {
display: grid;
gap: 14px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
display: block;
margin-bottom: 6px;
}
input,
select,
textarea {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid rgba(212, 175, 55, 0.4);
border-color: var(--accent);
}
.notice {
padding: 12px 16px;
background: #fff8e1;
border: 1px solid #f1e0b3;
border-radius: 12px;
color: #7a5d00;
margin-top: 12px;
font-size: 14px;
}
.login-card {
max-width: 420px;
margin: 12vh auto 0;
background: var(--surface);
padding: 32px;
border-radius: 20px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.login-card h1 {
margin-bottom: 8px;
}
.kpi {
display: flex;
flex-direction: column;
gap: 6px;
}
.kpi .value {
font-size: 24px;
font-weight: 600;
}
.mono {
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
}
@media (max-width: 1024px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.grid.two,
.grid.three,
.form-grid.two {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.main {
padding: 24px;
}
.top-bar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
@import "tailwindcss";
+12 -10
View File
@@ -1,12 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"lib": ["ES2022", "DOM"],
"types": ["vite/client"],
"skipLibCheck": true
},
"include": ["src"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx/dom",
"strict": true,
"lib": ["ES2022", "DOM"],
"types": ["vite/client"],
"skipLibCheck": true
},
"include": ["src"]
}
+11 -11
View File
@@ -1,15 +1,15 @@
import { defineConfig } from 'vite';
import { defineConfig } from "vite";
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8787';
const apiTarget = process.env.VITE_API_TARGET ?? "http://localhost:8787";
export default defineConfig({
build: {
outDir: 'dist'
},
server: {
proxy: {
'/api': apiTarget,
'/v1': apiTarget
}
}
build: {
outDir: "dist",
},
server: {
proxy: {
"/api": apiTarget,
"/v1": apiTarget,
},
},
});
+16 -16
View File
@@ -1,18 +1,18 @@
{
"name": "new-api-lite-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:migrate": "wrangler d1 migrations apply DB --local"
},
"dependencies": {
"hono": "^4.2.7"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240222.0",
"typescript": "^5.4.5",
"wrangler": "^3.48.0"
}
"name": "new-api-lite-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:migrate": "wrangler d1 migrations apply DB --local"
},
"dependencies": {
"hono": "^4.2.7"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240222.0",
"typescript": "^5.4.5",
"wrangler": "^3.48.0"
}
}
+14 -10
View File
@@ -1,15 +1,19 @@
import type { D1Database } from "@cloudflare/workers-types";
export type Bindings = {
DB: D1Database;
ADMIN_PASSWORD: string;
SESSION_TTL_HOURS?: string;
LOG_RETENTION_DAYS?: string;
CORS_ORIGIN?: string;
PROXY_RETRY_ROUNDS?: string;
PROXY_RETRY_DELAY_MS?: string;
NEW_API_ADMIN_TOKEN?: string;
NEWAPI_ADMIN_TOKEN?: string;
DB: D1Database;
CORS_ORIGIN?: string;
PROXY_RETRY_ROUNDS?: string;
PROXY_RETRY_DELAY_MS?: string;
};
export type Variables = {
adminSessionId?: string;
newApiUserId?: string | null;
tokenRecord?: unknown;
};
export type AppEnv = {
Bindings: Bindings;
Bindings: Bindings;
Variables: Variables;
};
+105 -59
View File
@@ -1,68 +1,114 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import type { AppEnv } from './env';
import authRoutes from './routes/auth';
import channelRoutes from './routes/channels';
import modelRoutes from './routes/models';
import tokenRoutes from './routes/tokens';
import usageRoutes from './routes/usage';
import dashboardRoutes from './routes/dashboard';
import settingsRoutes from './routes/settings';
import proxyRoutes from './routes/proxy';
import newapiChannelRoutes from './routes/newapiChannels';
import newapiUserRoutes from './routes/newapiUsers';
import newapiGroupRoutes from './routes/newapiGroups';
import { adminAuth } from './middleware/adminAuth';
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import type { AppEnv } from "./env";
import { adminAuth } from "./middleware/adminAuth";
import authRoutes from "./routes/auth";
import channelRoutes from "./routes/channels";
import dashboardRoutes from "./routes/dashboard";
import modelRoutes from "./routes/models";
import newapiChannelRoutes from "./routes/newapiChannels";
import newapiGroupRoutes from "./routes/newapiGroups";
import newapiUserRoutes from "./routes/newapiUsers";
import proxyRoutes from "./routes/proxy";
import settingsRoutes from "./routes/settings";
import tokenRoutes from "./routes/tokens";
import usageRoutes from "./routes/usage";
const app = new Hono<AppEnv>({ strict: false });
app.use('*', logger());
app.use(
'/api/*',
cors({
origin: (origin, c) => {
const allowed = c.env.CORS_ORIGIN ?? '*';
return allowed === '*' ? '*' : allowed.split(',').map((item) => item.trim());
},
allowHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-admin-token', 'New-Api-User'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
})
);
app.use(
'/v1/*',
cors({
origin: '*',
allowHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
allowMethods: ['GET', 'POST', 'OPTIONS']
})
);
app.use('/api/*', async (c, next) => {
if (
c.req.path === '/api/auth/login' ||
c.req.path.startsWith('/api/channel') ||
c.req.path.startsWith('/api/user') ||
c.req.path.startsWith('/api/group')
) {
return next();
}
return adminAuth(c, next);
app.use("*", async (c, next) => {
const method = c.req.method;
const path = c.req.path;
const contentType = c.req.header("content-type") ?? "";
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const raw = c.req.raw.clone();
let bodyType = "unknown";
let bodyKeys: string[] = [];
let bodySize = 0;
try {
const payload = await raw.json();
bodyType = "json";
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
bodyKeys = Object.keys(payload);
}
} catch {
try {
const text = await raw.text();
bodyType = "text";
bodySize = text.length;
} catch {
bodyType = "unreadable";
}
}
console.log("[request]", {
method,
path,
content_type: contentType,
body_type: bodyType,
body_keys: bodyKeys,
body_size: bodySize,
});
} else {
console.log("[request]", { method, path });
}
await next();
});
app.get('/health', (c) => c.json({ ok: true }));
app.use("*", logger());
app.use(
"/api/*",
cors({
origin: (_origin, c) => {
const allowed = c.env.CORS_ORIGIN ?? "*";
return allowed === "*"
? "*"
: allowed.split(",").map((item: string) => item.trim());
},
allowHeaders: [
"Content-Type",
"Authorization",
"x-api-key",
"x-admin-token",
"New-Api-User",
],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
}),
);
app.use(
"/v1/*",
cors({
origin: "*",
allowHeaders: ["Content-Type", "Authorization", "x-api-key"],
allowMethods: ["GET", "POST", "OPTIONS"],
}),
);
app.route('/api/auth', authRoutes);
app.route('/api/channels', channelRoutes);
app.route('/api/models', modelRoutes);
app.route('/api/tokens', tokenRoutes);
app.route('/api/usage', usageRoutes);
app.route('/api/dashboard', dashboardRoutes);
app.route('/api/settings', settingsRoutes);
app.route('/api/channel', newapiChannelRoutes);
app.route('/api/user', newapiUserRoutes);
app.route('/api/group', newapiGroupRoutes);
app.use("/api/*", async (c, next) => {
if (
c.req.path === "/api/auth/login" ||
c.req.path.startsWith("/api/channel") ||
c.req.path.startsWith("/api/user") ||
c.req.path.startsWith("/api/group")
) {
return next();
}
return adminAuth(c, next);
});
app.route('/v1', proxyRoutes);
app.get("/health", (c) => c.json({ ok: true }));
app.route("/api/auth", authRoutes);
app.route("/api/channels", channelRoutes);
app.route("/api/models", modelRoutes);
app.route("/api/tokens", tokenRoutes);
app.route("/api/usage", usageRoutes);
app.route("/api/dashboard", dashboardRoutes);
app.route("/api/settings", settingsRoutes);
app.route("/api/channel", newapiChannelRoutes);
app.route("/api/user", newapiUserRoutes);
app.route("/api/group", newapiGroupRoutes);
app.route("/v1", proxyRoutes);
export default app;
+26 -26
View File
@@ -1,36 +1,36 @@
import { createMiddleware } from 'hono/factory';
import { sha256Hex } from '../utils/crypto';
import { getBearerToken } from '../utils/request';
import { jsonError } from '../utils/http';
import type { AppEnv } from '../env';
import { createMiddleware } from "hono/factory";
import type { AppEnv } from "../env";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { getBearerToken } from "../utils/request";
/**
* Validates admin session tokens for management APIs.
*/
export const adminAuth = createMiddleware<AppEnv>(async (c, next) => {
const token = getBearerToken(c);
if (!token) {
return jsonError(c, 401, 'admin_token_required', 'admin_token_required');
}
const token = getBearerToken(c);
if (!token) {
return jsonError(c, 401, "admin_token_required", "admin_token_required");
}
const tokenHash = await sha256Hex(token);
const session = await c.env.DB.prepare(
'SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?'
)
.bind(tokenHash)
.first();
const tokenHash = await sha256Hex(token);
const session = await c.env.DB.prepare(
"SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?",
)
.bind(tokenHash)
.first();
if (!session) {
return jsonError(c, 401, 'invalid_admin_token', 'invalid_admin_token');
}
if (!session) {
return jsonError(c, 401, "invalid_admin_token", "invalid_admin_token");
}
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
await c.env.DB.prepare('DELETE FROM admin_sessions WHERE id = ?')
.bind(String(session.id))
.run();
return jsonError(c, 401, 'admin_session_expired', 'admin_session_expired');
}
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
await c.env.DB.prepare("DELETE FROM admin_sessions WHERE id = ?")
.bind(String(session.id))
.run();
return jsonError(c, 401, "admin_session_expired", "admin_session_expired");
}
c.set('adminSessionId', String(session.id));
await next();
c.set("adminSessionId", String(session.id));
await next();
});
+48 -41
View File
@@ -1,56 +1,63 @@
import { createMiddleware } from 'hono/factory';
import { sha256Hex } from '../utils/crypto';
import { getBearerToken } from '../utils/request';
import type { AppEnv } from '../env';
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { AppEnv } from "../env";
import { getAdminPasswordHash } from "../services/settings";
import { sha256Hex } from "../utils/crypto";
import { getBearerToken } from "../utils/request";
function newApiError(c: any, status: number, message: string) {
return c.json(
{
success: false,
message
},
status
);
function newApiError(
c: Context<AppEnv>,
status: ContentfulStatusCode,
message: string,
) {
return c.json(
{
success: false,
message,
},
status,
);
}
function readNewApiUserId(c: any): string | null {
return c.req.header('New-Api-User') ?? c.req.header('new-api-user') ?? null;
function readNewApiUserId(c: Context<AppEnv>): string | null {
return c.req.header("New-Api-User") ?? c.req.header("new-api-user") ?? null;
}
/**
* Validates New API admin tokens or admin sessions.
*/
export const newApiAuth = createMiddleware<AppEnv>(async (c, next) => {
const token = getBearerToken(c);
if (!token) {
return newApiError(c, 401, 'unauthorized');
}
const token = getBearerToken(c);
if (!token) {
return newApiError(c, 401, "unauthorized");
}
const envToken = c.env.NEW_API_ADMIN_TOKEN ?? c.env.NEWAPI_ADMIN_TOKEN;
if (envToken && token === envToken) {
c.set('newApiUserId', readNewApiUserId(c));
await next();
return;
}
const tokenHash = await sha256Hex(token);
const adminPasswordHash = await getAdminPasswordHash(c.env.DB);
if (adminPasswordHash && tokenHash === adminPasswordHash) {
c.set("newApiUserId", readNewApiUserId(c));
await next();
return;
}
const tokenHash = await sha256Hex(token);
const session = await c.env.DB.prepare(
'SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?'
)
.bind(tokenHash)
.first<{ id: string; expires_at: string }>();
const session = await c.env.DB.prepare(
"SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?",
)
.bind(tokenHash)
.first<{ id: string; expires_at: string }>();
if (!session) {
return newApiError(c, 401, 'unauthorized');
}
if (!session) {
return newApiError(c, 401, "unauthorized");
}
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
await c.env.DB.prepare('DELETE FROM admin_sessions WHERE id = ?')
.bind(String(session.id))
.run();
return newApiError(c, 401, 'session_expired');
}
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
await c.env.DB.prepare("DELETE FROM admin_sessions WHERE id = ?")
.bind(String(session.id))
.run();
return newApiError(c, 401, "session_expired");
}
c.set('newApiUserId', readNewApiUserId(c));
await next();
c.set("newApiUserId", readNewApiUserId(c));
await next();
});
+46 -42
View File
@@ -1,56 +1,60 @@
import { createMiddleware } from 'hono/factory';
import { sha256Hex } from '../utils/crypto';
import { getBearerToken } from '../utils/request';
import { jsonError } from '../utils/http';
import { canConsumeQuota, normalizeQuota } from '../services/quota';
import type { AppEnv } from '../env';
import { createMiddleware } from "hono/factory";
import type { AppEnv } from "../env";
import { canConsumeQuota, normalizeQuota } from "../services/quota";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { getBearerToken } from "../utils/request";
export type TokenRecord = {
id: string;
name: string;
quota_total: number | null;
quota_used: number;
status: string;
allowed_channels: string | null;
id: string;
name: string;
quota_total: number | null;
quota_used: number;
status: string;
allowed_channels: string | null;
};
/**
* Validates API tokens for the OpenAI-compatible proxy.
*/
export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
const token = getBearerToken(c);
if (!token) {
return jsonError(c, 401, 'token_required', 'token_required');
}
const token = getBearerToken(c);
if (!token) {
return jsonError(c, 401, "token_required", "token_required");
}
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 = ?'
)
.bind(tokenHash)
.first<TokenRecord>();
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 = ?",
)
.bind(tokenHash)
.first<TokenRecord>();
if (!record) {
return jsonError(c, 401, 'invalid_token', 'invalid_token');
}
if (!record) {
return jsonError(c, 401, "invalid_token", "invalid_token");
}
if (record.status !== 'active') {
return jsonError(c, 403, 'token_disabled', 'token_disabled');
}
if (record.status !== "active") {
return jsonError(c, 403, "token_disabled", "token_disabled");
}
const quotaTotal = record.quota_total === null || record.quota_total === undefined
? null
: Number(record.quota_total);
const quotaUsed = Number(record.quota_used ?? 0);
const normalized = normalizeQuota(Number.isNaN(quotaTotal) ? null : quotaTotal, Number.isNaN(quotaUsed) ? 0 : quotaUsed);
if (!canConsumeQuota(normalized.quotaTotal, normalized.quotaUsed, 1)) {
return jsonError(c, 402, 'quota_exceeded', 'quota_exceeded');
}
const quotaTotal =
record.quota_total === null || record.quota_total === undefined
? null
: Number(record.quota_total);
const quotaUsed = Number(record.quota_used ?? 0);
const normalized = normalizeQuota(
Number.isNaN(quotaTotal) ? null : quotaTotal,
Number.isNaN(quotaUsed) ? 0 : quotaUsed,
);
if (!canConsumeQuota(normalized.quotaTotal, normalized.quotaUsed, 1)) {
return jsonError(c, 402, "quota_exceeded", "quota_exceeded");
}
c.set('tokenRecord', {
...record,
quota_total: normalized.quotaTotal,
quota_used: normalized.quotaUsed
});
await next();
c.set("tokenRecord", {
...record,
quota_total: normalized.quotaTotal,
quota_used: normalized.quotaUsed,
});
await next();
});
+46 -35
View File
@@ -1,53 +1,64 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { addHours, nowIso } from '../utils/time';
import { generateToken, sha256Hex } from '../utils/crypto';
import { jsonError } from '../utils/http';
import { adminAuth } from '../middleware/adminAuth';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { adminAuth } from "../middleware/adminAuth";
import {
getAdminPasswordHash,
getSessionTtlHours,
setAdminPasswordHash,
} from "../services/settings";
import { generateToken, sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { addHours, nowIso } from "../utils/time";
const auth = new Hono<AppEnv>();
/**
* Logs in the admin user with a password.
*/
auth.post('/login', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body?.password) {
return jsonError(c, 400, 'password_required', 'password_required');
}
auth.post("/login", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body?.password) {
return jsonError(c, 400, "password_required", "password_required");
}
if (body.password !== c.env.ADMIN_PASSWORD) {
return jsonError(c, 401, 'invalid_password', 'invalid_password');
}
const password = String(body.password);
const passwordHash = await sha256Hex(password);
const storedHash = await getAdminPasswordHash(c.env.DB);
const rawToken = generateToken('admin_');
const tokenHash = await sha256Hex(rawToken);
const ttlHours = Number(c.env.SESSION_TTL_HOURS ?? '12');
const expiresAt = addHours(new Date(), Number.isNaN(ttlHours) ? 12 : ttlHours).toISOString();
if (!storedHash) {
await setAdminPasswordHash(c.env.DB, passwordHash);
} else if (passwordHash !== storedHash) {
return jsonError(c, 401, "invalid_password", "invalid_password");
}
await c.env.DB.prepare(
'INSERT INTO admin_sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)'
)
.bind(crypto.randomUUID(), tokenHash, expiresAt, nowIso())
.run();
const rawToken = generateToken("admin_");
const tokenHash = await sha256Hex(rawToken);
const ttlHours = await getSessionTtlHours(c.env.DB);
const expiresAt = addHours(new Date(), ttlHours).toISOString();
return c.json({
token: rawToken,
expires_at: expiresAt
});
await c.env.DB.prepare(
"INSERT INTO admin_sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)",
)
.bind(crypto.randomUUID(), tokenHash, expiresAt, nowIso())
.run();
return c.json({
token: rawToken,
expires_at: expiresAt,
});
});
/**
* Invalidates the current admin session token.
*/
auth.post('/logout', adminAuth, async (c) => {
const sessionId = c.get('adminSessionId');
if (sessionId) {
await c.env.DB.prepare('DELETE FROM admin_sessions WHERE id = ?')
.bind(String(sessionId))
.run();
}
return c.json({ ok: true });
auth.post("/logout", adminAuth, async (c) => {
const sessionId = c.get("adminSessionId");
if (sessionId) {
await c.env.DB.prepare("DELETE FROM admin_sessions WHERE id = ?")
.bind(String(sessionId))
.run();
}
return c.json({ ok: true });
});
export default auth;
+136 -105
View File
@@ -1,13 +1,27 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { nowIso } from '../utils/time';
import { jsonError } from '../utils/http';
import { safeJsonParse } from '../utils/json';
import { generateToken } from '../utils/crypto';
import { normalizeBaseUrl } from '../utils/url';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import type { ChannelRecord } from "../services/channels";
import { generateToken } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
import { nowIso } from "../utils/time";
import { normalizeBaseUrl } from "../utils/url";
const channels = new Hono<AppEnv>();
type ChannelPayload = {
id?: string | number;
channel_id?: string | number;
channelId?: string | number;
name?: string;
base_url?: string;
api_key?: string;
weight?: number;
status?: string;
rate_limit?: number;
models?: unknown[];
};
/**
* Resolves a channel id from request payload.
*
@@ -17,141 +31,158 @@ const channels = new Hono<AppEnv>();
* Returns:
* Channel id if provided.
*/
function resolveChannelId(body: any): string | null {
const candidate = body?.id ?? body?.channel_id ?? body?.channelId;
if (!candidate) {
return null;
}
const normalized = String(candidate).trim();
return normalized.length > 0 ? normalized : null;
function resolveChannelId(body: ChannelPayload | null): string | null {
const candidate = body?.id ?? body?.channel_id ?? body?.channelId;
if (!candidate) {
return null;
}
const normalized = String(candidate).trim();
return normalized.length > 0 ? normalized : null;
}
/**
* Lists all channels.
*/
channels.get('/', async (c) => {
const result = await c.env.DB.prepare('SELECT * FROM channels ORDER BY created_at DESC').all();
return c.json({ channels: result.results ?? [] });
channels.get("/", async (c) => {
const result = await c.env.DB.prepare(
"SELECT * FROM channels ORDER BY created_at DESC",
).all();
return c.json({ channels: result.results ?? [] });
});
/**
* Creates a new channel.
*/
channels.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body?.name || !body?.base_url || !body?.api_key) {
return jsonError(c, 400, 'missing_fields', 'missing_fields');
}
channels.post("/", async (c) => {
const body = (await c.req.json().catch(() => null)) as ChannelPayload | null;
if (!body?.name || !body?.base_url || !body?.api_key) {
return jsonError(c, 400, "missing_fields", "missing_fields");
}
const requestedId = resolveChannelId(body);
if (requestedId) {
const exists = await c.env.DB.prepare('SELECT id FROM channels WHERE id = ?').bind(requestedId).first();
if (exists) {
return jsonError(c, 409, 'channel_id_exists', 'channel_id_exists');
}
}
const requestedId = resolveChannelId(body);
if (requestedId) {
const exists = await c.env.DB.prepare(
"SELECT id FROM channels WHERE id = ?",
)
.bind(requestedId)
.first();
if (exists) {
return jsonError(c, 409, "channel_id_exists", "channel_id_exists");
}
}
const id = requestedId ?? generateToken('ch_');
const now = nowIso();
const id = requestedId ?? generateToken("ch_");
const now = nowIso();
await c.env.DB.prepare(
'INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(
id,
body.name,
normalizeBaseUrl(body.base_url),
body.api_key,
Number(body.weight ?? 1),
body.status ?? 'active',
body.rate_limit ?? 0,
JSON.stringify(body.models ?? []),
now,
now
)
.run();
await c.env.DB.prepare(
"INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
id,
body.name,
normalizeBaseUrl(String(body.base_url)),
body.api_key,
Number(body.weight ?? 1),
body.status ?? "active",
body.rate_limit ?? 0,
JSON.stringify(body.models ?? []),
now,
now,
)
.run();
return c.json({ id });
return c.json({ id });
});
/**
* Updates a channel.
*/
channels.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => null);
const id = c.req.param('id');
if (!body) {
return jsonError(c, 400, 'missing_body', 'missing_body');
}
channels.patch("/:id", async (c) => {
const body = (await c.req.json().catch(() => null)) as ChannelPayload | null;
const id = c.req.param("id");
if (!body) {
return jsonError(c, 400, "missing_body", "missing_body");
}
const current = await c.env.DB.prepare('SELECT * FROM channels WHERE id = ?').bind(id).first();
if (!current) {
return jsonError(c, 404, 'channel_not_found', 'channel_not_found');
}
const current = await c.env.DB.prepare("SELECT * FROM channels WHERE id = ?")
.bind(id)
.first<ChannelRecord>();
if (!current) {
return jsonError(c, 404, "channel_not_found", "channel_not_found");
}
const models = body.models ?? safeJsonParse(current.models_json, []);
const models = body.models ?? safeJsonParse(current.models_json, []);
await c.env.DB.prepare(
'UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, updated_at = ? WHERE id = ?'
)
.bind(
body.name ?? current.name,
normalizeBaseUrl(body.base_url ?? current.base_url),
body.api_key ?? current.api_key,
Number(body.weight ?? current.weight ?? 1),
body.status ?? current.status,
body.rate_limit ?? current.rate_limit ?? 0,
JSON.stringify(models),
nowIso(),
id
)
.run();
await c.env.DB.prepare(
"UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, updated_at = ? WHERE id = ?",
)
.bind(
body.name ?? current.name,
normalizeBaseUrl(String(body.base_url ?? current.base_url)),
body.api_key ?? current.api_key,
Number(body.weight ?? current.weight ?? 1),
body.status ?? current.status,
body.rate_limit ?? current.rate_limit ?? 0,
JSON.stringify(models),
nowIso(),
id,
)
.run();
return c.json({ ok: true });
return c.json({ ok: true });
});
/**
* Deletes a channel.
*/
channels.delete('/:id', async (c) => {
const id = c.req.param('id');
await c.env.DB.prepare('DELETE FROM channels WHERE id = ?').bind(id).run();
return c.json({ ok: true });
channels.delete("/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM channels WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
/**
* Tests channel connectivity and updates model list.
*/
channels.post('/:id/test', async (c) => {
const id = c.req.param('id');
const channel = await c.env.DB.prepare('SELECT * FROM channels WHERE id = ?').bind(id).first();
if (!channel) {
return jsonError(c, 404, 'channel_not_found', 'channel_not_found');
}
channels.post("/:id/test", async (c) => {
const id = c.req.param("id");
const channel = await c.env.DB.prepare("SELECT * FROM channels WHERE id = ?")
.bind(id)
.first();
if (!channel) {
return jsonError(c, 404, "channel_not_found", "channel_not_found");
}
const target = `${normalizeBaseUrl(String(channel.base_url))}/v1/models`;
const response = await fetch(target, {
method: 'GET',
headers: {
Authorization: `Bearer ${channel.api_key}`,
'x-api-key': String(channel.api_key),
'Content-Type': 'application/json'
}
});
const target = `${normalizeBaseUrl(String(channel.base_url))}/v1/models`;
const response = await fetch(target, {
method: "GET",
headers: {
Authorization: `Bearer ${channel.api_key}`,
"x-api-key": String(channel.api_key),
"Content-Type": "application/json",
},
});
if (!response.ok) {
await c.env.DB.prepare('UPDATE channels SET status = ?, updated_at = ? WHERE id = ?')
.bind('error', nowIso(), id)
.run();
return jsonError(c, 502, 'channel_unreachable', 'channel_unreachable');
}
if (!response.ok) {
await c.env.DB.prepare(
"UPDATE channels SET status = ?, updated_at = ? WHERE id = ?",
)
.bind("error", nowIso(), id)
.run();
return jsonError(c, 502, "channel_unreachable", "channel_unreachable");
}
const data = await response.json().catch(() => ({ data: [] }));
await c.env.DB.prepare('UPDATE channels SET status = ?, models_json = ?, updated_at = ? WHERE id = ?')
.bind('active', JSON.stringify(data), nowIso(), id)
.run();
const payload = (await response.json().catch(() => ({ data: [] }))) as
| { data?: unknown[] }
| unknown[];
const models = Array.isArray(payload) ? payload : (payload?.data ?? []);
await c.env.DB.prepare(
"UPDATE channels SET status = ?, models_json = ?, updated_at = ? WHERE id = ?",
)
.bind("active", JSON.stringify(payload), nowIso(), id)
.run();
return c.json({ ok: true, models: data?.data ?? [] });
return c.json({ ok: true, models });
});
export default channels;
+53 -48
View File
@@ -1,66 +1,71 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { Hono } from "hono";
import type { AppEnv } from "../env";
const dashboard = new Hono<AppEnv>();
function buildDateFilters(query: Record<string, string>) {
let sql = ' WHERE 1=1';
const params: Array<string> = [];
if (query.from) {
sql += ' AND created_at >= ?';
params.push(query.from);
}
if (query.to) {
sql += ' AND created_at <= ?';
params.push(query.to);
}
return { sql, params };
let sql = " WHERE 1=1";
const params: Array<string> = [];
if (query.from) {
sql += " AND created_at >= ?";
params.push(query.from);
}
if (query.to) {
sql += " AND created_at <= ?";
params.push(query.to);
}
return { sql, params };
}
/**
* Returns aggregated usage metrics.
*/
dashboard.get('/', async (c) => {
const query = c.req.query();
const { sql, params } = buildDateFilters(query);
dashboard.get("/", async (c) => {
const query = c.req.query();
const { sql, params } = buildDateFilters(query);
const summary = await c.env.DB.prepare(
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`
)
.bind(...params)
.first();
const summary = await c.env.DB.prepare(
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`,
)
.bind(...params)
.first();
const byDay = await c.env.DB.prepare(
`SELECT substr(created_at, 1, 10) as day, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY day ORDER BY day DESC LIMIT 30`
)
.bind(...params)
.all();
const byDay = await c.env.DB.prepare(
`SELECT substr(created_at, 1, 10) as day, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY day ORDER BY day DESC LIMIT 30`,
)
.bind(...params)
.all();
const byModel = await c.env.DB.prepare(
`SELECT model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY model ORDER BY requests DESC LIMIT 20`
)
.bind(...params)
.all();
const byModel = await c.env.DB.prepare(
`SELECT model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY model ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
const byChannel = await c.env.DB.prepare(
`SELECT channels.id as channel_id, channels.name as channel_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id${sql} GROUP BY channels.id, channels.name ORDER BY requests DESC LIMIT 20`
)
.bind(...params)
.all();
const byChannel = await c.env.DB.prepare(
`SELECT channels.id as channel_id, channels.name as channel_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id${sql} GROUP BY channels.id, channels.name ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
const byToken = await c.env.DB.prepare(
`SELECT tokens.id as token_id, tokens.name as token_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN tokens ON tokens.id = usage_logs.token_id${sql} GROUP BY tokens.id, tokens.name ORDER BY requests DESC LIMIT 20`
)
.bind(...params)
.all();
const byToken = await c.env.DB.prepare(
`SELECT tokens.id as token_id, tokens.name as token_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN tokens ON tokens.id = usage_logs.token_id${sql} GROUP BY tokens.id, tokens.name ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
return c.json({
summary: summary ?? { total_requests: 0, total_tokens: 0, avg_latency: 0, total_errors: 0 },
byDay: byDay.results ?? [],
byModel: byModel.results ?? [],
byChannel: byChannel.results ?? [],
byToken: byToken.results ?? []
});
return c.json({
summary: summary ?? {
total_requests: 0,
total_tokens: 0,
avg_latency: 0,
total_errors: 0,
},
byDay: byDay.results ?? [],
byModel: byModel.results ?? [],
byChannel: byChannel.results ?? [],
byToken: byToken.results ?? [],
});
});
export default dashboard;
+23 -15
View File
@@ -1,26 +1,34 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { extractModels } from '../services/channels';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { type ChannelRecord, extractModels } from "../services/channels";
const models = new Hono<AppEnv>();
/**
* Returns aggregated models from all channels.
*/
models.get('/', async (c) => {
const result = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?').bind('active').all();
const entries = (result.results ?? []).flatMap((channel) => extractModels(channel as any));
models.get("/", async (c) => {
const result = await c.env.DB.prepare(
"SELECT * FROM channels WHERE status = ?",
)
.bind("active")
.all();
const channels = (result.results ?? []) as ChannelRecord[];
const entries = channels.flatMap((channel) => extractModels(channel));
const map = new Map<string, { id: string; channels: { id: string; name: string }[] }>();
for (const entry of entries) {
const existing = map.get(entry.id) ?? { id: entry.id, channels: [] };
existing.channels.push({ id: entry.channelId, name: entry.channelName });
map.set(entry.id, existing);
}
const map = new Map<
string,
{ id: string; channels: { id: string; name: string }[] }
>();
for (const entry of entries) {
const existing = map.get(entry.id) ?? { id: entry.id, channels: [] };
existing.channels.push({ id: entry.channelId, name: entry.channelName });
map.set(entry.id, existing);
}
return c.json({
models: Array.from(map.values())
});
return c.json({
models: Array.from(map.values()),
});
});
export default models;
File diff suppressed because it is too large. Load diff
+34 -28
View File
@@ -1,37 +1,43 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { newApiAuth } from '../middleware/newApiAuth';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { newApiAuth } from "../middleware/newApiAuth";
const groups = new Hono<AppEnv>({ strict: false });
groups.use('*', newApiAuth);
groups.use("*", newApiAuth);
function collectGroups(rows: Array<{ group_name?: string | null }>): string[] {
const names = new Set<string>();
for (const row of rows) {
const raw = row.group_name;
if (!raw) {
continue;
}
raw
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
.forEach((item) => names.add(item));
}
if (names.size === 0) {
names.add('default');
}
return Array.from(names).sort((a, b) => a.localeCompare(b));
const names = new Set<string>();
for (const row of rows) {
const raw = row.group_name;
if (!raw) {
continue;
}
raw
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
.forEach((item) => {
names.add(item);
});
}
if (names.size === 0) {
names.add("default");
}
return Array.from(names).sort((a, b) => a.localeCompare(b));
}
groups.get('/', async (c) => {
const result = await c.env.DB.prepare('SELECT group_name FROM channels').all();
const data = collectGroups((result.results ?? []) as Array<{ group_name?: string | null }>);
return c.json({
success: true,
message: '',
data
});
groups.get("/", async (c) => {
const result = await c.env.DB.prepare(
"SELECT group_name FROM channels",
).all();
const data = collectGroups(
(result.results ?? []) as Array<{ group_name?: string | null }>,
);
return c.json({
success: true,
message: "",
data,
});
});
export default groups;
+23 -21
View File
@@ -1,27 +1,29 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { newApiAuth } from '../middleware/newApiAuth';
import { extractModelIds } from '../services/newapi';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { newApiAuth } from "../middleware/newApiAuth";
import { type ChannelRow, extractModelIds } from "../services/newapi";
const users = new Hono<AppEnv>({ strict: false });
users.use('*', newApiAuth);
users.use("*", newApiAuth);
users.get('/models', async (c) => {
const result = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?')
.bind('active')
.all();
const models = new Set<string>();
for (const row of result.results ?? []) {
for (const id of extractModelIds(row as any)) {
models.add(id);
}
}
const data = Array.from(models).map((id) => ({ id, name: id }));
return c.json({
success: true,
message: '',
data
});
users.get("/models", async (c) => {
const result = await c.env.DB.prepare(
"SELECT * FROM channels WHERE status = ?",
)
.bind("active")
.all();
const models = new Set<string>();
for (const row of (result.results ?? []) as ChannelRow[]) {
for (const id of extractModelIds(row)) {
models.add(id);
}
}
const data = Array.from(models).map((id) => ({ id, name: id }));
return c.json({
success: true,
message: "",
data,
});
});
export default users;
+224 -122
View File
@@ -1,29 +1,52 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { tokenAuth, type TokenRecord } from '../middleware/tokenAuth';
import { createWeightedOrder, extractModels, type ChannelRecord } from '../services/channels';
import { recordUsage } from '../services/usage';
import { jsonError } from '../utils/http';
import { safeJsonParse } from '../utils/json';
import { normalizeBaseUrl } from '../utils/url';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { type TokenRecord, tokenAuth } from "../middleware/tokenAuth";
import {
type ChannelRecord,
createWeightedOrder,
extractModels,
} from "../services/channels";
import { recordUsage } from "../services/usage";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
import { normalizeBaseUrl } from "../utils/url";
import {
type NormalizedUsage,
parseUsageFromHeaders,
parseUsageFromJson,
parseUsageFromSse,
} from "../utils/usage";
const proxy = new Hono<AppEnv>();
function channelSupportsModel(channel: ChannelRecord, model?: string | null): boolean {
if (!model) {
return true;
}
const models = extractModels(channel);
return models.some((entry) => entry.id === model);
type ExecutionContextLike = {
waitUntil: (promise: Promise<unknown>) => void;
};
function channelSupportsModel(
channel: ChannelRecord,
model?: string | null,
): boolean {
if (!model) {
return true;
}
const models = extractModels(channel);
return models.some((entry) => entry.id === model);
}
function filterAllowedChannels(channels: ChannelRecord[], tokenRecord: TokenRecord): ChannelRecord[] {
const allowed = safeJsonParse<string[] | null>(tokenRecord.allowed_channels, null);
if (!allowed || allowed.length === 0) {
return channels;
}
const allowedSet = new Set(allowed);
return channels.filter((channel) => allowedSet.has(channel.id));
function filterAllowedChannels(
channels: ChannelRecord[],
tokenRecord: TokenRecord,
): ChannelRecord[] {
const allowed = safeJsonParse<string[] | null>(
tokenRecord.allowed_channels,
null,
);
if (!allowed || allowed.length === 0) {
return channels;
}
const allowedSet = new Set(allowed);
return channels.filter((channel) => allowedSet.has(channel.id));
}
/**
@@ -36,7 +59,7 @@ function filterAllowedChannels(channels: ChannelRecord[], tokenRecord: TokenReco
* True if the status is retryable.
*/
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
return status === 408 || status === 429 || status >= 500;
}
/**
@@ -49,124 +72,203 @@ function isRetryableStatus(status: number): boolean {
* Promise resolved after delay.
*/
async function sleep(ms: number): Promise<void> {
if (ms <= 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, ms));
if (ms <= 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* OpenAI-compatible proxy handler.
*/
proxy.all('/*', tokenAuth, async (c) => {
const tokenRecord = c.get('tokenRecord') as TokenRecord;
const requestText = await c.req.text();
const parsedBody = requestText ? safeJsonParse<any>(requestText, null) : null;
const model = parsedBody?.model ?? null;
const isStream = parsedBody?.stream === true;
proxy.all("/*", tokenAuth, async (c) => {
const tokenRecord = c.get("tokenRecord") as TokenRecord;
let requestText = await c.req.text();
const parsedBody = requestText
? safeJsonParse<Record<string, unknown> | null>(requestText, null)
: null;
const model =
parsedBody?.model !== undefined && parsedBody?.model !== null
? String(parsedBody.model)
: null;
const isStream = parsedBody?.stream === true;
if (isStream && parsedBody && typeof parsedBody === "object") {
const streamOptions = (parsedBody as Record<string, unknown>)
.stream_options;
if (!streamOptions || typeof streamOptions !== "object") {
(parsedBody as Record<string, unknown>).stream_options = {
include_usage: true,
};
} else if (
(streamOptions as Record<string, unknown>).include_usage !== true
) {
(streamOptions as Record<string, unknown>).include_usage = true;
}
requestText = JSON.stringify(parsedBody);
}
const channelResult = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?')
.bind('active')
.all();
const activeChannels = (channelResult.results ?? []) as ChannelRecord[];
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
const modelChannels = allowedChannels.filter((channel) => channelSupportsModel(channel, model));
const candidates = modelChannels.length > 0 ? modelChannels : allowedChannels;
const channelResult = await c.env.DB.prepare(
"SELECT * FROM channels WHERE status = ?",
)
.bind("active")
.all();
const activeChannels = (channelResult.results ?? []) as ChannelRecord[];
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
const modelChannels = allowedChannels.filter((channel) =>
channelSupportsModel(channel, model),
);
const candidates = modelChannels.length > 0 ? modelChannels : allowedChannels;
if (candidates.length === 0) {
return jsonError(c, 503, 'no_available_channels', 'no_available_channels');
}
if (candidates.length === 0) {
return jsonError(c, 503, "no_available_channels", "no_available_channels");
}
const ordered = createWeightedOrder(candidates);
const targetPath = c.req.path;
const retryRounds = Math.max(1, Number(c.env.PROXY_RETRY_ROUNDS ?? '1'));
const retryDelayMs = Math.max(0, Number(c.env.PROXY_RETRY_DELAY_MS ?? '200'));
let lastResponse: Response | null = null;
let lastChannel: ChannelRecord | null = null;
const start = Date.now();
let selectedChannel: ChannelRecord | null = null;
let usagePayload: any = null;
const ordered = createWeightedOrder(candidates);
const targetPath = c.req.path;
const retryRounds = Math.max(1, Number(c.env.PROXY_RETRY_ROUNDS ?? "1"));
const retryDelayMs = Math.max(0, Number(c.env.PROXY_RETRY_DELAY_MS ?? "200"));
let lastResponse: Response | null = null;
let lastChannel: ChannelRecord | null = null;
const start = Date.now();
let selectedChannel: ChannelRecord | null = null;
let round = 0;
while (round < retryRounds && !selectedChannel) {
let shouldRetry = false;
for (const channel of ordered) {
lastChannel = channel;
const target = `${normalizeBaseUrl(channel.base_url)}${targetPath}${c.req.url.includes('?') ? `?${c.req.url.split('?')[1]}` : ''}`;
const headers = new Headers(c.req.header());
headers.set('Authorization', `Bearer ${channel.api_key}`);
headers.set('x-api-key', String(channel.api_key));
headers.delete('host');
headers.delete('content-length');
let round = 0;
while (round < retryRounds && !selectedChannel) {
let shouldRetry = false;
for (const channel of ordered) {
lastChannel = channel;
const target = `${normalizeBaseUrl(channel.base_url)}${targetPath}${c.req.url.includes("?") ? `?${c.req.url.split("?")[1]}` : ""}`;
const headers = new Headers(c.req.header());
headers.set("Authorization", `Bearer ${channel.api_key}`);
headers.set("x-api-key", String(channel.api_key));
headers.delete("host");
headers.delete("content-length");
try {
const response = await fetch(target, {
method: c.req.method,
headers,
body: requestText || undefined
});
try {
const response = await fetch(target, {
method: c.req.method,
headers,
body: requestText || undefined,
});
lastResponse = response;
if (response.ok) {
selectedChannel = channel;
if (!isStream && response.headers.get('content-type')?.includes('application/json')) {
const data = await response.clone().json().catch(() => null);
usagePayload = data?.usage ?? null;
}
break;
}
lastResponse = response;
if (response.ok) {
selectedChannel = channel;
break;
}
if (isRetryableStatus(response.status)) {
shouldRetry = true;
}
} catch {
lastResponse = null;
shouldRetry = true;
}
}
if (isRetryableStatus(response.status)) {
shouldRetry = true;
}
} catch {
lastResponse = null;
shouldRetry = true;
}
}
if (selectedChannel || !shouldRetry) {
break;
}
if (selectedChannel || !shouldRetry) {
break;
}
round += 1;
if (round < retryRounds) {
await sleep(retryDelayMs);
}
}
round += 1;
if (round < retryRounds) {
await sleep(retryDelayMs);
}
}
const latencyMs = Date.now() - start;
const latencyMs = Date.now() - start;
if (!lastResponse) {
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
model,
requestPath: targetPath,
totalTokens: 0,
latencyMs,
status: 'error'
});
return jsonError(c, 502, 'upstream_unavailable', 'upstream_unavailable');
}
if (!lastResponse) {
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
model,
requestPath: targetPath,
totalTokens: 0,
latencyMs,
status: "error",
});
return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable");
}
const channelForUsage = selectedChannel ?? lastChannel;
if (channelForUsage) {
const totalTokens = Number(usagePayload?.total_tokens ?? 0);
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
channelId: channelForUsage.id,
model,
requestPath: targetPath,
totalTokens: Number.isNaN(totalTokens) ? 0 : totalTokens,
promptTokens: Number(usagePayload?.prompt_tokens ?? 0),
completionTokens: Number(usagePayload?.completion_tokens ?? 0),
cost: 0,
latencyMs,
status: lastResponse.ok ? 'ok' : 'error'
});
}
const channelForUsage = selectedChannel ?? lastChannel;
if (channelForUsage && lastResponse) {
const record = async (usage: NormalizedUsage | null) => {
const normalized = usage ?? {
totalTokens: 0,
promptTokens: 0,
completionTokens: 0,
};
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
channelId: channelForUsage.id,
model,
requestPath: targetPath,
totalTokens: normalized.totalTokens,
promptTokens: normalized.promptTokens,
completionTokens: normalized.completionTokens,
cost: 0,
latencyMs,
status: lastResponse.ok ? "ok" : "error",
});
};
const logUsage = (
label: string,
usage: NormalizedUsage | null,
source: string,
) => {
console.log(`[usage] ${label}`, {
source,
total_tokens: usage?.totalTokens ?? 0,
prompt_tokens: usage?.promptTokens ?? 0,
completion_tokens: usage?.completionTokens ?? 0,
stream: isStream,
status: lastResponse.status,
model,
path: targetPath,
});
};
return lastResponse;
const headerUsage = parseUsageFromHeaders(lastResponse.headers);
let jsonUsage: NormalizedUsage | null = null;
if (
!isStream &&
lastResponse.ok &&
lastResponse.headers.get("content-type")?.includes("application/json")
) {
const data = await lastResponse
.clone()
.json()
.catch(() => null);
jsonUsage = parseUsageFromJson(data);
}
const immediateUsage = jsonUsage ?? headerUsage;
const immediateSource = jsonUsage
? "json"
: headerUsage
? "header"
: "none";
if (isStream && !immediateUsage) {
const executionCtx = (c as { executionCtx?: ExecutionContextLike })
.executionCtx;
const task = parseUsageFromSse(lastResponse.clone())
.then((streamUsage) => {
logUsage("stream", streamUsage, streamUsage ? "sse" : "sse-none");
return record(streamUsage);
})
.catch(() => undefined);
if (executionCtx?.waitUntil) {
executionCtx.waitUntil(task);
} else {
task.catch(() => undefined);
}
} else {
logUsage("immediate", immediateUsage, immediateSource);
await record(immediateUsage);
}
}
return lastResponse;
});
export default proxy;
+68 -19
View File
@@ -1,33 +1,82 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { getRetentionDays, setRetentionDays } from '../services/settings';
import { jsonError } from '../utils/http';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import {
getRetentionDays,
getSessionTtlHours,
isAdminPasswordSet,
setAdminPasswordHash,
setRetentionDays,
setSessionTtlHours,
} from "../services/settings";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
const settings = new Hono<AppEnv>();
/**
* Returns settings values.
*/
settings.get('/', async (c) => {
const fallback = Number(c.env.LOG_RETENTION_DAYS ?? '30');
const retention = await getRetentionDays(c.env.DB, Number.isNaN(fallback) ? 30 : fallback);
return c.json({ log_retention_days: retention });
settings.get("/", async (c) => {
const retention = await getRetentionDays(c.env.DB);
const sessionTtlHours = await getSessionTtlHours(c.env.DB);
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
return c.json({
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
admin_password_set: adminPasswordSet,
});
});
/**
* Updates settings values.
*/
settings.put('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body?.log_retention_days) {
return jsonError(c, 400, 'log_retention_days_required', 'log_retention_days_required');
}
const days = Number(body.log_retention_days);
if (Number.isNaN(days) || days < 1) {
return jsonError(c, 400, 'invalid_log_retention_days', 'invalid_log_retention_days');
}
await setRetentionDays(c.env.DB, days);
return c.json({ ok: true });
settings.put("/", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) {
return jsonError(c, 400, "settings_required", "settings_required");
}
let touched = false;
if (body.log_retention_days !== undefined) {
const days = Number(body.log_retention_days);
if (Number.isNaN(days) || days < 1) {
return jsonError(
c,
400,
"invalid_log_retention_days",
"invalid_log_retention_days",
);
}
await setRetentionDays(c.env.DB, days);
touched = true;
}
if (body.session_ttl_hours !== undefined) {
const hours = Number(body.session_ttl_hours);
if (Number.isNaN(hours) || hours < 1) {
return jsonError(
c,
400,
"invalid_session_ttl_hours",
"invalid_session_ttl_hours",
);
}
await setSessionTtlHours(c.env.DB, hours);
touched = true;
}
if (typeof body.admin_password === "string" && body.admin_password.trim()) {
const hash = await sha256Hex(body.admin_password.trim());
await setAdminPasswordHash(c.env.DB, hash);
touched = true;
}
if (!touched) {
return jsonError(c, 400, "settings_empty", "settings_empty");
}
return c.json({ ok: true });
});
export default settings;
+107 -91
View File
@@ -1,127 +1,143 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { generateToken, sha256Hex } from '../utils/crypto';
import { nowIso } from '../utils/time';
import { jsonError } from '../utils/http';
import { safeJsonParse } from '../utils/json';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { generateToken, sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
import { nowIso } from "../utils/time";
const tokens = new Hono<AppEnv>();
type TokenRow = {
id: string;
name: string;
key_prefix: string;
quota_total: number | null;
quota_used: number;
status: string;
allowed_channels: string | null;
token_plain?: string | null;
};
/**
* Lists API tokens.
*/
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'
)
.all();
return c.json({ tokens: result.results ?? [] });
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",
).all();
return c.json({ tokens: result.results ?? [] });
});
/**
* Creates a new API token.
*/
tokens.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body?.name) {
return jsonError(c, 400, 'name_required', 'name_required');
}
tokens.post("/", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body?.name) {
return jsonError(c, 400, "name_required", "name_required");
}
const rawToken = generateToken('sk-');
const tokenHash = await sha256Hex(rawToken);
const id = crypto.randomUUID();
const now = nowIso();
const keyPrefix = rawToken.slice(0, 8);
const quotaTotal = body.quota_total === null || body.quota_total === undefined
? null
: Number(body.quota_total);
const rawToken = generateToken("sk-");
const tokenHash = await sha256Hex(rawToken);
const id = crypto.randomUUID();
const now = nowIso();
const keyPrefix = rawToken.slice(0, 8);
const quotaTotal =
body.quota_total === null || body.quota_total === undefined
? null
: Number(body.quota_total);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(
id,
body.name,
tokenHash,
keyPrefix,
rawToken,
Number.isNaN(quotaTotal) ? null : quotaTotal,
0,
body.status ?? 'active',
JSON.stringify(body.allowed_channels ?? null),
now,
now
)
.run();
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
id,
body.name,
tokenHash,
keyPrefix,
rawToken,
Number.isNaN(quotaTotal) ? null : quotaTotal,
0,
body.status ?? "active",
JSON.stringify(body.allowed_channels ?? null),
now,
now,
)
.run();
return c.json({
id,
token: rawToken
});
return c.json({
id,
token: rawToken,
});
});
/**
* Updates an API token.
*/
tokens.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => null);
if (!body) {
return jsonError(c, 400, 'missing_body', 'missing_body');
}
tokens.patch("/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json().catch(() => null);
if (!body) {
return jsonError(c, 400, "missing_body", "missing_body");
}
const existing = await c.env.DB.prepare('SELECT * FROM tokens WHERE id = ?').bind(id).first();
if (!existing) {
return jsonError(c, 404, 'token_not_found', 'token_not_found');
}
const existing = await c.env.DB.prepare("SELECT * FROM tokens WHERE id = ?")
.bind(id)
.first<TokenRow>();
if (!existing) {
return jsonError(c, 404, "token_not_found", "token_not_found");
}
const existingAllowed = safeJsonParse(existing.allowed_channels, null);
const quotaTotalUpdate = body.quota_total === null || body.quota_total === undefined
? existing.quota_total
: Number(body.quota_total);
const quotaUsedUpdate = body.quota_used === null || body.quota_used === undefined
? existing.quota_used
: Number(body.quota_used);
const existingAllowed = safeJsonParse(existing.allowed_channels, null);
const quotaTotalUpdate =
body.quota_total === null || body.quota_total === undefined
? existing.quota_total
: Number(body.quota_total);
const quotaUsedUpdate =
body.quota_used === null || body.quota_used === undefined
? existing.quota_used
: Number(body.quota_used);
await c.env.DB
.prepare(
'UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, updated_at = ? WHERE id = ?'
)
.bind(
body.name ?? existing.name,
Number.isNaN(quotaTotalUpdate) ? existing.quota_total : quotaTotalUpdate,
Number.isNaN(quotaUsedUpdate) ? existing.quota_used : quotaUsedUpdate,
body.status ?? existing.status,
JSON.stringify(body.allowed_channels ?? existingAllowed ?? null),
nowIso(),
id
)
.run();
await c.env.DB.prepare(
"UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, updated_at = ? WHERE id = ?",
)
.bind(
body.name ?? existing.name,
Number.isNaN(quotaTotalUpdate) ? existing.quota_total : quotaTotalUpdate,
Number.isNaN(quotaUsedUpdate) ? existing.quota_used : quotaUsedUpdate,
body.status ?? existing.status,
JSON.stringify(body.allowed_channels ?? existingAllowed ?? null),
nowIso(),
id,
)
.run();
return c.json({ ok: true });
return c.json({ ok: true });
});
/**
* Reveals a stored API token.
*/
tokens.get('/:id/reveal', async (c) => {
const id = c.req.param('id');
const record = await c.env.DB.prepare('SELECT token_plain FROM tokens WHERE id = ?').bind(id).first();
if (!record) {
return jsonError(c, 404, 'token_not_found', 'token_not_found');
}
return c.json({ token: record.token_plain ?? null });
tokens.get("/:id/reveal", async (c) => {
const id = c.req.param("id");
const record = await c.env.DB.prepare(
"SELECT token_plain FROM tokens WHERE id = ?",
)
.bind(id)
.first<{ token_plain?: string | null }>();
if (!record) {
return jsonError(c, 404, "token_not_found", "token_not_found");
}
return c.json({ token: record.token_plain ?? null });
});
/**
* Deletes an API token.
*/
tokens.delete('/:id', async (c) => {
const id = c.req.param('id');
await c.env.DB.prepare('DELETE FROM tokens WHERE id = ?').bind(id).run();
return c.json({ ok: true });
tokens.delete("/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM tokens WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
export default tokens;
+38 -36
View File
@@ -1,49 +1,51 @@
import { Hono } from 'hono';
import type { AppEnv } from '../env';
import { getRetentionDays } from '../services/settings';
import { pruneUsageLogs } from '../services/usage';
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { getRetentionDays } from "../services/settings";
import { pruneUsageLogs } from "../services/usage";
const usage = new Hono<AppEnv>();
/**
* Lists usage logs with filters.
*/
usage.get('/', async (c) => {
const query = c.req.query();
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';
usage.get("/", async (c) => {
const query = c.req.query();
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 >= ?';
params.push(query.from);
}
if (query.to) {
sql += ' AND usage_logs.created_at <= ?';
params.push(query.to);
}
if (query.model) {
sql += ' AND usage_logs.model = ?';
params.push(query.model);
}
if (query.channel_id) {
sql += ' AND usage_logs.channel_id = ?';
params.push(query.channel_id);
}
if (query.token_id) {
sql += ' AND usage_logs.token_id = ?';
params.push(query.token_id);
}
if (query.from) {
sql += " AND usage_logs.created_at >= ?";
params.push(query.from);
}
if (query.to) {
sql += " AND usage_logs.created_at <= ?";
params.push(query.to);
}
if (query.model) {
sql += " AND usage_logs.model = ?";
params.push(query.model);
}
if (query.channel_id) {
sql += " AND usage_logs.channel_id = ?";
params.push(query.channel_id);
}
if (query.token_id) {
sql += " AND usage_logs.token_id = ?";
params.push(query.token_id);
}
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 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 fallback = Number(c.env.LOG_RETENTION_DAYS ?? '30');
const retention = await getRetentionDays(c.env.DB, Number.isNaN(fallback) ? 30 : fallback);
await pruneUsageLogs(c.env.DB, retention);
const retention = await getRetentionDays(c.env.DB);
await pruneUsageLogs(c.env.DB, retention);
const result = await c.env.DB.prepare(sql).bind(...params).all();
return c.json({ logs: result.results ?? [] });
const result = await c.env.DB.prepare(sql)
.bind(...params)
.all();
return c.json({ logs: result.results ?? [] });
});
export default usage;
+53 -44
View File
@@ -1,62 +1,71 @@
import { safeJsonParse } from '../utils/json';
import { safeJsonParse } from "../utils/json";
export type ChannelRecord = {
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
rate_limit?: number | null;
models_json?: string | null;
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
rate_limit?: number | null;
models_json?: string | null;
};
export type ModelEntry = {
id: string;
label: string;
channelId: string;
channelName: string;
id: string;
label: string;
channelId: string;
channelName: string;
};
type ModelPayload = {
id?: string | number;
};
/**
* Returns channels in a weighted random order.
*/
export function createWeightedOrder(channels: ChannelRecord[]): ChannelRecord[] {
const pool = channels.map((channel) => ({
...channel,
weight: Math.max(1, Number(channel.weight) || 1)
}));
const ordered: ChannelRecord[] = [];
while (pool.length > 0) {
const total = pool.reduce((sum, channel) => sum + channel.weight, 0);
let roll = Math.random() * total;
const index = pool.findIndex((channel) => {
roll -= channel.weight;
return roll <= 0;
});
const [selected] = pool.splice(index < 0 ? 0 : index, 1);
ordered.push(selected);
}
return ordered;
export function createWeightedOrder(
channels: ChannelRecord[],
): ChannelRecord[] {
const pool = channels.map((channel) => ({
...channel,
weight: Math.max(1, Number(channel.weight) || 1),
}));
const ordered: ChannelRecord[] = [];
while (pool.length > 0) {
const total = pool.reduce((sum, channel) => sum + channel.weight, 0);
let roll = Math.random() * total;
const index = pool.findIndex((channel) => {
roll -= channel.weight;
return roll <= 0;
});
const [selected] = pool.splice(index < 0 ? 0 : index, 1);
ordered.push(selected);
}
return ordered;
}
/**
* Extracts model entries from a channel record.
*/
export function extractModels(channel: ChannelRecord): ModelEntry[] {
const raw = safeJsonParse<any>(channel.models_json, null);
const models = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: [];
const raw = safeJsonParse<ModelPayload[] | { data?: ModelPayload[] } | null>(
channel.models_json,
null,
);
const models = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: [];
return models
.filter((model) => model?.id)
.map((model) => ({
id: String(model.id),
label: String(model.id),
channelId: channel.id,
channelName: channel.name
}));
return models
.filter((model) => model?.id)
.map((model) => ({
id: String(model.id),
label: String(model.id),
channelId: channel.id,
channelName: channel.name,
}));
}
+265 -211
View File
@@ -1,253 +1,307 @@
import { safeJsonParse } from '../utils/json';
import { normalizeBaseUrl } from '../utils/url';
import { safeJsonParse } from "../utils/json";
import { normalizeBaseUrl } from "../utils/url";
export type ChannelRow = {
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
rate_limit?: number | null;
models_json?: string | null;
type?: number | null;
group_name?: string | null;
priority?: number | null;
metadata_json?: string | null;
test_time?: number | string | null;
response_time_ms?: number | null;
created_at?: string | null;
updated_at?: string | null;
id: string;
name: string;
base_url: string;
api_key: string;
weight: number;
status: string;
rate_limit?: number | null;
models_json?: string | null;
type?: number | null;
group_name?: string | null;
priority?: number | null;
metadata_json?: string | null;
test_time?: number | string | null;
response_time_ms?: number | null;
created_at?: string | null;
updated_at?: string | null;
};
export type NewApiChannel = {
id: string;
name: string;
type: number;
status: number;
priority: number;
weight: number;
models: string;
group: string;
response_time: number;
test_time: number;
base_url?: string;
key?: string;
created_time?: number;
updated_time?: number;
id: string;
name: string;
type: number;
status: number;
priority: number;
weight: number;
models: string;
group: string;
response_time: number;
test_time: number;
base_url?: string;
key?: string;
created_time?: number;
updated_time?: number;
};
export type ParsedChannelInput = {
id?: string;
name?: string;
type?: number;
base_url?: string;
api_key?: string;
weight?: number;
status?: string;
rate_limit?: number;
models: string[];
models_json: string;
group_name?: string | null;
priority?: number;
metadata_json?: string | null;
id?: string;
name?: string;
type?: number;
base_url?: string;
api_key?: string;
weight?: number;
status?: string;
rate_limit?: number;
models: string[];
models_json: string;
group_name?: string | null;
priority?: number;
metadata_json?: string | null;
};
type ModelLike = { id?: unknown };
const KNOWN_KEYS = new Set([
'id',
'name',
'type',
'key',
'api_key',
'base_url',
'baseUrl',
'weight',
'status',
'rate_limit',
'rateLimit',
'models',
'model',
'model_list',
'models_list',
'group',
'group_name',
'groups',
'priority'
"id",
"name",
"type",
"key",
"api_key",
"base_url",
"baseUrl",
"weight",
"status",
"rate_limit",
"rateLimit",
"models",
"model",
"model_list",
"models_list",
"group",
"group_name",
"groups",
"priority",
]);
function toNumber(value: unknown, fallback: number | null = null): number | null {
if (value === null || value === undefined) {
return fallback;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? fallback : parsed;
function toModelId(item: unknown): string {
if (item && typeof item === "object" && "id" in item) {
const value = (item as ModelLike).id;
return value === undefined || value === null ? "" : String(value);
}
if (item === undefined || item === null) {
return "";
}
return String(item);
}
function toNumber(
value: unknown,
fallback: number | null = null,
): number | null {
if (value === null || value === undefined) {
return fallback;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? fallback : parsed;
}
export function normalizeModelsInput(input: unknown): string[] {
if (!input) {
return [];
}
if (Array.isArray(input)) {
return input.map((item) => String(item)).filter((item) => item.length > 0);
}
if (typeof input === 'string') {
return input
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
if (typeof input === 'object') {
const raw = input as { data?: unknown[] };
if (Array.isArray(raw.data)) {
return raw.data
.map((item: any) => String(item?.id ?? item))
.filter((item) => item.length > 0);
}
}
return [];
if (!input) {
return [];
}
if (Array.isArray(input)) {
return input.map((item) => String(item)).filter((item) => item.length > 0);
}
if (typeof input === "string") {
return input
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
if (typeof input === "object") {
const raw = input as { data?: unknown[] };
if (Array.isArray(raw.data)) {
return raw.data
.map((item) => toModelId(item))
.filter((item) => item.length > 0);
}
}
return [];
}
export function modelsToJson(models: string[]): string {
const normalized = models
.map((model) => String(model).trim())
.filter((model) => model.length > 0);
return JSON.stringify(normalized.map((id) => ({ id })));
const normalized = models
.map((model) => String(model).trim())
.filter((model) => model.length > 0);
return JSON.stringify(normalized.map((id) => ({ id })));
}
export function extractModelIds(channel: ChannelRow): string[] {
const raw = safeJsonParse<any>(channel.models_json, null);
const models = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: [];
return models
.map((model: any) => String(model?.id ?? model))
.filter((model: string) => model.length > 0);
const raw = safeJsonParse<ModelLike[] | { data?: ModelLike[] } | null>(
channel.models_json,
null,
);
const models = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: [];
return models
.map((model) => toModelId(model))
.filter((model: string) => model.length > 0);
}
export function toInternalStatus(status: unknown): string {
if (status === undefined || status === null) {
return 'active';
}
if (typeof status === 'string') {
const normalized = status.trim().toLowerCase();
if (['1', 'true', 'enabled', 'enable', 'active'].includes(normalized)) {
return 'active';
}
if (['0', '2', 'false', 'disabled', 'disable', 'inactive'].includes(normalized)) {
return 'disabled';
}
}
if (typeof status === 'number') {
return status === 1 ? 'active' : 'disabled';
}
if (typeof status === 'boolean') {
return status ? 'active' : 'disabled';
}
return 'active';
if (status === undefined || status === null) {
return "active";
}
if (typeof status === "string") {
const normalized = status.trim().toLowerCase();
if (["1", "true", "enabled", "enable", "active"].includes(normalized)) {
return "active";
}
if (
["0", "2", "false", "disabled", "disable", "inactive"].includes(
normalized,
)
) {
return "disabled";
}
}
if (typeof status === "number") {
return status === 1 ? "active" : "disabled";
}
if (typeof status === "boolean") {
return status ? "active" : "disabled";
}
return "active";
}
export function toNewApiStatus(status: string | null | undefined): number {
return status === 'active' ? 1 : 2;
return status === "active" ? 1 : 2;
}
export function normalizeChannelInput(body: any): ParsedChannelInput {
const hasModels =
body &&
(Object.prototype.hasOwnProperty.call(body, 'models') ||
Object.prototype.hasOwnProperty.call(body, 'model') ||
Object.prototype.hasOwnProperty.call(body, 'model_list') ||
Object.prototype.hasOwnProperty.call(body, 'models_list'));
const models = hasModels
? normalizeModelsInput(body?.models ?? body?.model ?? body?.model_list ?? body?.models_list)
: [];
const hasGroup =
body &&
(Object.prototype.hasOwnProperty.call(body, 'group') ||
Object.prototype.hasOwnProperty.call(body, 'group_name') ||
Object.prototype.hasOwnProperty.call(body, 'groups'));
const groupInput = body?.group ?? body?.group_name ?? null;
const groupsInput = Array.isArray(body?.groups) ? body.groups.map((item: any) => String(item)) : null;
const groupName = !hasGroup
? undefined
: groupInput
? String(groupInput)
: groupsInput && groupsInput.length > 0
? groupsInput.join(',')
: null;
export function normalizeChannelInput(
body: Record<string, unknown> | null,
): ParsedChannelInput {
const hasModels =
body &&
(Object.hasOwn(body, "models") ||
Object.hasOwn(body, "model") ||
Object.hasOwn(body, "model_list") ||
Object.hasOwn(body, "models_list"));
const models = hasModels
? normalizeModelsInput(
body?.models ?? body?.model ?? body?.model_list ?? body?.models_list,
)
: [];
const hasGroup =
body &&
(Object.hasOwn(body, "group") ||
Object.hasOwn(body, "group_name") ||
Object.hasOwn(body, "groups"));
const groupInput = body?.group ?? body?.group_name ?? null;
const groupsInput = Array.isArray(body?.groups)
? (body.groups as unknown[]).map((item) => String(item))
: null;
const groupName = !hasGroup
? undefined
: groupInput
? String(groupInput)
: groupsInput && groupsInput.length > 0
? groupsInput.join(",")
: null;
const metadata: Record<string, unknown> = {};
if (body && typeof body === 'object') {
for (const [key, value] of Object.entries(body)) {
if (KNOWN_KEYS.has(key)) {
continue;
}
metadata[key] = value;
}
}
const metadata: Record<string, unknown> = {};
if (body && typeof body === "object") {
for (const [key, value] of Object.entries(body)) {
if (KNOWN_KEYS.has(key)) {
continue;
}
metadata[key] = value;
}
}
const hasType = body && Object.prototype.hasOwnProperty.call(body, 'type');
const hasWeight = body && Object.prototype.hasOwnProperty.call(body, 'weight');
const hasStatus = body && Object.prototype.hasOwnProperty.call(body, 'status');
const hasRateLimit =
body &&
(Object.prototype.hasOwnProperty.call(body, 'rate_limit') ||
Object.prototype.hasOwnProperty.call(body, 'rateLimit'));
const hasPriority = body && Object.prototype.hasOwnProperty.call(body, 'priority');
const hasType = body && Object.hasOwn(body, "type");
const hasWeight = body && Object.hasOwn(body, "weight");
const hasStatus = body && Object.hasOwn(body, "status");
const hasRateLimit =
body &&
(Object.hasOwn(body, "rate_limit") || Object.hasOwn(body, "rateLimit"));
const hasPriority = body && Object.hasOwn(body, "priority");
return {
id: body?.id !== undefined && body?.id !== null ? String(body.id) : undefined,
name: body?.name ? String(body.name) : undefined,
type: hasType ? toNumber(body?.type, 1) ?? 1 : undefined,
base_url: body?.base_url ?? body?.baseUrl,
api_key: body?.key ?? body?.api_key,
weight: hasWeight ? toNumber(body?.weight, 1) ?? 1 : undefined,
status: hasStatus ? toInternalStatus(body?.status) : undefined,
rate_limit: hasRateLimit ? toNumber(body?.rate_limit ?? body?.rateLimit, 0) ?? 0 : undefined,
models,
models_json: modelsToJson(models),
group_name: groupName,
priority: hasPriority ? toNumber(body?.priority, 0) ?? 0 : undefined,
metadata_json: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null
};
return {
id:
body?.id !== undefined && body?.id !== null ? String(body.id) : undefined,
name: body?.name ? String(body.name) : undefined,
type: hasType ? (toNumber(body?.type, 1) ?? 1) : undefined,
base_url:
body?.base_url !== undefined && body?.base_url !== null
? String(body.base_url)
: body?.baseUrl !== undefined && body?.baseUrl !== null
? String(body.baseUrl)
: undefined,
api_key:
body?.key !== undefined && body?.key !== null
? String(body.key)
: body?.api_key !== undefined && body?.api_key !== null
? String(body.api_key)
: undefined,
weight: hasWeight ? (toNumber(body?.weight, 1) ?? 1) : undefined,
status: hasStatus ? toInternalStatus(body?.status) : undefined,
rate_limit: hasRateLimit
? (toNumber(body?.rate_limit ?? body?.rateLimit, 0) ?? 0)
: undefined,
models,
models_json: modelsToJson(models),
group_name: groupName,
priority: hasPriority ? (toNumber(body?.priority, 0) ?? 0) : undefined,
metadata_json:
Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
};
}
export function mergeMetadata(existing: string | null | undefined, incoming: string | null | undefined): string | null {
const base = safeJsonParse<Record<string, unknown>>(existing, {});
const updates = safeJsonParse<Record<string, unknown>>(incoming, {});
const merged = { ...base, ...updates };
return Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
export function mergeMetadata(
existing: string | null | undefined,
incoming: string | null | undefined,
): string | null {
const base = safeJsonParse<Record<string, unknown>>(existing, {});
const updates = safeJsonParse<Record<string, unknown>>(incoming, {});
const merged = { ...base, ...updates };
return Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
}
export function toNewApiChannel(channel: ChannelRow): NewApiChannel {
const models = extractModelIds(channel);
const metadata = safeJsonParse<Record<string, unknown>>(channel.metadata_json, {});
const createdTime = channel.created_at ? Date.parse(channel.created_at) : NaN;
const updatedTime = channel.updated_at ? Date.parse(channel.updated_at) : NaN;
const models = extractModelIds(channel);
const metadata = safeJsonParse<Record<string, unknown>>(
channel.metadata_json,
{},
);
const createdTime = channel.created_at ? Date.parse(channel.created_at) : NaN;
const updatedTime = channel.updated_at ? Date.parse(channel.updated_at) : NaN;
return {
id: channel.id,
name: channel.name,
type: Number(channel.type ?? 1),
status: toNewApiStatus(channel.status),
priority: Number(channel.priority ?? 0),
weight: Number(channel.weight ?? 1),
models: models.join(','),
group: channel.group_name ?? '',
response_time: Number(channel.response_time_ms ?? 0),
test_time: toNumber(channel.test_time, 0) ?? 0,
base_url: channel.base_url,
key: channel.api_key,
created_time: Number.isNaN(createdTime) ? undefined : Math.floor(createdTime / 1000),
updated_time: Number.isNaN(updatedTime) ? undefined : Math.floor(updatedTime / 1000),
...metadata
};
return {
id: channel.id,
name: channel.name,
type: Number(channel.type ?? 1),
status: toNewApiStatus(channel.status),
priority: Number(channel.priority ?? 0),
weight: Number(channel.weight ?? 1),
models: models.join(","),
group: channel.group_name ?? "",
response_time: Number(channel.response_time_ms ?? 0),
test_time: toNumber(channel.test_time, 0) ?? 0,
base_url: channel.base_url,
key: channel.api_key,
created_time: Number.isNaN(createdTime)
? undefined
: Math.floor(createdTime / 1000),
updated_time: Number.isNaN(updatedTime)
? undefined
: Math.floor(updatedTime / 1000),
...metadata,
};
}
export function normalizeBaseUrlInput(value: string | undefined | null): string | null {
if (!value) {
return null;
}
return normalizeBaseUrl(String(value));
export function normalizeBaseUrlInput(
value: string | undefined | null,
): string | null {
if (!value) {
return null;
}
return normalizeBaseUrl(String(value));
}
+17 -10
View File
@@ -1,19 +1,26 @@
/**
* Determines whether a token can consume more quota.
*/
export function canConsumeQuota(quotaTotal: number | null, quotaUsed: number, increment: number): boolean {
if (quotaTotal === null || quotaTotal === undefined) {
return true;
}
return quotaUsed + increment <= quotaTotal;
export function canConsumeQuota(
quotaTotal: number | null,
quotaUsed: number,
increment: number,
): boolean {
if (quotaTotal === null || quotaTotal === undefined) {
return true;
}
return quotaUsed + increment <= quotaTotal;
}
/**
* Normalizes quota numbers to safe values.
*/
export function normalizeQuota(quotaTotal?: number | null, quotaUsed?: number | null) {
return {
quotaTotal: quotaTotal ?? null,
quotaUsed: quotaUsed ?? 0
};
export function normalizeQuota(
quotaTotal?: number | null,
quotaUsed?: number | null,
) {
return {
quotaTotal: quotaTotal ?? null,
quotaUsed: quotaUsed ?? 0,
};
}
+110 -26
View File
@@ -1,41 +1,125 @@
import type { D1Database } from '@cloudflare/workers-types';
import { nowIso } from '../utils/time';
import type { D1Database } from "@cloudflare/workers-types";
import { nowIso } from "../utils/time";
const RETENTION_KEY = 'log_retention_days';
const DEFAULT_LOG_RETENTION_DAYS = 30;
const DEFAULT_SESSION_TTL_HOURS = 12;
const RETENTION_KEY = "log_retention_days";
const SESSION_TTL_KEY = "session_ttl_hours";
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
async function readSetting(
db: D1Database,
key: string,
): Promise<string | null> {
const setting = await db
.prepare("SELECT value FROM settings WHERE key = ?")
.bind(key)
.first<{ value?: string }>();
return setting?.value ? String(setting.value) : null;
}
async function upsertSetting(
db: D1Database,
key: string,
value: string,
): Promise<void> {
await db
.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(key, value, nowIso())
.run();
}
function parsePositiveNumber(value: string | null, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed > 0) {
return parsed;
}
return fallback;
}
/**
* Returns the log retention days from settings or env fallback.
* Returns the log retention days from settings or default fallback.
*/
export async function getRetentionDays(db: D1Database, fallback: number): Promise<number> {
const setting = await db.prepare('SELECT value FROM settings WHERE key = ?').bind(RETENTION_KEY).first();
if (setting?.value) {
const parsed = Number(setting.value);
if (!Number.isNaN(parsed) && parsed > 0) {
return parsed;
}
}
return fallback;
export async function getRetentionDays(db: D1Database): Promise<number> {
const value = await readSetting(db, RETENTION_KEY);
return parsePositiveNumber(value, DEFAULT_LOG_RETENTION_DAYS);
}
/**
* Updates the log retention days setting.
*/
export async function setRetentionDays(db: D1Database, days: number): Promise<void> {
const value = Math.max(1, Math.floor(days)).toString();
await db
.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
.bind(RETENTION_KEY, value, nowIso())
.run();
export async function setRetentionDays(
db: D1Database,
days: number,
): Promise<void> {
const value = Math.max(1, Math.floor(days)).toString();
await upsertSetting(db, RETENTION_KEY, value);
}
/**
* Returns the session TTL hours from settings or default fallback.
*/
export async function getSessionTtlHours(db: D1Database): Promise<number> {
const value = await readSetting(db, SESSION_TTL_KEY);
return parsePositiveNumber(value, DEFAULT_SESSION_TTL_HOURS);
}
/**
* Updates the session TTL hours setting.
*/
export async function setSessionTtlHours(
db: D1Database,
hours: number,
): Promise<void> {
const value = Math.max(1, Math.floor(hours)).toString();
await upsertSetting(db, SESSION_TTL_KEY, value);
}
/**
* Returns the admin password hash.
*/
export async function getAdminPasswordHash(
db: D1Database,
): Promise<string | null> {
return readSetting(db, ADMIN_PASSWORD_HASH_KEY);
}
/**
* Updates the admin password hash.
*/
export async function setAdminPasswordHash(
db: D1Database,
hash: string,
): Promise<void> {
if (!hash) {
return;
}
await upsertSetting(db, ADMIN_PASSWORD_HASH_KEY, hash);
}
/**
* Returns whether the admin password is set.
*/
export async function isAdminPasswordSet(db: D1Database): Promise<boolean> {
const hash = await getAdminPasswordHash(db);
return Boolean(hash);
}
/**
* Loads generic settings as a key/value map.
*/
export async function listSettings(db: D1Database): Promise<Record<string, string>> {
const result = await db.prepare('SELECT key, value FROM settings').all();
const map: Record<string, string> = {};
for (const row of result.results ?? []) {
map[String(row.key)] = String(row.value);
}
return map;
export async function listSettings(
db: D1Database,
): Promise<Record<string, string>> {
const result = await db.prepare("SELECT key, value FROM settings").all();
const map: Record<string, string> = {};
for (const row of result.results ?? []) {
map[String(row.key)] = String(row.value);
}
return map;
}
+55 -44
View File
@@ -1,58 +1,69 @@
import type { D1Database } from '@cloudflare/workers-types';
import { nowIso } from '../utils/time';
import type { D1Database } from "@cloudflare/workers-types";
import { nowIso } from "../utils/time";
export type UsageInput = {
tokenId?: string | null;
channelId?: string | null;
model?: string | null;
requestPath?: string | null;
totalTokens?: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
cost?: number | null;
latencyMs?: number | null;
status?: string | null;
tokenId?: string | null;
channelId?: string | null;
model?: string | null;
requestPath?: string | null;
totalTokens?: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
cost?: number | null;
latencyMs?: number | null;
status?: string | null;
};
/**
* Inserts a usage record and updates token quota.
*/
export async function recordUsage(db: D1Database, input: UsageInput): Promise<void> {
const id = crypto.randomUUID();
const createdAt = nowIso();
await db
.prepare(
'INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(
id,
input.tokenId ?? null,
input.channelId ?? null,
input.model ?? null,
input.requestPath ?? null,
input.totalTokens ?? 0,
input.promptTokens ?? 0,
input.completionTokens ?? 0,
input.cost ?? 0,
input.latencyMs ?? 0,
input.status ?? 'ok',
createdAt
)
.run();
export async function recordUsage(
db: D1Database,
input: UsageInput,
): Promise<void> {
const id = crypto.randomUUID();
const createdAt = nowIso();
await db
.prepare(
"INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
id,
input.tokenId ?? null,
input.channelId ?? null,
input.model ?? null,
input.requestPath ?? null,
input.totalTokens ?? 0,
input.promptTokens ?? 0,
input.completionTokens ?? 0,
input.cost ?? 0,
input.latencyMs ?? 0,
input.status ?? "ok",
createdAt,
)
.run();
if (input.tokenId && input.totalTokens) {
await db
.prepare('UPDATE tokens SET quota_used = quota_used + ?, updated_at = ? WHERE id = ?')
.bind(input.totalTokens, createdAt, input.tokenId)
.run();
}
if (input.tokenId && input.totalTokens) {
await db
.prepare(
"UPDATE tokens SET quota_used = quota_used + ?, updated_at = ? WHERE id = ?",
)
.bind(input.totalTokens, createdAt, input.tokenId)
.run();
}
}
/**
* Deletes usage logs older than the retention window.
*/
export async function pruneUsageLogs(db: D1Database, retentionDays: number): Promise<void> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
await db.prepare('DELETE FROM usage_logs WHERE created_at < ?').bind(cutoff.toISOString()).run();
export async function pruneUsageLogs(
db: D1Database,
retentionDays: number,
): Promise<void> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
await db
.prepare("DELETE FROM usage_logs WHERE created_at < ?")
.bind(cutoff.toISOString())
.run();
}
+11 -11
View File
@@ -4,20 +4,20 @@ const encoder = new TextEncoder();
* Creates a SHA-256 hex digest from input text.
*/
export async function sha256Hex(input: string): Promise<string> {
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
/**
* Generates a URL-safe random token with an optional prefix.
*/
export function generateToken(prefix = ''): string {
const bytes = crypto.getRandomValues(new Uint8Array(24));
const base = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `${prefix}${base}`;
export function generateToken(prefix = ""): string {
const bytes = crypto.getRandomValues(new Uint8Array(24));
const base = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `${prefix}${base}`;
}
+15 -9
View File
@@ -1,14 +1,20 @@
import type { Context } from 'hono';
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
/**
* Sends a JSON error response with a consistent shape.
*/
export function jsonError(c: Context, status: number, message: string, code?: string) {
return c.json(
{
error: message,
code
},
status
);
export function jsonError(
c: Context,
status: ContentfulStatusCode,
message: string,
code?: string,
) {
return c.json(
{
error: message,
code,
},
status,
);
}
+12 -9
View File
@@ -1,13 +1,16 @@
/**
* Safely parses JSON or returns a fallback value.
*/
export function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
export function safeJsonParse<T>(
value: string | null | undefined,
fallback: T,
): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
+6 -6
View File
@@ -1,12 +1,12 @@
import type { Context } from 'hono';
import type { Context } from "hono";
/**
* Reads a bearer token from common headers.
*/
export function getBearerToken(c: Context): string | null {
const auth = c.req.header('Authorization');
if (auth && auth.toLowerCase().startsWith('bearer ')) {
return auth.slice(7).trim();
}
return c.req.header('x-api-key') ?? c.req.header('x-admin-token') ?? null;
const auth = c.req.header("Authorization");
if (auth?.toLowerCase().startsWith("bearer ")) {
return auth.slice(7).trim();
}
return c.req.header("x-api-key") ?? c.req.header("x-admin-token") ?? null;
}
+4 -4
View File
@@ -2,14 +2,14 @@
* Returns the current ISO timestamp.
*/
export function nowIso(): string {
return new Date().toISOString();
return new Date().toISOString();
}
/**
* Adds hours to a date and returns a new Date.
*/
export function addHours(date: Date, hours: number): Date {
const copy = new Date(date.getTime());
copy.setHours(copy.getHours() + hours);
return copy;
const copy = new Date(date.getTime());
copy.setHours(copy.getHours() + hours);
return copy;
}
+5 -2
View File
@@ -8,6 +8,9 @@
* Normalized base URL without trailing slashes or /v1.
*/
export function normalizeBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, '');
return trimmed.replace(/\/v1$/i, '');
if (!baseUrl) {
return "";
}
const trimmed = baseUrl.trim().replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
+168
View File
@@ -0,0 +1,168 @@
import { safeJsonParse } from "./json";
export type NormalizedUsage = {
totalTokens: number;
promptTokens: number;
completionTokens: number;
};
function toNumber(value: unknown): number | null {
if (value === null || value === undefined) {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function pickNumber(...values: Array<unknown>): number | null {
for (const value of values) {
const parsed = toNumber(value);
if (parsed !== null) {
return parsed;
}
}
return null;
}
export function normalizeUsage(raw: unknown): NormalizedUsage | null {
if (!raw || typeof raw !== "object") {
return null;
}
const data = raw as Record<string, unknown>;
const promptTokens = pickNumber(
data.prompt_tokens,
data.promptTokens,
data.input_tokens,
data.inputTokens,
);
const completionTokens = pickNumber(
data.completion_tokens,
data.completionTokens,
data.output_tokens,
data.outputTokens,
);
let totalTokens = pickNumber(
data.total_tokens,
data.totalTokens,
data.total,
data.tokens,
data.token_count,
);
if (
totalTokens === null &&
(promptTokens !== null || completionTokens !== null)
) {
totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0);
}
if (totalTokens === null) {
return null;
}
return {
totalTokens,
promptTokens: promptTokens ?? 0,
completionTokens: completionTokens ?? 0,
};
}
export function parseUsageFromJson(payload: unknown): NormalizedUsage | null {
if (!payload || typeof payload !== "object") {
return null;
}
const data = payload as Record<string, unknown>;
const usage =
data.usage ??
(data.data && typeof data.data === "object"
? (data.data as Record<string, unknown>).usage
: null);
return normalizeUsage(usage);
}
export function parseUsageFromHeaders(
headers: Headers,
): NormalizedUsage | null {
const jsonHeader = headers.get("x-usage") ?? headers.get("x-openai-usage");
if (jsonHeader) {
const parsed = safeJsonParse<unknown>(jsonHeader, null);
const normalized = normalizeUsage(parsed);
if (normalized) {
return normalized;
}
}
const totalTokens = pickNumber(
headers.get("x-usage-total-tokens"),
headers.get("x-openai-usage-total-tokens"),
);
const promptTokens = pickNumber(
headers.get("x-usage-prompt-tokens"),
headers.get("x-openai-usage-prompt-tokens"),
);
const completionTokens = pickNumber(
headers.get("x-usage-completion-tokens"),
headers.get("x-openai-usage-completion-tokens"),
);
if (
totalTokens === null &&
promptTokens === null &&
completionTokens === null
) {
return null;
}
return {
totalTokens: totalTokens ?? (promptTokens ?? 0) + (completionTokens ?? 0),
promptTokens: promptTokens ?? 0,
completionTokens: completionTokens ?? 0,
};
}
export async function parseUsageFromSse(
response: Response,
): Promise<NormalizedUsage | null> {
if (!response.body) {
return null;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let usage: NormalizedUsage | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
if (payload && payload !== "[DONE]") {
const parsed = safeJsonParse<unknown>(payload, null);
const candidate = parseUsageFromJson(parsed);
if (candidate) {
usage = candidate;
}
}
}
newlineIndex = buffer.indexOf("\n");
}
}
const remaining = buffer.trim();
if (remaining.startsWith("data:")) {
const payload = remaining.slice(5).trim();
if (payload && payload !== "[DONE]") {
const parsed = safeJsonParse<unknown>(payload, null);
const candidate = parseUsageFromJson(parsed);
if (candidate) {
usage = candidate;
}
}
}
return usage;
}
+10 -10
View File
@@ -1,12 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["@cloudflare/workers-types"],
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["@cloudflare/workers-types"],
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
-4
View File
@@ -3,13 +3,9 @@ main = "src/index.ts"
compatibility_date = "2026-02-14"
[vars]
ADMIN_PASSWORD = "lsy"
CORS_ORIGIN = "http://localhost:5173"
SESSION_TTL_HOURS = "12"
LOG_RETENTION_DAYS = "30"
PROXY_RETRY_ROUNDS = "2"
PROXY_RETRY_DELAY_MS = "200"
NEW_API_ADMIN_TOKEN = "sk-newapi-admin"
[[d1_databases]]
binding = "DB"
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/*",
"!bun.lock",
"!helloagents",
"!.agents",
"!tests",
"!**/node_modules"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+111
View File
@@ -8,6 +8,7 @@
"wrangler": "4",
},
"devDependencies": {
"@biomejs/biome": "2.3.15",
"@types/node": "^20.11.30",
"typescript": "^5.4.5",
"vitest": "^1.5.3",
@@ -15,7 +16,13 @@
},
"apps/admin": {
"name": "new-api-lite-admin",
"dependencies": {
"hono": "^4.11.9",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.4.5",
"vite": "^5.2.0",
},
@@ -33,6 +40,26 @@
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@biomejs/biome": ["@biomejs/biome@2.3.15", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.3.15.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.12.1", "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.12.1.tgz", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA=="],
@@ -163,6 +190,10 @@
"@jest/schemas": ["@jest/schemas@29.6.3", "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
@@ -231,6 +262,36 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.14.tgz", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.33", "https://registry.npmmirror.com/@types/node/-/node-20.19.33.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="],
@@ -289,6 +350,8 @@
"diff-sequences": ["diff-sequences@29.6.3", "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"esbuild": ["esbuild@0.27.3", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
@@ -313,6 +376,8 @@
"glob-to-regexp": ["glob-to-regexp@0.4.1", "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hono": ["hono@4.11.9", "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"human-signals": ["human-signals@5.0.0", "https://registry.npmmirror.com/human-signals/-/human-signals-5.0.0.tgz", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
@@ -323,10 +388,36 @@
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"local-pkg": ["local-pkg@0.5.1", "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="],
"loupe": ["loupe@2.3.7", "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="],
@@ -423,6 +514,10 @@
"supports-color": ["supports-color@10.2.2", "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinypool": ["tinypool@0.8.4", "https://registry.npmmirror.com/tinypool/-/tinypool-0.8.4.tgz", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="],
@@ -467,6 +562,22 @@
"zod": ["zod@3.22.3", "https://registry.npmmirror.com/zod/-/zod-3.22.3.tgz", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"miniflare/sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"mlly/pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+44
View File
@@ -3,6 +3,21 @@
## [Unreleased]
### 微调
- **[tooling]**: 修复 bun check 脚本名称(移除尾随空格)
- 类型: 微调(无方案包)
- 文件: package.json
- **[admin-ui]**: 渠道创建移除 ID 字段并校验名称唯一
- 类型: 微调(无方案包)
- 文件: apps/admin/src/main.tsx:250-287, 664-829
- **[worker]**: 全局记录收到的请求概要
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts
- **[worker]**: base_url 为空时返回空字符串避免崩溃
- 类型: 微调(无方案包)
- 文件: apps/worker/src/utils/url.ts
- **[tooling]**: dev 脚本改为 Bun workspace 执行
- 类型: 微调(无方案包)
- 文件: package.json
- **[admin-ui]**: 本地开发增加 Vite proxy 解决前后端端口不一致
- 类型: 微调(无方案包)
- 文件: apps/admin/vite.config.ts, README.md
@@ -36,6 +51,35 @@
- **[worker]**: 新增 `/api/group` 兼容接口并放行鉴权
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/newapiGroups.ts, tests/worker/newapi.test.ts
- **[proxy]**: 流式请求自动补 `stream_options.include_usage` 以获取 usage
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/proxy.ts
## [0.4.1] - 2026-02-15
### 变更
- **[admin-ui]**: 管理台入口拆分为模块、扁平化 features,并将 AppShell 调整为 AppLayout
- 方案: [202602152325_admin-ui-modularize](archive/2026-02/202602152325_admin-ui-modularize/)
- 决策: admin-ui-modularize#D001(功能域拆分)
## [0.4.0] - 2026-02-15
### 新增
- **[channels]**: New API 标签批量权重/启用/停用接口
- 方案: [202602152211_newapi-tag-sync](archive/2026-02/202602152211_newapi-tag-sync/)
## [0.3.1] - 2026-02-15
### 修复
- **[proxy]**: 增强 usage 解析以修复使用日志与数据面板 token 统计为 0
- 方案: [202602151843_fix-usage-tokens](archive/2026-02/202602151843_fix-usage-tokens/)
## [0.3.0] - 2026-02-15
### 变更
- **[admin-ui]**: 管理台改为 Hono + TSX DOM 渲染并接入 Tailwind v4
- 方案: [202602151628_admin-ui-hono-tsx-tailwind](archive/2026-02/202602151628_admin-ui-hono-tsx-tailwind/)
- 决策: admin-ui-hono-tsx-tailwind#D001(采用 Hono JSX DOM + Tailwind)
## [0.2.1] - 2026-02-15
+1 -1
View File
@@ -16,7 +16,7 @@
## 知识库状态
```yaml
最后更新: 2026-02-15 02:03
最后更新: 2026-02-15 17:17
模块数量: 9
待执行方案: 0
```
+2 -1
View File
@@ -21,9 +21,10 @@
### 主要依赖
| 依赖 | 版本 | 用途 |
|------|------|------|
| Hono | 待定 | Worker 路由与中间件 |
| Hono | 待定 | Worker 路由与管理台 TSX |
| Wrangler | 待定 | Workers 构建与部署 |
| Vite | 待定 | 管理台构建 |
| TailwindCSS | 待定 | 管理台样式与设计系统 |
## 3. 项目概述
+5
View File
@@ -10,7 +10,12 @@
## 行为规范
- Vite 构建静态文件
- 使用 `hono/jsx/dom` + TSX 渲染管理台入口
- 入口文件为 `apps/admin/src/App.tsx`(`main.tsx` 已移除)
- 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块
- 样式基于 Tailwind v4(结合全局主题变量)
- 前端状态集中在单页
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- Token 仅本地保存,不回显
## 依赖关系
+4
View File
@@ -16,6 +16,9 @@
- `POST /api/channel` New API 兼容新增
- `PUT /api/channel` New API 兼容更新
- `DELETE /api/channel/:id` New API 兼容删除
- `PUT /api/channel/tag` New API 兼容标签批量更新
- `POST /api/channel/tag/enabled` New API 兼容标签批量启用
- `POST /api/channel/tag/disabled` New API 兼容标签批量停用
- `GET /api/channel/test/:id` New API 兼容连通性测试
- `GET /api/channel/fetch_models/:id` New API 兼容模型拉取
- `GET /api/group` New API 兼容分组列表
@@ -26,6 +29,7 @@
- 连通性测试调用 `/v1/models`
- 测试成功后更新 `models_json`
- New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json`
- New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新
- New API 分组列表从 `group_name` 字段解析,空时返回 `default`
## 依赖关系
+2 -1
View File
@@ -11,7 +11,8 @@
## 行为规范
- 基于令牌鉴权
- 按渠道权重随机排序
- 非流式响应解析 usage 字段
- 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
- 可配置失败重试轮询(响应 5xx/429 时触发)
## 依赖关系
+24 -20
View File
@@ -1,22 +1,26 @@
{
"name": "new-api-lite",
"private": true,
"packageManager": "bun@1.3.9",
"workspaces": [
"apps/*"
],
"scripts": {
"dev:worker": "npm --workspace apps/worker run dev",
"dev:admin": "npm --workspace apps/admin run dev",
"test": "vitest",
"fix": "bun pm pkg fix"
},
"devDependencies": {
"@types/node": "^20.11.30",
"typescript": "^5.4.5",
"vitest": "^1.5.3"
},
"dependencies": {
"wrangler": "4"
}
"name": "api-workers",
"private": true,
"packageManager": "bun@1.3.9",
"workspaces": [
"apps/*"
],
"scripts": {
"dev:worker": "bun --filter new-api-lite-worker dev",
"dev:admin": "bun --filter new-api-lite-admin dev",
"test": "vitest",
"format": "bunx --bun biome format --write .",
"lint": "bunx --bun biome lint --write .",
"check": "bunx --bun biome check --write .",
"typecheck": "bunx --bun tsc --noEmit"
},
"devDependencies": {
"@biomejs/biome": "2.3.15",
"@types/node": "^20.11.30",
"typescript": "^5.4.5",
"vitest": "^1.5.3"
},
"dependencies": {
"wrangler": "4"
}
}
+18 -8
View File
@@ -1,10 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"types": ["vitest/globals"],
"skipLibCheck": true
}
"exclude": ["**/dist", "helloagents", "tests", "**/node_modules"],
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx/dom",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"typeRoots": [
"./node_modules",
"./node_modules/@types",
"./apps/admin/node_modules",
"./apps/worker/node_modules"
],
"types": ["vitest/globals", "vite/client", "@cloudflare/workers-types"],
"skipLibCheck": true
}
}
+5 -5
View File
@@ -1,8 +1,8 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node'
}
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
},
});