diff --git a/admin-web/README.md b/admin-web/README.md
index 6a2f889..8c77c76 100644
--- a/admin-web/README.md
+++ b/admin-web/README.md
@@ -1,6 +1,11 @@
# AIOA 管理设计中心
-React + TypeScript 管理端,当前提供可视化表单设计器 MVP:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
+React + TypeScript 管理端,当前提供两个配置工作台:
+
+- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
+- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
+
+流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
## 本地运行
diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx
index 4c83afc..711a94f 100644
--- a/admin-web/src/App.tsx
+++ b/admin-web/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
+import { ProcessBindings } from './ProcessBindings';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
@@ -20,14 +21,19 @@ export function App() {
}, []);
if (loading) return
正在恢复管理会话…
;
if (!authenticated) return ;
- return userManager.signoutRedirect()} />;
+ return userManager.signoutRedirect()} />;
}
function Login() {
return A
AIOA 管理设计中心
设计表单、发布版本并绑定 Flowable 流程
;
}
-function Designer({ onLogout }: { onLogout: () => void }) {
+function Workspace({ onLogout }: { onLogout: () => void }) {
+ const [view, setView] = useState<'forms' | 'bindings'>('forms');
+ return AIOA管理设计中心
{view === 'forms' ?
:
}
;
+}
+
+function Designer() {
const [formKey, setFormKey] = useState('leave-request');
const [title, setTitle] = useState('请假申请');
const [fields, setFields] = useState([
@@ -62,7 +68,7 @@ function Designer({ onLogout }: { onLogout: () => void }) {
}
return
-
+
可视化表单设计器Schema 驱动移动端卡片
{message &&
setMessage('')}>{message}
}
diff --git a/admin-web/src/ProcessBindings.tsx b/admin-web/src/ProcessBindings.tsx
new file mode 100644
index 0000000..8602a16
--- /dev/null
+++ b/admin-web/src/ProcessBindings.tsx
@@ -0,0 +1,90 @@
+import { useEffect, useMemo, useState } from 'react';
+import { api } from './api';
+
+type FormVersion = { form_key: string; version: number; status: string };
+type ProcessDefinition = { id: string; key: string; name?: string; version: number; suspended: boolean };
+type Binding = {
+ id: string; business_type: string; form_key: string; form_version: number;
+ process_definition_key: string; leave_type?: string; min_duration_minutes?: number;
+ max_duration_minutes?: number; priority: number; status: 'ACTIVE' | 'INACTIVE';
+};
+
+const leaveTypes = [{ value: '', label: '全部请假类型' }, { value: 'PERSONAL', label: '事假' }, { value: 'SICK', label: '病假' }, { value: 'ANNUAL', label: '年假' }];
+const minutesToDays = (value?: number) => value == null ? '不限' : `${Number((value / 480).toFixed(1))} 天`;
+
+export function ProcessBindings() {
+ const [forms, setForms] = useState([]);
+ const [definitions, setDefinitions] = useState([]);
+ const [bindings, setBindings] = useState([]);
+ const [message, setMessage] = useState('');
+ const [formKey, setFormKey] = useState('leave-request');
+ const published = forms.filter(form => form.status === 'PUBLISHED');
+ const selectedVersions = published.filter(form => form.form_key === formKey);
+ const [formVersion, setFormVersion] = useState(1);
+ const [processKey, setProcessKey] = useState('leaveApproval');
+ const [leaveType, setLeaveType] = useState('');
+ const [minDays, setMinDays] = useState('');
+ const [maxDays, setMaxDays] = useState('');
+ const [priority, setPriority] = useState(100);
+
+ async function load() {
+ try {
+ const [formResult, definitionResult, bindingResult] = await Promise.all([
+ api('/admin/process-configuration/forms'),
+ api('/admin/workflows/definitions'),
+ api('/admin/process-configuration/bindings'),
+ ]);
+ setForms(formResult); setDefinitions(definitionResult); setBindings(bindingResult);
+ const firstPublished = formResult.find(form => form.status === 'PUBLISHED');
+ if (firstPublished) { setFormKey(firstPublished.form_key); setFormVersion(firstPublished.version); }
+ const firstDefinition = definitionResult.find(definition => !definition.suspended);
+ if (firstDefinition) setProcessKey(firstDefinition.key);
+ } catch (error) { setMessage(error instanceof Error ? error.message : '配置加载失败'); }
+ }
+ useEffect(() => { void load(); }, []);
+ useEffect(() => { if (selectedVersions.length && !selectedVersions.some(form => form.version === formVersion)) setFormVersion(selectedVersions[0].version); }, [formKey, forms]);
+
+ const ruleSummary = useMemo(() => {
+ const type = leaveTypes.find(item => item.value === leaveType)?.label ?? leaveType;
+ return `${type} · ${minDays || '0'}–${maxDays || '∞'} 天 · 优先级 ${priority}`;
+ }, [leaveType, minDays, maxDays, priority]);
+
+ async function createBinding() {
+ try {
+ await api('/admin/process-configuration/bindings', { method: 'POST', body: JSON.stringify({
+ businessType: 'LEAVE_REQUEST', formKey, formVersion, processDefinitionKey: processKey,
+ leaveType: leaveType || null,
+ minDurationMinutes: minDays === '' ? null : Math.round(Number(minDays) * 480),
+ maxDurationMinutes: maxDays === '' ? null : Math.round(Number(maxDays) * 480), priority,
+ }) });
+ setMessage('流程绑定已启用'); await load();
+ } catch (error) { setMessage(error instanceof Error ? error.message : '绑定创建失败'); }
+ }
+
+ async function toggle(binding: Binding) {
+ try {
+ const status = binding.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
+ await api(`/admin/process-configuration/bindings/${binding.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
+ setMessage(status === 'ACTIVE' ? '绑定已启用' : '绑定已停用'); await load();
+ } catch (error) { setMessage(error instanceof Error ? error.message : '状态修改失败'); }
+ }
+
+ return
+ {message && setMessage('')}>{message}
}
+ 流程选择与绑定
客户端只提交业务数据,服务端按类型、时长和优先级选择 Flowable 流程。
服务端权威路由
+
+
新建路由规则
+
+
+
+
+
+
+
匹配规则{ruleSummary}
+
+
+
运行时选择
业务提交LEAVE_REQUEST
→规则匹配类型 · 时长 · 优先级
→启动流程{processKey || '请选择流程'}
✓ 仅引用已发布表单
✓ 优先级高的精确规则先匹配
✓ 无匹配规则时拒绝启动流程
✓ 已运行实例不会随绑定变更
+
+ 现有绑定
{bindings.length === 0 ?
暂无绑定
: bindings.map(binding =>
{binding.form_key} · v{binding.form_version}{binding.business_type} → {binding.process_definition_key}
{binding.leave_type || '全部类型'}{minutesToDays(binding.min_duration_minutes)} – {minutesToDays(binding.max_duration_minutes)}P{binding.priority}
)}
+ ;
+}
diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css
index d7c7e1f..a76f203 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)}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}.headerActions{display:flex;gap:8px}.ghost{background:#eef1f7;color:#334}.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 - 134px)}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}@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}}
+: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)}}
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 a537c4f..1b73eae 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
@@ -36,7 +36,11 @@ class ProcessConfigurationController(
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
}
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map {
- val a=actor(jwt);if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
+ val a=actor(jwt)
+ if(!isValidDurationRange(body.minDurationMinutes,body.maxDurationMinutes)) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_BINDING_RANGE_INVALID","时长范围无效")
+ val publishedFormExists=dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM form.definition WHERE tenant_id=? AND form_key=? AND version=? AND status='PUBLISHED') found",a.tenantId,body.formKey,body.formVersion)?.get("found",Boolean::class.java)==true
+ if(!publishedFormExists) throw ApiException(HttpStatus.BAD_REQUEST,"PUBLISHED_FORM_NOT_FOUND","已发布表单版本不存在")
+ if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
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")
}
@@ -46,3 +50,4 @@ class ProcessConfigurationController(
data class FormVersionCommand(val formKey:String,val dataSchema:Map,val uiSchema:Map)
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
data class BindingStatusCommand(val status:String)
+internal fun isValidDurationRange(min:Long?,max:Long?):Boolean = min?.let { it>=0 } != false && max?.let { it>=0 } != false && (min==null || max==null || min<=max)
diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationValidationTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationValidationTest.kt
new file mode 100644
index 0000000..6aa2a0b
--- /dev/null
+++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationValidationTest.kt
@@ -0,0 +1,19 @@
+package com.all8ai.aioa.admin.configuration
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ProcessConfigurationValidationTest {
+ @Test fun `accepts open and ordered duration ranges`() {
+ assertTrue(isValidDurationRange(null,null))
+ assertTrue(isValidDurationRange(0,480))
+ assertTrue(isValidDurationRange(1440,null))
+ }
+
+ @Test fun `rejects negative or reversed duration ranges`() {
+ assertFalse(isValidDurationRange(-1,480))
+ assertFalse(isValidDurationRange(960,480))
+ assertFalse(isValidDurationRange(0,-1))
+ }
+}
diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md
index d7035f9..4c739fe 100644
--- a/docs/engineering/roadmap.md
+++ b/docs/engineering/roadmap.md
@@ -58,6 +58,7 @@
- [x] 表单版本发布、业务流程绑定与服务端路由选择
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
+- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
## Definition of Done