|
|
@@ -0,0 +1,837 @@
|
|
|
+# OA Person Select(人员)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:** Deliver FormCreate `person` control (dept-aligned UI) so designers can drag「人员」, configure multi-select / max / condition participation; conditions use **contains-only** with OR semantics; runtime + designer preview + start-page flow preview all work.
|
|
|
+
|
|
|
+**Architecture:** Mirror `dept` / `money` four-layer FcDesigner extension: `OaPersonSelect.vue` + DragRule + `setupOaPersonSelect` on main app and `oa-fc-host`. Persist `{ id, name }` / array. Engine matches `op === 'contains'` via id-set intersection (not formula `==`). Preview: device canvas `._fc-m-drag.mobile` (400px) +「预览」dialog `ViewForm` must render the registered component and stay full-width / wrap tags.
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI · pytest · Vue 3 · FormCreate / `@form-create/designer` · Element Plus · existing `fetchUserList`
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-07-30-oa-person-select-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| Path | Responsibility |
|
|
|
+|------|----------------|
|
|
|
+| Create: `web-admin/src/utils/oa-person-value.ts` | normalize / display helpers |
|
|
|
+| Create: `web-admin/src/utils/oa-person-value.test.ts` | Node unit tests |
|
|
|
+| Create: `web-admin/src/utils/oa-person-select-rule.ts` | Designer DragRule |
|
|
|
+| Create: `web-admin/src/utils/oa-form-create-person.ts` | `setupOaPersonSelect` |
|
|
|
+| Create: `web-admin/src/components/oa-approval/OaPersonSelect.vue` | Runtime + designer canvas / preview |
|
|
|
+| Modify: `web-admin/src/main.ts` | register person |
|
|
|
+| Modify: `web-admin/src/oa-fc-host/main.ts` | register person (designer + 预览 dialog) |
|
|
|
+| Modify: `web-admin/src/assets/styles/core/oa-fc-runtime.scss` | `.oa-person-select` full-width |
|
|
|
+| Modify: `web-admin/src/components/oa-approval/oa-condition-fields.ts` | `person` + `contains` |
|
|
|
+| Modify: `web-admin/src/components/oa-approval/flow-tree/types.ts` | `ConditionOp` / value type |
|
|
|
+| Modify: `web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue` | person multi-select RHS |
|
|
|
+| Modify: `web-admin/src/utils/oa-approval-print.ts` | print names |
|
|
|
+| Create: `backend/app/services/oa_person_select.py` | form_data validation + id extract helpers |
|
|
|
+| Create: `backend/tests/test_oa_person_select.py` | validation tests |
|
|
|
+| Modify: `backend/app/services/oa_publish.py` | FILLABLE + CONDITION eligible |
|
|
|
+| Modify: `backend/app/services/oa_engine.py` | `contains` in `_match_condition_group`;`start_instance` validate |
|
|
|
+| Modify: `backend/tests/test_oa_engine.py` | contains match cases |
|
|
|
+| Modify: `backend/app/services/oa_related_approval.py` | summary format |
|
|
|
+| Modify: `backend/tests/test_oa_related_approval.py` | person format assert |
|
|
|
+| Modify: `docs/superpowers/specs/2026-07-30-oa-person-select-design.md` | preview section + status |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Preview compatibility (must cover)
|
|
|
+
|
|
|
+FormCreate has **two** preview surfaces in `oa-fc-host`:
|
|
|
+
|
|
|
+1. **Device switch**(顶部 PC / Pad / Mobile):`._fc-m-drag` gets class `mobile` → **width 400px**(见 `@form-create/designer` `style/index.css`)。`window.matchMedia('(max-width: 767px)')` **不会**因 device 切换变 true(iframe 仍很宽)。因此:
|
|
|
+ - **不要**仅靠 `matchMedia` 才能在手机画布上可用;PC 的 `el-select` 必须在 400px 容器内全宽、tag 可换行、不撑破布局。
|
|
|
+ - 窄屏抽屉路径仍保留:真实窄 viewport(<768)或后续真机使用。
|
|
|
+2. **「预览」按钮** → `el-dialog` + `ViewForm`:必须已 `formCreate.component('person', …)`,否则预览里控件空白/报错。双侧 `setupOaPersonSelect` 已覆盖。
|
|
|
+3. **发起页流程预览**(`fetchOaFlowPreview`):改 `form_data` 后走同一套 `_match_condition_group`;`contains` 引擎改完后自然生效。手测:人员勾选进入条件名单 → 预览走对应分支。
|
|
|
+
|
|
|
+CSS:在 `oa-fc-runtime.scss` 把 `.oa-person-select` 与 `.oa-dept-select` 并列,保证 `width/max-width/min-width:0`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: Person value helpers (TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/utils/oa-person-value.ts`
|
|
|
+- Create: `web-admin/src/utils/oa-person-value.test.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing tests**
|
|
|
+
|
|
|
+```ts
|
|
|
+// web-admin/src/utils/oa-person-value.test.ts
|
|
|
+import { describe, it } from 'node:test'
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import {
|
|
|
+ extractPersonIds,
|
|
|
+ formatPersonDisplay,
|
|
|
+ isPersonValueItem,
|
|
|
+ normalizePersonModelValue
|
|
|
+} from './oa-person-value'
|
|
|
+
|
|
|
+describe('oa-person-value', () => {
|
|
|
+ it('isPersonValueItem', () => {
|
|
|
+ assert.equal(isPersonValueItem({ id: 'u1', name: '张三' }), true)
|
|
|
+ assert.equal(isPersonValueItem({ id: '', name: 'x' }), false)
|
|
|
+ assert.equal(isPersonValueItem(null), false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('normalize single', () => {
|
|
|
+ assert.deepEqual(normalizePersonModelValue({ id: 'u1', name: '张三' }, false), {
|
|
|
+ id: 'u1',
|
|
|
+ name: '张三'
|
|
|
+ })
|
|
|
+ assert.equal(normalizePersonModelValue(null, false), null)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('normalize multiple', () => {
|
|
|
+ assert.deepEqual(
|
|
|
+ normalizePersonModelValue(
|
|
|
+ [
|
|
|
+ { id: 'u1', name: '张三' },
|
|
|
+ { id: 'u2', name: '李四' }
|
|
|
+ ],
|
|
|
+ true
|
|
|
+ ),
|
|
|
+ [
|
|
|
+ { id: 'u1', name: '张三' },
|
|
|
+ { id: 'u2', name: '李四' }
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ assert.deepEqual(normalizePersonModelValue(null, true), [])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('format and extract ids', () => {
|
|
|
+ assert.equal(
|
|
|
+ formatPersonDisplay([
|
|
|
+ { id: 'u1', name: '张三' },
|
|
|
+ { id: 'u2', name: '李四' }
|
|
|
+ ]),
|
|
|
+ '张三、李四'
|
|
|
+ )
|
|
|
+ assert.deepEqual(
|
|
|
+ extractPersonIds({ id: 'u1', name: '张三' }),
|
|
|
+ ['u1']
|
|
|
+ )
|
|
|
+ assert.deepEqual(
|
|
|
+ extractPersonIds([
|
|
|
+ { id: 'u1', name: '张三' },
|
|
|
+ { id: 'u2', name: '李四' }
|
|
|
+ ]),
|
|
|
+ ['u1', 'u2']
|
|
|
+ )
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run tests — expect FAIL**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd web-admin && pnpm exec tsx --test src/utils/oa-person-value.test.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: module not found / FAIL
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement**
|
|
|
+
|
|
|
+```ts
|
|
|
+// web-admin/src/utils/oa-person-value.ts
|
|
|
+export type OaPersonValueItem = { id: string; name: string }
|
|
|
+
|
|
|
+export function isPersonValueItem(v: unknown): v is OaPersonValueItem {
|
|
|
+ if (!v || typeof v !== 'object') return false
|
|
|
+ const o = v as Record<string, unknown>
|
|
|
+ return String(o.id || '').trim() !== '' && String(o.name || '').trim() !== ''
|
|
|
+}
|
|
|
+
|
|
|
+export function normalizePersonModelValue(
|
|
|
+ raw: unknown,
|
|
|
+ multiple: boolean
|
|
|
+): OaPersonValueItem | OaPersonValueItem[] | null {
|
|
|
+ if (multiple) {
|
|
|
+ if (Array.isArray(raw)) {
|
|
|
+ return raw.filter(isPersonValueItem).map((x) => ({
|
|
|
+ id: String(x.id).trim(),
|
|
|
+ name: String(x.name).trim()
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ if (isPersonValueItem(raw)) {
|
|
|
+ return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }]
|
|
|
+ }
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ if (isPersonValueItem(raw)) {
|
|
|
+ return { id: String(raw.id).trim(), name: String(raw.name).trim() }
|
|
|
+ }
|
|
|
+ if (Array.isArray(raw) && raw.length === 1 && isPersonValueItem(raw[0])) {
|
|
|
+ return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+export function formatPersonDisplay(raw: unknown): string {
|
|
|
+ if (Array.isArray(raw)) {
|
|
|
+ return raw
|
|
|
+ .filter(isPersonValueItem)
|
|
|
+ .map((x) => x.name)
|
|
|
+ .filter(Boolean)
|
|
|
+ .join('、')
|
|
|
+ }
|
|
|
+ if (isPersonValueItem(raw)) return raw.name
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+export function extractPersonIds(raw: unknown): string[] {
|
|
|
+ const n = Array.isArray(raw)
|
|
|
+ ? raw.filter(isPersonValueItem)
|
|
|
+ : isPersonValueItem(raw)
|
|
|
+ ? [raw]
|
|
|
+ : []
|
|
|
+ const out: string[] = []
|
|
|
+ const seen = new Set<string>()
|
|
|
+ for (const x of n) {
|
|
|
+ const id = String(x.id).trim()
|
|
|
+ if (!id || seen.has(id)) continue
|
|
|
+ seen.add(id)
|
|
|
+ out.push(id)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run tests — expect PASS**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd web-admin && pnpm exec tsx --test src/utils/oa-person-value.test.ts
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/utils/oa-person-value.ts web-admin/src/utils/oa-person-value.test.ts
|
|
|
+git commit -m "feat(web-admin): add OA person value normalize helpers"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: Backend person validation + publish whitelist (TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/services/oa_person_select.py`
|
|
|
+- Create: `backend/tests/test_oa_person_select.py`
|
|
|
+- Modify: `backend/app/services/oa_publish.py` (`FILLABLE_TYPES` + `CONDITION_ELIGIBLE_TYPES`)
|
|
|
+- Modify: `backend/app/services/oa_engine.py` (`start_instance` call validate)
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing tests**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/tests/test_oa_person_select.py
|
|
|
+import unittest
|
|
|
+from app.services.oa_person_select import (
|
|
|
+ extract_person_ids,
|
|
|
+ person_contains_match,
|
|
|
+ validate_person_form_values,
|
|
|
+)
|
|
|
+
|
|
|
+class TestOaPersonSelect(unittest.TestCase):
|
|
|
+ def test_validate_multi_ok(self):
|
|
|
+ schema = {
|
|
|
+ "rule": [
|
|
|
+ {
|
|
|
+ "type": "person",
|
|
|
+ "field": "F1",
|
|
|
+ "title": "协同人",
|
|
|
+ "props": {"multiple": True},
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ data = {"F1": [{"id": "u1", "name": "张三"}, {"id": "u2", "name": "李四"}]}
|
|
|
+ self.assertEqual(validate_person_form_values(schema, data), [])
|
|
|
+
|
|
|
+ def test_validate_required_empty(self):
|
|
|
+ schema = {
|
|
|
+ "rule": [
|
|
|
+ {
|
|
|
+ "type": "person",
|
|
|
+ "field": "F1",
|
|
|
+ "title": "协同人",
|
|
|
+ "$required": True,
|
|
|
+ "props": {"multiple": True},
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ errs = validate_person_form_values(schema, {"F1": []})
|
|
|
+ self.assertTrue(any("必填" in e for e in errs))
|
|
|
+
|
|
|
+ def test_validate_max(self):
|
|
|
+ schema = {
|
|
|
+ "rule": [
|
|
|
+ {
|
|
|
+ "type": "person",
|
|
|
+ "field": "F1",
|
|
|
+ "title": "协同人",
|
|
|
+ "props": {"multiple": True, "max": 1},
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ errs = validate_person_form_values(
|
|
|
+ schema,
|
|
|
+ {"F1": [{"id": "u1", "name": "A"}, {"id": "u2", "name": "B"}]},
|
|
|
+ )
|
|
|
+ self.assertTrue(any("上限" in e for e in errs))
|
|
|
+
|
|
|
+ def test_extract_and_contains(self):
|
|
|
+ self.assertEqual(extract_person_ids({"id": "u1", "name": "A"}), {"u1"})
|
|
|
+ self.assertTrue(
|
|
|
+ person_contains_match(
|
|
|
+ [{"id": "u1", "name": "A"}],
|
|
|
+ ["u1", "u2"],
|
|
|
+ )
|
|
|
+ )
|
|
|
+ self.assertFalse(
|
|
|
+ person_contains_match(
|
|
|
+ [{"id": "u3", "name": "C"}],
|
|
|
+ ["u1", "u2"],
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ unittest.main()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run — expect FAIL**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_person_select.py -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement `oa_person_select.py`**
|
|
|
+
|
|
|
+Copy structure from `oa_dept_select.py`, replace type `"dept"` → `"person"`, labels「人员」, and add:
|
|
|
+
|
|
|
+```python
|
|
|
+def extract_person_ids(raw: Any) -> set:
|
|
|
+ ids = set()
|
|
|
+ items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, dict) else [])
|
|
|
+ for item in items:
|
|
|
+ if not isinstance(item, dict):
|
|
|
+ continue
|
|
|
+ pid = str(item.get("id") or "").strip()
|
|
|
+ if pid:
|
|
|
+ ids.add(pid)
|
|
|
+ return ids
|
|
|
+
|
|
|
+
|
|
|
+def person_contains_match(form_value: Any, cond_ids: Any) -> bool:
|
|
|
+ form_ids = extract_person_ids(form_value)
|
|
|
+ if isinstance(cond_ids, str):
|
|
|
+ want = {cond_ids.strip()} if cond_ids.strip() else set()
|
|
|
+ elif isinstance(cond_ids, list):
|
|
|
+ want = {str(x).strip() for x in cond_ids if str(x).strip()}
|
|
|
+ else:
|
|
|
+ want = set()
|
|
|
+ return bool(form_ids & want)
|
|
|
+```
|
|
|
+
|
|
|
+Validation rules mirror dept (`multiple` default True, max, required, `{id,name}` shape). **Do not** require live DB user existence in this task (shape-only, same spirit as dept).
|
|
|
+
|
|
|
+- [ ] **Step 4: Whitelist + start_instance**
|
|
|
+
|
|
|
+In `oa_publish.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+# FILLABLE_TYPES add:
|
|
|
+"person",
|
|
|
+
|
|
|
+# CONDITION_ELIGIBLE_TYPES add:
|
|
|
+"person",
|
|
|
+```
|
|
|
+
|
|
|
+In `oa_engine.py` `start_instance` next to dept/money:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.services.oa_person_select import validate_person_form_values
|
|
|
+person_errs = validate_person_form_values(schema, data)
|
|
|
+if person_errs:
|
|
|
+ raise OaEngineError(";".join(person_errs))
|
|
|
+```
|
|
|
+
|
|
|
+(Match exact error-raising pattern already used for money/dept.)
|
|
|
+
|
|
|
+- [ ] **Step 5: Run tests PASS + commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_person_select.py -v
|
|
|
+git add backend/app/services/oa_person_select.py backend/tests/test_oa_person_select.py \
|
|
|
+ backend/app/services/oa_publish.py backend/app/services/oa_engine.py
|
|
|
+git commit -m "feat(backend): validate OA person field and whitelist type"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Engine `contains` matching (TDD) — also powers 发起流程预览
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/services/oa_engine.py` (`_match_condition_group`)
|
|
|
+- Modify: `backend/tests/test_oa_engine.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add failing tests**
|
|
|
+
|
|
|
+```python
|
|
|
+def test_condition_contains_person(self):
|
|
|
+ groups = [
|
|
|
+ {
|
|
|
+ "name": "hit",
|
|
|
+ "rules": [
|
|
|
+ {"field": "Fperson", "op": "contains", "value": ["u1", "u2"]}
|
|
|
+ ],
|
|
|
+ "child": {"id": "n1", "type": "end"},
|
|
|
+ },
|
|
|
+ {"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
|
|
|
+ ]
|
|
|
+ g = _match_condition_group(
|
|
|
+ groups,
|
|
|
+ {"Fperson": [{"id": "u2", "name": "李四"}]},
|
|
|
+ )
|
|
|
+ self.assertEqual(g["name"], "hit")
|
|
|
+ g2 = _match_condition_group(
|
|
|
+ groups,
|
|
|
+ {"Fperson": [{"id": "u9", "name": "其他人"}]},
|
|
|
+ )
|
|
|
+ self.assertEqual(g2["name"], "default")
|
|
|
+
|
|
|
+def test_condition_contains_person_single_object(self):
|
|
|
+ groups = [
|
|
|
+ {
|
|
|
+ "name": "hit",
|
|
|
+ "rules": [{"field": "Fperson", "op": "contains", "value": ["u1"]}],
|
|
|
+ "child": {"id": "n1", "type": "end"},
|
|
|
+ },
|
|
|
+ {"name": "default", "isDefault": True, "child": {"id": "n2", "type": "end"}},
|
|
|
+ ]
|
|
|
+ g = _match_condition_group(
|
|
|
+ groups, {"Fperson": {"id": "u1", "name": "张三"}}
|
|
|
+ )
|
|
|
+ self.assertEqual(g["name"], "hit")
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run — expect FAIL**(当前会把 `contains` 塞进公式字符串并失败/走默认)
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_person -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Patch `_match_condition_group`**
|
|
|
+
|
|
|
+When building/evaluating list `rules`, **do not** stringify `contains` into formula. Evaluate AND of rules with a helper:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.services.oa_person_select import person_contains_match
|
|
|
+
|
|
|
+def _eval_condition_rules(rules: list, form_data: dict) -> bool:
|
|
|
+ for r in rules:
|
|
|
+ if not isinstance(r, dict):
|
|
|
+ return False
|
|
|
+ field = r.get("field")
|
|
|
+ op = r.get("op") or "=="
|
|
|
+ val = r.get("value")
|
|
|
+ if field is None:
|
|
|
+ return False
|
|
|
+ if op == "contains":
|
|
|
+ if not person_contains_match(form_data.get(field), val):
|
|
|
+ return False
|
|
|
+ continue
|
|
|
+ # existing formula path for one rule:
|
|
|
+ if isinstance(val, str):
|
|
|
+ expr = f"{{{field}}} {op} '{val}'"
|
|
|
+ else:
|
|
|
+ expr = f"{{{field}}} {op} {val}"
|
|
|
+ try:
|
|
|
+ if not eval_formula(expr, form_data):
|
|
|
+ return False
|
|
|
+ except FormulaError:
|
|
|
+ return False
|
|
|
+ return True
|
|
|
+```
|
|
|
+
|
|
|
+In `_match_condition_group`, when `rules` is a non-empty list:
|
|
|
+
|
|
|
+```python
|
|
|
+try:
|
|
|
+ if _eval_condition_rules(rules, form_data):
|
|
|
+ return g
|
|
|
+except FormulaError:
|
|
|
+ continue
|
|
|
+```
|
|
|
+
|
|
|
+Keep `when` string path unchanged for legacy.
|
|
|
+
|
|
|
+- [ ] **Step 4: Run full helper tests PASS + commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestOaEngineHelpers -v
|
|
|
+git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py
|
|
|
+git commit -m "feat(backend): evaluate person contains rules in condition groups"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: DragRule + register + OaPersonSelect(含预览布局)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/utils/oa-person-select-rule.ts`
|
|
|
+- Create: `web-admin/src/utils/oa-form-create-person.ts`
|
|
|
+- Create: `web-admin/src/components/oa-approval/OaPersonSelect.vue`
|
|
|
+- Modify: `web-admin/src/main.ts`
|
|
|
+- Modify: `web-admin/src/oa-fc-host/main.ts`
|
|
|
+- Modify: `web-admin/src/assets/styles/core/oa-fc-runtime.scss`
|
|
|
+
|
|
|
+- [ ] **Step 1: DragRule**(对齐 `oa-dept-select-rule.ts`)
|
|
|
+
|
|
|
+```ts
|
|
|
+// web-admin/src/utils/oa-person-select-rule.ts
|
|
|
+let _uidSeq = 0
|
|
|
+function uniqueId() {
|
|
|
+ const num = 370 + ++_uidSeq
|
|
|
+ return (
|
|
|
+ 'F' +
|
|
|
+ Math.random().toString(36).slice(3, 6) +
|
|
|
+ Number(`${Date.now()}`).toString(36) +
|
|
|
+ num.toString(36) +
|
|
|
+ 'p'
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+export const personSelectDragRule = {
|
|
|
+ menu: 'main' as const,
|
|
|
+ icon: 'icon-user', // if missing in icon font, use 'icon-input' like other custom fields
|
|
|
+ label: '人员',
|
|
|
+ name: 'person',
|
|
|
+ input: true,
|
|
|
+ mask: true,
|
|
|
+ validate: ['string', 'array', 'object'],
|
|
|
+ event: ['change'],
|
|
|
+ rule() {
|
|
|
+ return {
|
|
|
+ type: 'person',
|
|
|
+ field: uniqueId(),
|
|
|
+ title: '人员',
|
|
|
+ info: '',
|
|
|
+ $required: false,
|
|
|
+ props: {
|
|
|
+ multiple: true,
|
|
|
+ max: undefined as number | undefined
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ props() {
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ type: 'switch',
|
|
|
+ field: 'multiple',
|
|
|
+ title: '允许多选',
|
|
|
+ value: true,
|
|
|
+ props: { activeText: '是', inactiveText: '否' }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'inputNumber',
|
|
|
+ field: 'max',
|
|
|
+ title: '最大可选数',
|
|
|
+ value: undefined,
|
|
|
+ props: { min: 1, precision: 0, placeholder: '不填则不限制' }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'input',
|
|
|
+ field: 'formCreateInfo',
|
|
|
+ title: '提示信息'
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+If `icon-user` missing in designer icon set, fall back to `icon-input` (verify in designer left panel).
|
|
|
+
|
|
|
+- [ ] **Step 2: setup + main registration**
|
|
|
+
|
|
|
+```ts
|
|
|
+// web-admin/src/utils/oa-form-create-person.ts
|
|
|
+import FcDesigner from '@form-create/designer'
|
|
|
+import OaPersonSelect from '@/components/oa-approval/OaPersonSelect.vue'
|
|
|
+import { personSelectDragRule } from '@/utils/oa-person-select-rule'
|
|
|
+
|
|
|
+const FLAG = '__oaPersonSelectConfigured'
|
|
|
+
|
|
|
+export function setupOaPersonSelect(formCreate?: { component?: (name: string, c: unknown) => void }) {
|
|
|
+ if ((FcDesigner as any)[FLAG]) return
|
|
|
+ FcDesigner.component('person', OaPersonSelect)
|
|
|
+ if (typeof formCreate?.component === 'function') {
|
|
|
+ formCreate.component('person', OaPersonSelect)
|
|
|
+ }
|
|
|
+ if (typeof FcDesigner.addDragRule === 'function') {
|
|
|
+ FcDesigner.addDragRule(personSelectDragRule)
|
|
|
+ }
|
|
|
+ ;(FcDesigner as any)[FLAG] = true
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+In `main.ts` and `oa-fc-host/main.ts` after money/dept:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { setupOaPersonSelect } from '@/utils/oa-form-create-person'
|
|
|
+setupOaPersonSelect(FcDesigner.formCreate)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement `OaPersonSelect.vue`**
|
|
|
+
|
|
|
+Mirror `OaDeptSelect.vue` structure with these differences:
|
|
|
+
|
|
|
+- Options from `fetchUserList({ status: '1', page, pageSize })` paginated to end(同 `start/index.vue` `loadUsers`,用 `MAX_ADMIN_LIST_PAGE_SIZE`)。
|
|
|
+- PC: `el-select` filterable clearable;`:multiple="multiple"`;options `value=id`,`label = nickName || userNo`。
|
|
|
+- Map selection → `{ id, name }` via in-memory `Map` + already-selected names.
|
|
|
+- Narrow (`matchMedia max-width 767px`): trigger + drawer + checkbox list / radio click(仿部门,列表非树)。
|
|
|
+- `append-to-body` on drawer so it works above designer chrome / preview dialog.
|
|
|
+- Styles: root `.oa-person-select` full width;`:deep(.el-select)` / tags `flex-wrap: wrap`;`min-height` 触控友好(对齐 money 40/44)。
|
|
|
+
|
|
|
+Skeleton(关键部分):
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="oa-person-select">
|
|
|
+ <template v-if="disabled">
|
|
|
+ <span class="oa-person-select__readonly">{{ displayText || '—' }}</span>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="isNarrow">
|
|
|
+ <!-- trigger + el-drawer:搜索 + el-checkbox-group / 单选列表 -->
|
|
|
+ </template>
|
|
|
+ <el-select
|
|
|
+ v-else
|
|
|
+ :model-value="selectModel"
|
|
|
+ class="oa-person-select__select"
|
|
|
+ filterable
|
|
|
+ clearable
|
|
|
+ :multiple="multiple"
|
|
|
+ collapse-tags
|
|
|
+ collapse-tags-tooltip
|
|
|
+ placeholder="请选择人员"
|
|
|
+ style="width: 100%"
|
|
|
+ @update:model-value="onSelectChange"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="u in userOptions"
|
|
|
+ :key="u.id"
|
|
|
+ :label="u.label"
|
|
|
+ :value="u.id"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+```
|
|
|
+
|
|
|
+Notes:
|
|
|
+
|
|
|
+- `collapse-tags` 避免 mobile 400px 画布被大量 tag 撑爆;完整名单仍在 model / 抽屉确认里。
|
|
|
+- Preview dialog 与 mobile 画布都走 PC `el-select` 路径(iframe 宽),故 **全宽 + wrap/collapse** 是预览兼容关键。
|
|
|
+
|
|
|
+- [ ] **Step 4: `oa-fc-runtime.scss`**
|
|
|
+
|
|
|
+```scss
|
|
|
+ .oa-dept-select,
|
|
|
+ .oa-money-field,
|
|
|
+ .oa-person-select,
|
|
|
+ .oa-related-approval {
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Browser smoke(预览必测)**
|
|
|
+
|
|
|
+1. 打开设计器「2 表单设计」→ 左侧有「人员」→ 拖入。
|
|
|
+2. 切换顶部 **Mobile** 设备:控件不横向溢出;可选人。
|
|
|
+3. 点工具栏 **预览**:表单模式可选人、多选写入。
|
|
|
+4. 保存发布;发起页可选人提交。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/utils/oa-person-select-rule.ts \
|
|
|
+ web-admin/src/utils/oa-form-create-person.ts \
|
|
|
+ web-admin/src/components/oa-approval/OaPersonSelect.vue \
|
|
|
+ web-admin/src/main.ts web-admin/src/oa-fc-host/main.ts \
|
|
|
+ web-admin/src/assets/styles/core/oa-fc-runtime.scss
|
|
|
+git commit -m "feat(web-admin): register OA person select for designer and preview"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: Condition UI(仅「包含」)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/components/oa-approval/oa-condition-fields.ts`
|
|
|
+- Modify: `web-admin/src/components/oa-approval/flow-tree/types.ts`
|
|
|
+- Modify: `web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue`
|
|
|
+- Modify: `web-admin/src/components/oa-approval/flow-tree/FlowBranch.vue`(若 `formatConditionPreview` 需展示 id→名;优先在 `formatConditionPreview` 内用候选或 raw ids)
|
|
|
+
|
|
|
+- [ ] **Step 1: Types + ops**
|
|
|
+
|
|
|
+```ts
|
|
|
+// types.ts
|
|
|
+export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains'
|
|
|
+
|
|
|
+export interface ConditionRule {
|
|
|
+ field: string
|
|
|
+ op: ConditionOp
|
|
|
+ value: string | number | string[]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```ts
|
|
|
+// oa-condition-fields.ts — CONDITION_ELIGIBLE_TYPES add 'person'
|
|
|
+// ConditionOp type sync
|
|
|
+export function opsForFieldType(type: string): Array<{ value: ConditionOp; label: string }> {
|
|
|
+ if (type === 'person') {
|
|
|
+ return [{ value: 'contains', label: '包含' }]
|
|
|
+ }
|
|
|
+ if (type === 'radio' || type === 'select') {
|
|
|
+ return [
|
|
|
+ { value: '==', label: '等于' },
|
|
|
+ { value: '!=', label: '不等于' }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ { value: '>', label: '大于' },
|
|
|
+ { value: '>=', label: '大于等于' },
|
|
|
+ { value: '<', label: '小于' },
|
|
|
+ { value: '<=', label: '小于等于' },
|
|
|
+ { value: '==', label: '等于' },
|
|
|
+ { value: '!=', label: '不等于' }
|
|
|
+ ]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Update `formatConditionPreview` for array values:
|
|
|
+
|
|
|
+```ts
|
|
|
+const valueText = Array.isArray(r.value)
|
|
|
+ ? r.value.join('、')
|
|
|
+ : r.value ?? '?'
|
|
|
+```
|
|
|
+
|
|
|
+(更好:ConditionRuleEditor 存 id,预览时用已加载用户 map 显示姓名——在 Editor 内维护 label。)
|
|
|
+
|
|
|
+- [ ] **Step 2: ConditionRuleEditor**
|
|
|
+
|
|
|
+When `meta(field)?.type === 'person'`:
|
|
|
+
|
|
|
+- Op select can be disabled single option「包含」.
|
|
|
+- Value: `el-select` multiple filterable,options from `fetchUserList`(组件 mount 时加载一次,与 FlowDesigner 相同)。
|
|
|
+- `onValueChange` 写入 `string[]`。
|
|
|
+- On field change to person: `op: 'contains', value: []` then user fills.
|
|
|
+
|
|
|
+Also fix `onValueChange` typing to allow `string[]`.
|
|
|
+
|
|
|
+- [ ] **Step 3: Smoke**
|
|
|
+
|
|
|
+1. 人员字段勾选「参与流程条件分支」。
|
|
|
+2. 「3 流程设计」条件可选该字段,运算符仅「包含」,右侧多选人。
|
|
|
+3. 发起页改人员选择 → 底部流程预览分支变化。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/components/oa-approval/oa-condition-fields.ts \
|
|
|
+ web-admin/src/components/oa-approval/flow-tree/types.ts \
|
|
|
+ web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue
|
|
|
+git commit -m "feat(web-admin): person field contains-only condition editor"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: Print + related-approval summary
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/utils/oa-approval-print.ts`
|
|
|
+- Modify: `backend/app/services/oa_related_approval.py`
|
|
|
+- Modify: `backend/tests/test_oa_related_approval.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Print**
|
|
|
+
|
|
|
+After dept block:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { formatPersonDisplay } from '@/utils/oa-person-value'
|
|
|
+
|
|
|
+if (type === 'person') {
|
|
|
+ const text = formatPersonDisplay(value)
|
|
|
+ out.push({ label, text: text || '-' })
|
|
|
+ continue
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Backend `_format_value`**
|
|
|
+
|
|
|
+Same as dept branch but `typ == "person"`.
|
|
|
+
|
|
|
+- [ ] **Step 3: Test + commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
|
|
+git add web-admin/src/utils/oa-approval-print.ts \
|
|
|
+ backend/app/services/oa_related_approval.py \
|
|
|
+ backend/tests/test_oa_related_approval.py
|
|
|
+git commit -m "feat(oa): format person fields in print and related summary"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: Spec status + hand-test checklist
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/superpowers/specs/2026-07-30-oa-person-select-design.md`
|
|
|
+
|
|
|
+- [ ] **Step 1: Mark status「已实现」;确认文档含预览章节(device + 预览按钮 + 发起流程预览)**
|
|
|
+
|
|
|
+- [ ] **Step 2: Full hand-test checklist**
|
|
|
+
|
|
|
+- [ ] 设计器拖入人员;单/多选、max
|
|
|
+- [ ] Mobile 设备画布不溢出、可选
|
|
|
+- [ ] 「预览」弹窗表单模式可选、提交校验
|
|
|
+- [ ] useInCondition + 流程 contains
|
|
|
+- [ ] 发起页选人 → 流程预览分支正确
|
|
|
+- [ ] 提交、详情、打印姓名快照
|
|
|
+- [ ] 金额/部门回归
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/specs/2026-07-30-oa-person-select-design.md
|
|
|
+git commit -m "docs: mark OA person select design implemented"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Update graphs if code changed**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh all
|
|
|
+```
|
|
|
+
|
|
|
+(仅 backend/web-admin 有实质代码提交后再跑。)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage self-check
|
|
|
+
|
|
|
+| Spec 要求 | Task |
|
|
|
+|-----------|------|
|
|
|
+| type=person 四层扩展 | 4 |
|
|
|
+| `{id,name}` 存值 | 1, 4 |
|
|
|
+| 对齐部门 UI + max | 4 |
|
|
|
+| useInCondition | 5(eligible types) |
|
|
|
+| 仅 contains,OR | 3, 5 |
|
|
|
+| 发布白名单 / 校验 | 2 |
|
|
|
+| 打印/摘要 | 6 |
|
|
|
+| 设计器 Mobile 画布 + 预览按钮 | 4(preview section) |
|
|
|
+| 发起流程预览 | 3(engine)+ 5 smoke |
|
|
|
+
|
|
|
+**Placeholder scan:** none intentional.
|
|
|
+**Type consistency:** `person` / `contains` / `OaPersonValueItem` / `string[]` value aligned across tasks.
|