2026-07-27-oa-dept-select.md 29 KB

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

# 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
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
# 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
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py -v

Expected: all tests PASS.

  • Step 5: Commit
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:

    try:
        data = apply_formulas_and_linkages(schema, data)
    except FormulaError as e:
        raise OaEngineError(f"公式计算失败: {e}") from e

Insert:

    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
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
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

// 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
// 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)
// 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:

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:

<!-- 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:

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.
<!-- 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
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:

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:

    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
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
./scripts/update-graph.sh all

Commit graph artifacts if the script dirties tracked files:

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?