feat: 接入运维真实数据与三角色权限链路

- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
郑龙捷
2026-09-02 15:02:47 +08:00
parent 6d044784ec
commit 74dd97428f
51 changed files with 2142 additions and 1062 deletions
+11 -28
View File
@@ -9,7 +9,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { FilterSelect, MetricLineChart, OperationsPageHeader, SortingComboChart } from "./OperationsUi";
import { FEATURE_METRICS, MONITOR_MONTHS, SORTING_DISTRIBUTION, modelTrend } from "./modelData";
import { useOperationsData } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
import { useCanOperationsAction } from "./operationsRole";
import { latestReports, type ReportStatus, type ReportType } from "./reportData";
import { useReportStore } from "./reportStore";
@@ -29,28 +29,11 @@ function ReportTypeBadge({ type }: { type: ReportType }) {
return <span className={type === "监控报告" ? "inline-flex rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong" : "inline-flex rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning"}>{type}</span>;
}
function ScoreDistributionTable({ modelId }: { modelId: string }) {
const offset = [...modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0) % 5;
const rows = [
{ bin: "(0,580]", count: 310 + offset * 7 },
{ bin: "[580,620)", count: 742 + offset * 11 },
{ bin: "[620,660)", count: 2645 + offset * 19 },
{ bin: "[660,700)", count: 4812 + offset * 23 },
{ bin: "[700,+)", count: 3657 + offset * 17 },
];
const total = rows.reduce((sum, row) => sum + row.count, 0);
let cumulative = 0;
function ScoreDistributionTable() {
return (
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader>
<TableBody>
{rows.map((row) => {
const share = row.count / total;
cumulative += share;
return <TableRow key={row.bin}><TableCell className="font-mono text-xs">{row.bin}</TableCell><TableCell className="tabular-nums">{row.count.toLocaleString("zh-CN")}</TableCell><TableCell className="tabular-nums">{(share * 100).toFixed(2)}%</TableCell><TableCell className="tabular-nums">{(cumulative * 100).toFixed(2)}%</TableCell></TableRow>;
})}
<TableRow><TableCell className="font-semibold"></TableCell><TableCell className="font-semibold tabular-nums">{total.toLocaleString("zh-CN")}</TableCell><TableCell className="font-semibold">100.00%</TableCell><TableCell></TableCell></TableRow>
</TableBody>
<TableBody><TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={4}></TableCell></TableRow></TableBody>
</Table>
);
}
@@ -58,7 +41,6 @@ function ScoreDistributionTable({ modelId }: { modelId: string }) {
export default function ReportPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
const [searchParams] = useSearchParams();
const requestedId = searchParams.get("report");
const allReports = useReportStore((state) => state.reports);
@@ -77,8 +59,9 @@ export default function ReportPage() {
const isHistorical = Boolean(report && report.monitorMonth !== "2026-07");
const ksTrend = model ? modelTrend(model, "ks") : [];
const psiTrend = model ? modelTrend(model, "psi") : [];
const canEdit = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "report:edit");
const canSend = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "report:send");
const canEdit = !isHistorical && useCanOperationsAction("report:edit");
const canExport = useCanOperationsAction("report:export");
const canSend = !isHistorical && useCanOperationsAction("report:send");
const updateFilter = <K extends keyof Filters>(key: K, value: Filters[K]) => {
setFilters((current) => ({ ...current, [key]: value }));
@@ -97,11 +80,11 @@ export default function ReportPage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title={report ? `${report.type} · ${report.bank} ${report.modelName}` : "监控诊断报告"}
description={report ? `${report.monitorMonth} 周期 · 自动生成于 ${report.generatedAt} · 报告输出日期 ${report.outputDate}` : "当前筛选条件下没有报告"}
actions={report && <><Button variant="outline" onClick={printReport}><Download /> PDF</Button>{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.success("草稿已保存,并同步至历史报告汇总(Mock)"); }}><Save />稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => { updateReport(report.reportId, { status: "已发送业务团队", unreadDays: 0, synced: true }); toast.success("已模拟发送至业务团队"); }}><Send /></Button>}</>}
actions={report && <>{canExport && <Button variant="outline" onClick={printReport}><Download /> PDF</Button>}{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.info("报告保存接口尚未接入"); }}><Save />稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => toast.info("报告发送接口尚未接入")}><Send /></Button>}</>}
/>
<Card>
@@ -135,14 +118,14 @@ export default function ReportPage() {
<h3 className="text-xl font-bold text-foreground">{report.modelName}</h3>
<div className="grid grid-cols-4 gap-4">
{[
["验证样本", "12,186 户"], ["坏样本", "842 户"], ["KS", `${model.ks.toFixed(2)}%`], ["PSI", `${model.psi.toFixed(2)}%`],
["验证样本", ""], ["坏样本", ""], ["KS", `${model.ks.toFixed(2)}%`], ["PSI", `${model.psi.toFixed(2)}%`],
].map(([label, value]) => <div className="rounded-xl bg-muted/50 p-4" key={label}><span className="text-xs text-muted-foreground">{label}</span><strong className="mt-2 block text-xl tabular-nums text-foreground">{value}</strong></div>)}
</div>
</section>
<section className="space-y-3">
<h4 className="text-base font-semibold text-foreground"></h4>
<ScoreDistributionTable modelId={model.modelId} />
<ScoreDistributionTable />
</section>
<section className="space-y-3">
@@ -165,7 +148,7 @@ export default function ReportPage() {
</p>
</section>
<p className="rounded-xl border border-border p-4 text-xs leading-5 text-muted-foreground"> Mock</p>
<p className="rounded-xl border border-border p-4 text-xs leading-5 text-muted-foreground"></p>
</CardContent>
</Card>
</>