Просмотр исходного кода

feat(oa): add dept select FormCreate control with print formatting

Register type=dept on main app and oa-fc-host, with PC tree-select and
narrow drawer pickers storing {id,name} path snapshots; format dept values
in print and related-summary flatten.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
2e6e1f47d6

+ 12 - 0
backend/app/services/oa_related_approval.py

@@ -104,6 +104,18 @@ def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
         else:
             n = 0
         return f"已关联 {n} 条", False
+    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
     if isinstance(raw, list):
         text = "、".join(str(x) for x in raw if x is not None and str(x) != "")
     elif isinstance(raw, dict):

+ 24 - 0
backend/tests/test_oa_related_approval.py

@@ -37,6 +37,30 @@ class TestFlatten(unittest.TestCase):
         self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
         self.assertTrue(fields[3].get("fullWidth"))
 
+    def test_dept_formats_path_names(self):
+        schema = {
+            "rule": [
+                {"type": "dept", "field": "dept", "title": "部门"},
+                {
+                    "type": "dept",
+                    "field": "depts",
+                    "title": "多部门",
+                    "props": {"multiple": True},
+                },
+            ]
+        }
+        data = {
+            "dept": {"id": "d1", "name": "中科兴皖 / 研发"},
+            "depts": [
+                {"id": "d1", "name": "中科兴皖 / 研发"},
+                {"id": "d2", "name": "中科兴皖 / 质量"},
+            ],
+        }
+        fields = flatten_form_fields_for_summary(schema, data)
+        by_label = {f["label"]: f["value"] for f in fields}
+        self.assertEqual(by_label["部门"], "中科兴皖 / 研发")
+        self.assertEqual(by_label["多部门"], "中科兴皖 / 研发、中科兴皖 / 质量")
+
     def test_related_approval_field_includes_related_ids(self):
         schema = {
             "rule": [

+ 306 - 0
web-admin/src/components/oa-approval/OaDeptSelect.vue

@@ -0,0 +1,306 @@
+<!-- FormCreate 自定义控件:部门选择(Fresh Mint · PC 树选 + 窄屏抽屉) -->
+<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"
+    >
+      <template #default="{ data }">
+        <span>{{ data.label }}</span>
+      </template>
+    </el-tree-select>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+  import { Check, Close } from '@element-plus/icons-vue'
+  import { ElMessage } from 'element-plus'
+  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) {
+    const matches = e && 'matches' in e ? e.matches : !!mql?.matches
+    isNarrow.value = 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>

+ 2 - 0
web-admin/src/main.ts

@@ -17,6 +17,7 @@ import FcDesigner from '@form-create/designer'
 import formCreateAutoImport from '@form-create/element-ui/auto-import'
 import { setupFormCreateUpload } from '@/utils/oa-form-create-upload'
 import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
+import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
 
 // FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
 // 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
@@ -24,6 +25,7 @@ FcDesigner.formCreate.use(formCreateAutoImport)
 // 内置「上传」默认 action=/,改为走附件上传 API,避免 POST 前端原点 404
 setupFormCreateUpload(FcDesigner.formCreate)
 setupOaRelatedApproval(FcDesigner.formCreate)
+setupOaDeptSelect(FcDesigner.formCreate)
 
 function maskSecret(value?: string) {
   if (!value) return '(empty)'

+ 2 - 0
web-admin/src/oa-fc-host/main.ts

@@ -9,6 +9,7 @@ import 'element-plus/dist/index.css'
 import FcDesigner from '@form-create/designer'
 import { setupFormCreateUpload } from '@/utils/oa-form-create-upload'
 import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
+import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
 import App from './App.vue'
 
 const app = createApp(App)
@@ -22,4 +23,5 @@ app.use(FcDesigner)
 app.use(FcDesigner.formCreate)
 setupFormCreateUpload(FcDesigner.formCreate)
 setupOaRelatedApproval(FcDesigner.formCreate)
+setupOaDeptSelect(FcDesigner.formCreate)
 app.mount('#app')

+ 7 - 0
web-admin/src/utils/oa-approval-print.ts

@@ -12,6 +12,7 @@ import {
   type RelatedPrintBlock
 } from '@/utils/oa-approval-print-related'
 import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
+import { formatDeptDisplay } from '@/utils/oa-dept-value'
 
 export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
 
@@ -237,6 +238,12 @@ async function walkRules(
       continue
     }
 
+    if (type === 'dept') {
+      const text = formatDeptDisplay(value)
+      out.push({ label, text: text || '-' })
+      continue
+    }
+
     if (UPLOAD_TYPES.has(type) || String(rule.name || '').toLowerCase().includes('upload')) {
       const urls = extractUrls(value)
       out.push({ label, urls: urls.length ? urls : [], text: urls.length ? undefined : '-' })

+ 60 - 0
web-admin/src/utils/oa-dept-select-rule.ts

@@ -0,0 +1,60 @@
+/** 与 FormCreate 官方 unique 同类:生成不重复 field */
+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'
+  )
+}
+
+/** FormCreate Designer DragRule:部门选择 */
+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 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: '提示信息'
+      }
+    ]
+  }
+}

+ 45 - 0
web-admin/src/utils/oa-dept-value.ts

@@ -0,0 +1,45 @@
+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 ''
+}

+ 17 - 0
web-admin/src/utils/oa-form-create-dept.ts

@@ -0,0 +1,17 @@
+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
+}