Add visual approval flow designer

This commit is contained in:
Codex
2026-08-07 14:24:04 +08:00
parent 8322800b57
commit 94b003356e
7 changed files with 1062 additions and 257 deletions

300
app/approval/page.tsx Normal file
View File

@@ -0,0 +1,300 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import defaultFlow from "../data/approval-flow.json";
import styles from "./ApprovalDesigner.module.css";
type NodeType = "start" | "approval" | "condition" | "cc" | "end";
type ApprovalMode = "single" | "any" | "all";
type FlowNode = {
id: string;
type: NodeType;
title: string;
description: string;
approvers: string[];
mode: ApprovalMode;
allowTransfer?: boolean;
autoPass?: boolean;
branches?: Branch[];
};
type Branch = {
id: string;
label: string;
expression: string;
node: FlowNode;
};
type FlowData = {
id: string;
name: string;
description: string;
status: string;
version: string;
updatedAt: string;
nodes: FlowNode[];
};
const library: Array<{ type: NodeType; label: string; description: string; icon: string; tone: string }> = [
{ type: "approval", label: "审批人", description: "指定成员或角色审批", icon: "✓", tone: "blue" },
{ type: "condition", label: "条件分支", description: "根据表单字段自动分流", icon: "⑂", tone: "orange" },
{ type: "cc", label: "抄送人", description: "将审批结果通知成员", icon: "◎", tone: "purple" },
];
const people = ["直属主管", "部门负责人", "财务负责人", "预算管理员", "采购专员", "法务负责人"];
export default function ApprovalDesignerPage() {
const [flow, setFlow] = useState<FlowData>(defaultFlow as unknown as FlowData);
const [selectedId, setSelectedId] = useState("approval-1");
const [zoom, setZoom] = useState(1);
const [previewOpen, setPreviewOpen] = useState(false);
const [jsonOpen, setJsonOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [toast, setToast] = useState("");
const [saved, setSaved] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const stored = window.localStorage.getItem("orbit-approval-flow");
if (stored) {
try { setFlow(JSON.parse(stored)); }
catch { window.localStorage.removeItem("orbit-approval-flow"); }
}
setLoaded(true);
}, []);
useEffect(() => {
if (!loaded) return;
setSaved(false);
const timer = window.setTimeout(() => {
window.localStorage.setItem("orbit-approval-flow", JSON.stringify(flow));
setSaved(true);
}, 500);
return () => window.clearTimeout(timer);
}, [flow, loaded]);
const selectedNode = useMemo(() => flow.nodes.find((node) => node.id === selectedId), [flow.nodes, selectedId]);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2400);
};
const updateNode = (patch: Partial<FlowNode>) => {
setFlow((current) => ({
...current,
nodes: current.nodes.map((node) => node.id === selectedId ? { ...node, ...patch } : node),
updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }),
}));
};
const addNode = (type: NodeType) => {
const id = `${type}-${Date.now()}`;
const common = { id, type, approvers: [] as string[], mode: "single" as ApprovalMode };
let node: FlowNode;
if (type === "approval") node = { ...common, title: "新审批节点", description: "请选择审批人", approvers: ["直属主管"], allowTransfer: true };
else if (type === "cc") node = { ...common, title: "新抄送节点", description: "审批通过后自动抄送", approvers: ["申请人"] };
else node = {
...common,
title: "新条件分支",
description: "根据表单字段设置条件",
branches: [
{ id: `${id}-a`, label: "条件 1", expression: "totalAmount >= 1000", node: { ...common, id: `${id}-node-a`, type: "approval", title: "条件审批人", description: "满足条件时执行", approvers: ["部门负责人"] } },
{ id: `${id}-b`, label: "其他情况", expression: "otherwise", node: { ...common, id: `${id}-node-b`, type: "cc", title: "默认抄送", description: "未满足条件时执行", approvers: ["申请人"] } },
],
};
setFlow((current) => {
const endIndex = current.nodes.findIndex((item) => item.type === "end");
const nodes = [...current.nodes];
nodes.splice(endIndex < 0 ? nodes.length : endIndex, 0, node);
return { ...current, nodes, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }) };
});
setSelectedId(id);
setPaletteOpen(false);
notify(`已添加${type === "approval" ? "审批" : type === "condition" ? "条件" : "抄送"}节点`);
};
const deleteNode = () => {
if (!selectedNode || selectedNode.type === "start" || selectedNode.type === "end") return;
setFlow((current) => ({ ...current, nodes: current.nodes.filter((node) => node.id !== selectedId) }));
setSelectedId("start-1");
notify("节点已删除");
};
const validateFlow = () => {
const emptyApprovers = flow.nodes.filter((node) => (node.type === "approval" || node.type === "cc") && node.approvers.length === 0);
const invalidBranches = flow.nodes.filter((node) => node.type === "condition" && (!node.branches || node.branches.length < 2));
if (emptyApprovers.length || invalidBranches.length) notify(`发现 ${emptyApprovers.length + invalidBranches.length} 项配置需要完善`);
else notify(`流程校验通过,共 ${flow.nodes.length} 个主节点`);
};
const publish = () => {
window.localStorage.setItem("orbit-approval-flow", JSON.stringify({ ...flow, status: "published" }));
setFlow((current) => ({ ...current, status: "published" }));
notify("演示流程已发布到本地");
};
const resetFlow = () => {
setFlow(defaultFlow as unknown as FlowData);
setSelectedId("approval-1");
window.localStorage.removeItem("orbit-approval-flow");
notify("已恢复 JSON 示例流程");
};
return (
<div className={styles.designerShell}>
<header className={styles.topbar}>
<div className={styles.topbarLeft}>
<Link href="/" className={styles.backButton} aria-label="返回后台模板"></Link>
<span className={styles.brandMark}><i /><i /><i /></span>
<div className={styles.processIdentity}>
<div><input value={flow.name} onChange={(event) => setFlow((current) => ({ ...current, name: event.target.value }))} aria-label="流程名称" /><span className={flow.status === "published" ? styles.published : styles.draft}>{flow.status === "published" ? "已发布" : "草稿"}</span></div>
<small>{saved ? "✓ 已自动保存到本地" : "正在保存…"} · {flow.version}</small>
</div>
</div>
<div className={styles.topbarActions}>
<button className={styles.quietButton} onClick={() => setJsonOpen(true)}> JSON</button>
<button className={styles.quietButton} onClick={validateFlow}> </button>
<button className={styles.quietButton} onClick={() => setPreviewOpen(true)}> </button>
<button className={styles.publishButton} onClick={publish}></button>
</div>
</header>
<div className={styles.workspace}>
<aside className={`${styles.palette} ${paletteOpen ? styles.paletteOpen : ""}`}>
<div className={styles.paletteHeader}><div><strong></strong><small></small></div><button onClick={() => setPaletteOpen(false)}>×</button></div>
<div className={styles.paletteSection}>
<p></p>
{library.map((item) => (
<button key={item.type} className={styles.paletteItem} draggable onDragStart={(event) => event.dataTransfer.setData("nodeType", item.type)} onClick={() => addNode(item.type)}>
<span className={`${styles.paletteIcon} ${styles[item.tone]}`}>{item.icon}</span>
<span><strong>{item.label}</strong><small>{item.description}</small></span>
<b></b>
</button>
))}
</div>
<div className={styles.paletteSection}>
<p></p>
<button className={`${styles.paletteItem} ${styles.disabledItem}`} onClick={() => notify("自动化节点为后续扩展示例")}><span className={`${styles.paletteIcon} ${styles.green}`}></span><span><strong></strong><small></small></span><b></b></button>
<button className={`${styles.paletteItem} ${styles.disabledItem}`} onClick={() => notify("数据操作节点为后续扩展示例")}><span className={`${styles.paletteIcon} ${styles.gray}`}></span><span><strong></strong><small></small></span><b></b></button>
</div>
<div className={styles.helpCard}><span>?</span><div><strong></strong><p></p></div></div>
</aside>
<main className={styles.canvasArea} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { const type = event.dataTransfer.getData("nodeType") as NodeType; if (type) addNode(type); }}>
<div className={styles.canvasToolbar}>
<button className={styles.mobilePaletteButton} onClick={() => setPaletteOpen(true)}> </button>
<div className={styles.breadcrumb}><span></span><b>/</b><span></span><b>/</b><strong>{flow.name}</strong></div>
<div className={styles.canvasTools}><button onClick={() => setZoom(Math.max(.8, zoom - .1))}></button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom(Math.min(1.2, zoom + .1))}></button><button onClick={() => setZoom(1)}></button></div>
</div>
<div className={styles.canvasScroller}>
<div className={styles.canvasGrid}>
<div className={styles.flowWrap} style={{ transform: `scale(${zoom})` }}>
{flow.nodes.map((node, index) => (
<div className={styles.flowStep} key={node.id}>
{node.type === "condition" ? (
<ConditionNode node={node} selected={selectedId === node.id} onSelect={() => setSelectedId(node.id)} />
) : (
<ProcessNode node={node} selected={selectedId === node.id} onSelect={() => setSelectedId(node.id)} />
)}
{index < flow.nodes.length - 1 && <div className={styles.connector}><i /></div>}
</div>
))}
<button className={styles.canvasAdd} onClick={() => addNode("approval")}><span></span></button>
</div>
</div>
</div>
</main>
<aside className={styles.propertyPanel}>
{selectedNode ? <NodeSettings node={selectedNode} updateNode={updateNode} deleteNode={deleteNode} notify={notify} /> : <div className={styles.noSelection}><span></span><strong></strong><p></p></div>}
</aside>
</div>
{previewOpen && <PreviewModal flow={flow} onClose={() => setPreviewOpen(false)} />}
{jsonOpen && <JsonDrawer flow={flow} resetFlow={resetFlow} onClose={() => setJsonOpen(false)} notify={notify} />}
{toast && <div className={styles.toast}><span></span>{toast}</div>}
</div>
);
}
function ProcessNode({ node, selected, onSelect }: { node: FlowNode; selected: boolean; onSelect: () => void }) {
const meta = {
start: { icon: "▶", tone: "green", eyebrow: "流程发起" },
approval: { icon: "✓", tone: "blue", eyebrow: node.mode === "all" ? "会签审批" : node.mode === "any" ? "或签审批" : "单人审批" },
cc: { icon: "◎", tone: "purple", eyebrow: "抄送通知" },
end: { icon: "■", tone: "gray", eyebrow: "流程完成" },
condition: { icon: "⑂", tone: "orange", eyebrow: "条件分支" },
}[node.type];
return (
<button className={`${styles.processNode} ${selected ? styles.selectedNode : ""} ${styles[`node_${node.type}`]}`} onClick={onSelect}>
<span className={`${styles.nodeIcon} ${styles[meta.tone]}`}>{meta.icon}</span>
<span className={styles.nodeCopy}><small>{meta.eyebrow}</small><strong>{node.title}</strong><em>{node.description}</em></span>
{node.approvers.length > 0 && <span className={styles.approverPreview}>{node.approvers.slice(0, 2).map((person) => <i key={person}>{person.slice(0, 1)}</i>)}<b>{node.approvers.length} </b></span>}
{node.type !== "start" && node.type !== "end" && <span className={styles.nodeMore}></span>}
</button>
);
}
function ConditionNode({ node, selected, onSelect }: { node: FlowNode; selected: boolean; onSelect: () => void }) {
return (
<div className={`${styles.conditionGroup} ${selected ? styles.selectedCondition : ""}`}>
<button className={styles.conditionTitle} onClick={onSelect}><span className={`${styles.nodeIcon} ${styles.orange}`}></span><span><small></small><strong>{node.title}</strong></span><b> </b></button>
<div className={styles.branchRail} />
<div className={styles.branchGrid}>
{node.branches?.map((branch, index) => (
<div className={styles.branchLane} key={branch.id}>
<div className={styles.branchLabel}><span>{index + 1}</span><strong>{branch.label}</strong><code>{branch.expression}</code></div>
<div className={styles.branchConnector} />
<ProcessNode node={branch.node} selected={false} onSelect={onSelect} />
</div>
))}
</div>
<div className={styles.branchJoin}><i /></div>
</div>
);
}
function NodeSettings({ node, updateNode, deleteNode, notify }: { node: FlowNode; updateNode: (patch: Partial<FlowNode>) => void; deleteNode: () => void; notify: (message: string) => void }) {
const [tab, setTab] = useState("节点设置");
const addPerson = () => {
const next = people.find((person) => !node.approvers.includes(person));
if (!next) return notify("可选示例审批人已全部添加");
updateNode({ approvers: [...node.approvers, next] });
};
return (
<>
<div className={styles.settingsHeader}><div><span className={`${styles.nodeIcon} ${styles[node.type === "condition" ? "orange" : node.type === "cc" ? "purple" : node.type === "start" ? "green" : "blue"]}`}>{node.type === "condition" ? "⑂" : node.type === "cc" ? "◎" : "✓"}</span><span><strong>{node.title}</strong><small> ID · {node.id}</small></span></div>{node.type !== "start" && node.type !== "end" && <button onClick={deleteNode}></button>}</div>
<div className={styles.settingsTabs}>{["节点设置", "表单权限", "高级"].map((item) => <button key={item} className={tab === item ? styles.activeTab : ""} onClick={() => setTab(item)}>{item}</button>)}</div>
<div className={styles.settingsBody}>
{tab === "节点设置" && <>
<label className={styles.field}><span></span><input value={node.title} onChange={(event) => updateNode({ title: event.target.value })} /></label>
<label className={styles.field}><span></span><textarea value={node.description} onChange={(event) => updateNode({ description: event.target.value })} rows={3} /></label>
{node.type === "approval" && <>
<div className={styles.settingGroup}><div className={styles.settingLabel}><strong></strong><small></small></div><div className={styles.segmented}>{[["single", "单人"], ["any", "或签"], ["all", "会签"]].map(([value, label]) => <button key={value} className={node.mode === value ? styles.segmentActive : ""} onClick={() => updateNode({ mode: value as ApprovalMode })}>{label}</button>)}</div></div>
<ApproverPicker node={node} updateNode={updateNode} addPerson={addPerson} />
</>}
{node.type === "cc" && <ApproverPicker node={node} updateNode={updateNode} addPerson={addPerson} title="抄送成员" />}
{node.type === "condition" && <div className={styles.branchSettings}>{node.branches?.map((branch, index) => <div key={branch.id}><div><span>{index + 1}</span><strong> {index + 1}</strong></div><label className={styles.field}><span></span><input value={branch.label} onChange={(event) => updateNode({ branches: node.branches?.map((item) => item.id === branch.id ? { ...item, label: event.target.value } : item) })} /></label><label className={styles.field}><span></span><input value={branch.expression} onChange={(event) => updateNode({ branches: node.branches?.map((item) => item.id === branch.id ? { ...item, expression: event.target.value } : item) })} /></label></div>)}</div>}
{(node.type === "start" || node.type === "end") && <div className={styles.infoBox}><span>i</span><p>{node.type === "start" ? "发起节点用于设置流程适用范围和发起人权限。" : "结束节点会汇总审批结果,并生成完整的流程记录。"}</p></div>}
</>}
{tab === "表单权限" && <div className={styles.permissions}><div className={styles.permissionHead}><span></span><span></span><span></span></div>{["采购事由", "采购金额", "费用归属", "附件凭证"].map((field, index) => <div key={field}><strong>{field}</strong><input type="checkbox" defaultChecked /><input type="checkbox" defaultChecked={index < 2} /></div>)}</div>}
{tab === "高级" && <div className={styles.advancedSettings}><label><span><strong></strong><small></small></span><input type="checkbox" checked={node.allowTransfer ?? true} onChange={(event) => updateNode({ allowTransfer: event.target.checked })} /></label><label><span><strong></strong><small></small></span><input type="checkbox" checked={node.autoPass ?? false} onChange={(event) => updateNode({ autoPass: event.target.checked })} /></label><button onClick={() => notify("超时规则已添加为演示配置")}> </button></div>}
</div>
</>
);
}
function ApproverPicker({ node, updateNode, addPerson, title = "审批人" }: { node: FlowNode; updateNode: (patch: Partial<FlowNode>) => void; addPerson: () => void; title?: string }) {
return <div className={styles.approverSetting}><div className={styles.settingLabel}><strong>{title}</strong><small></small></div><div className={styles.peopleList}>{node.approvers.map((person) => <div key={person}><span>{person.slice(0, 1)}</span><p><strong>{person}</strong><small>{person.includes("负责人") ? "角色" : "动态成员"}</small></p><button onClick={() => updateNode({ approvers: node.approvers.filter((item) => item !== person) })}>×</button></div>)}</div><button className={styles.addPeople} onClick={addPerson}> </button></div>;
}
function PreviewModal({ flow, onClose }: { flow: FlowData; onClose: () => void }) {
return <div className={styles.modalBackdrop} onMouseDown={onClose}><div className={styles.previewModal} onMouseDown={(event) => event.stopPropagation()}><header><div><span></span><div><h2></h2><p> 8,600 </p></div></div><button onClick={onClose}>×</button></header><div className={styles.previewForm}><div><span></span><strong> · </strong></div><div><span></span><strong>¥ 8,600.00</strong></div><div><span></span><strong className={styles.hitBranch}> 5,000 </strong></div></div><div className={styles.previewRoute}>{flow.nodes.map((node, index) => <div key={node.id}><span className={styles.previewDot}>{index + 1}</span><p><strong>{node.title}</strong><small>{node.type === "condition" ? "命中高金额分支" : node.approvers.join("、") || node.description}</small></p>{index < flow.nodes.length - 1 && <i />}</div>)}</div><footer><button onClick={onClose}></button><button onClick={onClose}></button></footer></div></div>;
}
function JsonDrawer({ flow, resetFlow, onClose, notify }: { flow: FlowData; resetFlow: () => void; onClose: () => void; notify: (message: string) => void }) {
return <div className={styles.modalBackdrop} onMouseDown={onClose}><aside className={styles.jsonDrawer} onMouseDown={(event) => event.stopPropagation()}><header><div><h2>JSON </h2><p></p></div><button onClick={onClose}>×</button></header><pre>{JSON.stringify(flow, null, 2)}</pre><footer><button onClick={resetFlow}></button><button onClick={() => { navigator.clipboard?.writeText(JSON.stringify(flow, null, 2)); notify("JSON 已复制"); }}> JSON</button></footer></aside></div>;
}