291 lines
18 KiB
TypeScript
291 lines
18 KiB
TypeScript
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;
|
||
|
|
}
|