From 89c44a61a7f3eb772a74cdebce86982494c148ca Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 22:45:06 +0800 Subject: [PATCH] feat: add process binding designer --- admin-web/README.md | 7 +- admin-web/src/App.tsx | 12 ++- admin-web/src/ProcessBindings.tsx | 90 +++++++++++++++++++ admin-web/src/styles.css | 2 +- .../ProcessConfigurationController.kt | 7 +- .../ProcessConfigurationValidationTest.kt | 19 ++++ docs/engineering/roadmap.md | 1 + 7 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 admin-web/src/ProcessBindings.tsx create mode 100644 backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationValidationTest.kt 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
-
AIOA表单设计器
+
可视化表单设计器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