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
| 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 |
FormCreate has two preview surfaces in oa-fc-host:
._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 可换行、不撑破布局。el-dialog + ViewForm:必须已 formCreate.component('person', …),否则预览里控件空白/报错。双侧 setupOaPersonSelect 已覆盖。fetchOaFlowPreview):改 form_data 后走同一套 _match_condition_group;contains 引擎改完后自然生效。手测:人员勾选进入条件名单 → 预览走对应分支。CSS:在 oa-fc-runtime.scss 把 .oa-person-select 与 .oa-dept-select 并列,保证 width/max-width/min-width:0。
Files:
web-admin/src/utils/oa-person-value.tsCreate: web-admin/src/utils/oa-person-value.test.ts
[ ] Step 1: Write failing tests
// 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']
)
})
})
cd web-admin && pnpm exec tsx --test src/utils/oa-person-value.test.ts
Expected: module not found / FAIL
// 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
}
cd web-admin && pnpm exec tsx --test src/utils/oa-person-value.test.ts
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"
Files:
backend/app/services/oa_person_select.pybackend/tests/test_oa_person_select.pybackend/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
# 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()
cd backend && source venv/bin/activate && pytest tests/test_oa_person_select.py -v
oa_person_select.pyCopy structure from oa_dept_select.py, replace type "dept" → "person", labels「人员」, and add:
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).
In oa_publish.py:
# FILLABLE_TYPES add:
"person",
# CONDITION_ELIGIBLE_TYPES add:
"person",
In oa_engine.py start_instance next to dept/money:
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.)
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"
contains matching (TDD) — also powers 发起流程预览Files:
backend/app/services/oa_engine.py (_match_condition_group)Modify: backend/tests/test_oa_engine.py
[ ] Step 1: Add failing tests
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")
contains 塞进公式字符串并失败/走默认)cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_person -v
_match_condition_groupWhen building/evaluating list rules, do not stringify contains into formula. Evaluate AND of rules with a helper:
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:
try:
if _eval_condition_rules(rules, form_data):
return g
except FormulaError:
continue
Keep when string path unchanged for legacy.
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"
Files:
web-admin/src/utils/oa-person-select-rule.tsweb-admin/src/utils/oa-form-create-person.tsweb-admin/src/components/oa-approval/OaPersonSelect.vueweb-admin/src/main.tsweb-admin/src/oa-fc-host/main.tsModify: web-admin/src/assets/styles/core/oa-fc-runtime.scss
[ ] Step 1: DragRule(对齐 oa-dept-select-rule.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).
// 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:
import { setupOaPersonSelect } from '@/utils/oa-form-create-person'
setupOaPersonSelect(FcDesigner.formCreate)
OaPersonSelect.vueMirror OaDeptSelect.vue structure with these differences:
fetchUserList({ status: '1', page, pageSize }) paginated to end(同 start/index.vue loadUsers,用 MAX_ADMIN_LIST_PAGE_SIZE)。el-select filterable clearable;:multiple="multiple";options value=id,label = nickName || userNo。{ id, name } via in-memory Map + already-selected names.matchMedia max-width 767px): trigger + drawer + checkbox list / radio click(仿部门,列表非树)。append-to-body on drawer so it works above designer chrome / preview dialog..oa-person-select full width;:deep(.el-select) / tags flex-wrap: wrap;min-height 触控友好(对齐 money 40/44)。Skeleton(关键部分):
<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
.oa-dept-select,
.oa-money-field,
.oa-person-select,
.oa-related-approval {
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"
Files:
web-admin/src/components/oa-approval/oa-condition-fields.tsweb-admin/src/components/oa-approval/flow-tree/types.tsweb-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vueModify: web-admin/src/components/oa-approval/flow-tree/FlowBranch.vue(若 formatConditionPreview 需展示 id→名;优先在 formatConditionPreview 内用候选或 raw ids)
[ ] Step 1: Types + ops
// types.ts
export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains'
export interface ConditionRule {
field: string
op: ConditionOp
value: string | number | string[]
}
// 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:
const valueText = Array.isArray(r.value)
? r.value.join('、')
: r.value ?? '?'
(更好:ConditionRuleEditor 存 id,预览时用已加载用户 map 显示姓名——在 Editor 内维护 label。)
When meta(field)?.type === 'person':
el-select multiple filterable,options from fetchUserList(组件 mount 时加载一次,与 FlowDesigner 相同)。onValueChange 写入 string[]。op: 'contains', value: [] then user fills.Also fix onValueChange typing to allow string[].
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"
Files:
web-admin/src/utils/oa-approval-print.tsbackend/app/services/oa_related_approval.pyModify: backend/tests/test_oa_related_approval.py
[ ] Step 1: Print
After dept block:
import { formatPersonDisplay } from '@/utils/oa-person-value'
if (type === 'person') {
const text = formatPersonDisplay(value)
out.push({ label, text: text || '-' })
continue
}
_format_valueSame as dept branch but typ == "person".
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"
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
git add docs/superpowers/specs/2026-07-30-oa-person-select-design.md
git commit -m "docs: mark OA person select design implemented"
./scripts/update-graph.sh all
(仅 backend/web-admin 有实质代码提交后再跑。)
| 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.