Selaa lähdekoodia

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 <cursoragent@cursor.com>
zlwu 3 kuukautta sitten
vanhempi
commit
22be6d8b36

+ 6 - 1
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

+ 18 - 3
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
+                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.add(field)
+                seen_form.add(field)
                 if field not in indexed:
                     errors.append(f"条件引用了无效字段:{field}")
             child = g.get("child")

+ 46 - 0
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()

+ 1 - 1
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 可复用)  
-**状态:** 已评审待实现  
+**状态:** 已实现  
 
 ---
 

+ 7 - 6
web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue

@@ -48,7 +48,7 @@
             />
           </el-select>
           <el-select
-            v-if="isPersonField(rule.field)"
+            v-if="isPersonLikeField(rule.field)"
             :model-value="personValueIds(rule.value)"
             multiple
             filterable
@@ -152,8 +152,9 @@
     return props.candidates.find((c) => 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()
     }

+ 19 - 3
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<string> {
   return refs
 }
 
+/** 会锁定表单「参与条件」勾选的引用字段(排除系统伪字段) */
+export function collectFormConditionReferencedFields(flow: unknown): Set<string> {
+  const refs = collectConditionReferencedFields(flow)
+  refs.delete(INITIATOR_CONDITION_FIELD)
+  return refs
+}
+
 /** 发布/保存前:useInCondition ⇒ $required / required = true;写回顶层 useInCondition */
 export function normalizeSchemaConditionRequired(schema: unknown): {
   rule: unknown[]

+ 6 - 9
web-admin/src/views/oa-approval/designer/index.vue

@@ -46,9 +46,6 @@
                       上传
                     </el-button>
                   </el-upload>
-                  <el-button class="btn-ghost" @click="basic.iconUrl = pickRandomOaAppIconUrl()">
-                    换默认
-                  </el-button>
                 </div>
               </el-form-item>
               <el-form-item label="应用名称">
@@ -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;