From 22be6d8b367e643a42daf34af016cdffc1184a10 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 30 Jul 2026 13:07:44 +0800 Subject: [PATCH] feat(oa): add initiator system field to condition branches Allow __initiator__ contains matching in publish validation and the flow designer candidate list, excluding it from form useInCondition locks. Co-authored-by: Cursor --- backend/app/services/oa_engine.py | 7 ++- backend/app/services/oa_publish.py | 21 +++++++-- backend/tests/test_oa_publish.py | 46 +++++++++++++++++++ ...026-07-30-oa-initiator-condition-design.md | 2 +- .../flow-tree/ConditionRuleEditor.vue | 13 +++--- .../oa-approval/oa-condition-fields.ts | 22 +++++++-- .../src/views/oa-approval/designer/index.vue | 15 +++--- 7 files changed, 103 insertions(+), 23 deletions(-) diff --git a/backend/app/services/oa_engine.py b/backend/app/services/oa_engine.py index fc854556..55406ef2 100644 --- a/backend/app/services/oa_engine.py +++ b/backend/app/services/oa_engine.py @@ -1431,7 +1431,12 @@ def _eval_condition_rules( if op == "contains": if field == INITIATOR_CONDITION_FIELD: uid = (initiator_user_id or "").strip() - allowed = {str(x or "").strip() for x in (val or [])} + if isinstance(val, list): + allowed = {str(x or "").strip() for x in val if str(x or "").strip()} + elif isinstance(val, str) and val.strip(): + allowed = {val.strip()} + else: + allowed = set() if not uid or uid not in allowed: return False continue diff --git a/backend/app/services/oa_publish.py b/backend/app/services/oa_publish.py index c6f62cf8..6cad05df 100644 --- a/backend/app/services/oa_publish.py +++ b/backend/app/services/oa_publish.py @@ -68,6 +68,9 @@ VALID_ASSIGNEE_TYPES = frozenset( VALID_NODE_TYPES = frozenset({"start", "approval", "cc", "condition", "end"}) +# 系统条件伪字段(非表单控件) +INITIATOR_CONDITION_FIELD = "__initiator__" + # 发布允许的控件 type(未知 type 拒绝) ALLOWED_CONTROL_TYPES = FILLABLE_TYPES | LAYOUT_TYPES @@ -295,7 +298,7 @@ def validate_flow_tree(flow_tree: Any) -> List[str]: def validate_condition_rules_against_form(form_schema: Any, flow_tree: Any) -> List[str]: indexed = index_condition_fields(form_schema) errors: List[str] = [] - seen: Set[str] = set() + seen_form: Set[str] = set() def collect_fields(node: Optional[Dict[str, Any]]) -> None: if not node or not isinstance(node, dict): @@ -308,9 +311,21 @@ def validate_condition_rules_against_form(form_schema: Any, flow_tree: Any) -> L if not isinstance(r, dict): continue field = (r.get("field") or "").strip() - if not field or field in seen: + if not field: continue - seen.add(field) + if field == INITIATOR_CONDITION_FIELD: + op = (r.get("op") or "").strip() + val = r.get("value") + if op != "contains": + errors.append("发起人条件仅支持「包含」") + elif not isinstance(val, list) or not any( + str(x).strip() for x in val + ): + errors.append("发起人包含条件须选择至少一名用户") + continue + if field in seen_form: + continue + seen_form.add(field) if field not in indexed: errors.append(f"条件引用了无效字段:{field}") child = g.get("child") diff --git a/backend/tests/test_oa_publish.py b/backend/tests/test_oa_publish.py index 27cdc859..996b29ae 100644 --- a/backend/tests/test_oa_publish.py +++ b/backend/tests/test_oa_publish.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch from app.services.oa_publish import ( count_fillable_fields, + validate_condition_rules_against_form, validate_control_types, validate_draft_for_publish, validate_flow_tree, @@ -242,6 +243,51 @@ class TestConditionPublish(unittest.TestCase): ) self.assertTrue(any("无效字段" in e or "参与" in e for e in errs)) + def test_initiator_condition_field_allowed(self): + errs = validate_condition_rules_against_form( + {"rule": []}, + _condition_flow( + rules=[ + { + "field": "__initiator__", + "op": "contains", + "value": ["u1"], + } + ] + ), + ) + self.assertEqual(errs, []) + + def test_initiator_condition_empty_value_rejected(self): + errs = validate_condition_rules_against_form( + {"rule": []}, + _condition_flow( + rules=[ + { + "field": "__initiator__", + "op": "contains", + "value": [], + } + ] + ), + ) + self.assertTrue(any("发起人" in e and ("包含" in e or "用户" in e) for e in errs)) + + def test_initiator_condition_bad_op_rejected(self): + errs = validate_condition_rules_against_form( + {"rule": []}, + _condition_flow( + rules=[ + { + "field": "__initiator__", + "op": "==", + "value": ["u1"], + } + ] + ), + ) + self.assertTrue(any("发起人" in e and "包含" in e for e in errs)) + if __name__ == "__main__": unittest.main() diff --git a/docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md b/docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md index 7ef72a29..8e58f8b7 100644 --- a/docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md +++ b/docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md @@ -3,7 +3,7 @@ **日期:** 2026-07-30 **范围:** `web-admin` 审批设计器步骤 3(流程设计)条件编辑;`backend` 条件匹配、发布校验、流程预览 **关联:** `docs/superpowers/specs/2026-07-15-oa-condition-from-form-design.md`(原非目标「系统变量」本期落地发起人);`docs/superpowers/specs/2026-07-30-oa-person-select-design.md`(contains / 人员多选 UI 可复用) -**状态:** 已评审待实现 +**状态:** 已实现 --- diff --git a/web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue b/web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue index 4d4eee89..15aa8a84 100644 --- a/web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue +++ b/web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue @@ -48,7 +48,7 @@ /> c.field === field) } - function isPersonField(field: string) { - return meta(field)?.type === 'person' + function isPersonLikeField(field: string) { + const t = meta(field)?.type + return t === 'person' || t === 'initiator' } function personValueIds(v: unknown): string[] { @@ -163,7 +164,7 @@ } function defaultValueForType(type: string, opts?: ConditionCandidate['options']) { - if (type === 'person') return [] as string[] + if (type === 'person' || type === 'initiator') return [] as string[] return opts?.[0]?.value ?? '' } @@ -285,13 +286,13 @@ } onMounted(() => { - if (props.candidates.some((c) => c.type === 'person')) { + if (props.candidates.some((c) => c.type === 'person' || c.type === 'initiator')) { loadUsers() } }) watch( - () => props.candidates.some((c) => c.type === 'person'), + () => props.candidates.some((c) => c.type === 'person' || c.type === 'initiator'), (need) => { if (need && !userOptions.value.length) loadUsers() } diff --git a/web-admin/src/components/oa-approval/oa-condition-fields.ts b/web-admin/src/components/oa-approval/oa-condition-fields.ts index 9c810302..f71df6c5 100644 --- a/web-admin/src/components/oa-approval/oa-condition-fields.ts +++ b/web-admin/src/components/oa-approval/oa-condition-fields.ts @@ -11,6 +11,15 @@ export const CONDITION_ELIGIBLE_TYPES = new Set([ 'date' ]) +/** 系统条件伪字段:发起人(非常驻表单控件) */ +export const INITIATOR_CONDITION_FIELD = '__initiator__' + +export const INITIATOR_CONDITION_CANDIDATE: ConditionCandidate = { + field: INITIATOR_CONDITION_FIELD, + title: '发起人', + type: 'initiator' +} + export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains' export interface ConditionCandidate { @@ -22,7 +31,7 @@ export interface ConditionCandidate { } export function opsForFieldType(type: string): Array<{ value: ConditionOp; label: string }> { - if (type === 'person') { + if (type === 'person' || type === 'initiator') { return [{ value: 'contains', label: '包含' }] } // 单选/下拉为离散选项,仅等于/不等于;输入框常填数字参与区间比较,与数字/金额/日期同套运算符 @@ -66,9 +75,9 @@ export function flattenFormRules(schema: unknown): any[] { return out } -/** 勾选了 useInCondition 的合格字段 */ +/** 勾选了 useInCondition 的合格字段 + 系统「发起人」 */ export function listConditionCandidates(schema: unknown): ConditionCandidate[] { - const out: ConditionCandidate[] = [] + const out: ConditionCandidate[] = [INITIATOR_CONDITION_CANDIDATE] for (const rule of flattenFormRules(schema)) { const type = String(rule.type || '') if (!CONDITION_ELIGIBLE_TYPES.has(type)) continue @@ -115,6 +124,13 @@ export function collectConditionReferencedFields(flow: unknown): Set { return refs } +/** 会锁定表单「参与条件」勾选的引用字段(排除系统伪字段) */ +export function collectFormConditionReferencedFields(flow: unknown): Set { + const refs = collectConditionReferencedFields(flow) + refs.delete(INITIATOR_CONDITION_FIELD) + return refs +} + /** 发布/保存前:useInCondition ⇒ $required / required = true;写回顶层 useInCondition */ export function normalizeSchemaConditionRequired(schema: unknown): { rule: unknown[] diff --git a/web-admin/src/views/oa-approval/designer/index.vue b/web-admin/src/views/oa-approval/designer/index.vue index 5a11113a..823b98ef 100644 --- a/web-admin/src/views/oa-approval/designer/index.vue +++ b/web-admin/src/views/oa-approval/designer/index.vue @@ -46,9 +46,6 @@ 上传 - - 换默认 - @@ -126,11 +123,11 @@ import { createDefaultFlow, ensureFlowRoot } from '@/components/oa-approval/flow-tree/types' import OaFormDesignerEmbed from '@/components/oa-approval/OaFormDesignerEmbed.vue' import { - collectConditionReferencedFields, + collectFormConditionReferencedFields, listConditionCandidates, normalizeSchemaConditionRequired } from '@/components/oa-approval/oa-condition-fields' - import { pickRandomOaAppIconUrl, resolveOaAppIconUrl } from '@/utils/oa-app-icons' + import { resolveOaAppIconUrl } from '@/utils/oa-app-icons' defineOptions({ name: 'OaApprovalDesigner' }) @@ -175,7 +172,7 @@ const flowTreeJson = ref(createDefaultFlow()) const flowConditionFields = computed(() => - Array.from(collectConditionReferencedFields(flowTreeJson.value)) + Array.from(collectFormConditionReferencedFields(flowTreeJson.value)) ) const conditionCandidates = computed(() => listConditionCandidates(formSchema.value)) @@ -447,10 +444,10 @@ .oa-designer__icon-preview { display: block; - width: 48px; - height: 48px; + width: 80px; + height: 80px; overflow: hidden; - border-radius: 10px; + border-radius: 16px; img { display: block;