feat: add process binding designer

This commit is contained in:
selfrelease
2026-07-18 22:45:06 +08:00
parent 342a1fcb33
commit 89c44a61a7
7 changed files with 132 additions and 6 deletions
+6 -1
View File
@@ -1,6 +1,11 @@
# AIOA 管理设计中心
React + TypeScript 管理端,当前提供可视化表单设计器 MVP:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
React + TypeScript 管理端,当前提供两个配置工作台:
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
## 本地运行
+9 -3
View File
@@ -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 <div className="center"></div>;
if (!authenticated) return <Login />;
return <Designer onLogout={() => userManager.signoutRedirect()} />;
return <Workspace onLogout={() => userManager.signoutRedirect()} />;
}
function Login() {
return <main className="login"><div className="brandMark">A</div><h1>AIOA </h1><p> Flowable </p><button onClick={() => userManager.signinRedirect()}>使 Keycloak </button></main>;
}
function Designer({ onLogout }: { onLogout: () => void }) {
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>;
}
function Designer() {
const [formKey, setFormKey] = useState('leave-request');
const [title, setTitle] = useState('请假申请');
const [fields, setFields] = useState<Field[]>([
@@ -62,7 +68,7 @@ function Designer({ onLogout }: { onLogout: () => void }) {
}
return <div className="appShell">
<header><div><strong>AIOA</strong><span></span></div><div className="headerActions"><button className="ghost" onClick={onLogout}>退</button><button onClick={save}>稿</button></div></header>
<div className="subHeader"><div><strong></strong><span>Schema </span></div><button onClick={save}>稿</button></div>
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<section className="meta"><label> Key<input value={formKey} onChange={e => setFormKey(e.target.value)} /></label><label><input value={title} onChange={e => setTitle(e.target.value)} /></label></section>
<main className="designer">
+90
View File
@@ -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<FormVersion[]>([]);
const [definitions, setDefinitions] = useState<ProcessDefinition[]>([]);
const [bindings, setBindings] = useState<Binding[]>([]);
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<FormVersion[]>('/admin/process-configuration/forms'),
api<ProcessDefinition[]>('/admin/workflows/definitions'),
api<Binding[]>('/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 <section className="bindingPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="bindingIntro"><div><h1></h1><p> Flowable </p></div><span className="safeBadge"></span></div>
<div className="bindingGrid">
<div className="configCard"><h2></h2>
<label><input value="请假申请" disabled /></label>
<div className="twoColumns"><label><select value={formKey} onChange={e => setFormKey(e.target.value)}>{[...new Set(published.map(form => form.form_key))].map(key => <option key={key}>{key}</option>)}</select></label><label><select value={formVersion} onChange={e => setFormVersion(Number(e.target.value))}>{selectedVersions.map(form => <option key={form.version} value={form.version}>v{form.version} · </option>)}</select></label></div>
<label>Flowable <select value={processKey} onChange={e => setProcessKey(e.target.value)}>{definitions.filter(definition => !definition.suspended).map(definition => <option key={definition.id} value={definition.key}>{definition.name || definition.key} · v{definition.version}</option>)}</select></label>
<label><select value={leaveType} onChange={e => setLeaveType(e.target.value)}>{leaveTypes.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
<div className="twoColumns"><label><input type="number" min="0" step="0.5" value={minDays} onChange={e => setMinDays(e.target.value)} placeholder="不限" /></label><label><input type="number" min="0" step="0.5" value={maxDays} onChange={e => setMaxDays(e.target.value)} placeholder="不限" /></label></div>
<label><input type="number" value={priority} onChange={e => setPriority(Number(e.target.value))} /></label>
<div className="rulePreview"><strong></strong><span>{ruleSummary}</span></div>
<button className="primaryWide" onClick={createBinding} disabled={!formKey || !processKey}></button>
</div>
<div className="flowCard"><h2></h2><div className="flowPreview"><div className="flowNode source"><span>LEAVE_REQUEST</span></div><i></i><div className="flowNode decision"><span> · · </span></div><i></i><div className="flowNode target"><span>{processKey || '请选择流程'}</span></div></div><div className="guardrails"><p> </p><p> </p><p> </p><p> </p></div></div>
</div>
<div className="bindingList"><h2></h2>{bindings.length === 0 ? <p className="empty"></p> : bindings.map(binding => <article key={binding.id} className={binding.status === 'ACTIVE' ? '' : 'inactive'}><div><strong>{binding.form_key} · v{binding.form_version}</strong><span>{binding.business_type} {binding.process_definition_key}</span></div><div className="conditions"><span>{binding.leave_type || '全部类型'}</span><span>{minutesToDays(binding.min_duration_minutes)} {minutesToDays(binding.max_duration_minutes)}</span><span>P{binding.priority}</span></div><button className={binding.status === 'ACTIVE' ? 'dangerGhost' : ''} onClick={() => toggle(binding)}>{binding.status === 'ACTIVE' ? '停用' : '启用'}</button></article>)}</div>
</section>;
}
File diff suppressed because one or more lines are too long
@@ -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<String,Any?> {
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<String,Any>,val uiSchema:Map<String,Any>)
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)
@@ -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))
}
}
+1
View File
@@ -58,6 +58,7 @@
- [x] 表单版本发布、业务流程绑定与服务端路由选择
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
## Definition of Done