2026-07-15-oa-condition-from-form.md 28 KB

OA 条件分支表单字段可视化 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在表单设计勾选「参与流程条件分支」并锁定必填;在流程设计用字段+运算符+值配置条件,去掉手写 {days} > 5 公式框。

Architecture: Schema 规则增加 useInCondition;流程 conditionGroups[].rules 存结构化条件;设计器壳把表单候选字段传入 FlowDesigner;发布校验在 oa_publish 拦截无效引用与仅存量 when;引擎继续走已有 _match_condition_group 的 rules 路径。

Tech Stack: Vue 3 + FormCreate Designer(iframe oa-fc-host)、Element Plus、FastAPI/oa_publish/oa_engine、pytest

Spec: docs/superpowers/specs/2026-07-15-oa-condition-from-form-design.md
Demo: docs/mockups/oa-approval-condition-from-form-demo.html


File map

File Responsibility
Create: web-admin/src/components/oa-approval/oa-condition-fields.ts 白名单 type、从 schema 提取候选字段、扫 flow 引用、规范化 required
Create: web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue 分支规则可视化编辑器(字段/op/值)
Modify: web-admin/src/components/oa-approval/flow-tree/types.ts ConditionRule 类型;ConditionGroup.rules 收窄
Modify: web-admin/src/oa-fc-host/App.vue 属性面板开关 + 必填锁定 + 引用拦截(收 flowRefs)
Modify: web-admin/src/components/oa-approval/OaFormDesignerEmbed.vue postMessage:下发 flowConditionFields
Modify: web-admin/src/views/oa-approval/designer/index.vue 传候选字段给 FlowDesigner;保存前规范化 schema
Modify: web-admin/src/components/oa-approval/flow-tree/FlowDesigner.vue 去掉 when 输入;挂 ConditionRuleEditor;保存清 when
Modify: web-admin/src/components/oa-approval/flow-tree/FlowBranch.vue 分支标签人话预览
Modify: backend/app/services/oa_publish.py 条件 rules / useInCondition / when-only 发布校验;可选纠正 required
Modify: backend/tests/test_oa_publish.py 发布校验用例
Modify: backend/tests/test_oa_engine.py rules 列表匹配用例

Task 1: 共享解析工具 + 引擎 rules 单测

Files:

  • Create: web-admin/src/components/oa-approval/oa-condition-fields.ts
  • Modify: backend/tests/test_oa_engine.py
  • Test: backend/tests/test_oa_engine.py

  • [ ] Step 1: 写失败/增量引擎单测(rules 路径)

在 TestOaEngineHelpers 增加:

def test_condition_match_rules_list(self):
    groups = [
        {
            "name": "gt3",
            "rules": [{"field": "days", "op": ">", "value": 3}],
            "child": {"id": "n1", "type": "end"},
        },
        {"name": "default", "isDefault": True, "rules": [], "child": {"id": "n2", "type": "end"}},
    ]
    self.assertEqual(_match_condition_group(groups, {"days": 5})["name"], "gt3")
    self.assertEqual(_match_condition_group(groups, {"days": 1})["name"], "default")

def test_condition_match_rules_and(self):
    groups = [
        {
            "name": "both",
            "rules": [
                {"field": "days", "op": ">", "value": 3},
                {"field": "leaveType", "op": "==", "value": "事假"},
            ],
            "child": {"id": "n1", "type": "end"},
        },
        {"name": "default", "rules": [], "isDefault": True, "child": {"id": "n2", "type": "end"}},
    ]
    self.assertEqual(
        _match_condition_group(groups, {"days": 5, "leaveType": "事假"})["name"],
        "both",
    )
    self.assertEqual(
        _match_condition_group(groups, {"days": 5, "leaveType": "病假"})["name"],
        "default",
    )
  • Step 2: 跑测试确认现有引擎已能通过(预期 PASS;若 FAIL 再修引擎,本需求默认不改引擎)

Run:

cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_match_rules_list tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_match_rules_and -v

Expected: PASS(_match_condition_group 已支持 rules 列表)。

  • Step 3: 创建前端共享模块

web-admin/src/components/oa-approval/oa-condition-fields.ts:

/** 允许「参与流程条件分支」的 FormCreate type */
export const CONDITION_ELIGIBLE_TYPES = new Set([
  'inputNumber',
  'number',
  'money',
  'radio',
  'select',
  'datePicker',
  'date'
])

export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!='

export interface ConditionCandidate {
  field: string
  title: string
  type: string
  /** 单选/下拉开选项 label/value */
  options?: Array<{ label: string; value: string | number }>
}

export function opsForFieldType(type: string): Array<{ value: ConditionOp; label: string }> {
  if (type === 'radio' || type === 'select') {
    return [
      { value: '==', label: '等于' },
      { value: '!=', label: '不等于' }
    ]
  }
  return [
    { value: '>', label: '大于' },
    { value: '>=', label: '大于等于' },
    { value: '<', label: '小于' },
    { value: '<=', label: '小于等于' },
    { value: '==', label: '等于' },
    { value: '!=', label: '不等于' }
  ]
}

function walkRules(rules: unknown[], out: any[]) {
  for (const r of rules) {
    if (!r || typeof r !== 'object') continue
    const rule = r as any
    out.push(rule)
    const children = rule.children || rule.props?.rule || []
    if (Array.isArray(children)) walkRules(children, out)
  }
}

export function flattenFormRules(schema: unknown): any[] {
  if (!schema) return []
  if (Array.isArray(schema)) {
    const out: any[] = []
    walkRules(schema, out)
    return out
  }
  const root = schema as any
  const rules = root.rule || root.rules || []
  const out: any[] = []
  if (Array.isArray(rules)) walkRules(rules, out)
  return out
}

/** 勾选了 useInCondition 的合格字段 */
export function listConditionCandidates(schema: unknown): ConditionCandidate[] {
  const out: ConditionCandidate[] = []
  for (const rule of flattenFormRules(schema)) {
    const type = String(rule.type || '')
    if (!CONDITION_ELIGIBLE_TYPES.has(type)) continue
    const field = String(rule.field || rule.name || '')
    if (!field) continue
    const flagged = rule.useInCondition === true || rule.props?.useInCondition === true
    if (!flagged) continue
    const options = (rule.options || rule.props?.options || [])
      .map((o: any) =>
        typeof o === 'object'
          ? { label: String(o.label ?? o.text ?? o.value), value: o.value }
          : { label: String(o), value: o }
      )
    out.push({
      field,
      title: String(rule.title || rule.label || field),
      type,
      options: options.length ? options : undefined
    })
  }
  return out
}

/** 流程树中条件 rules 引用的 field 集合 */
export function collectConditionReferencedFields(flow: unknown): Set<string> {
  const refs = new Set<string>()
  const walk = (node: any) => {
    if (!node || typeof node !== 'object') return
    for (const g of node.conditionGroups || []) {
      for (const r of g.rules || []) {
        if (r && r.field) refs.add(String(r.field))
      }
      if (g.child) walk(g.child)
    }
    if (node.child) walk(node.child)
  }
  const root = (flow as any)?.root?.type ? (flow as any).root : flow
  walk(root)
  return refs
}

/** 发布/保存前:useInCondition ⇒ $required / required = true;写回顶层 useInCondition */
export function normalizeSchemaConditionRequired(schema: unknown): {
  rule: unknown[]
  option: Record<string, unknown>
} {
  const plain = JSON.parse(JSON.stringify(schema || {}))
  const rules = Array.isArray(plain.rule) ? plain.rule : Array.isArray(plain) ? plain : []
  const walk = (list: any[]) => {
    for (const rule of list) {
      const flagged = rule.useInCondition === true || rule.props?.useInCondition === true
      if (flagged) {
        rule.useInCondition = true
        if (rule.props && typeof rule.props === 'object') {
          rule.props.useInCondition = true
        }
        rule.$required = true
        rule.required = true
      }
      const children = rule.children || rule.props?.rule
      if (Array.isArray(children)) walk(children)
    }
  }
  walk(rules)
  return {
    rule: rules,
    option: plain.option && typeof plain.option === 'object' ? plain.option : {}
  }
}

export function formatConditionPreview(
  rules: Array<{ field: string; op: string; value: unknown }>,
  candidates: ConditionCandidate[]
): string {
  const titleOf = (f: string) => candidates.find((c) => c.field === f)?.title || f
  const opLabel: Record<string, string> = {
    '>': '大于',
    '>=': '大于等于',
    '<': '小于',
    '<=': '小于等于',
    '==': '等于',
    '!=': '不等于'
  }
  if (!rules?.length) return '未配置'
  return rules
    .map((r) => `${titleOf(r.field)} ${opLabel[r.op] || r.op} ${r.value ?? '?'}`)
    .join(' 且 ')
}
  • Step 4: Commit
git add web-admin/src/components/oa-approval/oa-condition-fields.ts backend/tests/test_oa_engine.py
git commit -m "feat(oa): add condition field helpers and rules-match tests"

Task 2: 发布校验(后端)

Files:

  • Modify: backend/app/services/oa_publish.py
  • Modify: backend/tests/test_oa_publish.py

  • [ ] Step 1: 写失败的发布校验测试

在 test_oa_publish.py 增加:

def _condition_flow(rules=None, when=None, is_default=False):
    return {
        "type": "start",
        "name": "发起人",
        "child": {
            "type": "condition",
            "name": "条件",
            "conditionGroups": [
                {
                    "name": "分支1",
                    "rules": rules if rules is not None else [],
                    "when": when,
                    "isDefault": False,
                    "child": {
                        "type": "approval",
                        "name": "审批A",
                        "approverConfig": {"assigneeType": "dept_leader"},
                        "child": {"type": "end", "name": "结束"},
                    },
                },
                {
                    "name": "默认",
                    "rules": [],
                    "isDefault": True,
                    "child": {
                        "type": "approval",
                        "name": "审批B",
                        "approverConfig": {"assigneeType": "dept_leader"},
                        "child": {"type": "end", "name": "结束"},
                    },
                },
            ],
        },
    }


class TestConditionPublish(unittest.TestCase):
    def test_when_only_rejected(self):
        errs = validate_flow_tree(_condition_flow(rules=[], when="{days} > 3"))
        self.assertTrue(any("可视化" in e or "重新配置" in e or "rules" in e for e in errs))

    def test_rules_empty_non_default_rejected(self):
        errs = validate_flow_tree(_condition_flow(rules=[]))
        self.assertTrue(any("条件" in e for e in errs))

    def test_rules_ok(self):
        errs = validate_flow_tree(
            _condition_flow(rules=[{"field": "days", "op": ">", "value": 3}])
        )
        self.assertEqual(errs, [])

    def test_invalid_field_reference(self):
        db = MagicMock()
        with patch(
            "app.services.oa_publish.validate_oa_roles_referenced",
            return_value=[],
        ):
            errs = validate_draft_for_publish(
                db,
                tenant_no="zkxw",
                form_schema={
                    "rule": [
                        {
                            "type": "inputNumber",
                            "field": "days",
                            "title": "天数",
                            "useInCondition": True,
                            "$required": True,
                        }
                    ]
                },
                flow_tree=_condition_flow(
                    rules=[{"field": "other", "op": ">", "value": 1}]
                ),
            )
        self.assertTrue(any("无效字段" in e or "other" in e for e in errs))

    def test_field_not_flagged_rejected(self):
        db = MagicMock()
        with patch(
            "app.services.oa_publish.validate_oa_roles_referenced",
            return_value=[],
        ):
            errs = validate_draft_for_publish(
                db,
                tenant_no="zkxw",
                form_schema={
                    "rule": [
                        {
                            "type": "inputNumber",
                            "field": "days",
                            "title": "天数",
                            "useInCondition": False,
                        }
                    ]
                },
                flow_tree=_condition_flow(
                    rules=[{"field": "days", "op": ">", "value": 3}]
                ),
            )
        self.assertTrue(any("无效字段" in e or "参与" in e for e in errs))

并确保文件顶部已 import patch、MagicMock、validate_draft_for_publish、validate_flow_tree。

  • Step 2: 跑测试确认 FAIL

Run:

cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py::TestConditionPublish -v

Expected: FAIL(新断言尚无实现)。

  • Step 3: 实现 oa_publish 校验
  1. 在模块增加白名单与辅助函数:
CONDITION_ELIGIBLE_TYPES = frozenset(
    {"inputNumber", "number", "money", "radio", "select", "datePicker", "date"}
)


def _rule_use_in_condition(rule: Dict[str, Any]) -> bool:
    if rule.get("useInCondition") is True:
        return True
    props = rule.get("props") or {}
    return isinstance(props, dict) and props.get("useInCondition") is True


def index_condition_fields(form_schema: Any) -> Dict[str, Dict[str, Any]]:
    """field -> rule(仅 useInCondition 且 type 合格)"""
    flat: List[Dict[str, Any]] = []
    _walk_form_rules(_extract_rules(form_schema), flat)
    out: Dict[str, Dict[str, Any]] = {}
    for rule in flat:
        t = (rule.get("type") or "").strip()
        field = (rule.get("field") or rule.get("name") or "").strip()
        if not field or t not in CONDITION_ELIGIBLE_TYPES:
            continue
        if _rule_use_in_condition(rule):
            out[field] = rule
    return out


def normalize_use_in_condition_required(form_schema: Any) -> Any:
    """就地/深拷贝皆可:打标字段强制 required/$required。发布前调用。"""
    import copy

    schema = copy.deepcopy(form_schema) if form_schema is not None else {"rule": []}
    flat: List[Dict[str, Any]] = []
    rules = _extract_rules(schema)
    # 直接 walk 原 children 树修正
    def walk(list_rules: List[Dict[str, Any]]) -> None:
        for rule in list_rules:
            if _rule_use_in_condition(rule):
                rule["useInCondition"] = True
                rule["$required"] = True
                rule["required"] = True
                props = rule.get("props")
                if isinstance(props, dict):
                    props["useInCondition"] = True
            children = rule.get("children") or (rule.get("props") or {}).get("rule") or []
            if isinstance(children, list):
                walk([c for c in children if isinstance(c, dict)])

    if isinstance(schema, list):
        walk([r for r in schema if isinstance(r, dict)])
    elif isinstance(schema, dict):
        r = schema.get("rule")
        if isinstance(r, list):
            walk([x for x in r if isinstance(x, dict)])
    return schema
  1. 在 validate_flow_tree 的 ntype == "condition" 分支内,遍历每个 group 增加:
is_default = bool(g.get("isDefault"))
rules = g.get("rules") if isinstance(g.get("rules"), list) else []
when = (g.get("when") or "").strip() if isinstance(g.get("when"), str) else ""
valid_rules = [
    r
    for r in rules
    if isinstance(r, dict)
    and (r.get("field") or "").strip()
    and (r.get("op") or "").strip()
    and r.get("value") is not None
    and str(r.get("value")).strip() != ""
]
if is_default:
    pass  # 默认分支不要求 rules
elif valid_rules:
    pass
elif when and not valid_rules:
    errors.append(f"{path}.条件组[{i}]:请用可视化规则重新配置条件(不再支持仅公式)")
else:
    errors.append(f"{path}.条件组[{i}]:请完善条件分支规则")
  1. 新增 validate_condition_rules_against_form(form_schema, flow_tree) -> List[str]:收集所有非默认组 rules[].field,对照 index_condition_fields;缺失或不在索引则追加 条件引用了无效字段:{field}。

  2. 在 validate_draft_for_publish 中:

form_schema = normalize_use_in_condition_required(form_schema)
errors.extend(validate_control_types(form_schema))
errors.extend(validate_flow_tree(flow_tree))
errors.extend(validate_condition_rules_against_form(form_schema, flow_tree))
errors.extend(validate_oa_roles_referenced(...))

注意:publish_app 若在校验通过后写库,应把规范化后的 form_schema 写回草稿/版本(在 publish_app 内对 draft schema 调用同一 normalize,再校验)。打开 publish_app 找到读取 form_schema_json 处,先 normalize 再 validate,成功则用 normalize 后的 JSON 落库。

  • Step 4: 跑测试确认 PASS
cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py::TestConditionPublish tests/test_oa_publish.py::TestOaPublishValidate -v

Expected: PASS(既有用例不因默认分支空 rules 误伤;仅 when 的条件组在 validate_flow_tree 失败)。

  • Step 5: Commit
git add backend/app/services/oa_publish.py backend/tests/test_oa_publish.py
git commit -m "feat(oa): validate visual condition rules on publish"

Task 3: 表单设计器 — useInCondition 开关

Files:

  • Modify: web-admin/src/oa-fc-host/App.vue
  • Modify: web-admin/src/components/oa-approval/OaFormDesignerEmbed.vue
  • Modify: web-admin/src/views/oa-approval/designer/index.vue(仅接线 flowRefs;完整 Flow 在 Task 4)

FormCreate Designer 属性面板扩展方式以安装版为准。实现时先在 iframe 控制台打印 Object.keys(getDesigner()||{}),优先用官方能力;下列为可落地方案(与仓库附件注册同级):

  • Step 1: Embed 增加 flow 引用下发

OaFormDesignerEmbed.vue:

const props = defineProps<{
  modelValue?: { rule?: unknown; option?: unknown } | null
  /** 已被流程条件引用的 field 列表(用于拦截取消勾选) */
  flowConditionFields?: string[]
}>()

const MSG = {
  READY: 'oa-fc:ready',
  SET: 'oa-fc:set-schema',
  GET: 'oa-fc:get-schema',
  SCHEMA: 'oa-fc:schema',
  FLOW_REFS: 'oa-fc:set-flow-refs'
} as const

function pushFlowRefs() {
  if (!frameReady.value) return
  postToFrame({
    type: MSG.FLOW_REFS,
    fields: Array.isArray(props.flowConditionFields) ? props.flowConditionFields : []
  })
}

watch(() => props.flowConditionFields, pushFlowRefs, { deep: true })
// 在 READY 处理里一并 pushFlowRefs()
  • Step 2: 设计器壳计算 refs 并传入 embed

在 designer/index.vue:

import { computed } from 'vue'
import { collectConditionReferencedFields } from '@/components/oa-approval/oa-condition-fields'

const flowConditionFields = computed(() =>
  Array.from(collectConditionReferencedFields(flowTreeJson.value))
)

模板:

<OaFormDesignerEmbed
  v-if="formDesignerMounted"
  ref="formEmbedRef"
  v-model="formSchema"
  :flow-condition-fields="flowConditionFields"
/>

在 collectFormSchema / 保存 / 发布前:

import { normalizeSchemaConditionRequired } from '@/components/oa-approval/oa-condition-fields'
// after fetchSchema:
const normalized = normalizeSchemaConditionRequired(raw)
formSchema.value = normalized
return normalized
  • Step 3: oa-fc-host 实现属性面板开关

在 App.vue:

  1. import CONDITION_ELIGIBLE_TYPES。
  2. 维护 flowConditionFields = ref<string[]>([]),在 onMessage 处理 oa-fc:set-flow-refs。
  3. 在 onMounted 注册控件后调用 setupUseInConditionUi(getDesigner())。

推荐实现策略(按优先级尝试,选一种做完并手测):

策略 A(优先): 若 designer 暴露 appendConfigData / 自定义 rule 表单 API,则追加:

{
  type: 'switch',
  field: 'useInCondition',
  title: '参与流程条件分支',
  value: false,
  props: { activeText: '是', inactiveText: '否' }
}

仅当 activeRule.type ∈ CONDITION_ELIGIBLE_TYPES 时显示(隐藏逻辑用 designer 的 hidden 函数或监听 active 动态改 form)。

策略 B(回退): 在宿主右侧 _fc-r 顶部用固定 Vue/DOM 注入条:

<div class="oa-cond-flag" v-if="showFlag">
  <el-switch v-model="flag" @change="onFlagChange" /> 参与流程条件分支
  <p class="tip">勾选后该字段必填且不可改为选填</p>
</div>

通过监听 designer 选中 rule(轮询 getActiveRule / MutationObserver / designer 事件,以实现时探查结果为准),读写 rule.useInCondition,并:

  • 打开:rule.useInCondition = true;rule.$required = true;禁用设计器自带必填按钮(查 ._fc-r 内「必填」开关加 pointer-events:none + class)。
  • 关闭:若 flowConditionFields 含 rule.field,ElMessage.warning('请先在流程设计中删除对该字段的条件') 并强制开关仍为 true;否则允许关闭并恢复必填可编辑。

开关变更后调用内部 API 更新 rule(如 designer.updateRule / 改对象触发变更,以实现时探查为准),确保 getRule() 能读到。

  • Step 4: 手测表单步
  1. pnpm --dir web-admin dev,打开某应用设计器步骤 2。
  2. 拖入「数字」控件,能勾选「参与流程条件分支」;必填被勾上且无法关掉。
  3. 附件控件无该开关。
  4. 保存草稿后重新打开,useInCondition 仍在。
  • Step 5: Commit
git add web-admin/src/oa-fc-host/App.vue web-admin/src/components/oa-approval/OaFormDesignerEmbed.vue web-admin/src/views/oa-approval/designer/index.vue
git commit -m "feat(oa): mark form fields for condition branches with required lock"

Task 4: 流程设计 — 可视化条件编辑器

Files:

  • Modify: web-admin/src/components/oa-approval/flow-tree/types.ts
  • Create: web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue
  • Modify: web-admin/src/components/oa-approval/flow-tree/FlowDesigner.vue
  • Modify: web-admin/src/components/oa-approval/flow-tree/FlowBranch.vue
  • Modify: web-admin/src/views/oa-approval/designer/index.vue

  • [ ] Step 1: 收窄类型

types.ts:

export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!='

export interface ConditionRule {
  field: string
  op: ConditionOp
  value: string | number
}

export interface ConditionGroup {
  name: string
  when?: string
  rules?: ConditionRule[]
  isDefault?: boolean
  child: FlowNode | null
}

新建节点时保持 rules: []、when 不写或 ''。

  • Step 2: 创建 ConditionRuleEditor.vue

Props:

defineProps<{
  modelValue: ConditionRule[]
  disabled?: boolean
  candidates: import('../oa-condition-fields').ConditionCandidate[]
  legacyWhen?: string
}>()
emit('update:modelValue', rules)

UI:

  • 若 legacyWhen 有值且 modelValue 为空:el-alert「检测到旧版条件公式,请用下方可视化重新配置」。
  • v-for 每行:字段 el-select(candidates,label=title)+ 运算符 select(opsForFieldType)+ 值(select 选项或 input)。
  • 「+ 且条件」按钮;无 candidates 时整块空态:「请先到「2 表单设计」勾选参与条件分支的字段」。
  • disabled(默认分支)时控件禁用。

  • [ ] Step 3: 改 FlowDesigner 条件抽屉

Props 增加:

candidates?: ConditionCandidate[]

替换条件公式表单项:

<template v-if="editing.type === 'condition'">
  <div v-for="(g, idx) in editing.conditionGroups || []" :key="idx" class="cond-group-editor">
    <el-form-item :label="`分支 ${idx + 1} 名称`">
      <el-input v-model="g.name" />
    </el-form-item>
    <ConditionRuleEditor
      v-model="g.rules"
      :disabled="!!g.isDefault"
      :candidates="candidates"
      :legacy-when="g.when"
    />
    <el-form-item label="默认分支">
      <el-switch v-model="g.isDefault" @change="(v) => onDefaultChange(idx, !!v)" />
    </el-form-item>
  </div>
  ...
</template>

保存节点(现有 applyDrawer / 等价函数)时对每个 group:

if (g.isDefault) {
  g.rules = []
  g.when = ''
} else {
  g.when = '' // 新配置清空公式,避免双源
  g.rules = (g.rules || []).filter((r) => r.field && r.op && r.value !== '' && r.value != null)
}

addConditionGroup 仍 rules: [],不要初始化 when 示例文案。

  • Step 4: FlowBranch 人话预览

Props 增加可选 candidates(或由父传入 title map)。标签文本:

import { formatConditionPreview } from '../oa-condition-fields'
// ...
if (g.isDefault) return g.name || '默认'
return formatConditionPreview(g.rules || [], props.candidates || [])

FlowDesigner 把 candidates 传给 FlowBranch。

  • Step 5: designer 接线
<FlowDesigner
  v-model="flowTreeJson"
  :candidates="conditionCandidates"
/>
const conditionCandidates = computed(() => listConditionCandidates(formSchema.value))

切换到流程步 onStepChange:若在 form 步,先 await collectFormSchema() 刷新 formSchema,再让 candidates 更新。

  • Step 6: 手测流程步
  1. 步骤 2 勾选「请假天数」。
  2. 步骤 3 条件分支:下拉仅见「请假天数」,配置大于 3;树上显示「请假天数 大于 3」。
  3. 无公式输入框。
  4. 回到步骤 2 试图取消勾选(在已引用时)被拦截。
  • Step 7: Commit
git add web-admin/src/components/oa-approval/flow-tree/types.ts \
  web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue \
  web-admin/src/components/oa-approval/flow-tree/FlowDesigner.vue \
  web-admin/src/components/oa-approval/flow-tree/FlowBranch.vue \
  web-admin/src/views/oa-approval/designer/index.vue
git commit -m "feat(oa): visual condition branch editor driven by form fields"

Task 5: 端到端验收 + 图谱更新

Files: 无新文件(手测 + 脚本)

  • Step 1: 后端全量相关测试
cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py tests/test_oa_engine.py tests/test_oa_formula.py -v

Expected: PASS。

  • Step 2: 前端手测清单(对照 spec §9)
# 场景 期望
1 勾选数字字段参与分支 必填锁定
2 未勾选字段 流程条件下拉里不出现
3 可视化配置并发布 成功;发起填值走对应分支
4 仅存旧 when 的草稿发布 失败并提示重新配置
5 UI 无条件公式输入框
6 取消仍被引用的勾选 拦截
  • Step 3: 更新知识图谱
./scripts/update-graph.sh frontend
# 若改了 backend/oa_publish.py:
./scripts/update-graph.sh backend
  • Step 4: Commit(若有图谱变更)
git add web-admin/graphify-out backend/graphify-out
git commit -m "chore: update graphify after oa condition-from-form"

Self-review (plan vs spec)

Spec 要求 Task
勾选参与分支 Task 3
勾选锁定必填 Task 3 + normalize Task 1/2
控件白名单 Task 1 CONDITION_ELIGIBLE_TYPES
纯可视化、无高级公式 Task 4 删除 when 输入
rules AND 引擎已有;Task 1 测试
取消勾选有引用拦截 Task 3 flowRefs
发布校验无效字段 / when-only Task 2
存量 when 运行可读、发布不可 Task 2 + 引擎不改 when 路径
Demo 仅参考 无需改代码任务

无 TBD 占位;FormCreate 属性面板用策略 A/B 明确回退,避免实现卡死。