This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/approval/page.tsx
2026-08-07 14:24:04 +08:00

301 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>;
}