feat(usage): 扩展使用日志指标并优化管理台分页

- 记录首 token 延迟、stream、reasoning_effort 并更新迁移与 schema
- 管理台 Usage/Tokens/Channels 增强分页与展示字段
This commit is contained in:
lsy committed 2026-02-16 17:54:57 +08:00
1 parent 9e7c43edd0
commit 1b0bc99a79
18 files changed
+732 -220

No files matched your search

+1
View File
@@ -172,3 +172,4 @@ bunx wrangler d1 migrations apply DB --remote
- `CLOUDFLARE_ACCOUNT_ID`:Cloudflare 账户 ID(若 token 无法自动识别账户)
确保 `apps/worker/wrangler.toml` 中已填写 `database_id`。
可选:仓库级 Actions 变量 `SPA_DEPLOY` 可用于控制自动部署开关(未设置时默认启用)。
+47 -2
View File
@@ -58,11 +58,14 @@ const App = () => {
useState<SettingsForm>(initialSettingsForm);
const [channelPage, setChannelPage] = useState(1);
const [channelPageSize, setChannelPageSize] = useState(10);
const [tokenPage, setTokenPage] = useState(1);
const [tokenPageSize, setTokenPageSize] = useState(10);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [channelForm, setChannelForm] = useState<ChannelForm>(() => ({
...initialChannelForm,
}));
const [isChannelModalOpen, setChannelModalOpen] = useState(false);
const [isTokenModalOpen, setTokenModalOpen] = useState(false);
const updateToken = useCallback((next: string | null) => {
setToken(next);
@@ -104,7 +107,7 @@ const App = () => {
}, [apiFetch]);
const loadUsage = useCallback(async () => {
const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=50");
const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=200");
setData((prev) => ({ ...prev, usage: result.logs }));
}, [apiFetch]);
@@ -214,6 +217,15 @@ const App = () => {
setChannelPage(1);
}, []);
const handleTokenPageChange = useCallback((next: number) => {
setTokenPage(next);
}, []);
const handleTokenPageSizeChange = useCallback((next: number) => {
setTokenPageSize(next);
setTokenPage(1);
}, []);
const handleTabChange = useCallback((tabId: TabId) => {
setActiveTab(tabId);
}, []);
@@ -231,6 +243,11 @@ const App = () => {
setNotice("");
}, []);
const openTokenCreate = useCallback(() => {
setTokenModalOpen(true);
setNotice("");
}, []);
const startChannelEdit = useCallback((channel: Channel) => {
setEditingChannel(channel);
setChannelForm({
@@ -243,6 +260,10 @@ const App = () => {
setNotice("");
}, []);
const closeTokenModal = useCallback(() => {
setTokenModalOpen(false);
}, []);
const handleChannelSubmit = useCallback(
async (event: Event) => {
event.preventDefault();
@@ -314,6 +335,8 @@ const App = () => {
});
setNotice(`新令牌: ${result.token}`);
form.reset();
setTokenModalOpen(false);
setTokenPage(1);
await loadTokens();
} catch (error) {
setNotice((error as Error).message);
@@ -463,11 +486,24 @@ const App = () => {
const start = (channelPage - 1) * channelPageSize;
return data.channels.slice(start, start + channelPageSize);
}, [channelPage, channelPageSize, data.channels]);
const tokenTotal = data.tokens.length;
const tokenTotalPages = useMemo(
() => Math.max(1, Math.ceil(tokenTotal / tokenPageSize)),
[tokenTotal, tokenPageSize],
);
const pagedTokens = useMemo(() => {
const start = (tokenPage - 1) * tokenPageSize;
return data.tokens.slice(start, start + tokenPageSize);
}, [data.tokens, tokenPage, tokenPageSize]);
useEffect(() => {
setChannelPage((prev) => Math.min(prev, channelTotalPages));
}, [channelTotalPages]);
useEffect(() => {
setTokenPage((prev) => Math.min(prev, tokenTotalPages));
}, [tokenTotalPages]);
const activeLabel = useMemo(
() => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台",
[activeTab],
@@ -514,7 +550,16 @@ const App = () => {
if (activeTab === "tokens") {
return (
<TokensView
tokens={data.tokens}
pagedTokens={pagedTokens}
tokenPage={tokenPage}
tokenPageSize={tokenPageSize}
tokenTotal={tokenTotal}
tokenTotalPages={tokenTotalPages}
isTokenModalOpen={isTokenModalOpen}
onCreate={openTokenCreate}
onCloseModal={closeTokenModal}
onPageChange={handleTokenPageChange}
onPageSizeChange={handleTokenPageSizeChange}
onSubmit={handleTokenSubmit}
onReveal={handleTokenReveal}
onToggle={handleTokenToggle}
+12 -5
View File
@@ -15,17 +15,24 @@ export type Token = {
quota_total: number | null;
quota_used: number;
status: string;
created_at?: string | null;
updated_at?: string | null;
};
export type UsageLog = {
id: string;
model: string;
channel_id: string;
model: string | null;
channel_id: string | null;
channel_name?: string | null;
token_id: string;
token_id: string | null;
token_name?: string | null;
total_tokens: number;
latency_ms: number;
total_tokens: number | null;
prompt_tokens?: number | null;
completion_tokens?: number | null;
latency_ms: number | null;
first_token_latency_ms?: number | null;
stream?: boolean | number | null;
reasoning_effort?: string | number | null;
status: string;
created_at: string;
};
+33 -1
View File
@@ -7,11 +7,21 @@
* Returns:
* A human-friendly datetime string or "-".
*/
const pad2 = (value: number) => String(value).padStart(2, "0");
export const formatDateTime = (value?: string | null) => {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(
date.getDate(),
)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(
date.getSeconds(),
)}`;
};
/**
@@ -25,3 +35,25 @@ export const formatDateTime = (value?: string | null) => {
*/
export const toggleStatus = (value: string) =>
value === "active" ? "disabled" : "active";
export type PageItem = number | "ellipsis";
export const buildPageItems = (current: number, total: number): PageItem[] => {
if (total <= 6) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const items: PageItem[] = [1, 2, 3];
if (current > 3 && current < total - 1) {
items.push("ellipsis", current);
}
items.push("ellipsis", total - 1, total);
return items.filter((item, index, array) => {
if (item === "ellipsis" && array[index - 1] === "ellipsis") {
return false;
}
if (typeof item === "number") {
return array.indexOf(item) === index;
}
return true;
});
};
+48 -27
View File
@@ -1,4 +1,5 @@
import type { Channel, ChannelForm } from "../core/types";
import { buildPageItems } from "../core/utils";
type ChannelsViewProps = {
channelForm: ChannelForm;
@@ -21,6 +22,8 @@ type ChannelsViewProps = {
onFormChange: (patch: Partial<ChannelForm>) => void;
};
const pageSizeOptions = [10, 20, 50];
/**
* Renders the channels management view.
*
@@ -51,6 +54,7 @@ export const ChannelsView = ({
onFormChange,
}: ChannelsViewProps) => {
const isEditing = Boolean(editingChannel);
const pageItems = buildPageItems(channelPage, channelTotalPages);
return (
<div class="space-y-5">
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
@@ -69,25 +73,6 @@ export const ChannelsView = ({
>
新增渠道
</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">
@@ -176,29 +161,65 @@ export const ChannelsView = ({
)}
</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">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-stone-500">共 {channelTotalPages} 页</span>
<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"
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={channelPage <= 1}
onClick={() => onPageChange(Math.max(1, channelPage - 1))}
>
上一页
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
...
</span>
) : (
<button
class={`h-8 min-w-[32px] rounded-full border px-3 text-xs font-semibold 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 ${
item === channelPage
? "border-stone-900 bg-stone-900 text-white shadow-md"
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
}`}
type="button"
key={item}
onClick={() => onPageChange(item)}
>
{item}
</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"
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={channelPage >= channelTotalPages}
onClick={() =>
onPageChange(Math.min(channelTotalPages, channelPage + 1))
}
>
下一页
&gt;
</button>
</div>
<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),
);
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
</div>
</div>
{isChannelModalOpen && (
+254 -112
View File
@@ -1,13 +1,25 @@
import type { Token } from "../core/types";
import { buildPageItems, formatDateTime } from "../core/utils";
type TokensViewProps = {
tokens: Token[];
pagedTokens: Token[];
tokenPage: number;
tokenPageSize: number;
tokenTotal: number;
tokenTotalPages: number;
isTokenModalOpen: boolean;
onCreate: () => void;
onCloseModal: () => void;
onPageChange: (next: number) => void;
onPageSizeChange: (next: number) => void;
onSubmit: (event: Event) => void;
onReveal: (id: string) => void;
onToggle: (id: string, status: string) => void;
onDelete: (id: string) => void;
};
const pageSizeOptions = [10, 20, 50];
/**
* Renders the tokens management view.
*
@@ -18,122 +30,252 @@ type TokensViewProps = {
* Tokens JSX element.
*/
export const TokensView = ({
tokens,
pagedTokens,
tokenPage,
tokenPageSize,
tokenTotal,
tokenTotalPages,
isTokenModalOpen,
onCreate,
onCloseModal,
onPageChange,
onPageSizeChange,
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
/>
}: TokensViewProps) => {
const pageItems = buildPageItems(tokenPage, tokenTotalPages);
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>
</div>
</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 class="mt-4 overflow-hidden rounded-xl border border-stone-200">
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1.2fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
<div>名称</div>
<div>状态</div>
<div>已用/额度</div>
<div>前缀</div>
<div>创建时间</div>
<div>操作</div>
</div>
{pagedTokens.length === 0 ? (
<div class="px-4 py-10 text-center text-sm text-stone-500">
暂无令牌,请先创建。
</div>
) : (
<div class="divide-y divide-stone-100">
{pagedTokens.map((tokenItem) => {
const isActive = tokenItem.status === "active";
return (
<div
class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1.2fr)] items-center gap-3 px-4 py-4 text-sm"
key={tokenItem.id}
>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-stone-900">
{tokenItem.name}
</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">
{tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"}
</div>
<div class="text-sm text-stone-700">
{tokenItem.key_prefix ?? "-"}
</div>
<div class="text-sm text-stone-700">
{formatDateTime(tokenItem.created_at)}
</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={() => onReveal(tokenItem.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(tokenItem.id, tokenItem.status)}
>
切换
</button>
<button
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => onDelete(tokenItem.id)}
>
删除
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-stone-500">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-stone-500">共 {tokenTotalPages} 页</span>
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={tokenPage <= 1}
onClick={() => onPageChange(Math.max(1, tokenPage - 1))}
>
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
...
</span>
) : (
<button
class={`h-8 min-w-[32px] rounded-full border px-3 text-xs font-semibold 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 ${
item === tokenPage
? "border-stone-900 bg-stone-900 text-white shadow-md"
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
}`}
type="button"
key={item}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={tokenPage >= tokenTotalPages}
onClick={() =>
onPageChange(Math.min(tokenTotalPages, tokenPage + 1))
}
>
&gt;
</button>
</div>
<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={tokenPageSize}
onChange={(event) => {
onPageSizeChange(
Number((event.currentTarget as HTMLSelectElement).value),
);
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
</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>
{isTokenModalOpen && (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
<div class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
生成令牌
</h3>
<p class="text-xs text-stone-500">
创建后可在列表中查看与管理。
</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="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>
<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"
>
生成令牌
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
);
};
+222 -64
View File
@@ -1,11 +1,31 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import type { UsageLog } from "../core/types";
import { formatDateTime } from "../core/utils";
import { buildPageItems, formatDateTime } from "../core/utils";
type UsageViewProps = {
usage: UsageLog[];
onRefresh: () => void;
};
const pageSizeOptions = [50, 100, 200];
const formatTokens = (value: number | null | undefined) =>
value === null || value === undefined ? "-" : value;
const formatSeconds = (value: number | null | undefined) => {
if (value === null || value === undefined || Number.isNaN(value)) {
return "-";
}
return `${(value / 1000).toFixed(2)} s`;
};
const formatStream = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return "-";
}
return value ? "是" : "否";
};
/**
* Renders the usage logs view.
*
@@ -15,67 +35,205 @@ type UsageViewProps = {
* 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>
export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1);
const total = usage.length;
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize],
);
useEffect(() => {
setPage(1);
}, [pageSize]);
useEffect(() => {
setPage((prev) => Math.min(prev, totalPages));
}, [totalPages]);
const pagedUsage = useMemo(() => {
const start = (page - 1) * pageSize;
return usage.slice(start, start + pageSize);
}, [usage, page, pageSize]);
const pageItems = useMemo(
() => buildPageItems(page, totalPages),
[page, totalPages],
);
return (
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
使用日志
</h3>
<p class="text-xs text-stone-500">
追踪每次调用的令牌与关键性能指标。
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={onRefresh}
>
刷新
</button>
</div>
</div>
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200">
<div class="h-[520px] overflow-auto">
<table class="min-w-[1100px] w-full border-collapse text-sm">
<thead>
<tr>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
时间
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
模型
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
渠道
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
令牌
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
输入 Tokens
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
输出 Tokens
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
用时
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
首 token 延迟
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
流式
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
推理强度
</th>
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
状态
</th>
</tr>
</thead>
<tbody>
{pagedUsage.length === 0 ? (
<tr>
<td
class="px-3 py-10 text-center text-sm text-stone-500"
colSpan={11}
>
暂无日志。
</td>
</tr>
) : (
pagedUsage.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.token_name ?? log.token_id ?? "-"}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{formatTokens(log.prompt_tokens)}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{formatTokens(log.completion_tokens)}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{formatSeconds(log.latency_ms)}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{formatSeconds(log.first_token_latency_ms)}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{formatStream(log.stream)}
</td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
{log.reasoning_effort ?? "-"}
</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>
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-stone-500">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-stone-500">共 {totalPages} 页</span>
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={page <= 1}
onClick={() => setPage(Math.max(1, page - 1))}
>
&lt;
</button>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
...
</span>
) : (
<button
class={`h-8 min-w-[32px] rounded-full border px-3 text-xs font-semibold 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 ${
item === page
? "border-stone-900 bg-stone-900 text-white shadow-md"
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
}`}
type="button"
key={item}
onClick={() => setPage(item)}
>
{item}
</button>
),
)}
<button
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
disabled={page >= totalPages}
onClick={() => setPage(Math.min(totalPages, page + 1))}
>
&gt;
</button>
</div>
<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={pageSize}
onChange={(event) => {
setPageSize(
Number((event.currentTarget as HTMLSelectElement).value),
);
}}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
</div>
</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>
);
);
};
+3
View File
@@ -35,6 +35,9 @@ CREATE TABLE IF NOT EXISTS usage_logs (
completion_tokens INTEGER,
cost REAL,
latency_ms INTEGER,
first_token_latency_ms INTEGER,
stream INTEGER,
reasoning_effort TEXT,
status TEXT,
created_at TEXT NOT NULL
);
@@ -0,0 +1,3 @@
ALTER TABLE usage_logs ADD COLUMN first_token_latency_ms INTEGER;
ALTER TABLE usage_logs ADD COLUMN stream INTEGER;
ALTER TABLE usage_logs ADD COLUMN reasoning_effort TEXT;
+3
View File
@@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS usage_logs (
completion_tokens INTEGER,
cost REAL,
latency_ms INTEGER,
first_token_latency_ms INTEGER,
stream INTEGER,
reasoning_effort TEXT,
status TEXT,
created_at TEXT NOT NULL
);
+33 -5
View File
@@ -92,6 +92,17 @@ proxy.all("/*", tokenAuth, async (c) => {
? String(parsedBody.model)
: null;
const isStream = parsedBody?.stream === true;
const reasoningEffortRaw =
parsedBody && typeof parsedBody === "object"
? ((parsedBody as Record<string, unknown>).reasoning_effort ??
(parsedBody as Record<string, unknown>).reasoningEffort ??
null)
: null;
const reasoningEffort =
typeof reasoningEffortRaw === "string" ||
typeof reasoningEffortRaw === "number"
? reasoningEffortRaw
: null;
if (isStream && parsedBody && typeof parsedBody === "object") {
const streamOptions = (parsedBody as Record<string, unknown>)
.stream_options;
@@ -185,6 +196,9 @@ proxy.all("/*", tokenAuth, async (c) => {
requestPath: targetPath,
totalTokens: 0,
latencyMs,
firstTokenLatencyMs: isStream ? null : latencyMs,
stream: isStream,
reasoningEffort,
status: "error",
});
return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable");
@@ -192,12 +206,17 @@ proxy.all("/*", tokenAuth, async (c) => {
const channelForUsage = selectedChannel ?? lastChannel;
if (channelForUsage && lastResponse) {
const record = async (usage: NormalizedUsage | null) => {
const record = async (
usage: NormalizedUsage | null,
firstTokenLatencyMs?: number | null,
) => {
const normalized = usage ?? {
totalTokens: 0,
promptTokens: 0,
completionTokens: 0,
};
const resolvedFirstTokenLatencyMs =
firstTokenLatencyMs ?? (isStream ? null : latencyMs);
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
channelId: channelForUsage.id,
@@ -208,6 +227,9 @@ proxy.all("/*", tokenAuth, async (c) => {
completionTokens: normalized.completionTokens,
cost: 0,
latencyMs,
firstTokenLatencyMs: resolvedFirstTokenLatencyMs,
stream: isStream,
reasoningEffort,
status: lastResponse.ok ? "ok" : "error",
});
};
@@ -248,13 +270,19 @@ proxy.all("/*", tokenAuth, async (c) => {
? "header"
: "none";
if (isStream && !immediateUsage) {
if (isStream) {
const executionCtx = (c as { executionCtx?: ExecutionContextLike })
.executionCtx;
const task = parseUsageFromSse(lastResponse.clone())
.then((streamUsage) => {
logUsage("stream", streamUsage, streamUsage ? "sse" : "sse-none");
return record(streamUsage);
const usageValue = immediateUsage ?? streamUsage.usage;
const source = immediateUsage
? immediateSource
: streamUsage.usage
? "sse"
: "sse-none";
logUsage("stream", usageValue, source);
return record(usageValue, streamUsage.firstTokenLatencyMs);
})
.catch(() => undefined);
if (executionCtx?.waitUntil) {
@@ -264,7 +292,7 @@ proxy.all("/*", tokenAuth, async (c) => {
}
} else {
logUsage("immediate", immediateUsage, immediateSource);
await record(immediateUsage);
await record(immediateUsage, latencyMs);
}
}
+19 -1
View File
@@ -11,6 +11,9 @@ export type UsageInput = {
completionTokens?: number | null;
cost?: number | null;
latencyMs?: number | null;
firstTokenLatencyMs?: number | null;
stream?: boolean | number | null;
reasoningEffort?: string | number | null;
status?: string | null;
};
@@ -23,9 +26,21 @@ export async function recordUsage(
): Promise<void> {
const id = crypto.randomUUID();
const createdAt = nowIso();
const streamValue =
input.stream === null || input.stream === undefined
? null
: typeof input.stream === "number"
? input.stream
: input.stream
? 1
: 0;
const reasoningValue =
input.reasoningEffort === null || input.reasoningEffort === undefined
? null
: String(input.reasoningEffort);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, first_token_latency_ms, stream, reasoning_effort, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
id,
@@ -38,6 +53,9 @@ export async function recordUsage(
input.completionTokens ?? 0,
input.cost ?? 0,
input.latencyMs ?? 0,
input.firstTokenLatencyMs ?? null,
streamValue,
reasoningValue,
input.status ?? "ok",
createdAt,
)
+16 -3
View File
@@ -6,6 +6,11 @@ export type NormalizedUsage = {
completionTokens: number;
};
export type StreamUsage = {
usage: NormalizedUsage | null;
firstTokenLatencyMs: number | null;
};
function toNumber(value: unknown): number | null {
if (value === null || value === undefined) {
return null;
@@ -119,14 +124,16 @@ export function parseUsageFromHeaders(
export async function parseUsageFromSse(
response: Response,
): Promise<NormalizedUsage | null> {
): Promise<StreamUsage> {
if (!response.body) {
return null;
return { usage: null, firstTokenLatencyMs: null };
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let usage: NormalizedUsage | null = null;
const start = Date.now();
let firstTokenLatencyMs: number | null = null;
while (true) {
const { done, value } = await reader.read();
@@ -141,6 +148,9 @@ export async function parseUsageFromSse(
if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
if (payload && payload !== "[DONE]") {
if (firstTokenLatencyMs === null) {
firstTokenLatencyMs = Date.now() - start;
}
const parsed = safeJsonParse<unknown>(payload, null);
const candidate = parseUsageFromJson(parsed);
if (candidate) {
@@ -156,6 +166,9 @@ export async function parseUsageFromSse(
if (remaining.startsWith("data:")) {
const payload = remaining.slice(5).trim();
if (payload && payload !== "[DONE]") {
if (firstTokenLatencyMs === null) {
firstTokenLatencyMs = Date.now() - start;
}
const parsed = safeJsonParse<unknown>(payload, null);
const candidate = parseUsageFromJson(parsed);
if (candidate) {
@@ -164,5 +177,5 @@ export async function parseUsageFromSse(
}
}
return usage;
return { usage, firstTokenLatencyMs };
}
+32
View File
@@ -3,6 +3,12 @@
## [Unreleased]
### 微调
- **[worker]**: 补齐 admin 静态目录占位以通过 wrangler assets 检查
- 类型: 微调(无方案包)
- 文件: apps/admin/dist/.gitkeep
- **[worker]**: 修正 wrangler assets 配置并为非 API 路由回退静态资源
- 类型: 微调(无方案包)
- 文件: apps/worker/wrangler.toml:15-19, apps/worker/src/index.ts:114-127
- **[tooling]**: 修复 bun check 脚本名称(移除尾随空格)
- 类型: 微调(无方案包)
- 文件: package.json
@@ -12,6 +18,12 @@
- **[ci]**: 自动部署加入远程 D1 迁移步骤
- 类型: 微调(无方案包)
- 文件: .github/workflows/deploy.yml
- **[ci]**: 对齐 CloudPaste 风格的 SPA 自动部署流程与数据库初始化
- 类型: 微调(无方案包)
- 文件: .github/workflows/deploy.yml
- **[docs]**: 说明 SPA_DEPLOY 自动部署开关
- 类型: 微调(无方案包)
- 文件: README.md
- **[admin-ui]**: 渠道创建移除 ID 字段并校验名称唯一
- 类型: 微调(无方案包)
- 文件: apps/admin/src/main.tsx:250-287, 664-829
@@ -61,6 +73,26 @@
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/proxy.ts
## [0.4.5] - 2026-02-16
### 变更
- **[admin-ui]**: 令牌管理改为列表视图并支持分页弹窗创建
- 方案: [202602161600_token-list-ui](archive/2026-02/202602161600_token-list-ui/)
## [0.4.4] - 2026-02-16
### 修复
- **[admin-ui]**: 使用日志默认每页 50 条并修正本地时间显示
- 方案: [202602161433_usage-log-fixes](archive/2026-02/202602161433_usage-log-fixes/)
- **[usage/proxy]**: 使用日志补充首 token 延迟、流式与推理强度记录
- 方案: [202602161433_usage-log-fixes](archive/2026-02/202602161433_usage-log-fixes/)
## [0.4.3] - 2026-02-16
### 变更
- **[admin-ui]**: 使用日志支持分页与指标拆分展示
- 方案: [202602161355_usage-view-metrics](archive/2026-02/202602161355_usage-view-metrics/)
## [0.4.2] - 2026-02-16
### 变更
+3
View File
@@ -17,6 +17,9 @@
- 样式基于 Tailwind v4(结合全局主题变量)
- 前端状态集中在单页
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
- Token 仅本地保存,不回显
## 依赖关系
+1
View File
@@ -14,6 +14,7 @@
- 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
- 可配置失败重试轮询(响应 5xx/429 时触发)
- 记录流式请求标记、首 token 延迟与推理强度到 usage_logs
## 依赖关系
- `channels` / `tokens` / `usage_logs` 表
+1
View File
@@ -16,6 +16,7 @@
- 令牌可通过 reveal 接口再次查看
- 默认允许所有渠道
- 额度消耗由代理调用记录
- 管理台列表展示创建时间与 key_prefix 前缀
## 依赖关系
- `tokens` 表
+1
View File
@@ -12,6 +12,7 @@
- 代理调用统一写入 `usage_logs`
- 保留天数可配置,查询时触发清理
- 查询结果附带渠道与令牌名称
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
## 依赖关系
- `usage_logs` 表