diff --git a/admin-web/README.md b/admin-web/README.md
index 8c77c76..c7b13e1 100644
--- a/admin-web/README.md
+++ b/admin-web/README.md
@@ -4,6 +4,7 @@ React + TypeScript 管理端,当前提供两个配置工作台:
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
+- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx
index 711a94f..7575f22 100644
--- a/admin-web/src/App.tsx
+++ b/admin-web/src/App.tsx
@@ -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
AIOA管理设计中心
{view === 'forms' ?
:
}
;
+ const [view, setView] = useState<'forms' | 'processes' | 'bindings'>('forms');
+ return AIOA管理设计中心
{view === 'forms' ?
: view === 'processes' ?
:
}
;
}
function Designer() {
diff --git a/admin-web/src/ProcessDesigner.tsx b/admin-web/src/ProcessDesigner.tsx
new file mode 100644
index 0000000..a06deda
--- /dev/null
+++ b/admin-web/src/ProcessDesigner.tsx
@@ -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('SERIAL');
+ const [thresholdDays, setThresholdDays] = useState(3);
+ const [steps, setSteps] = useState([
+ { 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) { 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
+ {message && setMessage('')}>{message}
}
+ 可视化流程设计器
使用安全模板设计串行、并行和条件审批,并直接发布到 Flowable。
+
+ {modes.map(item => )}
+
+
{modes.find(item => item.value === mode)?.label}{summary}
+
+ {mode === 'PARALLEL' && <>
>}
+
{effectiveSteps.map((step, index) =>
{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 &&
${thresholdDays}天` : undefined} />})}
+ {mode === 'PARALLEL' && <>
>}
+
+
+
+ ;
+}
+
+function ProcessNode({ kind, title, subtitle }: { kind: string; title: string; subtitle: string }) { return {title}{subtitle}
; }
+function Arrow({ label }: { label?: string }) { return {label && {label}}→
; }
diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css
index a76f203..a02fcfb 100644
--- a/admin-web/src/styles.css
+++ b/admin-web/src/styles.css
@@ -1 +1 @@
-:root{font-family:Inter,"PingFang SC",system-ui,sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{margin:0}button{border:0;border-radius:10px;background:#425aa5;color:white;padding:10px 14px;font-weight:650;cursor:pointer}button:hover{filter:brightness(.95)}button:disabled{opacity:.5;cursor:not-allowed}input,textarea,select{width:100%;border:1px solid #d7dce8;border-radius:9px;padding:10px;background:white;font:inherit}.center,.login{min-height:100vh;display:grid;place-content:center;text-align:center;gap:12px}.login{max-width:460px;margin:auto}.brandMark{width:72px;height:72px;border-radius:22px;display:grid;place-content:center;margin:auto;background:#dce3ff;color:#304887;font-size:36px;font-weight:900}header{height:68px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}header strong{font-size:24px;margin-right:16px}header span{color:#6e7688}header nav{display:flex;gap:6px;background:#f1f3f8;padding:4px;border-radius:12px}.navButton,.navActive{background:transparent;color:#687087}.navActive{background:#fff;color:#304887;box-shadow:0 2px 8px #20263615}.ghost{background:#eef1f7;color:#334}.subHeader{height:66px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}.subHeader strong{display:block}.subHeader span{display:block;color:#80889a;font-size:12px;margin-top:4px}.meta{display:flex;gap:18px;padding:16px 24px;background:#fff}.meta label{display:grid;grid-template-columns:auto 230px;gap:8px;align-items:center}.designer{display:grid;grid-template-columns:220px minmax(380px,1fr) 300px;min-height:calc(100vh - 200px)}aside{padding:18px;background:#fff;border-right:1px solid #e3e6ee}aside h2{font-size:15px;margin:10px 0}.palette{display:block;width:100%;text-align:left;margin-bottom:8px;background:#eef2ff;color:#304887}.canvas{padding:28px;overflow:auto}.phone{width:390px;min-height:690px;margin:auto;background:#f9fafc;border:10px solid #202636;border-radius:40px;padding:26px 18px;box-shadow:0 20px 50px #29304a26}.phoneHeader{text-align:center;font-size:20px;font-weight:800;margin-bottom:22px}.field{position:relative;background:white;border:2px solid transparent;border-radius:14px;padding:14px;margin-bottom:12px;box-shadow:0 3px 12px #26304a12}.field.selected{border-color:#5c70bb}.field label{display:block;font-size:13px;font-weight:700;margin-bottom:8px}.fieldActions{position:absolute;right:7px;top:6px;display:none;gap:3px}.field:hover .fieldActions,.field.selected .fieldActions{display:flex}.fieldActions button{padding:3px 7px;border-radius:6px}.field small{position:absolute;left:-9px;top:-9px;background:#425aa5;color:white;border-radius:50%;width:20px;height:20px;text-align:center}.properties{border:0;border-left:1px solid #e3e6ee}.properties label{display:block;margin-bottom:14px;font-size:13px;font-weight:650}.properties .check{display:flex;gap:8px;align-items:center}.properties .check input{width:auto}.properties textarea{min-height:110px}.properties pre{font-size:10px;white-space:pre-wrap;background:#151b2b;color:#dce6ff;padding:10px;border-radius:8px;max-height:260px;overflow:auto}.version{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee;font-size:12px}.version button{padding:5px 8px}.toast{position:fixed;z-index:10;top:78px;left:50%;transform:translateX(-50%);background:#172033;color:white;padding:10px 18px;border-radius:10px}.bindingPage{padding:30px;max-width:1280px;margin:auto}.bindingIntro{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.bindingIntro h1{margin:0 0 8px}.bindingIntro p{margin:0;color:#737b8d}.safeBadge{background:#e7f8ee;color:#247348;padding:8px 12px;border-radius:20px;font-size:12px;font-weight:700}.bindingGrid{display:grid;grid-template-columns:440px 1fr;gap:20px}.configCard,.flowCard,.bindingList{background:#fff;border:1px solid #e5e8ef;border-radius:16px;padding:22px;box-shadow:0 8px 24px #28324b0a}.configCard h2,.flowCard h2,.bindingList h2{font-size:17px;margin:0 0 18px}.configCard label{display:block;font-size:12px;font-weight:700;margin-bottom:13px}.configCard label input,.configCard label select{margin-top:6px}.twoColumns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.rulePreview{background:#f2f5ff;border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:5px;margin:4px 0 14px;font-size:12px}.rulePreview span{color:#5d6680}.primaryWide{width:100%}.flowPreview{display:flex;align-items:center;justify-content:center;gap:12px;min-height:180px;background:#f8f9fc;border-radius:14px;padding:20px}.flowPreview i{font-style:normal;color:#8c94a7;font-size:24px}.flowNode{min-width:130px;text-align:center;padding:16px 12px;border:2px solid #dbe1ef;border-radius:14px;font-weight:750;background:#fff}.flowNode span{display:block;font-size:10px;font-weight:500;color:#778095;margin-top:6px}.flowNode.decision{border-color:#e8bc5e}.flowNode.target{border-color:#5871c3}.guardrails{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:16px;color:#4d5870;font-size:12px}.guardrails p{margin:0;background:#f6f8fb;padding:10px;border-radius:8px}.bindingList{margin-top:20px}.bindingList article{display:grid;grid-template-columns:1.3fr 1.5fr auto;gap:20px;align-items:center;padding:14px 0;border-top:1px solid #edf0f5}.bindingList article.inactive{opacity:.55}.bindingList article div:first-child span{display:block;color:#7a8293;font-size:12px;margin-top:5px}.conditions{display:flex;gap:8px;flex-wrap:wrap}.conditions span{background:#eef2fb;color:#415374;padding:5px 8px;border-radius:8px;font-size:11px}.dangerGhost{background:#fff0f0;color:#a23b3b}.empty{color:#8a91a0}@media(max-width:1000px){.designer{grid-template-columns:180px 1fr}.properties{grid-column:1/-1}.meta{flex-direction:column}.meta label{grid-template-columns:100px 1fr}.bindingGrid{grid-template-columns:1fr}.bindingPage{padding:18px}.flowPreview{flex-direction:column}.flowPreview i{transform:rotate(90deg)}}
+:root{font-family:Inter,"PingFang SC",system-ui,sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{margin:0}button{border:0;border-radius:10px;background:#425aa5;color:white;padding:10px 14px;font-weight:650;cursor:pointer}button:hover{filter:brightness(.95)}button:disabled{opacity:.5;cursor:not-allowed}input,textarea,select{width:100%;border:1px solid #d7dce8;border-radius:9px;padding:10px;background:white;font:inherit}.center,.login{min-height:100vh;display:grid;place-content:center;text-align:center;gap:12px}.login{max-width:460px;margin:auto}.brandMark{width:72px;height:72px;border-radius:22px;display:grid;place-content:center;margin:auto;background:#dce3ff;color:#304887;font-size:36px;font-weight:900}header{height:68px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}header strong{font-size:24px;margin-right:16px}header span{color:#6e7688}header nav{display:flex;gap:6px;background:#f1f3f8;padding:4px;border-radius:12px}.navButton,.navActive{background:transparent;color:#687087}.navActive{background:#fff;color:#304887;box-shadow:0 2px 8px #20263615}.ghost{background:#eef1f7;color:#334}.subHeader{height:66px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}.subHeader strong{display:block}.subHeader span{display:block;color:#80889a;font-size:12px;margin-top:4px}.meta{display:flex;gap:18px;padding:16px 24px;background:#fff}.meta label{display:grid;grid-template-columns:auto 230px;gap:8px;align-items:center}.designer{display:grid;grid-template-columns:220px minmax(380px,1fr) 300px;min-height:calc(100vh - 200px)}aside{padding:18px;background:#fff;border-right:1px solid #e3e6ee}aside h2{font-size:15px;margin:10px 0}.palette{display:block;width:100%;text-align:left;margin-bottom:8px;background:#eef2ff;color:#304887}.canvas{padding:28px;overflow:auto}.phone{width:390px;min-height:690px;margin:auto;background:#f9fafc;border:10px solid #202636;border-radius:40px;padding:26px 18px;box-shadow:0 20px 50px #29304a26}.phoneHeader{text-align:center;font-size:20px;font-weight:800;margin-bottom:22px}.field{position:relative;background:white;border:2px solid transparent;border-radius:14px;padding:14px;margin-bottom:12px;box-shadow:0 3px 12px #26304a12}.field.selected{border-color:#5c70bb}.field label{display:block;font-size:13px;font-weight:700;margin-bottom:8px}.fieldActions{position:absolute;right:7px;top:6px;display:none;gap:3px}.field:hover .fieldActions,.field.selected .fieldActions{display:flex}.fieldActions button{padding:3px 7px;border-radius:6px}.field small{position:absolute;left:-9px;top:-9px;background:#425aa5;color:white;border-radius:50%;width:20px;height:20px;text-align:center}.properties{border:0;border-left:1px solid #e3e6ee}.properties label{display:block;margin-bottom:14px;font-size:13px;font-weight:650}.properties .check{display:flex;gap:8px;align-items:center}.properties .check input{width:auto}.properties textarea{min-height:110px}.properties pre{font-size:10px;white-space:pre-wrap;background:#151b2b;color:#dce6ff;padding:10px;border-radius:8px;max-height:260px;overflow:auto}.version{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee;font-size:12px}.version button{padding:5px 8px}.toast{position:fixed;z-index:10;top:78px;left:50%;transform:translateX(-50%);background:#172033;color:white;padding:10px 18px;border-radius:10px}.bindingPage{padding:30px;max-width:1280px;margin:auto}.bindingIntro{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.bindingIntro h1{margin:0 0 8px}.bindingIntro p{margin:0;color:#737b8d}.safeBadge{background:#e7f8ee;color:#247348;padding:8px 12px;border-radius:20px;font-size:12px;font-weight:700}.bindingGrid{display:grid;grid-template-columns:440px 1fr;gap:20px}.configCard,.flowCard,.bindingList{background:#fff;border:1px solid #e5e8ef;border-radius:16px;padding:22px;box-shadow:0 8px 24px #28324b0a}.configCard h2,.flowCard h2,.bindingList h2{font-size:17px;margin:0 0 18px}.configCard label{display:block;font-size:12px;font-weight:700;margin-bottom:13px}.configCard label input,.configCard label select{margin-top:6px}.twoColumns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.rulePreview{background:#f2f5ff;border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:5px;margin:4px 0 14px;font-size:12px}.rulePreview span{color:#5d6680}.primaryWide{width:100%}.flowPreview{display:flex;align-items:center;justify-content:center;gap:12px;min-height:180px;background:#f8f9fc;border-radius:14px;padding:20px}.flowPreview i{font-style:normal;color:#8c94a7;font-size:24px}.flowNode{min-width:130px;text-align:center;padding:16px 12px;border:2px solid #dbe1ef;border-radius:14px;font-weight:750;background:#fff}.flowNode span{display:block;font-size:10px;font-weight:500;color:#778095;margin-top:6px}.flowNode.decision{border-color:#e8bc5e}.flowNode.target{border-color:#5871c3}.guardrails{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:16px;color:#4d5870;font-size:12px}.guardrails p{margin:0;background:#f6f8fb;padding:10px;border-radius:8px}.bindingList{margin-top:20px}.bindingList article{display:grid;grid-template-columns:1.3fr 1.5fr auto;gap:20px;align-items:center;padding:14px 0;border-top:1px solid #edf0f5}.bindingList article.inactive{opacity:.55}.bindingList article div:first-child span{display:block;color:#7a8293;font-size:12px;margin-top:5px}.conditions{display:flex;gap:8px;flex-wrap:wrap}.conditions span{background:#eef2fb;color:#415374;padding:5px 8px;border-radius:8px;font-size:11px}.dangerGhost{background:#fff0f0;color:#a23b3b}.empty{color:#8a91a0}.processDesignerPage{padding:28px;max-width:1380px;margin:auto}.processTop{display:flex;align-items:center;justify-content:space-between}.processTop h1{margin:0 0 7px}.processTop p{margin:0;color:#737b8d}.processMeta{display:flex;gap:16px;background:#fff;border:1px solid #e5e8ef;border-radius:14px;padding:16px;margin:20px 0}.processMeta label{display:grid;grid-template-columns:auto 240px;align-items:center;gap:10px;font-size:12px;font-weight:700}.modeSelector{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px}.modeButton,.modeActive{text-align:left;background:#fff;color:#374158;border:2px solid #e3e7ef}.modeActive{border-color:#5269b4;background:#f4f6ff}.modeSelector strong,.modeSelector span{display:block}.modeSelector span{font-size:11px;color:#7a8293;margin-top:5px}.processWorkspace{display:grid;grid-template-columns:1fr 300px;min-height:560px}.processCanvas{background:#eef1f7;padding:24px;border-radius:16px 0 0 16px;overflow:auto}.templateHint{display:flex;justify-content:space-between;color:#657087;font-size:12px}.processGraph{min-width:680px;min-height:430px;display:flex;align-items:center;justify-content:center;gap:12px}.templateNode,.approvalNode{background:#fff;border:2px solid #d7ddea;border-radius:14px;min-width:125px;padding:16px 12px;text-align:center;color:#263149}.templateNode small,.approvalNode small{display:block;color:#7e8799;font-size:10px;margin-top:6px}.templateNode.start{border-radius:30px;border-color:#55a77d}.templateNode.end{border-radius:30px;border-color:#ce6570}.templateNode.gateway{transform:rotate(45deg);min-width:100px;width:100px;height:100px;display:grid;place-content:center}.templateNode.gateway>*{transform:rotate(-45deg)}.graphArrow{position:relative;color:#7e8799;font-size:24px;text-align:center}.graphArrow span{position:absolute;white-space:nowrap;font-size:9px;top:-14px;left:50%;transform:translateX(-50%);color:#b06e24}.linearNodes{display:flex;align-items:center;gap:12px}.graphStep{display:flex;align-items:center;gap:12px}.approvalNode{position:relative}.approvalNode>span{position:absolute;left:-8px;top:-8px;background:#5269b4;color:#fff;border-radius:50%;width:20px;height:20px;display:grid;place-content:center;font-size:10px}.approvalNode.nodeSelected{border-color:#5269b4;box-shadow:0 0 0 4px #5269b419}.parallelBranches{display:flex;flex-direction:column;gap:14px}.processProperties{border:1px solid #e5e8ef;border-radius:0 16px 16px 0}.processProperties label{display:block;font-size:12px;font-weight:700;margin-bottom:14px}.processProperties label input,.processProperties label select{margin-top:6px}.nodeActions{display:flex;gap:6px;margin-bottom:18px}.nodeActions button{padding:7px 9px;font-size:11px}.securityNote{background:#fff8e8;border-radius:10px;padding:12px;margin-top:20px;font-size:11px;color:#695933}.securityNote p{margin:6px 0 0;line-height:1.6}@media(max-width:1000px){.designer{grid-template-columns:180px 1fr}.properties{grid-column:1/-1}.meta{flex-direction:column}.meta label{grid-template-columns:100px 1fr}.bindingGrid{grid-template-columns:1fr}.bindingPage{padding:18px}.flowPreview{flex-direction:column}.flowPreview i{transform:rotate(90deg)}.processWorkspace{grid-template-columns:1fr}.processProperties{border-radius:0 0 16px 16px}.modeSelector{grid-template-columns:1fr}.processMeta{flex-direction:column}}
diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt
index 1b73eae..9b56440 100644
--- a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt
+++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt
@@ -45,6 +45,14 @@ class ProcessConfigurationController(
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
}
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
+ @PostMapping("/processes/deploy") fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map {
+ val a=actor(jwt);val xml=generateProcessTemplate(body)
+ val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy()
+ val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult()
+ ?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败")
+ audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode))
+ return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id)
+ }
private fun validateSchema(data:Map,ui:Map){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
}
data class FormVersionCommand(val formKey:String,val dataSchema:Map,val uiSchema:Map)
diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessTemplateGenerator.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessTemplateGenerator.kt
new file mode 100644
index 0000000..084a0bd
--- /dev/null
+++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessTemplateGenerator.kt
@@ -0,0 +1,82 @@
+package com.all8ai.aioa.admin.configuration
+
+import com.all8ai.aioa.shared.web.ApiException
+import org.springframework.http.HttpStatus
+
+data class ApprovalStepCommand(val name:String,val assigneeVariable:String)
+data class ProcessTemplateCommand(
+ val key:String,val name:String,val mode:String,val steps:List,
+ val conditionVariable:String="durationMinutes",val conditionThreshold:Long=1440,
+)
+
+private val safeKey=Regex("[A-Za-z][A-Za-z0-9_-]{2,63}")
+private val allowedAssignees=setOf("approverId","oaAdministratorId","hrReviewerId")
+private val allowedConditions=setOf("durationMinutes")
+
+fun generateProcessTemplate(command:ProcessTemplateCommand):String {
+ if(!safeKey.matches(command.key)) invalid("流程 Key 必须以字母开头且只能包含字母、数字、下划线或连字符")
+ if(command.name.isBlank()||command.name.length>100) invalid("流程名称长度无效")
+ if(command.mode !in setOf("SERIAL","PARALLEL","CONDITIONAL")) invalid("流程模式无效")
+ if(command.mode=="CONDITIONAL" && command.steps.size!=2 || command.mode!="CONDITIONAL" && command.steps.size !in 1..6) invalid("审批节点数量无效")
+ if(command.steps.any{it.name.isBlank()||it.name.length>80||it.assigneeVariable !in allowedAssignees}) invalid("审批节点配置无效")
+ if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效")
+ val body=when(command.mode){
+ "SERIAL"->serial(command.steps)
+ "PARALLEL"->parallel(command.steps)
+ else->conditional(command.steps,command.conditionVariable,command.conditionThreshold)
+ }
+ return """
+
+
+$body
+
+"""
+}
+
+private fun serial(steps:List):String=buildString {
+ appendLine(" ")
+ appendLine(" ")
+ steps.forEachIndexed { index,step ->
+ appendDecisionTask(index,step,if(index==steps.lastIndex) "approvedEnd" else "task${index+1}")
+ }
+ appendEnds()
+}.trimEnd()
+
+private fun parallel(steps:List):String=buildString {
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+ steps.forEachIndexed { index,step ->
+ appendLine(" ")
+ appendDecisionTask(index,step,"parallelJoin")
+ }
+ appendLine(" ")
+ appendLine(" ")
+ appendEnds()
+}.trimEnd()
+
+private fun conditional(steps:List,variable:String,threshold:Long):String=buildString {
+ appendLine(" ")
+ appendLine(" ")
+ appendDecisionTask(0,steps[0],"routeDecision")
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" $threshold}]]>")
+ appendDecisionTask(1,steps[1],"approvedEnd")
+ appendEnds()
+}.trimEnd()
+
+private fun StringBuilder.appendDecisionTask(index:Int,step:ApprovalStepCommand,approvedTarget:String) {
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+}
+
+private fun StringBuilder.appendEnds(){
+ appendLine(" ")
+ appendLine(" ")
+}
+private fun xml(value:String)=value.replace("&","&").replace("<","<").replace(">",">").replace("\"",""")
+private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessTemplateGeneratorTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessTemplateGeneratorTest.kt
new file mode 100644
index 0000000..8a2299d
--- /dev/null
+++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessTemplateGeneratorTest.kt
@@ -0,0 +1,29 @@
+package com.all8ai.aioa.admin.configuration
+
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertFails
+
+class ProcessTemplateGeneratorTest {
+ private val manager=ApprovalStepCommand("主管审批","approverId")
+ private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
+
+ @Test fun `generates serial approval with rejection path`() {
+ val xml=generateProcessTemplate(ProcessTemplateCommand("serialDemo","串行审批","SERIAL",listOf(manager,oa)))
+ assertContains(xml,"sourceRef=\"decision0\" targetRef=\"task1\"")
+ assertContains(xml,"targetRef=\"rejectedEnd\"")
+ }
+
+ @Test fun `generates parallel gateways and condition expressions`() {
+ val parallel=generateProcessTemplate(ProcessTemplateCommand("parallelDemo","并行审批","PARALLEL",listOf(manager,oa)))
+ assertContains(parallel,"parallelGateway id=\"parallelSplit\"")
+ assertContains(parallel,"parallelGateway id=\"parallelJoin\"")
+ val conditional=generateProcessTemplate(ProcessTemplateCommand("conditionalDemo","条件审批","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
+ assertContains(conditional,"durationMinutes <= 960")
+ }
+
+ @Test fun `rejects unsafe identifiers and assignee expressions`() {
+ assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) }
+ assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) }
+ }
+}
diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml
index 9bb6269..89c010a 100644
--- a/contracts/openapi/aioa-v1.yaml
+++ b/contracts/openapi/aioa-v1.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: AIOA API
- version: 0.15.0
+ version: 0.16.0
servers:
- url: /api/v1
security:
@@ -17,6 +17,8 @@ paths:
post: { operationId: createProcessBinding, summary: 创建业务流程路由绑定, responses: { "200": { description: 已创建并启用绑定 } } }
/admin/process-configuration/bindings/{id}/status:
put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } }
+ /admin/process-configuration/processes/deploy:
+ post: { operationId: deployProcessTemplate, summary: 校验并部署受约束的可视化流程模板, responses: { "200": { description: Flowable 流程定义已部署 } } }
/admin/organization/departments:
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md
index 4c739fe..d9078fb 100644
--- a/docs/engineering/roadmap.md
+++ b/docs/engineering/roadmap.md
@@ -59,6 +59,7 @@
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
+- [x] Web 串行、并行、条件流程可视化设计与受约束 Flowable 部署
## Definition of Done
diff --git a/docs/engineering/workflow-capabilities.md b/docs/engineering/workflow-capabilities.md
index 716a468..e372707 100644
--- a/docs/engineering/workflow-capabilities.md
+++ b/docs/engineering/workflow-capabilities.md
@@ -51,3 +51,12 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
```
该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
+## 可视化流程模板
+
+Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowable BPMN 模板:
+
+- 串行审批:审批节点依次执行,任一节点驳回即终止。
+- 并行会签:多个审批节点同时执行,全部通过后汇聚,任一驳回即终止其他分支。
+- 条件审批:首个审批通过后按请假时长阈值决定直接结束或增加复核节点。
+
+流程 Key、节点数量、审批人变量、条件变量和阈值均由后端校验。管理端不能上传任意 BPMN XML、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。