Files
zk-data-agent/frontend/app/app/admin/page.tsx
T
2026-05-13 14:29:06 +08:00

466 lines
13 KiB
TypeScript

"use client";
import {
ActivityIcon,
DatabaseIcon,
RefreshCwIcon,
Trash2Icon,
UserPlusIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type AdminSummary = {
totals?: {
accounts: number;
sessions: number;
tool_calls: number;
tokens: number;
};
memory_queue?: MemoryQueue;
accounts?: AccountRow[];
};
type AccountRow = {
account_id: string;
registered: boolean;
session_count: number;
tool_calls: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
latest_session_at: number;
models: Record<string, number>;
user_memory_lines: number;
skill_memory_count: number;
};
type MemoryQueue = {
totals: {
events: number;
pending: number;
processing: number;
done: number;
failed: number;
};
accounts: Array<{
account_id: string;
events: number;
pending: number;
processing: number;
done: number;
failed: number;
}>;
};
type MemoryEvent = {
id: string;
session_id: string;
skills: string[];
signals: string[];
priority: number;
status: string;
error?: string | null;
created_at: string;
updated_at: string;
};
const TOKEN_KEY = "zk-admin-token";
export default function AdminPage() {
const [token, setToken] = useState("");
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setToken(localStorage.getItem(TOKEN_KEY) ?? "");
}, []);
async function login() {
setError("");
setIsLoading(true);
try {
const response = await fetch("/api/admin/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = (await response.json()) as {
token?: string;
detail?: string;
};
if (!response.ok || !payload.token) {
throw new Error(payload.detail ?? "登录失败");
}
localStorage.setItem(TOKEN_KEY, payload.token);
setToken(payload.token);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setIsLoading(false);
}
}
if (!token) {
return (
<main className="flex min-h-dvh items-center justify-center bg-background px-4">
<div className="w-full max-w-sm rounded-lg border bg-card p-6 shadow-sm">
<div className="mb-5">
<h1 className="font-semibold text-xl"></h1>
<p className="mt-1 text-muted-foreground text-sm">
</p>
</div>
<div className="grid gap-3">
<Input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="管理员账号"
/>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="管理员密码"
onKeyDown={(event) => {
if (event.key === "Enter") login();
}}
/>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button onClick={login} disabled={isLoading}>
{isLoading ? "登录中..." : "登录"}
</Button>
</div>
</div>
</main>
);
}
return <AdminDashboard token={token} onLogout={() => setToken("")} />;
}
function AdminDashboard({
token,
onLogout,
}: {
token: string;
onLogout: () => void;
}) {
const [summary, setSummary] = useState<AdminSummary | null>(null);
const [accounts, setAccounts] = useState<AccountRow[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState("");
const [events, setEvents] = useState<MemoryEvent[]>([]);
const [newAccountId, setNewAccountId] = useState("");
const [notice, setNotice] = useState("");
const selectedAccount = useMemo(
() => accounts.find((item) => item.account_id === selectedAccountId),
[accounts, selectedAccountId],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: 首次进入后台时加载一次即可,后续由刷新按钮触发。
useEffect(() => {
refresh();
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: 账号切换时加载对应记忆;函数体依赖当前 token。
useEffect(() => {
if (!selectedAccountId) return;
loadAccountMemory(selectedAccountId);
}, [selectedAccountId]);
async function refresh() {
const [summaryPayload, accountPayload] = await Promise.all([
adminFetch<AdminSummary>(token, "/summary"),
adminFetch<AccountRow[]>(token, "/accounts"),
]);
setSummary(summaryPayload);
setAccounts(accountPayload);
const firstAccount =
selectedAccountId || accountPayload[0]?.account_id || "";
setSelectedAccountId(firstAccount);
if (firstAccount) await loadAccountMemory(firstAccount);
}
async function loadAccountMemory(accountId: string) {
const memoryPayload = await adminFetch<{ events: MemoryEvent[] }>(
token,
`/memory?account_id=${encodeURIComponent(accountId)}`,
);
setEvents(memoryPayload.events ?? []);
}
async function createAccount() {
if (!newAccountId.trim()) return;
const payload = await adminFetch<{
account_id: string;
initial_password: string;
}>(token, "/accounts", {
method: "POST",
body: JSON.stringify({ account_id: newAccountId.trim() }),
});
setNotice(
`已创建 ${payload.account_id},初始密码 ${payload.initial_password}`,
);
setNewAccountId("");
await refresh();
}
async function deleteAccount(accountId: string) {
if (!confirm(`确定删除账号 ${accountId} 及其本地数据吗?`)) return;
await adminFetch(token, `/accounts/${encodeURIComponent(accountId)}`, {
method: "DELETE",
});
setNotice(`已删除 ${accountId}`);
setSelectedAccountId("");
await refresh();
}
function logout() {
localStorage.removeItem(TOKEN_KEY);
onLogout();
}
return (
<main className="min-h-dvh bg-background text-foreground">
<header className="sticky top-0 z-10 border-b bg-background/90 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-5 py-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<DatabaseIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<h1 className="font-semibold text-lg">ZK Data Agent </h1>
<p className="text-muted-foreground text-xs">
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCwIcon className="size-4" />
</Button>
<Button variant="ghost" size="sm" onClick={logout}>
退
</Button>
</div>
</header>
<div className="mx-auto grid max-w-7xl gap-4 px-5 py-5">
{notice ? (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
{notice}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-4">
<Metric title="用户" value={summary?.totals?.accounts ?? 0} />
<Metric title="会话" value={summary?.totals?.sessions ?? 0} />
<Metric title="工具调用" value={summary?.totals?.tool_calls ?? 0} />
<Metric
title="Token"
value={formatNumber(summary?.totals?.tokens ?? 0)}
/>
</div>
<div className="grid min-h-[640px] gap-4 lg:grid-cols-[320px_1fr]">
<section className="rounded-lg border bg-card">
<div className="border-b p-3">
<div className="mb-3 flex items-center gap-2 font-medium">
<UsersIcon className="size-4" />
</div>
<div className="flex gap-2">
<Input
value={newAccountId}
placeholder="新账号"
onChange={(event) => setNewAccountId(event.target.value)}
/>
<Button size="icon" variant="outline" onClick={createAccount}>
<UserPlusIcon className="size-4" />
</Button>
</div>
</div>
<div className="max-h-[560px] overflow-y-auto p-2">
{accounts.map((account) => (
<button
key={account.account_id}
type="button"
className={`mb-1 flex w-full flex-col rounded-md px-3 py-2 text-left transition-colors hover:bg-muted ${
selectedAccountId === account.account_id ? "bg-muted" : ""
}`}
onClick={() => setSelectedAccountId(account.account_id)}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate font-medium text-sm">
{account.account_id}
</span>
<span className="text-muted-foreground text-xs">
{account.session_count}
</span>
</span>
<span className="mt-1 text-muted-foreground text-xs">
{account.tool_calls} ·{" "}
{formatNumber(account.total_tokens)} tokens
</span>
</button>
))}
</div>
</section>
<section className="grid gap-4">
<div className="grid gap-3 md:grid-cols-4">
<Metric title="账号" value={selectedAccount?.account_id ?? "-"} />
<Metric
title="用户记忆行"
value={selectedAccount?.user_memory_lines ?? 0}
/>
<Metric
title="Skill 记忆"
value={selectedAccount?.skill_memory_count ?? 0}
/>
<Metric
title="队列 pending"
value={
summary?.memory_queue?.accounts.find(
(item) => item.account_id === selectedAccountId,
)?.pending ?? 0
}
/>
</div>
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="grid gap-4 content-start">
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b p-3 font-medium">
<ActivityIcon className="size-4" />
</div>
<div className="grid grid-cols-2 gap-2 p-3 text-sm">
<SmallStat
label="全部"
value={summary?.memory_queue?.totals.events ?? 0}
/>
<SmallStat
label="待处理"
value={summary?.memory_queue?.totals.pending ?? 0}
/>
<SmallStat
label="处理中"
value={summary?.memory_queue?.totals.processing ?? 0}
/>
<SmallStat
label="失败"
value={summary?.memory_queue?.totals.failed ?? 0}
/>
</div>
</div>
{selectedAccountId ? (
<Button
variant="destructive"
onClick={() => deleteAccount(selectedAccountId)}
>
<Trash2Icon className="size-4" />
</Button>
) : null}
</div>
<div className="rounded-lg border bg-card">
<div className="border-b p-3 font-medium"></div>
<div className="max-h-[520px] overflow-y-auto p-2">
{events.length ? (
events.map((event) => (
<div
key={event.id}
className="mb-2 rounded-md border bg-muted/20 p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{event.status}</span>
<span className="text-muted-foreground">
P{event.priority}
</span>
</div>
<div className="mt-1 truncate text-muted-foreground">
{event.session_id}
</div>
<div className="mt-1">
{event.signals.join(" / ") || "无信号"}
</div>
{event.error ? (
<div className="mt-1 text-destructive">
{event.error}
</div>
) : null}
</div>
))
) : (
<p className="p-4 text-center text-muted-foreground text-sm">
</p>
)}
</div>
</div>
</div>
</section>
</div>
</div>
</main>
);
}
function Metric({ title, value }: { title: string; value: string | number }) {
return (
<div className="rounded-lg border bg-card p-3">
<div className="text-muted-foreground text-xs">{title}</div>
<div className="mt-1 truncate font-semibold text-xl">{value}</div>
</div>
);
}
function SmallStat({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="rounded-md border bg-muted/20 p-2">
<div className="font-medium">{value}</div>
<div className="text-muted-foreground text-xs">{label}</div>
</div>
);
}
async function adminFetch<T>(
token: string,
path: string,
init?: RequestInit,
): Promise<T> {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(`/api/admin${path}${separator}token=${token}`, {
...init,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
cache: "no-store",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.detail ?? payload.error ?? "请求失败");
}
return payload as T;
}
function formatNumber(value: number) {
return new Intl.NumberFormat("zh-CN", {
notation: value > 10000 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value);
}