feat(usage): 扩展使用日志指标并优化管理台分页
- 记录首 token 延迟、stream、reasoning_effort 并更新迁移与 schema - 管理台 Usage/Tokens/Channels 增强分页与展示字段
This commit is contained in:
1 parent
9e7c43edd0
commit
1b0bc99a79
18 files changed
+732
-220
No files matched your search
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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))}
|
||||
>
|
||||
上一页
|
||||
<
|
||||
</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))
|
||||
}
|
||||
>
|
||||
下一页
|
||||
>
|
||||
</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 && (
|
||||
|
||||
@@ -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))}
|
||||
>
|
||||
<
|
||||
</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))
|
||||
}
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -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))}
|
||||
>
|
||||
<
|
||||
</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))}
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
- 样式基于 Tailwind v4(结合全局主题变量)
|
||||
- 前端状态集中在单页
|
||||
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
|
||||
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
||||
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
|
||||
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
|
||||
- Token 仅本地保存,不回显
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段
|
||||
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
|
||||
- 可配置失败重试轮询(响应 5xx/429 时触发)
|
||||
- 记录流式请求标记、首 token 延迟与推理强度到 usage_logs
|
||||
|
||||
## 依赖关系
|
||||
- `channels` / `tokens` / `usage_logs` 表
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 令牌可通过 reveal 接口再次查看
|
||||
- 默认允许所有渠道
|
||||
- 额度消耗由代理调用记录
|
||||
- 管理台列表展示创建时间与 key_prefix 前缀
|
||||
|
||||
## 依赖关系
|
||||
- `tokens` 表
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 代理调用统一写入 `usage_logs`
|
||||
- 保留天数可配置,查询时触发清理
|
||||
- 查询结果附带渠道与令牌名称
|
||||
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
|
||||
|
||||
## 依赖关系
|
||||
- `usage_logs` 表
|
||||
|
||||
Reference in new issue
Block a user