From e760de4bd3abc838de92409cb94a4918e972f32a Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 30 Jul 2026 12:08:06 +0800 Subject: [PATCH] docs: add OA person select implementation plan with preview notes. Include FormCreate mobile canvas and preview dialog constraints, plus start-page flow preview via contains matching. Co-authored-by: Cursor --- .../plans/2026-07-30-oa-person-select.md | 837 ++++++++++++++++++ .../2026-07-30-oa-person-select-design.md | 17 +- 2 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-30-oa-person-select.md diff --git a/docs/superpowers/plans/2026-07-30-oa-person-select.md b/docs/superpowers/plans/2026-07-30-oa-person-select.md new file mode 100644 index 00000000..2bed1598 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-oa-person-select.md @@ -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 + 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() + 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 + +``` + +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. diff --git a/docs/superpowers/specs/2026-07-30-oa-person-select-design.md b/docs/superpowers/specs/2026-07-30-oa-person-select-design.md index cad78467..75c2ebc0 100644 --- a/docs/superpowers/specs/2026-07-30-oa-person-select-design.md +++ b/docs/superpowers/specs/2026-07-30-oa-person-select-design.md @@ -140,6 +140,20 @@ type PersonFormValueMulti = OaPersonValueItem[] - `fetchUserList({ status: '1', page, pageSize })` 分页拉全量(与发起页 / 流程设计器选人一致)。 - 组件内维护 id→name 映射,将 select 的 id 还原为 `{ id, name }`。 +### 5.6 设计器「设备切换」与「预览」兼容 + +FormCreate 在 `oa-fc-host` 中有两处预览相关能力,人员控件 **SHALL** 同时可用: + +| 场景 | 行为约束 | +|------|----------| +| 顶部 **PC / Pad / Mobile** 设备切换 | Mobile 时画布 `._fc-m-drag.mobile` 固定约 **400px** 宽。iframe 视口仍可能 ≥768,**不能**仅依赖 `matchMedia` 才可用。PC 路径的 `el-select` **SHALL** 全宽、`min-width:0`,多选 tag 可折叠/换行,禁止横向撑破画布。 | +| 工具栏 **预览** 按钮 | 弹窗内 `ViewForm` 渲染同一套已注册 `person` 组件;`setupOaPersonSelect` 必须在 `oa-fc-host` 注册,否则预览空白。抽屉(若触发)`append-to-body`。 | +| 发起页 **流程预览** | 填写人员后 `form_data` 进入 `fetchOaFlowPreview`;条件 `contains` 由引擎集合匹配驱动分支展示。 | + +窄屏抽屉(`matchMedia max-width: 767px`)仍保留,用于真实窄 viewport / 真机,与部门一致。 + +全局运行时样式:`oa-fc-runtime.scss` 将 `.oa-person-select` 与 `.oa-dept-select` 并列,保证列宽内不溢出。 + --- ## 6. 条件分支 @@ -255,7 +269,8 @@ hit = |formIds ∩ condIds| > 0 ## 11. 验收标准 - [ ] 设计器左侧可拖入「人员」,可配单多选与 max -- [ ] 发起页可选人并正确提交 `{ id, name }` / 数组 +- [ ] Mobile 设备画布与「预览」弹窗中人员控件可正常选择且不横向溢出 +- [ ] 发起页可选人并正确提交 `{ id, name }` / 数组;流程预览随 contains 条件切换分支 - [ ] 勾选参与条件后,流程条件仅「包含」,右侧可多选人,任一命中走对应分支 - [ ] 详情与打印展示姓名;历史快照不随用户改名变化 - [ ] 未知控件发布校验通过;既有金额/部门行为无回归