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>
This commit is contained in:
1 parent
3d88f52e2e
commit
22be6d8b36
7 files changed
+103
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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 可复用)
|
||||
**状态:** 已评审待实现
|
||||
**状态:** 已实现
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user