143 lines
11 KiB
TypeScript
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="mx-auto flex max-w-screen-2xl 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>
|
|
);
|
|
}
|