Files
郑龙捷 74dd97428f feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

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

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

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

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
2026-09-02 15:02:47 +08:00

143 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { ArrowUpDown, ChevronLeft, ChevronRight, Download, FileClock, RotateCcw } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { FilterSelect, MultiSelectFilter, OperationsPageHeader } from "./OperationsUi";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { type MonitoringReport, type ReportStatus } from "./reportData";
import { useReportStore } from "./reportStore";
type Filters = {
bank: string[];
name: string[];
version: string[];
modelId: string[];
month: string[];
type: string[];
status: string[];
};
type SortKey = "bank" | "modelId" | "month" | "generatedAt";
function emptyFilters(): Filters {
return { bank: [], name: [], version: [], modelId: [], month: [], type: [], status: [] };
}
function unique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => right.localeCompare(left, "zh-CN"));
}
function parseValues(params: URLSearchParams, key: string): string[] {
return [...new Set(params.getAll(key).flatMap((value) => value.split(",")).filter(Boolean))];
}
function statusClass(status: ReportStatus): string {
if (status === "已发送业务团队") return "bg-success-soft text-success-strong";
if (status === "编辑中") return "bg-brand-soft text-primary";
return "bg-warning-soft text-warning";
}
function exportReports(rows: MonitoringReport[]) {
const header = ["银行", "模型名称", "版本", "模型ID", "月份", "报告类型", "生成时间", "输出日期", "状态"];
return exportRowsToExcel({
fileName: "模型历史报告汇总",
sheetName: "历史报告汇总",
headers: header,
rows: rows.map((row) => [row.bank, row.modelName, row.version, row.modelId, row.monitorMonth, row.type, row.generatedAt, row.outputDate, row.status]),
});
}
function matches(values: string[], value: string): boolean {
return !values.length || values.includes(value);
}
function SortableHead({ label, sortKey, currentKey, onSort }: { label: string; sortKey: SortKey; currentKey: SortKey; onSort: (key: SortKey) => void }) {
return <TableHead><button className="inline-flex items-center gap-1.5 font-medium hover:text-primary" type="button" onClick={() => onSort(sortKey)}>{label}<ArrowUpDown className={`size-3.5 ${currentKey === sortKey ? "text-primary" : "text-muted-foreground"}`} /></button></TableHead>;
}
export default function ReportSummaryPage() {
const navigate = useNavigate();
const reports = useReportStore((state) => state.reports);
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<Filters>(() => ({
bank: parseValues(searchParams, "bank"),
name: parseValues(searchParams, "name"),
version: parseValues(searchParams, "version"),
modelId: parseValues(searchParams, "modelId"),
month: parseValues(searchParams, "month"),
type: parseValues(searchParams, "type"),
status: parseValues(searchParams, "status"),
}));
const [sort, setSort] = useState<{ key: SortKey; direction: "asc" | "desc" }>({ key: "month", direction: "desc" });
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
useEffect(() => {
const params = new URLSearchParams();
(Object.keys(filters) as Array<keyof Filters>).forEach((key) => filters[key].forEach((value) => params.append(key, value)));
setSearchParams(params, { replace: true });
}, [filters, setSearchParams]);
const filteredRows = useMemo(() => reports.filter((report) => (
matches(filters.bank, report.bank)
&& matches(filters.name, report.modelName)
&& matches(filters.version, report.version)
&& matches(filters.modelId, report.modelId)
&& matches(filters.month, report.monitorMonth)
&& matches(filters.type, report.type)
&& matches(filters.status, report.status)
)), [filters, reports]);
const rows = useMemo(() => [...filteredRows].sort((left, right) => {
const pairs: Record<SortKey, [string, string]> = {
bank: [left.bank, right.bank],
modelId: [left.modelId, right.modelId],
month: [left.monitorMonth, right.monitorMonth],
generatedAt: [left.generatedAt, right.generatedAt],
};
const [a, b] = pairs[sort.key];
const result = a.localeCompare(b, "zh-CN");
return sort.direction === "asc" ? result : -result;
}), [filteredRows, sort]);
useEffect(() => setPage(1), [filters, pageSize, sort]);
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
const safePage = Math.min(page, pageCount);
const pageRows = rows.slice((safePage - 1) * pageSize, safePage * pageSize);
const update = <K extends keyof Filters>(key: K, value: string[]) => setFilters((current) => ({ ...current, [key]: value }));
const changeSort = (key: SortKey) => setSort((current) => current.key === key ? { key, direction: current.direction === "asc" ? "desc" : "asc" } : { key, direction: "asc" });
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="历史报告汇总" description="按银行、模型、版本、模型 ID 与月份任意组合多选,查看最新和历史报告。" actions={<Button onClick={() => void exportReports(rows)}><Download />导出 Excel</Button>} />
<Card className="overflow-visible">
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><FileClock className="size-5 text-primary" />全部报告</CardTitle><CardDescription> {rows.length} · 当前第 {safePage} / {pageCount} </CardDescription><CardAction className="flex gap-2"><Button variant="outline" size="sm" onClick={() => setFilters(emptyFilters())}><RotateCcw />重置筛选</Button><Button size="sm" onClick={() => void exportReports(rows)}><Download />导出 Excel</Button></CardAction></CardHeader>
<CardContent className="grid grid-cols-7 gap-3">
<MultiSelectFilter label="银行" values={filters.bank} options={unique(reports.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
<MultiSelectFilter label="模型名称" values={filters.name} options={unique(reports.map((item) => item.modelName))} onChange={(value) => update("name", value)} />
<MultiSelectFilter label="模型版本" values={filters.version} options={unique(reports.map((item) => item.version))} onChange={(value) => update("version", value)} />
<MultiSelectFilter label="模型 ID" values={filters.modelId} options={unique(reports.map((item) => item.modelId))} onChange={(value) => update("modelId", value)} />
<MultiSelectFilter label="月份" values={filters.month} options={unique(reports.map((item) => item.monitorMonth))} onChange={(value) => update("month", value)} />
<MultiSelectFilter label="报告类型" values={filters.type} options={["监控报告", "诊断报告"]} onChange={(value) => update("type", value)} />
<MultiSelectFilter label="状态" values={filters.status} options={["待模型团队阅读", "编辑中", "已发送业务团队"]} onChange={(value) => update("status", value)} />
</CardContent>
<CardContent className="max-h-[40rem] overflow-auto px-0 pt-0">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card"><TableRow><SortableHead label="银行" sortKey="bank" currentKey={sort.key} onSort={changeSort} /><TableHead>模型名称</TableHead><TableHead>版本</TableHead><SortableHead label="模型ID" sortKey="modelId" currentKey={sort.key} onSort={changeSort} /><SortableHead label="月份" sortKey="month" currentKey={sort.key} onSort={changeSort} /><TableHead>报告类型</TableHead><SortableHead label="生成时间" sortKey="generatedAt" currentKey={sort.key} onSort={changeSort} /><TableHead>输出日期</TableHead><TableHead>状态</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader>
<TableBody>
{pageRows.length ? pageRows.map((report) => <TableRow key={report.reportId}><TableCell className="font-medium text-foreground">{report.bank}</TableCell><TableCell>{report.modelName}</TableCell><TableCell>{report.version}</TableCell><TableCell className="font-mono text-xs">{report.modelId}</TableCell><TableCell className="font-mono text-xs">{report.monitorMonth}</TableCell><TableCell><span className={report.type === "监控报告" ? "rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong" : "rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning"}>{report.type}</span></TableCell><TableCell className="font-mono text-xs">{report.generatedAt}</TableCell><TableCell>{report.outputDate}</TableCell><TableCell><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${statusClass(report.status)}`}>{report.status}</span>{report.unreadDays >= 5 && report.status !== "已发送业务团队" && <span className="ml-2 rounded-full bg-danger-soft px-2.5 py-1 text-xs font-medium text-danger"> {report.unreadDays} </span>}{report.synced && <span className="ml-2 rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">已同步</span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => navigate(`/operations/reports?report=${report.reportId}`)}>打开</Button><Button variant="link" size="sm" onClick={() => toast.info("PDF 排版与下载将在报告服务接口接入后启用")}>导出 PDF</Button></TableCell></TableRow>) : <TableRow><TableCell colSpan={10} className="h-32 text-center text-muted-foreground">当前筛选条件下没有报告</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent>
<CardContent className="flex items-center justify-between border-t border-border py-3"><div className="flex items-center gap-2 text-xs text-muted-foreground"><span>每页</span><FilterSelect className="w-24" label="" value={String(pageSize)} allLabel="请选择" options={["10", "20", "50"]} onChange={(value) => setPageSize(Number(value))} /><span></span></div><div className="flex items-center gap-2"><Button variant="outline" size="icon-sm" aria-label="上一页" disabled={safePage <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}><ChevronLeft /></Button><span className="min-w-20 text-center text-xs text-muted-foreground">{safePage} / {pageCount}</span><Button variant="outline" size="icon-sm" aria-label="下一页" disabled={safePage >= pageCount} onClick={() => setPage((value) => Math.min(pageCount, value + 1))}><ChevronRight /></Button></div></CardContent>
</Card>
</div>
</section>
);
}