|
|
@@ -0,0 +1,833 @@
|
|
|
+# 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` 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+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:
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+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:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py::TestConditionPublish -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(新断言尚无实现)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `oa_publish` 校验**
|
|
|
+
|
|
|
+1. 在模块增加白名单与辅助函数:
|
|
|
+
|
|
|
+```python
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+2. 在 `validate_flow_tree` 的 `ntype == "condition"` 分支内,遍历每个 group 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+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}]:请完善条件分支规则")
|
|
|
+```
|
|
|
+
|
|
|
+3. 新增 `validate_condition_rules_against_form(form_schema, flow_tree) -> List[str]`:收集所有非默认组 `rules[].field`,对照 `index_condition_fields`;缺失或不在索引则追加 `条件引用了无效字段:{field}`。
|
|
|
+
|
|
|
+4. 在 `validate_draft_for_publish` 中:
|
|
|
+
|
|
|
+```python
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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`:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { computed } from 'vue'
|
|
|
+import { collectConditionReferencedFields } from '@/components/oa-approval/oa-condition-fields'
|
|
|
+
|
|
|
+const flowConditionFields = computed(() =>
|
|
|
+ Array.from(collectConditionReferencedFields(flowTreeJson.value))
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+模板:
|
|
|
+
|
|
|
+```vue
|
|
|
+<OaFormDesignerEmbed
|
|
|
+ v-if="formDesignerMounted"
|
|
|
+ ref="formEmbedRef"
|
|
|
+ v-model="formSchema"
|
|
|
+ :flow-condition-fields="flowConditionFields"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+在 `collectFormSchema` / 保存 / 发布前:
|
|
|
+
|
|
|
+```ts
|
|
|
+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,则追加:
|
|
|
+
|
|
|
+```ts
|
|
|
+{
|
|
|
+ type: 'switch',
|
|
|
+ field: 'useInCondition',
|
|
|
+ title: '参与流程条件分支',
|
|
|
+ value: false,
|
|
|
+ props: { activeText: '是', inactiveText: '否' }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+仅当 `activeRule.type` ∈ `CONDITION_ELIGIBLE_TYPES` 时显示(隐藏逻辑用 designer 的 `hidden` 函数或监听 active 动态改 form)。
|
|
|
+
|
|
|
+**策略 B(回退):** 在宿主右侧 `_fc-r` 顶部用固定 Vue/DOM 注入条:
|
|
|
+
|
|
|
+```html
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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:
|
|
|
+
|
|
|
+```ts
|
|
|
+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 增加:
|
|
|
+
|
|
|
+```ts
|
|
|
+candidates?: ConditionCandidate[]
|
|
|
+```
|
|
|
+
|
|
|
+替换条件公式表单项:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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:
|
|
|
+
|
|
|
+```ts
|
|
|
+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)。标签文本:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { formatConditionPreview } from '../oa-condition-fields'
|
|
|
+// ...
|
|
|
+if (g.isDefault) return g.name || '默认'
|
|
|
+return formatConditionPreview(g.rules || [], props.candidates || [])
|
|
|
+```
|
|
|
+
|
|
|
+`FlowDesigner` 把 `candidates` 传给 `FlowBranch`。
|
|
|
+
|
|
|
+- [ ] **Step 5: designer 接线**
|
|
|
+
|
|
|
+```vue
|
|
|
+<FlowDesigner
|
|
|
+ v-model="flowTreeJson"
|
|
|
+ :candidates="conditionCandidates"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+```ts
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 后端全量相关测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 更新知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh frontend
|
|
|
+# 若改了 backend/oa_publish.py:
|
|
|
+./scripts/update-graph.sh backend
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit(若有图谱变更)**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 明确回退,避免实现卡死。
|