301 lines
20 KiB
TypeScript
301 lines
20 KiB
TypeScript
"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>;
|
||
}
|