feat: update process designer, controller, and docs
This commit is contained in:
@@ -5,6 +5,7 @@ type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
|
||||
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
|
||||
type Step = { id: string; name: string; assigneeVariable: Assignee };
|
||||
type ApprovalRule = { variable: Assignee; label: string; sourceType: string; selector: string; scope: string; emptyPolicy: string; description: string };
|
||||
type ProcessHistory = { key: string; version: number; name: string; mode: Mode; status: string; createdAt: string; template: { key: string; name: string; mode: Mode; steps: { name: string; assigneeVariable: Assignee }[]; conditionThreshold: number } };
|
||||
const fallbackRules: ApprovalRule[] = [
|
||||
{ variable: 'approverId', label: '发起人所在部门主管', sourceType: 'POSITION', selector: 'manager', scope: 'APPLICANT_DEPARTMENT', emptyPolicy: 'REJECT_SUBMISSION', description: '根据发起人的主任职解析部门主管。' },
|
||||
{ variable: 'oaAdministratorId', label: '租户 OA 管理员', sourceType: 'ROLE', selector: 'oa_admin', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 OA 管理员。' },
|
||||
@@ -28,7 +29,17 @@ export function ProcessDesigner() {
|
||||
const [selected, setSelected] = useState(steps[0].id);
|
||||
const [message, setMessage] = useState('');
|
||||
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
|
||||
useEffect(() => { api<ApprovalRule[]>('/admin/process-configuration/approver-rules').then(setRules).catch(error => setMessage(error instanceof Error ? error.message : '审批规则加载失败')); }, []);
|
||||
const [history, setHistory] = useState<ProcessHistory[]>([]);
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
const [ruleResult, historyResult] = await Promise.all([
|
||||
api<ApprovalRule[]>('/admin/process-configuration/approver-rules'),
|
||||
api<ProcessHistory[]>('/admin/process-configuration/processes'),
|
||||
]);
|
||||
setRules(ruleResult); setHistory(historyResult);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : '流程配置加载失败'); }
|
||||
}
|
||||
useEffect(() => { void loadConfiguration(); }, []);
|
||||
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]);
|
||||
@@ -49,6 +60,12 @@ export function ProcessDesigner() {
|
||||
if (to < 0 || to >= steps.length) return;
|
||||
const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next);
|
||||
}
|
||||
function loadTemplate(item: ProcessHistory) {
|
||||
setKey(item.template.key); setName(item.template.name); setMode(item.template.mode);
|
||||
const restored = item.template.steps.map(step => ({ id: crypto.randomUUID(), name: step.name, assigneeVariable: step.assigneeVariable }));
|
||||
setSteps(restored); setSelected(restored[0]?.id ?? ''); setThresholdDays(item.template.conditionThreshold / 480);
|
||||
setMessage(`${item.key} v${item.version} 已加载,可修改后部署新版本`);
|
||||
}
|
||||
async function deploy() {
|
||||
try {
|
||||
const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({
|
||||
@@ -56,6 +73,7 @@ export function ProcessDesigner() {
|
||||
conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480),
|
||||
}) });
|
||||
setMessage(`${result.key} v${result.version} 已部署到 Flowable`);
|
||||
await loadConfiguration();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); }
|
||||
}
|
||||
|
||||
@@ -71,7 +89,7 @@ export function ProcessDesigner() {
|
||||
<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>{rules.find(item => item.variable === 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 })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span>来源</span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span>范围</span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small>无人匹配时:拒绝提交,不自动跳过</small></div>}<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>}{duplicateRestrictedRule && <div className="validationError">并行会签或条件复核必须选择不同审批人规则,以满足职责分离。</div>}<div className="securityNote"><strong>安全约束</strong><p>审批规则由后端提供并在流程启动时根据有效组织、岗位和角色解析;无人匹配或职责冲突时拒绝提交。</p></div></aside>
|
||||
<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 })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span>来源</span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span>范围</span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small>无人匹配时:拒绝提交,不自动跳过</small></div>}<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>}{duplicateRestrictedRule && <div className="validationError">并行会签或条件复核必须选择不同审批人规则,以满足职责分离。</div>}<div className="securityNote"><strong>安全约束</strong><p>审批规则由后端提供并在流程启动时根据有效组织、岗位和角色解析;无人匹配或职责冲突时拒绝提交。</p></div><div className="processHistory"><h2>已部署版本</h2>{history.length === 0 ? <p>暂无由设计器部署的版本</p> : history.map(item => <button key={`${item.key}:${item.version}`} onClick={() => loadTemplate(item)}><span><strong>{item.name}</strong><small>{item.key} · v{item.version} · {item.mode}</small></span><b>加载</b></button>)}</div></aside>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+13
-1
@@ -26,6 +26,17 @@ class ProcessConfigurationController(
|
||||
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
|
||||
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
|
||||
@GetMapping("/approver-rules") fun approverRules(@AuthenticationPrincipal jwt:Jwt):List<ApprovalRuleOption> { actor(jwt);return approvalRuleCatalog() }
|
||||
@GetMapping("/processes") fun processes(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> {
|
||||
val a=actor(jwt)
|
||||
return dsl.fetch("SELECT process_definition_key,version,process_definition_id,deployment_id,name,mode,status,template_spec::text template_spec,created_at FROM workflow.process_template WHERE tenant_id=? ORDER BY created_at DESC",a.tenantId).map { record ->
|
||||
mapOf(
|
||||
"key" to record.get("process_definition_key",String::class.java),"version" to record.get("version",Int::class.java),
|
||||
"processDefinitionId" to record.get("process_definition_id",String::class.java),"deploymentId" to record.get("deployment_id",String::class.java),
|
||||
"name" to record.get("name",String::class.java),"mode" to record.get("mode",String::class.java),"status" to record.get("status",String::class.java),
|
||||
"template" to mapper.readValue(record.get("template_spec",String::class.java),Map::class.java),"createdAt" to record.get("created_at"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
|
||||
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
|
||||
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
|
||||
@@ -46,11 +57,12 @@ 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<String,Any?> {
|
||||
@PostMapping("/processes/deploy") @Transactional fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map<String,Any?> {
|
||||
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","流程部署失败")
|
||||
dsl.execute("INSERT INTO workflow.process_template(tenant_id,process_definition_key,version,process_definition_id,deployment_id,name,mode,template_spec,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSONB),?)",a.tenantId,definition.key,definition.version,definition.id,deployment.id,body.name,body.mode,mapper.writeValueAsString(body),a.id)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE workflow.process_template (
|
||||
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||
process_definition_key VARCHAR(64) NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
process_definition_id VARCHAR(255) NOT NULL,
|
||||
deployment_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
mode VARCHAR(20) NOT NULL CHECK (mode IN ('SERIAL', 'PARALLEL', 'CONDITIONAL')),
|
||||
template_spec JSONB NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DEPLOYED' CHECK (status IN ('DEPLOYED', 'SUSPENDED')),
|
||||
created_by UUID NOT NULL REFERENCES identity.user_account(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (tenant_id, process_definition_key, version),
|
||||
UNIQUE (process_definition_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_process_template_tenant_created
|
||||
ON workflow.process_template (tenant_id, created_at DESC);
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOA API
|
||||
version: 0.16.0
|
||||
version: 0.17.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
@@ -19,6 +19,8 @@ paths:
|
||||
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/process-configuration/processes:
|
||||
get: { operationId: listProcessTemplates, summary: 查询可重新编辑的可视化流程部署历史, responses: { "200": { description: 流程模板版本列表 } } }
|
||||
/admin/process-configuration/approver-rules:
|
||||
get: { operationId: listApproverRules, summary: 查询流程设计器可用的组织审批人规则, responses: { "200": { description: 审批人规则目录 } } }
|
||||
/admin/organization/departments:
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
|
||||
- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
|
||||
- [x] Web 串行、并行、条件流程可视化设计与受约束 Flowable 部署
|
||||
- [x] 可视化流程设计源数据持久化、部署历史和版本重新加载
|
||||
|
||||
## Definition of Done
|
||||
|
||||
|
||||
@@ -64,3 +64,5 @@ Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowab
|
||||
审批规则目录由后端提供:部门主管通过发起人的有效主任职与部门内 `manager` 岗位解析;OA、HR 分别通过当前租户的 `oa_admin`、`hr_reviewer` 有效角色解析。所有规则只选择有效任职和正常账号;无人匹配时拒绝提交。并行会签和条件复核强制使用不同规则,避免同一人承担多个职责。
|
||||
|
||||
自动化执行测试使用独立内存 Flowable 引擎验证:短条件路径直接结束、长条件路径创建复核任务、并行会签等待全部审批、任一并行节点驳回时终止其余任务。
|
||||
|
||||
每次从设计器部署时,平台同时保存租户、流程 Key、Flowable 版本、部署标识和受约束模板 JSON。管理员可从部署历史重新加载任意版本,修改后以相同 Key 部署为新版本;运行中实例仍引用原有 `processDefinitionId`。
|
||||
|
||||
Reference in New Issue
Block a user