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
@@ -8,11 +8,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { MODEL_CATEGORIES, categoryName, type ModelCategoryId } from "./modelData";
import { WORKFLOWS, WORKFLOW_STAGES, type WorkflowFile, type WorkflowInstance } from "./workflowData";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
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: "" };
@@ -27,18 +26,21 @@ function statusOf(workflow: WorkflowInstance): "进行中" | "已完结" {
export default function WorkflowPage() {
const operationsRole = useOperationsRole();
const canCreate = isOperationsActionVisibleForRole(operationsRole, "workflow:create");
const canAdvance = isOperationsActionVisibleForRole(operationsRole, "workflow:advance");
const canBusinessConfirm = isOperationsActionVisibleForRole(operationsRole, "workflow:business-confirm");
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 [newBank, setNewBank] = useState("");
const [newCategory, setNewCategory] = useState("std");
const [newReuse, setNewReuse] = useState("独立开发");
const [localFiles, setLocalFiles] = usePersistentState<Record<string, WorkflowFile[]>>("a-card-workflow-local-files", {});
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) => (
@@ -85,12 +87,12 @@ export default function WorkflowPage() {
confirmed: target.stage === 3,
};
setLocalFiles((current) => ({ ...current, [key]: [...(current[key] ?? []), uploaded] }));
toast.success("材料已加入本地上传队列");
toast.info("材料已暂存当前页面,文件上传接口尚未接入");
};
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">
<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="全流程进度"
@@ -146,7 +148,7 @@ export default function WorkflowPage() {
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 = canAdvance || (canBusinessConfirm && stage.stage === 4);
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)}>
@@ -155,7 +157,7 @@ export default function WorkflowPage() {
{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.success("已模拟确认材料")}></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>}<Button variant="outline" size="sm" onClick={() => toast.success("已模拟发送催办通知")}><Bell /></Button>{canAdvance && stage.stage < 7 && <Button size="sm" onClick={() => toast.success("已模拟推进到下一环节")}> <ArrowRight /></Button>}</div>}</CardContent>}
{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>
);
})}
@@ -168,7 +170,7 @@ export default function WorkflowPage() {
<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.success(`已模拟发起需求:${newBank} · ${categoryName(newCategory as ModelCategoryId)} · ${newReuse}`); }}></Button></DialogFooter>
<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>