Files
story/web/src/pages/MaintenancePortal.tsx
T

291 lines
18 KiB
TypeScript
Raw Normal View History

2026-08-29 21:56:51 +08:00
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
Avatar,
Badge,
Breadcrumb,
Button,
Card,
Dropdown,
Input,
Layout,
Menu,
Select,
Space,
Spin,
Tag,
Tooltip,
type MenuProps
} from "antd";
import {
AlertOutlined,
ApiOutlined,
BarChartOutlined,
BellOutlined,
CloudServerOutlined,
DashboardOutlined,
DollarOutlined,
DatabaseOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ReloadOutlined,
SearchOutlined,
SettingOutlined,
ToolOutlined,
UserOutlined
} from "@ant-design/icons";
import { useAuth } from "../auth/useAuth";
import { platformApi, type ContentQualitySummary } from "../api/client";
import { StatusCard } from "../components/StatusCard";
import { OpsAlertWorkspace } from "./OpsAlertWorkspace";
import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
type MaintenanceArea = "overview" | "devices" | "config" | "quality" | "billing" | "alerts" | "diagnostics";
type HealthState = {
loading: boolean;
error?: string;
live?: string;
service?: string;
ready?: string;
checks?: Record<string, string>;
version?: string;
};
const areaMeta: Record<MaintenanceArea, { title: string; description: string; icon: ReactNode }> = {
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
diagnostics: { title: "联调与诊断", description: "处理依赖异常、失败作业、敏感访问、验收快照和恢复操作。", icon: <ToolOutlined /> }
};
export function MaintenancePortal() {
const auth = useAuth();
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
const [health, setHealth] = useState<HealthState>({ loading: true });
const refreshHealth = useCallback(async (signal?: AbortSignal) => {
setHealth((current) => ({ ...current, loading: true, error: undefined }));
try {
const [live, ready, version] = await Promise.all([
platformApi.liveness(signal),
platformApi.readiness(signal),
platformApi.version(signal)
]);
setHealth({
loading: false,
live: live.data.status,
service: live.data.service,
ready: ready.data.status,
checks: ready.data.checks,
version: version.data.version
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
setHealth({ loading: false, error: error instanceof Error ? error.message : "未知错误" });
}
}, []);
useEffect(() => {
const controller = new AbortController();
void refreshHealth(controller.signal);
return () => controller.abort();
}, [refreshHealth]);
useEffect(() => {
const onPopState = () => setArea(areaFromURL());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const operationsAccounts = useMemo(
() => auth.currentUser?.accounts.filter((account) => account.account_kind === "operator" && account.platform_role !== "customer") ?? [],
[auth.currentUser]
);
const hasOperationsAccess = operationsAccounts.length > 0;
const meta = areaMeta[area];
function navigate(next: MaintenanceArea) {
setArea(next);
if (window.innerWidth <= 720) setCollapsed(true);
const url = new URL(window.location.href);
if (next === "overview") url.searchParams.delete("area"); else url.searchParams.set("area", next);
window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`);
}
const menuItems: MenuProps["items"] = [
{ type: "group", label: "平台维护", children: [
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
{ key: "config", icon: <SettingOutlined />, label: "运行配置" },
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
{ key: "alerts", icon: <AlertOutlined />, label: "告警处置" },
{ key: "diagnostics", icon: <ToolOutlined />, label: "联调与诊断" }
] }
];
const accountMenu: MenuProps["items"] = [
{ key: "logout", icon: <UserOutlined />, label: "退出登录", onClick: () => void auth.logout() }
];
return <Layout className="maintenance-layout">
<Layout.Header className="maintenance-header">
<div className="maintenance-header__brand">
<Button className="maintenance-menu-toggle" type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} aria-label={collapsed ? "展开菜单" : "收起菜单"} onClick={() => setCollapsed((value) => !value)} />
<span className="maintenance-brand-mark"><CloudServerOutlined /></span>
<strong>安心陪伴</strong><span className="maintenance-brand-divider" /><span>维护中心</span>
</div>
<nav className="maintenance-top-nav" aria-label="维护中心主导航">
{(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => <button type="button" className={area === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{item.title.replace("维护", "")}</button>)}
</nav>
<div className="maintenance-header__actions">
<Input prefix={<SearchOutlined />} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" />
<Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip>
<Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} />
{auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"><Button type="text" className="maintenance-account"><Avatar size={30} icon={<UserOutlined />} /><span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span></Button></Dropdown> : <Button type="primary" onClick={() => void auth.login()}>维护人员登录</Button>}
</div>
</Layout.Header>
<Layout className="maintenance-layout__body">
<Layout.Sider className="maintenance-sider" width={224} collapsedWidth={72} collapsed={collapsed} trigger={null}>
<div className="maintenance-sider__title"><ApiOutlined />{collapsed ? null : <span>平台运维控制台</span>}</div>
<Menu mode="inline" selectedKeys={[area]} items={menuItems} onClick={({ key }) => navigate(key as MaintenanceArea)} />
<div className="maintenance-sider__meta">{collapsed ? <CloudServerOutlined /> : <><span>当前构建</span><strong>{health.version ?? "dev"}</strong></>}</div>
</Layout.Sider>
<Layout.Content className="maintenance-content">
<main className="maintenance-page">
<Breadcrumb items={[{ title: "维护中心" }, { title: meta.title }]} />
<header className="maintenance-page__header"><div><span className="maintenance-page__icon">{meta.icon}</span><div><h1>{meta.title}</h1><p>{meta.description}</p></div></div><Button icon={<ReloadOutlined />} loading={health.loading} onClick={() => void refreshHealth()}>刷新状态</Button></header>
{auth.loading ? <Card className="maintenance-auth-state"><Spin /><span>正在校验维护身份…</span></Card> : null}
{!auth.loading && auth.error ? <Alert type="error" showIcon message="维护身份读取失败" description={auth.error} action={<Space><Button onClick={() => void auth.retry()}>重试</Button><Button type="primary" onClick={() => void auth.login()}>重新登录</Button></Space>} /> : null}
{!auth.loading && !auth.currentUser && !auth.error ? <MaintenanceLogin onLogin={() => void auth.login()} /> : null}
{auth.currentUser && !hasOperationsAccess ? <Alert type="warning" showIcon message="当前账号没有维护权限" description="家庭客户角色不能进入维护端。请使用 ops_viewer、ops_operator、ops_approver 或 ops_admin 账号登录。" action={<Button onClick={() => window.location.assign("/")}>返回家庭端</Button>} /> : null}
{health.error ? <Alert type="error" showIcon message="平台健康检查失败" description={health.error} /> : null}
{auth.currentUser && auth.oidcUser && hasOperationsAccess ? <section className="maintenance-workspace">
{area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "diagnostics" ? <OpsDiagnosticsWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
</section> : null}
</main>
</Layout.Content>
</Layout>
</Layout>;
}
function ContentQualityWorkspace({ accessToken, accounts }: {
accessToken: string;
accounts: NonNullable<ReturnType<typeof useAuth>["currentUser"]>["accounts"];
}) {
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
const [items, setItems] = useState<ContentQualitySummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [revision, setRevision] = useState(0);
useEffect(() => {
if (!accounts.some((account) => account.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
}, [accounts, tenantID]);
useEffect(() => {
if (!tenantID) { setItems([]); return; }
const controller = new AbortController();
setLoading(true);
setError(undefined);
void platformApi.opsContentQuality(tenantID, accessToken, controller.signal)
.then((response) => setItems(response.data.items))
.catch((cause) => {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "内容质量读取失败");
})
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [accessToken, revision, tenantID]);
const totals = items.reduce((result, item) => ({
candidates: result.candidates + item.story_candidates + item.fact_candidates,
confirmed: result.confirmed + item.confirmed,
edited: result.edited + item.edited,
rejected: result.rejected + item.rejected,
issues: result.issues + Object.values(item.error_types).reduce((sum, count) => sum + count, 0)
}), { candidates: 0, confirmed: 0, edited: 0, rejected: 0, issues: 0 });
const reviewed = totals.confirmed + totals.edited + totals.rejected;
return <div className="quality-workspace">
<Card className="quality-toolbar">
<div><strong>质量反馈范围</strong><span>只展示当前维护账号有权访问的客户组织;统计不包含故事、事实或记忆正文。</span></div>
<Space wrap>
<Select value={tenantID || undefined} style={{ minWidth: 240 }} placeholder="选择客户组织" onChange={setTenantID} options={accounts.map((account) => ({ value: account.tenant_id, label: account.tenant_name }))} />
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => setRevision((value) => value + 1)}>刷新质量数据</Button>
</Space>
</Card>
{error ? <Alert type="error" showIcon message="内容质量读取失败" description={error} /> : null}
<section className="quality-summary-grid">
<QualityMetric label="生成候选" value={totals.candidates} detail="故事候选与事实候选合计" />
<QualityMetric label="人工审核" value={reviewed} detail={`确认 ${totals.confirmed} · 修改 ${totals.edited} · 拒绝 ${totals.rejected}`} />
<QualityMetric label="标记问题" value={totals.issues} detail="九类标准错误标签合计" />
<QualityMetric label="确认率" value={reviewed ? `${Math.round(totals.confirmed / reviewed * 100)}%` : "—"} detail="确认数 ÷ 已审核操作数" />
</section>
<Card className="quality-detail-card" title="模型与提示词版本明细" extra={<Tag color="blue">不展示私密正文</Tag>}>
{loading && !items.length ? <div className="quality-state"><Spin /><span>正在聚合审核信号…</span></div> : null}
{!loading && !items.length ? <div className="quality-state"><BarChartOutlined /><strong>暂无质量反馈</strong><span>家庭端完成故事、事实或记忆审核后,这里会按生成版本汇总。</span></div> : null}
{items.length ? <div className="quality-version-list">{items.map((item) => {
const itemReviewed = item.confirmed + item.edited + item.rejected;
return <article key={`${item.model_ref}\u0000${item.prompt_version}`}>
<header><div><strong>{item.model_ref}</strong><span>提示词 {item.prompt_version}</span></div><Tag>{itemReviewed} 次审核</Tag></header>
<div className="quality-version-metrics"><span>故事候选 <strong>{item.story_candidates}</strong></span><span>事实候选 <strong>{item.fact_candidates}</strong></span><span>确认 <strong>{item.confirmed}</strong></span><span>修改 <strong>{item.edited}</strong></span><span>拒绝 <strong>{item.rejected}</strong></span><span>停用 <strong>{item.disabled}</strong></span></div>
<div className="quality-error-types"><small>问题类型</small>{Object.entries(item.error_types).length ? Object.entries(item.error_types).sort((left, right) => right[1] - left[1]).map(([type, count]) => <Tag color="volcano" key={type}>{contentErrorTypeLabel(type)} {count}</Tag>) : <span>尚未标记问题类型</span>}</div>
</article>;
})}</div> : null}
</Card>
</div>;
}
function QualityMetric({ label, value, detail }: { label: string; value: number | string; detail: string }) {
return <Card className="quality-metric"><span>{label}</span><strong>{value}</strong><small>{detail}</small></Card>;
}
function contentErrorTypeLabel(value: string) {
return ({ PERSON_ERROR: "人物错误", TIME_ERROR: "时间错误", PLACE_ERROR: "地点错误", RELATION_ERROR: "关系错误", OVER_INFERENCE: "过度推断", KEY_POINT_OMISSION: "遗漏重点", DUPLICATE_CONTENT: "重复内容", UNNATURAL_EXPRESSION: "表达不自然", SENSITIVE_INFORMATION: "敏感信息" } as Record<string, string>)[value] ?? value;
}
function MaintenanceLogin({ onLogin }: { onLogin: () => void }) {
return <Card className="maintenance-login"><ToolOutlined /><h2>维护人员登录</h2><p>维护端与家庭端分离,仅向平台运营、审批和管理员角色开放;所有敏感查看和控制操作继续由后端审计。</p><Button type="primary" size="large" onClick={onLogin}>登录维护中心</Button></Card>;
}
function MaintenanceOverview({ health, accounts }: { health: HealthState; accounts: NonNullable<ReturnType<typeof useAuth>["currentUser"]>["accounts"] }) {
const dependencySummary = health.checks ? Object.entries(health.checks).map(([name, status]) => `${name}: ${status}`).join(" · ") : "等待依赖检查";
return <>
<section className="maintenance-status-grid">
<StatusCard label="API 进程" value={health.loading ? "检查中" : health.live === "alive" ? "运行正常" : "需要处理"} detail={health.service ? `当前服务:${health.service}` : "尚未获得服务信息"} tone={health.live === "alive" ? "good" : "warning"} />
<StatusCard label="必要依赖" value={health.loading ? "检查中" : health.ready === "ready" ? "全部就绪" : "存在异常"} detail={dependencySummary} tone={health.ready === "ready" ? "good" : health.loading ? "neutral" : "warning"} />
<StatusCard label="构建版本" value={health.version ?? "dev"} detail="版本、提交与构建时间由流水线注入" tone="neutral" />
</section>
<Card className="maintenance-role-card" title="当前维护范围" extra={<Tag color="green">服务端权限校验</Tag>}>
<div className="maintenance-role-list">{accounts.map((account) => <article key={account.user_id}><strong>{account.tenant_name}</strong><span>{roleLabel(account.platform_role)}</span><small>配置、费用、告警和诊断数据均按该客户组织隔离</small></article>)}</div>
</Card>
</>;
}
function areaFromURL(): MaintenanceArea {
const value = new URL(window.location.href).searchParams.get("area");
return value && value in areaMeta ? value as MaintenanceArea : "overview";
}
function roleLabel(role: string) {
return ({ ops_viewer: "维护查看员", ops_operator: "维护操作员", ops_approver: "维护审批员", ops_admin: "维护管理员" } as Record<string, string>)[role] ?? role;
}