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>
966 lines
29 KiB
Markdown
966 lines
29 KiB
Markdown
# 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?
|