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(EMPTY_FILTERS); const [selectedId, setSelectedId] = useState(WORKFLOWS[0]?.workflowId ?? ""); const [statisticsYear, setStatisticsYear] = useState("2026"); const [openStages, setOpenStages] = useState>(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>({}); const fileInputRef = useRef(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 = (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 (
{ addWorkflowFile(event.target.files?.[0]); event.target.value = ""; }} /> setNewRequestOpen(true)}>发起需求 : undefined} />
本平台负责流程展示、材料留痕、关键节点催办与审计;模型开发、测试执行和部署操作由模型平台完成。
历史流程统计各环节累计处理量,作为工作量统计口径 统计口径{WORKFLOW_STAGES.map((stage) => {stage.stage}. {stage.title})} 涉及银行数{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})}
流程实例共 {filtered.length} 个流程
item.bank))} onChange={(value) => update("bank", value)} /> ({ label: item.name, value: item.id }))} onChange={(value) => update("category", value)} /> item.initiatedAt.slice(0, 4)))} onChange={(value) => update("year", value)} /> update("status", value)} /> update("reuse", value)} />
({ 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)])); }} />
{workflow ? ( <> {workflow.title}{workflow.initiatedBy} · {workflow.initiatedAt}{statusOf(workflow) === "已完结" ? 已完结 : 第 {workflow.currentStage} 步进行中}
银行 / 模型
{workflow.bank} · {categoryName(workflow.category)} · {workflow.modelId}
模型来源
{workflow.reusedModel ? 复用 · {workflow.reusedModel} : 独立开发}
当前环节
{statusOf(workflow) === "已完结" ? `已于 ${workflow.completedAt} 完结` : `${workflow.currentStage}. ${WORKFLOW_STAGES[workflow.currentStage - 1]?.title}`}{workflow.stalledDays ? 停滞 {workflow.stalledDays} 天 : null}
总体进度{workflow.currentStage > 7 ? 100 : Math.round((workflow.currentStage - 1) / 7 * 100)}%
{WORKFLOW_STAGES.map((stage) =>
7 ? "bg-success" : stage.stage === workflow.currentStage ? "bg-primary" : "bg-muted"}`} />{stage.stage}. {stage.title}
)}
{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 ( {open &&
业务团队

{stage.businessDuty}

模型团队

{stage.modelDuty}

{files.length ? 材料名称上传人上传时间确认状态操作{files.map((file) => {file.name}{file.uploadedBy}{file.uploadedAt}{stage.stage === 3 ? 无需业务确认 : file.confirmed ? 已确认 : canBusinessConfirm ? : 待确认})}
:
本环节暂无材料
}{current &&
{canUpload && }{canFeedback && stage.stage === 2 && }{canBusinessConfirm && stage.stage === 5 && }{canAdvance && stage.stage < 7 && }{canOnline && stage.stage === 7 && }
}
}
); })}
) : 当前筛选条件下没有流程}
发起模型需求支持单银行需求,也可输入多个银行并选择复用通用模型。
({ label: item.name, value: item.id }))} onChange={setNewCategory} />
); }