diff --git a/admin-web/src/ProcessDesigner.tsx b/admin-web/src/ProcessDesigner.tsx index a06deda..d364816 100644 --- a/admin-web/src/ProcessDesigner.tsx +++ b/admin-web/src/ProcessDesigner.tsx @@ -1,11 +1,14 @@ -import { useMemo, useState } from 'react'; +import { useEffect, 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 复核人' }, +type ApprovalRule = { variable: Assignee; label: string; sourceType: string; selector: string; scope: string; emptyPolicy: string; description: string }; +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 管理员。' }, + { variable: 'hrReviewerId', label: '租户 HR 复核人', sourceType: 'ROLE', selector: 'hr_reviewer', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 HR 复核人。' }, ]; const modes: { value: Mode; label: string; hint: string }[] = [ { value: 'SERIAL', label: '串行审批', hint: '依次审批,任一驳回即结束' }, @@ -24,9 +27,13 @@ export function ProcessDesigner() { ]); const [selected, setSelected] = useState(steps[0].id); const [message, setMessage] = useState(''); + const [rules, setRules] = useState(fallbackRules); + useEffect(() => { api('/admin/process-configuration/approver-rules').then(setRules).catch(error => setMessage(error instanceof Error ? error.message : '审批规则加载失败')); }, []); 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]); + const duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length; + const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable); function changeMode(next: Mode) { setMode(next); @@ -54,17 +61,17 @@ export function ProcessDesigner() { return
{message &&
setMessage('')}>{message}
} -

可视化流程设计器

使用安全模板设计串行、并行和条件审批,并直接发布到 Flowable。

+

可视化流程设计器

使用安全模板设计串行、并行和条件审批,并直接发布到 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} />}
)}
+
{effectiveSteps.map((step, index) =>
{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && ${thresholdDays}天` : undefined} />}
)}
{mode === 'PARALLEL' && <>}
- +
; } diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index a02fcfb..19750e7 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}.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}} +: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 320px;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}.ruleDetail{background:#f3f6fb;border-radius:10px;padding:10px;margin:-5px 0 14px;font-size:11px}.ruleDetail>div{display:flex;justify-content:space-between;margin-bottom:6px}.ruleDetail span{color:#7d8596}.ruleDetail p{line-height:1.5;color:#526078}.ruleDetail small{color:#9a6429}.validationError{background:#fff0f0;color:#9d3939;border-radius:9px;padding:10px;font-size:11px;margin-top:12px}.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/ApprovalRuleCatalog.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ApprovalRuleCatalog.kt new file mode 100644 index 0000000..7acf0d2 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ApprovalRuleCatalog.kt @@ -0,0 +1,21 @@ +package com.all8ai.aioa.admin.configuration + +data class ApprovalRuleOption( + val variable:String,val label:String,val sourceType:String,val selector:String, + val scope:String,val emptyPolicy:String,val description:String, +) + +fun approvalRuleCatalog()=listOf( + ApprovalRuleOption( + "approverId","发起人所在部门主管","POSITION","manager","APPLICANT_DEPARTMENT","REJECT_SUBMISSION", + "根据发起人的有效主任职定位部门,再选择该部门有效的 manager 岗位人员。", + ), + ApprovalRuleOption( + "oaAdministratorId","租户 OA 管理员","ROLE","oa_admin","TENANT","REJECT_SUBMISSION", + "在当前租户内选择拥有有效 oa_admin 角色且账号状态正常的人员。", + ), + ApprovalRuleOption( + "hrReviewerId","租户 HR 复核人","ROLE","hr_reviewer","TENANT","REJECT_SUBMISSION", + "在当前租户内选择拥有有效 hr_reviewer 角色且账号状态正常的人员。", + ), +) 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 9b56440..94307ee 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 @@ -25,6 +25,7 @@ class ProcessConfigurationController( private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) } @GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List> { 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> { 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 { actor(jwt);return approvalRuleCatalog() } @PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map { 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)) 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 index 084a0bd..40b01ea 100644 --- 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 @@ -19,6 +19,7 @@ fun generateProcessTemplate(command:ProcessTemplateCommand):String { 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.mode in setOf("PARALLEL","CONDITIONAL") && command.steps.map{it.assigneeVariable}.distinct().size!=command.steps.size) invalid("并行或条件复核节点必须使用不同的审批人规则") if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效") val body=when(command.mode){ "SERIAL"->serial(command.steps) diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ApprovalRuleCatalogTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ApprovalRuleCatalogTest.kt new file mode 100644 index 0000000..4727e70 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/admin/configuration/ApprovalRuleCatalogTest.kt @@ -0,0 +1,15 @@ +package com.all8ai.aioa.admin.configuration + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ApprovalRuleCatalogTest { + @Test fun `catalog exposes only supported runtime variables and safe empty policy`() { + val catalog=approvalRuleCatalog() + assertEquals(setOf("approverId","oaAdministratorId","hrReviewerId"),catalog.map{it.variable}.toSet()) + assertTrue(catalog.all{it.emptyPolicy=="REJECT_SUBMISSION"}) + assertTrue(catalog.any{it.sourceType=="POSITION"&&it.scope=="APPLICANT_DEPARTMENT"}) + assertTrue(catalog.any{it.sourceType=="ROLE"&&it.scope=="TENANT"}) + } +} 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 index 8a2299d..8d4d65e 100644 --- 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 @@ -25,5 +25,6 @@ class ProcessTemplateGeneratorTest { @Test fun `rejects unsafe identifiers and assignee expressions`() { assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) } assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) } + assertFails { generateProcessTemplate(ProcessTemplateCommand("duplicateParallel","测试","PARALLEL",listOf(manager,manager))) } } } diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 89c010a..372704b 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -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/approver-rules: + get: { operationId: listApproverRules, summary: 查询流程设计器可用的组织审批人规则, responses: { "200": { description: 审批人规则目录 } } } /admin/organization/departments: get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } } post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } } diff --git a/docs/engineering/workflow-capabilities.md b/docs/engineering/workflow-capabilities.md index a370aee..faef109 100644 --- a/docs/engineering/workflow-capabilities.md +++ b/docs/engineering/workflow-capabilities.md @@ -61,4 +61,6 @@ Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowab 流程 Key、节点数量、审批人变量、条件变量和阈值均由后端校验。管理端不能上传任意 BPMN XML、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。 +审批规则目录由后端提供:部门主管通过发起人的有效主任职与部门内 `manager` 岗位解析;OA、HR 分别通过当前租户的 `oa_admin`、`hr_reviewer` 有效角色解析。所有规则只选择有效任职和正常账号;无人匹配时拒绝提交。并行会签和条件复核强制使用不同规则,避免同一人承担多个职责。 + 自动化执行测试使用独立内存 Flowable 引擎验证:短条件路径直接结束、长条件路径创建复核任务、并行会签等待全部审批、任一并行节点驳回时终止其余任务。