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

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

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

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

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

179 lines
18 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { ArrowRight, Bell, Check, ChevronDown, Download, FileUp, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { MODEL_CATEGORIES, categoryName, type ModelCategoryId } from "./modelData";
import { WORKFLOWS, WORKFLOW_STAGES, type WorkflowFile, type WorkflowInstance } from "./workflowData";
import { useCanOperationsAction, useOperationsRole } from "./operationsRole";
type Filters = { bank: string; category: string; year: string; status: string; reuse: string };
const EMPTY_FILTERS: Filters = { bank: "", category: "", year: "", status: "", reuse: "" };
function unique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
}
function statusOf(workflow: WorkflowInstance): "进行中" | "已完结" {
return workflow.currentStage > WORKFLOW_STAGES.length ? "已完结" : "进行中";
}
export default function WorkflowPage() {
const operationsRole = useOperationsRole();
const canCreate = useCanOperationsAction("workflow:create");
const canFeedback = useCanOperationsAction("workflow:feedback");
const canSubmitMaterial = useCanOperationsAction("workflow:submit-material");
const canAdvance = useCanOperationsAction("workflow:advance");
const canOnline = useCanOperationsAction("workflow:online");
const canBusinessConfirm = useCanOperationsAction("workflow:business-confirm");
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [selectedId, setSelectedId] = useState(WORKFLOWS[0]?.workflowId ?? "");
const [statisticsYear, setStatisticsYear] = useState("2026");
const [openStages, setOpenStages] = useState<Set<number>>(new Set([WORKFLOWS[0]?.currentStage ?? 1]));
const [newRequestOpen, setNewRequestOpen] = useState(false);
const [newBank, setNewBank] = useState("");
const [newCategory, setNewCategory] = useState("std");
const [newReuse, setNewReuse] = useState("独立开发");
const [localFiles, setLocalFiles] = useState<Record<string, WorkflowFile[]>>({});
const fileInputRef = useRef<HTMLInputElement | null>(null);
const uploadTargetRef = useRef<{ workflowId: string; stage: number } | null>(null);
const filtered = useMemo(() => WORKFLOWS.filter((workflow) => (
(!filters.bank || workflow.bank === filters.bank)
&& (!filters.category || workflow.category === filters.category)
&& (!filters.year || workflow.initiatedAt.startsWith(filters.year))
&& (!filters.status || statusOf(workflow) === filters.status)
&& (!filters.reuse || (workflow.reusedModel ? "复用通用模型" : "独立开发") === filters.reuse)
)), [filters]);
const workflow = filtered.find((item) => item.workflowId === selectedId) ?? filtered[0] ?? null;
const statisticsPool = WORKFLOWS.filter((item) => statisticsYear === "全部" || item.initiatedAt.startsWith(statisticsYear));
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => {
setFilters((current) => ({ ...current, [key]: value }));
setSelectedId("");
};
const toggleStage = (stage: number) => {
setOpenStages((current) => {
const next = new Set(current);
if (next.has(stage)) next.delete(stage);
else next.add(stage);
return next;
});
};
const chooseWorkflowFile = (workflowId: string, stage: number) => {
uploadTargetRef.current = { workflowId, stage };
fileInputRef.current?.click();
};
const addWorkflowFile = (file: File | undefined) => {
const target = uploadTargetRef.current;
if (!file || !target) return;
if (file.size > 50 * 1024 * 1024) {
toast.error("文件不能超过 50 MB");
return;
}
const key = `${target.workflowId}:${target.stage}`;
const uploaded: WorkflowFile = {
name: file.name,
uploadedBy: operationsRole === "business" ? "业务团队(当前用户)" : "模型团队(当前用户)",
uploadedAt: new Date().toLocaleString("zh-CN", { hour12: false }),
confirmed: target.stage === 3,
};
setLocalFiles((current) => ({ ...current, [key]: [...(current[key] ?? []), uploaded] }));
toast.info("材料已暂存当前页面,文件上传接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="flex w-full flex-col gap-6 pb-8">
<input ref={fileInputRef} className="hidden" type="file" accept=".doc,.docx,.xls,.xlsx,.pdf,.ppt,.pptx,.zip" onChange={(event) => { addWorkflowFile(event.target.files?.[0]); event.target.value = ""; }} />
<OperationsPageHeader
title="全流程进度"
description="展示部署上线前七阶段流程、材料留痕与关键节点催办;开发和上线操作仍在模型平台完成。"
actions={canCreate ? <Button onClick={() => setNewRequestOpen(true)}><Plus />发起需求</Button> : undefined}
/>
<div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary">
本平台负责流程展示、材料留痕、关键节点催办与审计;模型开发、测试执行和部署操作由模型平台完成。
</div>
<Card>
<CardHeader className="border-b border-border"><CardTitle>历史流程统计</CardTitle><CardDescription>各环节累计处理量,作为工作量统计口径</CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-36" label="统计范围" value={statisticsYear} allLabel="请选择" options={["2026", "2025", "全部"]} onChange={setStatisticsYear} /><Button variant="outline" size="sm" onClick={() => void exportRowsToExcel({ fileName: `历史流程统计_${statisticsYear}`, sheetName: "流程统计", headers: ["统计口径", ...WORKFLOW_STAGES.map((stage) => `${stage.stage}.${stage.title}`)], rows: [["涉及银行数", ...WORKFLOW_STAGES.map((stage) => new Set(statisticsPool.filter((item) => item.currentStage >= stage.stage).map((item) => item.bank)).size)], ["需求 / 流程数", ...WORKFLOW_STAGES.map((stage) => statisticsPool.filter((item) => item.currentStage >= stage.stage).length)], ["已完成", ...WORKFLOW_STAGES.map((stage) => statisticsPool.filter((item) => item.currentStage > stage.stage).length)]] })}><Download />导出 Excel</Button></CardAction></CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader><TableRow><TableHead>统计口径</TableHead>{WORKFLOW_STAGES.map((stage) => <TableHead className="text-center" key={stage.stage}>{stage.stage}. {stage.title}</TableHead>)}</TableRow></TableHeader>
<TableBody>
<TableRow><TableCell className="font-medium">涉及银行数</TableCell>{WORKFLOW_STAGES.map((stage) => <TableCell className="text-center font-semibold tabular-nums" key={stage.stage}>{new Set(statisticsPool.filter((item) => item.currentStage >= stage.stage).map((item) => item.bank)).size}</TableCell>)}</TableRow>
<TableRow><TableCell className="font-medium">需求 / 流程数</TableCell>{WORKFLOW_STAGES.map((stage) => <TableCell className="text-center tabular-nums" key={stage.stage}>{statisticsPool.filter((item) => item.currentStage >= stage.stage).length}</TableCell>)}</TableRow>
<TableRow><TableCell className="font-medium">已完成</TableCell>{WORKFLOW_STAGES.map((stage) => <TableCell className="text-center tabular-nums" key={stage.stage}>{statisticsPool.filter((item) => item.currentStage > stage.stage).length}</TableCell>)}</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="border-b border-border"><CardTitle>流程实例</CardTitle><CardDescription> {filtered.length} 个流程</CardDescription><CardAction><Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}><RotateCcw />重置筛选</Button></CardAction></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-5 gap-3">
<FilterSelect label="银行" value={filters.bank} options={unique(WORKFLOWS.map((item) => item.bank))} onChange={(value) => update("bank", value)} />
<FilterSelect label="模型大类" value={filters.category} options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={(value) => update("category", value)} />
<FilterSelect label="发起年份" value={filters.year} options={unique(WORKFLOWS.map((item) => item.initiatedAt.slice(0, 4)))} onChange={(value) => update("year", value)} />
<FilterSelect label="流程状态" value={filters.status} options={["进行中", "已完结"]} onChange={(value) => update("status", value)} />
<FilterSelect label="是否复用通用" value={filters.reuse} options={["复用通用模型", "独立开发"]} onChange={(value) => update("reuse", value)} />
</div>
<FilterSelect label="查看流程" value={workflow?.workflowId ?? ""} allLabel={filtered.length ? "请选择流程" : "无匹配流程"} options={filtered.map((item) => ({ label: `${item.title}${statusOf(item) === "已完结" ? "(已完结)" : ""}`, value: item.workflowId }))} onChange={(value) => { setSelectedId(value); const selected = filtered.find((item) => item.workflowId === value); if (selected) setOpenStages(new Set([Math.min(selected.currentStage, 7)])); }} />
</CardContent>
</Card>
{workflow ? (
<>
<Card>
<CardHeader><CardTitle>{workflow.title}</CardTitle><CardDescription>{workflow.initiatedBy} · {workflow.initiatedAt}</CardDescription><CardAction>{statusOf(workflow) === "已完结" ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong">已完结</span> : <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary"> {workflow.currentStage} 步进行中</span>}</CardAction></CardHeader>
<CardContent className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-6">
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-3 text-sm"><dt className="text-muted-foreground">银行 / 模型</dt><dd className="font-medium">{workflow.bank} · {categoryName(workflow.category)} · <span className="font-mono text-xs">{workflow.modelId}</span></dd><dt className="text-muted-foreground">模型来源</dt><dd>{workflow.reusedModel ? <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">复用 · {workflow.reusedModel}</span> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs">独立开发</span>}</dd><dt className="text-muted-foreground">当前环节</dt><dd>{statusOf(workflow) === "已完结" ? `已于 ${workflow.completedAt} 完结` : `${workflow.currentStage}. ${WORKFLOW_STAGES[workflow.currentStage - 1]?.title}`}{workflow.stalledDays ? <span className="ml-2 rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning">停滞 {workflow.stalledDays} </span> : null}</dd></dl>
<div><div className="mb-2 flex items-center justify-between text-sm"><span className="text-muted-foreground">总体进度</span><b className="tabular-nums text-primary">{workflow.currentStage > 7 ? 100 : Math.round((workflow.currentStage - 1) / 7 * 100)}%</b></div><div className="grid grid-cols-7 gap-2">{WORKFLOW_STAGES.map((stage) => <div key={stage.stage}><span className={`block h-2 rounded-full ${stage.stage < workflow.currentStage || workflow.currentStage > 7 ? "bg-success" : stage.stage === workflow.currentStage ? "bg-primary" : "bg-muted"}`} /><small className={`mt-2 block text-center text-3xs ${stage.stage === workflow.currentStage ? "font-semibold text-primary" : "text-muted-foreground"}`}>{stage.stage}. {stage.title}</small></div>)}</div></div>
</CardContent>
</Card>
<div className="space-y-3">
{WORKFLOW_STAGES.map((stage) => {
const done = workflow.currentStage > stage.stage;
const current = workflow.currentStage === stage.stage;
const files = [...(workflow.files[stage.stage] ?? []), ...(localFiles[`${workflow.workflowId}:${stage.stage}`] ?? [])];
const open = openStages.has(stage.stage);
const canUpload = canSubmitMaterial || (canBusinessConfirm && stage.stage === 4);
return (
<Card key={stage.stage} size="sm">
<button className="flex w-full cursor-pointer items-center gap-3 px-4 text-left" type="button" onClick={() => toggleStage(stage.stage)}>
<span className={`grid size-8 shrink-0 place-items-center rounded-full text-xs font-semibold ${done ? "bg-success text-white" : current ? "bg-primary text-white" : "bg-muted text-muted-foreground"}`}>{done ? <Check className="size-4" /> : stage.stage}</span>
<span className="flex-1"><b className="block text-sm text-foreground">{stage.title}</b><small className="text-xs text-muted-foreground">{files.length} 份材料{current && workflow.deadline ? ` · 最晚反馈 ${workflow.deadline}` : ""}</small></span>
{current && <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary">进行中</span>}{done && <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong">已完成</span>}{!done && !current && <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">未开始</span>}
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <CardContent className="space-y-4 border-t border-border pt-4"><div className="grid grid-cols-2 gap-4"><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground">业务团队</b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.businessDuty}</p></div><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground">模型团队</b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.modelDuty}</p></div></div>{files.length ? <Table><TableHeader><TableRow><TableHead>材料名称</TableHead><TableHead>上传人</TableHead><TableHead>上传时间</TableHead><TableHead>确认状态</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader><TableBody>{files.map((file) => <TableRow key={`${file.name}-${file.uploadedAt}`}><TableCell className="font-medium text-foreground">{file.name}</TableCell><TableCell>{file.uploadedBy}</TableCell><TableCell>{file.uploadedAt}</TableCell><TableCell>{stage.stage === 3 ? <span className="rounded-full bg-muted px-2.5 py-1 text-xs">无需业务确认</span> : file.confirmed ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong">已确认</span> : canBusinessConfirm ? <Button variant="outline" size="xs" onClick={() => toast.info("材料确认接口尚未接入")}>确认</Button> : <span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning">待确认</span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => toast.info("原始文件内容将在文件服务接入后下载")}>下载</Button></TableCell></TableRow>)}</TableBody></Table> : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground">本环节暂无材料</div>}{current && <div className="flex flex-wrap gap-2">{canUpload && <Button variant="outline" size="sm" onClick={() => chooseWorkflowFile(workflow.workflowId, stage.stage)}><FileUp />上传材料</Button>}{canFeedback && stage.stage === 2 && <Button variant="outline" size="sm" onClick={() => toast.info("流程反馈接口尚未接入")}>填写反馈</Button>}{canBusinessConfirm && stage.stage === 5 && <Button variant="outline" size="sm" onClick={() => toast.info("流程确认接口尚未接入")}>确认评审材料</Button>}<Button variant="outline" size="sm" onClick={() => toast.info("催办通知接口尚未接入")}><Bell />发起催办</Button>{canAdvance && stage.stage < 7 && <Button size="sm" onClick={() => toast.info("流程推进接口尚未接入")}>推进下一环节 <ArrowRight /></Button>}{canOnline && stage.stage === 7 && <Button size="sm" onClick={() => toast.info("模型上线接口尚未接入")}>确认上线 <ArrowRight /></Button>}</div>}</CardContent>}
</Card>
);
})}
</div>
</>
) : <Card><CardContent className="py-16 text-center text-sm text-muted-foreground">当前筛选条件下没有流程</CardContent></Card>}
</div>
<Dialog open={newRequestOpen} onOpenChange={setNewRequestOpen}>
<DialogContent>
<DialogHeader><DialogTitle>发起模型需求</DialogTitle><DialogDescription>支持单银行需求,也可输入多个银行并选择复用通用模型。</DialogDescription></DialogHeader>
<div className="space-y-4"><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption">银行</span><Input value={newBank} onChange={(event) => setNewBank(event.target.value)} placeholder="多个银行使用顿号分隔" /></label><FilterSelect label="模型大类" value={newCategory} allLabel="请选择" options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={setNewCategory} /><FilterSelect label="模型来源" value={newReuse} allLabel="请选择" options={["独立开发", "复用通用模型"]} onChange={setNewReuse} /></div>
<DialogFooter><Button variant="outline" onClick={() => setNewRequestOpen(false)}>取消</Button><Button onClick={() => { if (!newBank.trim()) { toast.error("请输入银行"); return; } setNewRequestOpen(false); toast.info("需求提交接口尚未接入"); }}>提交需求</Button></DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}