feat: add visual process designer

This commit is contained in:
selfrelease
2026-07-18 22:56:29 +08:00
parent 89c44a61a7
commit 9c366cc872
10 changed files with 210 additions and 4 deletions
+1
View File
@@ -4,6 +4,7 @@ React + TypeScript 管理端,当前提供两个配置工作台:
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
+3 -2
View File
@@ -3,6 +3,7 @@ import { api } from './api';
import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
import { ProcessBindings } from './ProcessBindings';
import { ProcessDesigner } from './ProcessDesigner';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
@@ -29,8 +30,8 @@ function Login() {
}
function Workspace({ onLogout }: { onLogout: () => void }) {
const [view, setView] = useState<'forms' | 'bindings'>('forms');
return <div className="workspace"><header><div><strong>AIOA</strong><span></span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}></button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}></button></nav><button className="ghost" onClick={onLogout}>退</button></header>{view === 'forms' ? <Designer /> : <ProcessBindings />}</div>;
const [view, setView] = useState<'forms' | 'processes' | 'bindings'>('forms');
return <div className="workspace"><header><div><strong>AIOA</strong><span></span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}></button><button className={view === 'processes' ? 'navActive' : 'navButton'} onClick={() => setView('processes')}></button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}></button></nav><button className="ghost" onClick={onLogout}>退</button></header>{view === 'forms' ? <Designer /> : view === 'processes' ? <ProcessDesigner /> : <ProcessBindings />}</div>;
}
function Designer() {
+73
View File
@@ -0,0 +1,73 @@
import { useMemo, useState } from 'react';
import { api } from './api';
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
type Step = { id: string; name: string; assigneeVariable: Assignee };
const assignees: { value: Assignee; label: string }[] = [
{ value: 'approverId', label: '部门主管' }, { value: 'oaAdministratorId', label: 'OA 管理员' }, { value: 'hrReviewerId', label: 'HR 复核人' },
];
const modes: { value: Mode; label: string; hint: string }[] = [
{ value: 'SERIAL', label: '串行审批', hint: '依次审批,任一驳回即结束' },
{ value: 'PARALLEL', label: '并行会签', hint: '同时审批,全部通过才结束' },
{ value: 'CONDITIONAL', label: '条件审批', hint: '达到时长阈值后增加复核' },
];
export function ProcessDesigner() {
const [key, setKey] = useState('leaveApprovalCustom');
const [name, setName] = useState('自定义请假审批');
const [mode, setMode] = useState<Mode>('SERIAL');
const [thresholdDays, setThresholdDays] = useState(3);
const [steps, setSteps] = useState<Step[]>([
{ id: crypto.randomUUID(), name: '部门主管审批', assigneeVariable: 'approverId' },
{ id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' },
]);
const [selected, setSelected] = useState(steps[0].id);
const [message, setMessage] = useState('');
const selectedStep = steps.find(step => step.id === selected);
const effectiveSteps = mode === 'CONDITIONAL' ? steps.slice(0, 2) : steps;
const summary = useMemo(() => modes.find(item => item.value === mode)?.hint, [mode]);
function changeMode(next: Mode) {
setMode(next);
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
}
function update(patch: Partial<Step>) { setSteps(steps.map(step => step.id === selected ? { ...step, ...patch } : step)); }
function addStep() {
const step: Step = { id: crypto.randomUUID(), name: `审批节点 ${steps.length + 1}`, assigneeVariable: 'approverId' };
setSteps([...steps, step]); setSelected(step.id);
}
function move(id: string, delta: number) {
const from = steps.findIndex(step => step.id === id), to = from + delta;
if (to < 0 || to >= steps.length) return;
const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next);
}
async function deploy() {
try {
const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({
key, name, mode, steps: effectiveSteps.map(({ name: stepName, assigneeVariable }) => ({ name: stepName, assigneeVariable })),
conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480),
}) });
setMessage(`${result.key} v${result.version} 已部署到 Flowable`);
} catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); }
}
return <section className="processDesignerPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><button onClick={deploy}></button></div>
<section className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></section>
<div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div>
<div className="processWorkspace">
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{assignees.find(item => item.value === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}` : undefined} />}</div>)}</div>
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
</div>
<aside className="processProperties"><h2></h2>{mode === 'CONDITIONAL' && <label><input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2></h2>{selectedStep ? <><label><input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label><select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{assignees.map(item => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label><div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}></button><button onClick={() => move(selectedStep.id, 1)}></button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}></button></div></> : <p></p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}> </button>}<div className="securityNote"><strong></strong><p></p></div></aside>
</div>
</section>;
}
function ProcessNode({ kind, title, subtitle }: { kind: string; title: string; subtitle: string }) { return <div className={`templateNode ${kind}`}><strong>{title}</strong><small>{subtitle}</small></div>; }
function Arrow({ label }: { label?: string }) { return <div className="graphArrow">{label && <span>{label}</span>}</div>; }
File diff suppressed because one or more lines are too long