Files
zkxw/docs/superpowers/plans/2026-07-27-oa-dept-select.md
T
zlwuandCursor 1d24f1c682 docs(oa): add department select implementation plan
Break the approved dept control design into TDD-ready tasks covering
validation, FormCreate registration, PC/narrow pickers, and print formatting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:21:54 +08:00

966 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OA Department Select Control 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 `dept` control so designers can drag「部门」, initiators can single/multi-select tenant org tree with full-path name snapshots, and narrow viewports use a full-screen/drawer picker.
**Architecture:** Mirror the `relatedApproval` four-layer FcDesigner extension: `OaDeptSelect.vue` + DragRule + `setupOaDeptSelect` on both main app and `oa-fc-host`. Persist `{ id, name }` (or array). Backend validates shape on `start_instance`. Print/summary format `name` with顿号.
**Tech Stack:** FastAPI · pytest · Vue 3 · FormCreate / `@form-create/designer` · Element Plus (`el-tree-select`, `el-drawer`) · `fetchDeptTree` · `buildPathLabelMapFromTreeRoots`
**Spec:** `docs/superpowers/specs/2026-07-27-oa-dept-select-design.md`
**Demo:** `docs/mockups/oa-dept-select-demo.html`
---
## File map
| Path | Responsibility |
|------|----------------|
| Create: `backend/app/services/oa_dept_select.py` | Walk schema; validate `dept` form_data shapes |
| Create: `backend/tests/test_oa_dept_select.py` | Unit tests for validation |
| Modify: `backend/app/services/oa_engine.py` | Call validate before persist on start |
| Modify: `backend/app/services/oa_related_approval.py` | Format `dept` in related-summary flatten |
| Create: `web-admin/src/utils/oa-dept-select-rule.ts` | DragRule + field unique helper |
| Create: `web-admin/src/utils/oa-form-create-dept.ts` | `setupOaDeptSelect` registration |
| Create: `web-admin/src/components/oa-approval/OaDeptSelect.vue` | Runtime PC tree-select + narrow drawer |
| Create: `web-admin/src/utils/oa-dept-value.ts` | Normalize/format `{id,name}` helpers (shared print + component) |
| Modify: `web-admin/src/main.ts` | Call `setupOaDeptSelect` |
| Modify: `web-admin/src/oa-fc-host/main.ts` | Same setup |
| Modify: `web-admin/src/utils/oa-approval-print.ts` | Print `dept` as path text, not JSON |
---
### Task 1: Backend dept form_data validation (TDD)
**Files:**
- Create: `backend/app/services/oa_dept_select.py`
- Create: `backend/tests/test_oa_dept_select.py`
- [ ] **Step 1: Write failing tests**
```python
# backend/tests/test_oa_dept_select.py
import unittest
from app.services.oa_dept_select import validate_dept_form_values
class TestValidateDeptFormValues(unittest.TestCase):
def test_multiple_ok(self):
schema = {
"rule": [
{
"type": "dept",
"field": "depts",
"title": "部门",
"$required": True,
"props": {"multiple": True, "max": 2},
}
]
}
data = {
"depts": [
{"id": "d1", "name": "中科兴皖 / 研发"},
{"id": "d2", "name": "中科兴皖 / 质量"},
]
}
self.assertEqual(validate_dept_form_values(schema, data), [])
def test_required_empty_array(self):
schema = {
"rule": [
{
"type": "dept",
"field": "depts",
"$required": True,
"props": {"multiple": True},
}
]
}
errs = validate_dept_form_values(schema, {"depts": []})
self.assertTrue(any("必填" in e or "部门" in e for e in errs))
def test_single_must_be_object(self):
schema = {
"rule": [
{
"type": "dept",
"field": "dept",
"props": {"multiple": False},
}
]
}
errs = validate_dept_form_values(schema, {"dept": [{"id": "d1", "name": "A"}]})
self.assertTrue(len(errs) >= 1)
def test_max_exceeded(self):
schema = {
"rule": [
{
"type": "dept",
"field": "depts",
"props": {"multiple": True, "max": 1},
}
]
}
data = {
"depts": [
{"id": "d1", "name": "A"},
{"id": "d2", "name": "B"},
]
}
errs = validate_dept_form_values(schema, data)
self.assertTrue(any("最大" in e or "上限" in e for e in errs))
def test_nested_fc_row(self):
schema = {
"rule": [
{
"type": "fcRow",
"children": [
{
"type": "dept",
"field": "d",
"required": True,
"props": {"multiple": False},
}
],
}
]
}
errs = validate_dept_form_values(schema, {})
self.assertTrue(len(errs) >= 1)
def test_no_dept_fields_ok(self):
schema = {"rule": [{"type": "input", "field": "t"}]}
self.assertEqual(validate_dept_form_values(schema, {"t": "x"}), [])
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run tests — expect FAIL**
```bash
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py -v
```
Expected: `ModuleNotFoundError` or import error for `oa_dept_select`.
- [ ] **Step 3: Implement validator**
```python
# backend/app/services/oa_dept_select.py
"""OA 部门控件:form_data 形态校验"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _walk_rules(rules: Any, out: List[dict]) -> None:
if not isinstance(rules, list):
return
for r in rules:
if not isinstance(r, dict):
continue
out.append(r)
children = r.get("children") or (r.get("props") or {}).get("rule") or []
if isinstance(children, list):
_walk_rules(children, out)
def _schema_rules(schema: Any) -> List[dict]:
if isinstance(schema, list):
out: List[dict] = []
_walk_rules(schema, out)
return out
if not isinstance(schema, dict):
return []
out: List[dict] = []
_walk_rules(schema.get("rule") or schema.get("rules") or [], out)
return out
def _is_required(rule: dict) -> bool:
if rule.get("$required") is True or rule.get("required") is True:
return True
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
return props.get("required") is True
def _is_multiple(rule: dict) -> bool:
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
# 默认多选(与设计一致)
if "multiple" not in props:
return True
return props.get("multiple") is True
def _max_count(rule: dict) -> Optional[int]:
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
raw = props.get("max")
if raw is None or raw == "":
return None
try:
n = int(raw)
except (TypeError, ValueError):
return None
return n if n > 0 else None
def _valid_item(obj: Any) -> bool:
if not isinstance(obj, dict):
return False
id_ = str(obj.get("id") or "").strip()
name = str(obj.get("name") or "").strip()
return bool(id_) and bool(name)
def validate_dept_form_values(
schema: Any, form_data: Optional[Dict[str, Any]]
) -> List[str]:
"""返回人类可读错误列表;空列表表示通过。不校验 id 是否仍存在于组织树。"""
data = form_data or {}
errors: List[str] = []
for rule in _schema_rules(schema):
if str(rule.get("type") or "") != "dept":
continue
field = str(rule.get("field") or "").strip()
if not field:
continue
title = str(rule.get("title") or rule.get("label") or field)
required = _is_required(rule)
multiple = _is_multiple(rule)
max_n = _max_count(rule)
raw = data.get(field)
if multiple:
if raw is None or raw == "":
if required:
errors.append(f"「{title}」为必填部门字段")
continue
if not isinstance(raw, list):
errors.append(f"「{title}」多选时须为数组")
continue
if required and len(raw) == 0:
errors.append(f"「{title}」为必填部门字段")
continue
if max_n is not None and len(raw) > max_n:
errors.append(f"「{title}」最多可选 {max_n} 个部门")
continue
for i, item in enumerate(raw):
if not _valid_item(item):
errors.append(f"「{title}」第 {i + 1} 项须含非空 id 与 name")
break
else:
if raw is None or raw == "":
if required:
errors.append(f"「{title}」为必填部门字段")
continue
if isinstance(raw, list):
errors.append(f"「{title}」单选时须为对象而非数组")
continue
if not _valid_item(raw):
errors.append(f"「{title}」须为含非空 id 与 name 的对象")
return errors
```
- [ ] **Step 4: Run tests — expect PASS**
```bash
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py -v
```
Expected: all tests PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/oa_dept_select.py backend/tests/test_oa_dept_select.py
git commit -m "feat(oa): validate dept control form_data shapes"
```
---
### Task 2: Wire validation into start_instance
**Files:**
- Modify: `backend/app/services/oa_engine.py` (inside `start_instance`, after `apply_formulas_and_linkages`)
- [ ] **Step 1: Add import and call**
After:
```python
try:
data = apply_formulas_and_linkages(schema, data)
except FormulaError as e:
raise OaEngineError(f"公式计算失败: {e}") from e
```
Insert:
```python
from app.services.oa_dept_select import validate_dept_form_values
dept_errs = validate_dept_form_values(schema, data)
if dept_errs:
raise OaEngineError(dept_errs[0])
```
- [ ] **Step 2: Quick regression**
```bash
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py tests/test_oa_related_approval.py tests/test_oa_publish.py -v --tb=short
```
Expected: PASS (no new failures).
- [ ] **Step 3: Commit**
```bash
git add backend/app/services/oa_engine.py
git commit -m "feat(oa): reject invalid dept form_data on start"
```
---
### Task 3: Frontend value helpers + DragRule + setup
**Files:**
- Create: `web-admin/src/utils/oa-dept-value.ts`
- Create: `web-admin/src/utils/oa-dept-select-rule.ts`
- Create: `web-admin/src/utils/oa-form-create-dept.ts`
- Modify: `web-admin/src/main.ts`
- Modify: `web-admin/src/oa-fc-host/main.ts`
- [ ] **Step 1: Create value helpers**
```typescript
// web-admin/src/utils/oa-dept-value.ts
export type OaDeptValueItem = { id: string; name: string }
export function isDeptValueItem(v: unknown): v is OaDeptValueItem {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
return String(o.id || '').trim() !== '' && String(o.name || '').trim() !== ''
}
/** 单选 → item|null;多选 → items(容错把单对象包成数组) */
export function normalizeDeptModelValue(
raw: unknown,
multiple: boolean
): OaDeptValueItem | OaDeptValueItem[] | null {
if (multiple) {
if (Array.isArray(raw)) {
return raw.filter(isDeptValueItem).map((x) => ({
id: String(x.id).trim(),
name: String(x.name).trim()
}))
}
if (isDeptValueItem(raw)) {
return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }]
}
return []
}
if (isDeptValueItem(raw)) {
return { id: String(raw.id).trim(), name: String(raw.name).trim() }
}
if (Array.isArray(raw) && raw.length === 1 && isDeptValueItem(raw[0])) {
return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() }
}
return null
}
export function formatDeptDisplay(raw: unknown): string {
if (Array.isArray(raw)) {
return raw
.filter(isDeptValueItem)
.map((x) => x.name)
.filter(Boolean)
.join('、')
}
if (isDeptValueItem(raw)) return raw.name
return ''
}
```
- [ ] **Step 2: Create DragRule**
```typescript
// web-admin/src/utils/oa-dept-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) +
'c'
)
}
export const deptSelectDragRule = {
menu: 'main' as const,
icon: 'icon-tree',
label: '部门',
name: 'dept',
input: true,
mask: true,
validate: ['string', 'array', 'object'],
event: ['change'],
rule() {
return {
type: 'dept',
field: uniqueId(),
title: '部门',
info: '',
$required: false,
props: {
multiple: true,
max: 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: '不填则不限制' },
// 单选时可隐藏:若设计器不支持联动,保留字段但运行时仅 multiple 时读取
},
{
type: 'input',
field: 'formCreateInfo',
title: '提示信息'
}
]
}
}
```
- [ ] **Step 3: Create setup (stub component import — Task 4 creates the Vue file)**
```typescript
// web-admin/src/utils/oa-form-create-dept.ts
import FcDesigner from '@form-create/designer'
import OaDeptSelect from '@/components/oa-approval/OaDeptSelect.vue'
import { deptSelectDragRule } from '@/utils/oa-dept-select-rule'
const FLAG = '__oaDeptSelectConfigured'
export function setupOaDeptSelect(formCreate?: { component?: (name: string, c: unknown) => void }) {
if ((FcDesigner as any)[FLAG]) return
FcDesigner.component('dept', OaDeptSelect)
if (typeof formCreate?.component === 'function') {
formCreate.component('dept', OaDeptSelect)
}
if (typeof FcDesigner.addDragRule === 'function') {
FcDesigner.addDragRule(deptSelectDragRule)
}
;(FcDesigner as any)[FLAG] = true
}
```
- [ ] **Step 4: Register in both entrypoints**
In `web-admin/src/main.ts`, after `setupOaRelatedApproval`:
```typescript
import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
// ...
setupOaRelatedApproval(FcDesigner.formCreate)
setupOaDeptSelect(FcDesigner.formCreate)
```
In `web-admin/src/oa-fc-host/main.ts`, same import + call after related approval setup.
- [ ] **Step 5: Commit after Task 4 adds the Vue file** (do not commit a broken import — combine with Task 4 commit, or add a minimal stub component first).
If committing this task alone first, create a minimal stub:
```vue
<!-- web-admin/src/components/oa-approval/OaDeptSelect.vue -->
<template>
<div class="oa-dept-select">部门选择(stub)</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'dept' })
defineProps<{ modelValue?: unknown; disabled?: boolean; multiple?: boolean; max?: number }>()
defineEmits<{ (e: 'update:modelValue', v: unknown): void; (e: 'change', v: unknown): void }>()
</script>
```
Then commit:
```bash
git add web-admin/src/utils/oa-dept-value.ts \
web-admin/src/utils/oa-dept-select-rule.ts \
web-admin/src/utils/oa-form-create-dept.ts \
web-admin/src/components/oa-approval/OaDeptSelect.vue \
web-admin/src/main.ts \
web-admin/src/oa-fc-host/main.ts
git commit -m "feat(oa): register dept FormCreate control scaffold"
```
---
### Task 4: Implement `OaDeptSelect.vue` (PC + narrow)
**Files:**
- Modify: `web-admin/src/components/oa-approval/OaDeptSelect.vue` (replace stub)
- [ ] **Step 1: Implement full component**
Key behaviors (must match spec):
1. On mount: `fetchDeptTree()`, build `pathMap` via `buildPathLabelMapFromTreeRoots(tree, { nameKey: 'deptName' })`.
2. Tree-select `data`: map nodes to `{ value: id, label: deptName, children }`.
3. Internal selection uses **id(s)**; on change, emit `{ id, name: pathMap.get(id) || label }`.
4. `multiple` prop default `true`; when false emit single object or `null`.
5. `max`: when multiple and length >= max, prevent adding (ElMessage warning).
6. `disabled` / readonly: show text via `formatDeptDisplay`, no picker.
7. Narrow (`matchMedia('(max-width: 767px)')`): hide tree-select; show trigger + `el-drawer` with `el-tree` (show-checkbox if multiple), search filter, 取消/确定; only confirm writes model.
8. Fresh Mint: drawer `modal-class` without blur; footer buttons with Close/Check icons.
```vue
<!-- web-admin/src/components/oa-approval/OaDeptSelect.vue -->
<template>
<div class="oa-dept-select">
<template v-if="disabled">
<span class="oa-dept-select__readonly">{{ displayText || '—' }}</span>
</template>
<template v-else-if="isNarrow">
<button type="button" class="oa-dept-select__trigger" @click="openDrawer">
<span v-if="displayText" class="oa-dept-select__trigger-text">{{ displayText }}</span>
<span v-else class="oa-dept-select__placeholder">请选择部门</span>
<span class="oa-dept-select__chev">▾</span>
</button>
<el-drawer
v-model="drawerVisible"
title="选择部门"
direction="btt"
size="75%"
append-to-body
:close-on-click-modal="false"
class="oa-dept-drawer"
modal-class="protocol-dialog-mask"
>
<el-input
v-model="keyword"
clearable
placeholder="搜索部门名称"
class="oa-dept-select__search"
/>
<el-tree
ref="treeRef"
class="oa-dept-select__tree"
:data="treeSelectData"
node-key="value"
:props="{ label: 'label', children: 'children' }"
:filter-node-method="filterNode"
:show-checkbox="multiple"
:check-strictly="true"
:highlight-current="!multiple"
default-expand-all
@node-click="onNarrowNodeClick"
@check="onNarrowCheck"
/>
<template #footer>
<el-button @click="drawerVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" @click="confirmDrawer">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-drawer>
</template>
<el-tree-select
v-else
:model-value="treeSelectModel"
class="oa-dept-select__tree-select"
:data="treeSelectData"
:props="{ value: 'value', label: 'label', children: 'children' }"
:multiple="multiple"
:render-after-expand="false"
filterable
clearable
check-strictly
default-expand-all
placeholder="请选择部门"
style="width: 100%"
@update:model-value="onTreeSelectChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Close } from '@element-plus/icons-vue'
import { fetchDeptTree } from '@/api/admin'
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
import {
formatDeptDisplay,
normalizeDeptModelValue,
type OaDeptValueItem
} from '@/utils/oa-dept-value'
defineOptions({ name: 'dept' })
const props = withDefaults(
defineProps<{
modelValue?: unknown
disabled?: boolean
multiple?: boolean
max?: number | null
formCreateInject?: Record<string, unknown>
}>(),
{ multiple: true, max: null }
)
const emit = defineEmits<{
(e: 'update:modelValue', v: OaDeptValueItem | OaDeptValueItem[] | null): void
(e: 'change', v: OaDeptValueItem | OaDeptValueItem[] | null): void
}>()
type TreeNode = { value: string; label: string; children?: TreeNode[] }
const pathMap = ref(new Map<string, string>())
const treeSelectData = ref<TreeNode[]>([])
const isNarrow = ref(false)
const drawerVisible = ref(false)
const keyword = ref('')
const pendingIds = ref<string[]>([])
const treeRef = ref<{
filter: (q: string) => void
setCheckedKeys: (keys: string[]) => void
setCurrentKey: (key: string | null) => void
getCheckedKeys: () => string[]
getCurrentKey: () => string | null
} | null>(null)
let mql: MediaQueryList | null = null
const normalized = computed(() => normalizeDeptModelValue(props.modelValue, props.multiple))
const displayText = computed(() => formatDeptDisplay(normalized.value))
const treeSelectModel = computed(() => {
const n = normalized.value
if (props.multiple) {
return Array.isArray(n) ? n.map((x) => x.id) : []
}
return n && !Array.isArray(n) ? n.id : undefined
})
function mapTree(nodes: Api.Dept.DeptItem[]): TreeNode[] {
return (nodes || []).map((n) => ({
value: n.id,
label: n.deptName || n.id,
children: n.children?.length ? mapTree(n.children) : undefined
}))
}
function emitFromIds(ids: string[]) {
const items: OaDeptValueItem[] = []
const seen = new Set<string>()
for (const id of ids) {
const tid = String(id || '').trim()
if (!tid || seen.has(tid)) continue
seen.add(tid)
const name = pathMap.value.get(tid) || tid
items.push({ id: tid, name })
}
if (props.multiple) {
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
if (max != null && items.length > max) {
ElMessage.warning(`最多可选 ${max} 个部门`)
return
}
emit('update:modelValue', items)
emit('change', items)
} else {
const one = items[0] || null
emit('update:modelValue', one)
emit('change', one)
}
}
function onTreeSelectChange(val: string | string[] | null | undefined) {
if (props.multiple) {
emitFromIds(Array.isArray(val) ? val.map(String) : [])
} else {
emitFromIds(val ? [String(val)] : [])
}
}
function openDrawer() {
const n = normalized.value
if (props.multiple) {
pendingIds.value = Array.isArray(n) ? n.map((x) => x.id) : []
} else {
pendingIds.value = n && !Array.isArray(n) ? [n.id] : []
}
drawerVisible.value = true
nextTick(() => {
if (props.multiple) {
treeRef.value?.setCheckedKeys(pendingIds.value)
} else {
treeRef.value?.setCurrentKey(pendingIds.value[0] || null)
}
})
}
function onNarrowNodeClick(data: TreeNode) {
if (props.multiple) return
pendingIds.value = [data.value]
treeRef.value?.setCurrentKey(data.value)
}
function onNarrowCheck() {
if (!props.multiple) return
pendingIds.value = (treeRef.value?.getCheckedKeys() || []).map(String)
}
function confirmDrawer() {
let ids = pendingIds.value
if (!props.multiple) {
const cur = treeRef.value?.getCurrentKey()
ids = cur ? [String(cur)] : []
} else {
ids = (treeRef.value?.getCheckedKeys() || []).map(String)
}
emitFromIds(ids)
drawerVisible.value = false
}
function filterNode(q: string, data: TreeNode) {
if (!q) return true
return (data.label || '').includes(q)
}
watch(keyword, (q) => {
treeRef.value?.filter(q || '')
})
async function loadTree() {
try {
const tree = (await fetchDeptTree()) || []
pathMap.value = buildPathLabelMapFromTreeRoots(tree, { nameKey: 'deptName' })
treeSelectData.value = mapTree(tree)
} catch {
pathMap.value = new Map()
treeSelectData.value = []
}
}
function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
isNarrow.value = !!(e ? ('matches' in e ? e.matches : (e as MediaQueryList).matches) : mql?.matches)
}
onMounted(() => {
loadTree()
mql = window.matchMedia('(max-width: 767px)')
syncNarrow(mql)
mql.addEventListener('change', syncNarrow)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', syncNarrow)
})
</script>
<style scoped lang="scss">
.oa-dept-select {
width: 100%;
}
.oa-dept-select__readonly {
color: var(--mint-text, #0f172a);
font-size: 14px;
line-height: 1.5;
word-break: break-all;
}
.oa-dept-select__trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 36px;
padding: 4px 12px;
border: 1px solid var(--mint-border, #cbd5e1);
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
}
.oa-dept-select__placeholder {
color: var(--mint-text-muted, #94a3b8);
}
.oa-dept-select__trigger-text {
color: var(--mint-text, #0f172a);
word-break: break-all;
}
.oa-dept-select__chev {
margin-left: 8px;
color: var(--mint-text-muted, #94a3b8);
font-size: 11px;
}
.oa-dept-select__search {
margin-bottom: 10px;
}
.oa-dept-select__tree {
max-height: 50vh;
overflow: auto;
}
</style>
```
Notes for implementer:
- `ElMessage` is auto-imported in main app; in `oa-fc-host` ensure Element Plus message works (already global).
- `check-strictly` so parent/child independent (any level selectable).
- Designer’s phone preview iframe may be narrow — drawer path will activate there.
- [ ] **Step 2: Smoke in browser**
1. Open `/#/system-manage/oa-approval/designer/:id` →「2 表单设计」→ left「部门」exists.
2. Drag in; toggle 允许多选.
3. Start app with dept field; select departments; submit; detail shows path(s).
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/components/oa-approval/OaDeptSelect.vue \
web-admin/src/utils/oa-dept-value.ts \
web-admin/src/utils/oa-dept-select-rule.ts \
web-admin/src/utils/oa-form-create-dept.ts \
web-admin/src/main.ts \
web-admin/src/oa-fc-host/main.ts
git commit -m "feat(oa): add dept select runtime with PC and narrow pickers"
```
---
### Task 5: Print + related-summary formatting
**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` (optional assert for dept format)
- [ ] **Step 1: Print special-case**
In `walkRules`, before generic `formatFieldValue`:
```typescript
import { formatDeptDisplay } from '@/utils/oa-dept-value'
// inside walkRules loop, after relatedApproval block:
if (type === 'dept') {
const text = formatDeptDisplay(value)
out.push({ label, text: text || '-' })
continue
}
```
- [ ] **Step 2: Backend summary format**
In `oa_related_approval.py` `_format_value`, before generic dict handling:
```python
if typ == "dept":
if isinstance(raw, list):
names = []
for x in raw:
if isinstance(x, dict):
n = str(x.get("name") or "").strip()
if n:
names.append(n)
return "、".join(names) if names else "", False
if isinstance(raw, dict):
return str(raw.get("name") or "").strip(), False
return "", False
```
- [ ] **Step 3: Commit**
```bash
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 dept values in print and related summaries"
```
---
### Task 6: Graph sync + manual E2E checklist
**Files:** none required for code (ops)
- [ ] **Step 1: Update knowledge graph**
```bash
./scripts/update-graph.sh all
```
Commit graph artifacts if the script dirties tracked files:
```bash
git add backend/graphify-out web-admin/graphify-out
git commit -m "chore(graphify): sync after oa dept select"
```
- [ ] **Step 2: Manual E2E (human)**
- [ ] Designer left menu shows「部门」; drag works
- [ ] Props: multiple on/off; max limits multi select
- [ ] PC tree-select any level; chips/path use ` / `
- [ ] Narrow / phone preview: drawer can finish selection
- [ ] Submit stores `{id,name}` / array; rename dept → old instance still shows snapshot `name`
- [ ] Print shows顿号-joined paths, not JSON
- [ ] App without dept unchanged
---
## Spec coverage checklist
| Spec section | Task |
|--------------|------|
| Custom `type=dept` four-layer | 3, 4 |
| Default multiple + prop toggle | 3 (DragRule), 4 |
| `{id,name}` / array + full path snapshot | 1, 4 |
| Any tree level | 4 (`check-strictly`) |
| Narrow full-screen/drawer | 4 |
| Backend validate on start | 1, 2 |
| No condition-branch switch | (no code; do not add to CONDITION_ELIGIBLE) |
| Print / summary | 5 |
| Publish whitelist already has `dept` | (no change) |
---
## Execution handoff
Plan complete and saved to `docs/superpowers/plans/2026-07-27-oa-dept-select.md`. Two execution options:
**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks
**2. Inline Execution** — run tasks in this session with executing-plans and checkpoints
Which approach?