230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
import type { AdminOverviewResponse } from "@dada/shared-contracts";
|
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
|
|
import "./admin-shell.css";
|
|
|
|
interface AdminSession {
|
|
admin: { role: "super_admin"; status: "active"; user_id: string };
|
|
audience: "admin";
|
|
authenticated: true;
|
|
expires_at: string;
|
|
}
|
|
|
|
interface AdminProtectedRouteProps {
|
|
children: ReactNode;
|
|
currentPath: string;
|
|
title: string;
|
|
}
|
|
|
|
const adminNavigation = [
|
|
{ href: "/admin", label: "总览", marker: "01" },
|
|
{ href: "/admin/users", label: "用户与点数", marker: "02" },
|
|
{ href: "/admin/invites", label: "邀请码", marker: "03" },
|
|
{ href: "/admin/models", label: "模型", marker: "04" },
|
|
{ href: "/admin/assets", label: "素材", marker: "05" },
|
|
{ href: "/admin/preview", label: "内部预览", marker: "06" },
|
|
{ href: "/admin/generations", label: "生成记录", marker: "07" },
|
|
{ href: "/admin/services-storage", label: "服务与存储", marker: "08" },
|
|
{ href: "/admin/audit", label: "审计", marker: "09" },
|
|
] as const;
|
|
|
|
function redirectToAdminLogin() {
|
|
window.location.replace("/admin/login");
|
|
}
|
|
|
|
export function AdminProtectedRoute({ children, currentPath, title }: AdminProtectedRouteProps) {
|
|
const [session, setSession] = useState<AdminSession>();
|
|
const [failed, setFailed] = useState(false);
|
|
const [revision, setRevision] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setFailed(false);
|
|
void fetch("/api/v1/admin-auth/session", { credentials: "same-origin", signal: controller.signal })
|
|
.then(async (response) => {
|
|
if (response.status === 401) {
|
|
redirectToAdminLogin();
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error("admin_session_unavailable");
|
|
const body = await response.json() as AdminSession;
|
|
if (body.audience !== "admin" || body.admin.role !== "super_admin" || body.admin.status !== "active") {
|
|
redirectToAdminLogin();
|
|
return;
|
|
}
|
|
setSession(body);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!(error instanceof DOMException && error.name === "AbortError")) setFailed(true);
|
|
});
|
|
return () => controller.abort();
|
|
}, [revision]);
|
|
|
|
if (!session) {
|
|
return (
|
|
<main className="admin-session-gate">
|
|
{failed ? (
|
|
<div role="alert">
|
|
<strong>管理员会话暂时无法确认</strong>
|
|
<button onClick={() => setRevision((value) => value + 1)} type="button">重试</button>
|
|
</div>
|
|
) : <p aria-live="polite">正在确认管理员会话</p>}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="admin-shell">
|
|
<a className="admin-skip-link" href="#admin-main">跳到主要内容</a>
|
|
<aside className="admin-sidebar">
|
|
<a className="admin-wordmark" href="/admin" aria-label="Dada 后台总览">
|
|
<span>DADA</span>
|
|
<small>OPERATIONS</small>
|
|
</a>
|
|
<nav aria-label="后台主导航">
|
|
{adminNavigation.map((item) => (
|
|
<a aria-current={currentPath === item.href ? "page" : undefined} href={item.href} key={item.href}>
|
|
<span aria-hidden="true">{item.marker}</span>
|
|
{item.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div className="admin-sidebar-foot">
|
|
<span>LOCAL P0-A</span>
|
|
<strong>独立管理员会话</strong>
|
|
</div>
|
|
</aside>
|
|
<div className="admin-shell-workspace">
|
|
<header className="admin-topbar">
|
|
<h1>{title}</h1>
|
|
<div className="admin-topbar-status">
|
|
<span><i aria-hidden="true" />状态摘要</span>
|
|
<code>{session.admin.user_id.slice(0, 8)}</code>
|
|
</div>
|
|
</header>
|
|
<div className="admin-shell-content">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const serviceLabels: Record<AdminOverviewResponse["services"][number]["service_id"], string> = {
|
|
ai_gateway: "AI 网关",
|
|
amap: "高德",
|
|
asset_root: "素材根",
|
|
resend: "Resend",
|
|
worker: "Worker",
|
|
};
|
|
|
|
const stateLabels = {
|
|
available: "正常",
|
|
degraded: "有异常",
|
|
paused: "已暂停",
|
|
unavailable: "不可用",
|
|
} as const;
|
|
|
|
function formatTime(value: string | null) {
|
|
if (!value) return "未记录";
|
|
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", month: "2-digit", day: "2-digit" }).format(new Date(value));
|
|
}
|
|
|
|
export function AdminOverviewPage() {
|
|
const [summary, setSummary] = useState<AdminOverviewResponse>();
|
|
const [failed, setFailed] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setFailed(false);
|
|
try {
|
|
const response = await fetch("/api/v1/admin/overview", { credentials: "same-origin" });
|
|
if (response.status === 401) {
|
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error("admin_overview_unavailable");
|
|
setSummary(await response.json() as AdminOverviewResponse);
|
|
} catch {
|
|
setFailed(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
const storagePercent = summary
|
|
? Math.min(100, (summary.storage.managed_content_bytes / summary.storage.limit_bytes) * 100)
|
|
: 0;
|
|
const hasServiceIssue = summary?.services.some((service) => service.status !== "available") ?? false;
|
|
|
|
return (
|
|
<main className="admin-overview" id="admin-main">
|
|
<header className="admin-page-heading">
|
|
<div><p>OPERATIONS / LIVE SUMMARY</p><h2>运营总览</h2></div>
|
|
{summary ? <time dateTime={summary.generated_at}>更新于 {formatTime(summary.generated_at)}</time> : null}
|
|
</header>
|
|
{summary && summary.storage.status !== "normal" ? (
|
|
<a className={`admin-capacity-alert is-${summary.storage.status}`} href="/admin/services-storage">
|
|
<span>本机内容容量</span>
|
|
<strong>{storagePercent.toFixed(1)}%</strong>
|
|
<span>{summary.storage.status === "critical" ? "接近上限" : summary.storage.status === "full" ? "已满" : "不可用"}</span>
|
|
</a>
|
|
) : null}
|
|
{loading && !summary ? (
|
|
<div aria-label="运营摘要加载中" className="admin-overview-loading"><span /><span /><span /><span /></div>
|
|
) : null}
|
|
{failed ? (
|
|
<div className="admin-overview-failure" role="alert">
|
|
<span>运营摘要暂时无法读取{summary ? `,当前保留 ${formatTime(summary.generated_at)} 的结果` : ""}。</span>
|
|
<button onClick={() => void load()} type="button">重试</button>
|
|
</div>
|
|
) : null}
|
|
{summary ? (
|
|
<>
|
|
<section aria-label="关键运营指标" className="admin-metric-band">
|
|
<a href="/admin/users"><span>普通用户名额</span><strong>{summary.user_slots.active_and_suspended} / {summary.user_slots.limit}</strong><small>active + suspended</small></a>
|
|
<a href="/admin/generations"><span>进行中任务</span><strong>{summary.generation_jobs.queued + summary.generation_jobs.running}</strong><small>排队 {summary.generation_jobs.queued} · 运行 {summary.generation_jobs.running}</small></a>
|
|
<a href="/admin/generations"><span>成本核对</span><strong>待人工核对 {summary.generation_jobs.pending_manual_review}</strong><small>最早 {formatTime(summary.generation_jobs.pending_manual_review_oldest_at)}</small></a>
|
|
<a href="/admin/assets"><span>清理任务</span><strong>{summary.asset_cleanup.pending_jobs}</strong><small>等待处理</small></a>
|
|
</section>
|
|
<div className="admin-overview-columns">
|
|
<section className="admin-status-section" aria-labelledby="model-status-heading">
|
|
<header><div><p>MODEL STATE</p><h3 id="model-status-heading">模型状态</h3></div><a href="/admin/models">查看</a></header>
|
|
<dl>
|
|
<div><dt>配置默认</dt><dd>{summary.models.configured_default_model_id ?? "无"}</dd></div>
|
|
<div><dt>运行时可用</dt><dd>{summary.models.runtime_available_count} / {summary.models.configured_model_count}</dd></div>
|
|
<div><dt>当前推荐</dt><dd>{summary.models.recommended_model_id ?? "无"}</dd></div>
|
|
</dl>
|
|
</section>
|
|
<section className="admin-status-section" aria-labelledby="service-status-heading">
|
|
<header><div><p>SERVICE STATE</p><h3 id="service-status-heading">服务状态</h3></div><a href="/admin/services-storage">{hasServiceIssue ? "有异常" : "全部正常"}</a></header>
|
|
<ul className="admin-service-list">
|
|
{summary.services.map((service) => <li key={service.service_id}><span>{serviceLabels[service.service_id]}</span><strong className={`is-${service.status}`}>{stateLabels[service.status]}</strong><time dateTime={service.checked_at ?? undefined}>{formatTime(service.checked_at)}</time></li>)}
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
<section className="admin-operation-strip" aria-labelledby="recent-operation-heading">
|
|
<header><div><p>AUDIT SNAPSHOT</p><h3 id="recent-operation-heading">最近后台操作</h3></div><a href="/admin/audit">查看全部</a></header>
|
|
{summary.recent_operations.length === 0 ? <p>当前无近期操作</p> : (
|
|
<table><thead><tr><th>时间</th><th>操作</th><th>对象摘要</th><th>结果</th></tr></thead><tbody>{summary.recent_operations.map((operation) => <tr key={operation.operation_id}><td>{formatTime(operation.created_at)}</td><td>{operation.operation_type}</td><td><code>{operation.target_ref}</code></td><td>{operation.result}</td></tr>)}</tbody></table>
|
|
)}
|
|
</section>
|
|
</>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export function AdminPlaceholderPage({ title }: { title: string }) {
|
|
return (
|
|
<main className="admin-placeholder" id="admin-main">
|
|
<header className="admin-page-heading"><div><p>OPERATIONS</p><h2>{title}</h2></div></header>
|
|
<section aria-label={`${title}安全摘要`}>
|
|
<div className="admin-placeholder-toolbar"><span>安全摘要</span><button disabled type="button">新建</button></div>
|
|
<p>当前无记录</p>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|