Explorar el Código

feat(oa): add person select control with contains conditions and print

Register FormCreate person field (dept-aligned UI), enable contains-only
workflow conditions, and format person values in print/related summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 3 meses
padre
commit
cf069c74ec

+ 1 - 1
backend/app/services/oa_related_approval.py

@@ -104,7 +104,7 @@ def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
         else:
             n = 0
         return f"已关联 {n} 条", False
-    if typ == "dept":
+    if (typ == "dept" or typ == "person"):
         if isinstance(raw, list):
             names = []
             for x in raw:

+ 24 - 0
backend/tests/test_oa_related_approval.py

@@ -61,6 +61,30 @@ class TestFlatten(unittest.TestCase):
         self.assertEqual(by_label["部门"], "中科兴皖 / 研发")
         self.assertEqual(by_label["多部门"], "中科兴皖 / 研发、中科兴皖 / 质量")
 
+    def test_person_formats_names(self):
+        schema = {
+            "rule": [
+                {"type": "person", "field": "p1", "title": "协同人"},
+                {
+                    "type": "person",
+                    "field": "p2",
+                    "title": "多人",
+                    "props": {"multiple": True},
+                },
+            ]
+        }
+        data = {
+            "p1": {"id": "u1", "name": "张三"},
+            "p2": [
+                {"id": "u1", "name": "张三"},
+                {"id": "u2", "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": [

+ 1 - 1
docs/superpowers/specs/2026-07-30-oa-person-select-design.md

@@ -3,7 +3,7 @@
 **日期:** 2026-07-30  
 **范围:** `web-admin` 审批设计器步骤 2(表单设计)+ 步骤 3(条件分支)+ 发起/详情/打印运行时;`backend` 发布白名单、发起校验、条件匹配  
 **关联:** `docs/superpowers/specs/2026-07-27-oa-dept-select-design.md`;`docs/superpowers/specs/2026-07-15-oa-condition-from-form-design.md`;`docs/superpowers/specs/2026-07-30-oa-money-field-design.md`  
-**状态:** 已评审待实现  
+**状态:** 已实现  
 
 ---
 

+ 17 - 0
web-admin/src/assets/styles/core/oa-fc-runtime.scss

@@ -32,15 +32,20 @@
 
   .el-form-item {
     width: 100%;
+    max-width: 100%;
+    min-width: 0;
     margin-right: 0;
   }
 
   .el-form-item__content {
     width: 100%;
+    max-width: 100%;
+    min-width: 0;
   }
 
   .el-input,
   .el-select,
+  .el-tree-select,
   .el-cascader,
   .el-textarea,
   .el-input-number,
@@ -48,6 +53,18 @@
   .el-date-editor.el-input,
   .el-date-editor.el-input__wrapper {
     width: 100%;
+    max-width: 100%;
+  }
+
+  /* 自定义控件根节点:防止 padding/边框撑破列宽 */
+  .oa-dept-select,
+  .oa-money-field,
+  .oa-person-select,
+  .oa-related-approval {
+    box-sizing: border-box;
+    width: 100%;
+    max-width: 100%;
+    min-width: 0;
   }
 
   /* FormCreate 上传:达到 limit 后隐藏上传入口(原 component-elm-upload style) */

+ 382 - 0
web-admin/src/components/oa-approval/OaPersonSelect.vue

@@ -0,0 +1,382 @@
+<!-- FormCreate 自定义控件:人员选择(Fresh Mint · PC 下拉 + 窄屏抽屉;预览画布全宽) -->
+<template>
+  <div class="oa-person-select">
+    <template v-if="disabled">
+      <span class="oa-person-select__readonly">{{ displayText || '—' }}</span>
+    </template>
+
+    <template v-else-if="isNarrow">
+      <button type="button" class="oa-person-select__trigger" @click="openDrawer">
+        <span v-if="displayText" class="oa-person-select__trigger-text">{{ displayText }}</span>
+        <span v-else class="oa-person-select__placeholder">请选择人员</span>
+        <span class="oa-person-select__chev">▾</span>
+      </button>
+      <el-drawer
+        v-model="drawerVisible"
+        title="选择人员"
+        direction="btt"
+        size="75%"
+        append-to-body
+        :close-on-click-modal="false"
+        class="oa-person-drawer"
+        modal-class="protocol-dialog-mask"
+      >
+        <el-input
+          v-model="keyword"
+          clearable
+          placeholder="搜索姓名或工号"
+          class="oa-person-select__search"
+        />
+        <div class="oa-person-select__list">
+          <label
+            v-for="u in filteredOptions"
+            :key="u.id"
+            class="oa-person-select__row"
+          >
+            <el-checkbox
+              v-if="multiple"
+              :model-value="pendingIds.includes(u.id)"
+              @change="(v: boolean | string | number) => onNarrowCheck(u.id, !!v)"
+            />
+            <el-radio
+              v-else
+              :model-value="pendingIds[0] || ''"
+              :value="u.id"
+              @change="() => onNarrowPick(u.id)"
+            />
+            <span class="oa-person-select__row-label">{{ u.label }}</span>
+            <span v-if="u.sub" class="oa-person-select__row-sub">{{ u.sub }}</span>
+          </label>
+          <div v-if="!filteredOptions.length" class="oa-person-select__empty">暂无人员</div>
+        </div>
+        <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-select
+      v-else
+      :model-value="selectModel"
+      class="oa-person-select__select"
+      filterable
+      clearable
+      :multiple="multiple"
+      collapse-tags
+      collapse-tags-tooltip
+      placeholder="请选择人员"
+      @update:model-value="onSelectChange"
+    >
+      <el-option
+        v-for="u in userOptions"
+        :key="u.id"
+        :label="u.label"
+        :value="u.id"
+      >
+        <span>{{ u.label }}</span>
+        <span v-if="u.sub" class="oa-person-select__opt-sub">{{ u.sub }}</span>
+      </el-option>
+    </el-select>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+  import { Check, Close } from '@element-plus/icons-vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchUserList } from '@/api/admin'
+  import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
+  import {
+    formatPersonDisplay,
+    normalizePersonModelValue,
+    type OaPersonValueItem
+  } from '@/utils/oa-person-value'
+
+  defineOptions({ name: 'person' })
+
+  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: OaPersonValueItem | OaPersonValueItem[] | null): void
+    (e: 'change', v: OaPersonValueItem | OaPersonValueItem[] | null): void
+  }>()
+
+  type UserOpt = { id: string; label: string; sub: string }
+
+  const nameMap = ref(new Map<string, string>())
+  const userOptions = ref<UserOpt[]>([])
+  const isNarrow = ref(false)
+  const drawerVisible = ref(false)
+  const keyword = ref('')
+  const pendingIds = ref<string[]>([])
+  let mql: MediaQueryList | null = null
+
+  const normalized = computed(() => normalizePersonModelValue(props.modelValue, !!props.multiple))
+
+  const displayText = computed(() => formatPersonDisplay(normalized.value))
+
+  const selectModel = 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
+  })
+
+  const filteredOptions = computed(() => {
+    const q = keyword.value.trim().toLowerCase()
+    if (!q) return userOptions.value
+    return userOptions.value.filter(
+      (u) =>
+        u.label.toLowerCase().includes(q) ||
+        u.sub.toLowerCase().includes(q) ||
+        u.id.toLowerCase().includes(q)
+    )
+  })
+
+  function displayNameOf(id: string): string {
+    return nameMap.value.get(id) || id
+  }
+
+  function emitFromIds(ids: string[]) {
+    const items: OaPersonValueItem[] = []
+    const seen = new Set<string>()
+    for (const id of ids) {
+      const tid = String(id || '').trim()
+      if (!tid || seen.has(tid)) continue
+      seen.add(tid)
+      items.push({ id: tid, name: displayNameOf(tid) })
+    }
+    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 onSelectChange(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] : []
+    }
+    keyword.value = ''
+    drawerVisible.value = true
+  }
+
+  function onNarrowCheck(id: string, checked: boolean) {
+    const set = new Set(pendingIds.value)
+    if (checked) set.add(id)
+    else set.delete(id)
+    pendingIds.value = [...set]
+  }
+
+  function onNarrowPick(id: string) {
+    pendingIds.value = [id]
+  }
+
+  function confirmDrawer() {
+    emitFromIds(pendingIds.value)
+    drawerVisible.value = false
+  }
+
+  async function loadUsers() {
+    try {
+      const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
+      const raw: any[] = []
+      let page = 1
+      for (;;) {
+        const res = await fetchUserList({ page, pageSize, status: '1' })
+        const list = res?.list || []
+        raw.push(...list)
+        if (list.length < pageSize) break
+        if (typeof res?.total === 'number' && raw.length >= res.total) break
+        page += 1
+      }
+      const map = new Map<string, string>()
+      const opts: UserOpt[] = []
+      for (const u of raw) {
+        const id = String(u.id || '').trim()
+        if (!id) continue
+        const nick = String(u.nickName || '').trim()
+        const userNo = String(u.userNo || '').trim()
+        const label = nick || userNo || id
+        map.set(id, label)
+        opts.push({ id, label, sub: nick && userNo && nick !== userNo ? userNo : '' })
+      }
+      // 保留已选快照姓名(用户列表可能已无此人)
+      const n = normalized.value
+      const kept = Array.isArray(n) ? n : n ? [n] : []
+      for (const x of kept) {
+        if (!map.has(x.id) && x.name) map.set(x.id, x.name)
+      }
+      nameMap.value = map
+      userOptions.value = opts
+    } catch {
+      nameMap.value = new Map()
+      userOptions.value = []
+    }
+  }
+
+  function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
+    const matches = e && 'matches' in e ? e.matches : !!mql?.matches
+    isNarrow.value = matches
+  }
+
+  onMounted(() => {
+    loadUsers()
+    mql = window.matchMedia('(max-width: 767px)')
+    syncNarrow(mql)
+    mql.addEventListener('change', syncNarrow)
+  })
+
+  onBeforeUnmount(() => {
+    mql?.removeEventListener('change', syncNarrow)
+  })
+</script>
+
+<style scoped lang="scss">
+  .oa-person-select {
+    box-sizing: border-box;
+    width: 100%;
+    max-width: 100%;
+    min-width: 0;
+  }
+
+  .oa-person-select__select {
+    width: 100%;
+    max-width: 100%;
+  }
+
+  .oa-person-select__select :deep(.el-select),
+  .oa-person-select__select :deep(.el-select__wrapper),
+  .oa-person-select__select :deep(.el-tooltip__trigger) {
+    width: 100%;
+    max-width: 100%;
+    min-width: 0;
+  }
+
+  .oa-person-select__select :deep(.el-select__selection) {
+    flex-wrap: wrap;
+  }
+
+  .oa-person-select__select :deep(.el-select__wrapper) {
+    min-height: 40px;
+  }
+
+  .oa-person-select__trigger {
+    display: flex;
+    box-sizing: border-box;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    max-width: 100%;
+    min-height: 40px;
+    padding: 4px 12px;
+    border: 1px solid var(--mint-border, #cbd5e1);
+    border-radius: 8px;
+    background: #fff;
+    text-align: left;
+    cursor: pointer;
+  }
+
+  .oa-person-select__readonly {
+    color: var(--mint-text, #0f172a);
+    font-size: 14px;
+    line-height: 1.5;
+    word-break: break-all;
+  }
+
+  .oa-person-select__placeholder {
+    color: var(--mint-text-muted, #94a3b8);
+  }
+
+  .oa-person-select__trigger-text {
+    min-width: 0;
+    color: var(--mint-text, #0f172a);
+    word-break: break-all;
+  }
+
+  .oa-person-select__chev {
+    flex-shrink: 0;
+    margin-left: 8px;
+    color: var(--mint-text-muted, #94a3b8);
+    font-size: 11px;
+  }
+
+  .oa-person-select__search {
+    margin-bottom: 10px;
+  }
+
+  .oa-person-select__list {
+    max-height: 50vh;
+    overflow: auto;
+  }
+
+  .oa-person-select__row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    min-height: 44px;
+    padding: 6px 4px;
+    cursor: pointer;
+  }
+
+  .oa-person-select__row-label {
+    min-width: 0;
+    color: var(--mint-text, #0f172a);
+  }
+
+  .oa-person-select__row-sub,
+  .oa-person-select__opt-sub {
+    margin-left: 8px;
+    font-size: 12px;
+    color: var(--mint-text-muted, #94a3b8);
+  }
+
+  .oa-person-select__empty {
+    padding: 16px;
+    color: var(--mint-text-muted, #94a3b8);
+    text-align: center;
+  }
+
+  @media (max-width: 767px) {
+    .oa-person-select__select :deep(.el-select__wrapper),
+    .oa-person-select__trigger {
+      min-height: 44px;
+    }
+  }
+</style>

+ 80 - 7
web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue

@@ -48,7 +48,25 @@
             />
           </el-select>
           <el-select
-            v-if="optionsFor(rule.field)?.length"
+            v-if="isPersonField(rule.field)"
+            :model-value="personValueIds(rule.value)"
+            multiple
+            filterable
+            collapse-tags
+            collapse-tags-tooltip
+            placeholder="选择人员(任一命中)"
+            :disabled="disabled"
+            @update:model-value="(v) => onValueChange(idx, Array.isArray(v) ? v.map(String) : [])"
+          >
+            <el-option
+              v-for="u in userOptions"
+              :key="u.id"
+              :label="u.label"
+              :value="u.id"
+            />
+          </el-select>
+          <el-select
+            v-else-if="optionsFor(rule.field)?.length"
             :model-value="rule.value"
             placeholder="值"
             :disabled="disabled"
@@ -95,8 +113,10 @@
 </template>
 
 <script setup lang="ts">
-  import { computed, watch } from 'vue'
+  import { computed, onMounted, ref, watch } from 'vue'
   import { Delete, Plus } from '@element-plus/icons-vue'
+  import { fetchUserList } from '@/api/admin'
+  import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
   import {
     opsForFieldType,
     type ConditionCandidate,
@@ -126,11 +146,27 @@
   }>()
 
   const localRules = computed(() => props.modelValue || [])
+  const userOptions = ref<Array<{ id: string; label: string }>>([])
 
   function meta(field: string) {
     return props.candidates.find((c) => c.field === field)
   }
 
+  function isPersonField(field: string) {
+    return meta(field)?.type === 'person'
+  }
+
+  function personValueIds(v: unknown): string[] {
+    if (Array.isArray(v)) return v.map(String)
+    if (v == null || v === '') return []
+    return [String(v)]
+  }
+
+  function defaultValueForType(type: string, opts?: ConditionCandidate['options']) {
+    if (type === 'person') return [] as string[]
+    return opts?.[0]?.value ?? ''
+  }
+
   function opsForRow(rule: ConditionRule) {
     const t = meta(rule.field)?.type || 'number'
     return opsForFieldType(t)
@@ -141,6 +177,7 @@
   }
 
   function valueText(v: unknown) {
+    if (Array.isArray(v)) return v.join('、')
     return v == null ? '' : String(v)
   }
 
@@ -167,7 +204,7 @@
       {
         field: first.field,
         op: opsForFieldType(first.type)[0].value,
-        value: first.options?.[0]?.value ?? ''
+        value: defaultValueForType(first.type, first.options)
       }
     ])
   }
@@ -184,11 +221,10 @@
     const next = [...localRules.value]
     const t = meta(field)?.type || 'number'
     const ops = opsForFieldType(t)
-    const opts = optionsFor(field)
     next[idx] = {
       field,
       op: ops[0].value,
-      value: opts?.[0]?.value ?? ''
+      value: defaultValueForType(t, optionsFor(field))
     }
     emitRules(next)
   }
@@ -201,7 +237,7 @@
 
   function onValueChange(idx: number, value: unknown) {
     const next = [...localRules.value]
-    next[idx] = { ...next[idx], value: value as string | number }
+    next[idx] = { ...next[idx], value: value as string | number | string[] }
     emitRules(next)
   }
 
@@ -213,7 +249,7 @@
       {
         field: first.field,
         op: opsForFieldType(first.type)[0].value,
-        value: first.options?.[0]?.value ?? ''
+        value: defaultValueForType(first.type, first.options)
       }
     ])
   }
@@ -223,6 +259,43 @@
     const next = localRules.value.filter((_, i) => i !== idx)
     emitRules(next)
   }
+
+  async function loadUsers() {
+    try {
+      const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
+      const raw: any[] = []
+      let page = 1
+      for (;;) {
+        const res = await fetchUserList({ page, pageSize, status: '1' })
+        const list = res?.list || []
+        raw.push(...list)
+        if (list.length < pageSize) break
+        if (typeof res?.total === 'number' && raw.length >= res.total) break
+        page += 1
+      }
+      userOptions.value = raw.map((u) => {
+        const id = String(u.id || '')
+        const nick = String(u.nickName || '').trim()
+        const userNo = String(u.userNo || '').trim()
+        return { id, label: nick || userNo || id }
+      }).filter((u) => u.id)
+    } catch {
+      userOptions.value = []
+    }
+  }
+
+  onMounted(() => {
+    if (props.candidates.some((c) => c.type === 'person')) {
+      loadUsers()
+    }
+  })
+
+  watch(
+    () => props.candidates.some((c) => c.type === 'person'),
+    (need) => {
+      if (need && !userOptions.value.length) loadUsers()
+    }
+  )
 </script>
 
 <style scoped lang="scss">

+ 2 - 2
web-admin/src/components/oa-approval/flow-tree/types.ts

@@ -15,12 +15,12 @@ export interface ApproverConfig {
   signMode?: 'or' | 'and'
 }
 
-export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!='
+export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains'
 
 export interface ConditionRule {
   field: string
   op: ConditionOp
-  value: string | number
+  value: string | number | string[]
 }
 
 export interface ConditionGroup {

+ 13 - 3
web-admin/src/components/oa-approval/oa-condition-fields.ts

@@ -4,13 +4,14 @@ export const CONDITION_ELIGIBLE_TYPES = new Set([
   'inputNumber',
   'number',
   'money',
+  'person',
   'radio',
   'select',
   'datePicker',
   'date'
 ])
 
-export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!='
+export type ConditionOp = '>' | '>=' | '<' | '<=' | '==' | '!=' | 'contains'
 
 export interface ConditionCandidate {
   field: string
@@ -21,6 +22,9 @@ export interface ConditionCandidate {
 }
 
 export function opsForFieldType(type: string): Array<{ value: ConditionOp; label: string }> {
+  if (type === 'person') {
+    return [{ value: 'contains', label: '包含' }]
+  }
   // 单选/下拉为离散选项,仅等于/不等于;输入框常填数字参与区间比较,与数字/金额/日期同套运算符
   if (type === 'radio' || type === 'select') {
     return [
@@ -161,10 +165,16 @@ export function formatConditionPreview(
     '<': '小于',
     '<=': '小于等于',
     '==': '等于',
-    '!=': '不等于'
+    '!=': '不等于',
+    contains: '包含'
   }
   if (!rules?.length) return '未配置'
   return rules
-    .map((r) => `${titleOf(r.field)} ${opLabel[r.op] || r.op} ${r.value ?? '?'}`)
+    .map((r) => {
+      const valueText = Array.isArray(r.value)
+        ? r.value.join('、')
+        : (r.value ?? '?')
+      return `${titleOf(r.field)} ${opLabel[r.op] || r.op} ${valueText}`
+    })
     .join(' 且 ')
 }

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

@@ -19,6 +19,7 @@ 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 { setupOaMoneyField } from '@/utils/oa-form-create-money'
+import { setupOaPersonSelect } from '@/utils/oa-form-create-person'
 
 // FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
 // 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
@@ -28,6 +29,7 @@ setupFormCreateUpload(FcDesigner.formCreate)
 setupOaRelatedApproval(FcDesigner.formCreate)
 setupOaDeptSelect(FcDesigner.formCreate)
 setupOaMoneyField(FcDesigner.formCreate)
+setupOaPersonSelect(FcDesigner.formCreate)
 
 function maskSecret(value?: string) {
   if (!value) return '(empty)'

+ 12 - 1
web-admin/src/oa-fc-host/main.ts

@@ -1,16 +1,22 @@
 /**
- * FormCreate 隔离宿主:不引入 Tailwind / 主应用全局样式,避免破坏 el-aside 三栏布局
+ * FormCreate 隔离宿主:不引入 Tailwind / 主应用全局样式,避免破坏 el-aside 三栏布局。
+ * 须挂 Pinia + 持久化 user,否则预览里自定义控件打 API 时 useUserStore 无实例,
+ * 会被误报成「无法连接服务器(8000)」;同源 iframe 可复用主应用 localStorage 中的 token。
  */
 import { createApp } from 'vue'
 import ElementPlus from 'element-plus'
 import zhCn from 'element-plus/es/locale/lang/zh-cn'
 import * as ElementPlusIconsVue from '@element-plus/icons-vue'
 import 'element-plus/dist/index.css'
+import '@styles/core/oa-fc-runtime.scss'
 import FcDesigner from '@form-create/designer'
+import { initStore } from '@/store'
+import { useUserStore } from '@/store/modules/user'
 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 { setupOaMoneyField } from '@/utils/oa-form-create-money'
+import { setupOaPersonSelect } from '@/utils/oa-form-create-person'
 import App from './App.vue'
 
 const app = createApp(App)
@@ -19,6 +25,10 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
   app.component(key, component)
 }
 
+initStore(app)
+// 触发 persist 还原 accessToken(与主窗口同源 localStorage)
+useUserStore()
+
 app.use(ElementPlus, { locale: zhCn })
 app.use(FcDesigner)
 app.use(FcDesigner.formCreate)
@@ -26,4 +36,5 @@ setupFormCreateUpload(FcDesigner.formCreate)
 setupOaRelatedApproval(FcDesigner.formCreate)
 setupOaDeptSelect(FcDesigner.formCreate)
 setupOaMoneyField(FcDesigner.formCreate)
+setupOaPersonSelect(FcDesigner.formCreate)
 app.mount('#app')

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

@@ -13,6 +13,7 @@ import {
 } from '@/utils/oa-approval-print-related'
 import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
 import { formatDeptDisplay } from '@/utils/oa-dept-value'
+import { formatPersonDisplay } from '@/utils/oa-person-value'
 import { formatMoneyDisplay } from '@/utils/money-format'
 import { toRmbUpper } from '@/utils/rmb-upper'
 
@@ -246,6 +247,12 @@ async function walkRules(
       continue
     }
 
+    if (type === 'person') {
+      const text = formatPersonDisplay(value)
+      out.push({ label, text: text || '-' })
+      continue
+    }
+
     if (type === 'money') {
       const showUpper = rule.props?.showUpper !== false
       const n =

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

@@ -0,0 +1,17 @@
+import FcDesigner from '@form-create/designer'
+import OaPersonSelect from '@/components/oa-approval/OaPersonSelect.vue'
+import { personSelectDragRule } from '@/utils/oa-person-select-rule'
+
+const FLAG = '__oaPersonSelectConfigured'
+
+export function setupOaPersonSelect(formCreate?: { component?: (name: string, c: unknown) => void }) {
+  if ((FcDesigner as any)[FLAG]) return
+  FcDesigner.component('person', OaPersonSelect)
+  if (typeof formCreate?.component === 'function') {
+    formCreate.component('person', OaPersonSelect)
+  }
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(personSelectDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}

+ 60 - 0
web-admin/src/utils/oa-person-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) +
+    'p'
+  )
+}
+
+/** FormCreate Designer DragRule:人员选择 */
+export const personSelectDragRule = {
+  menu: 'main' as const,
+  icon: 'icon-input',
+  label: '人员',
+  name: 'person',
+  input: true,
+  mask: true,
+  validate: ['string', 'array', 'object'],
+  event: ['change'],
+  rule() {
+    return {
+      type: 'person',
+      field: uniqueId(),
+      title: '人员',
+      info: '',
+      $required: false,
+      props: {
+        multiple: true,
+        max: undefined as number | undefined
+      }
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'switch',
+        field: 'multiple',
+        title: '允许多选',
+        value: true,
+        props: { activeText: '是', inactiveText: '否' }
+      },
+      {
+        type: 'inputNumber',
+        field: 'max',
+        title: '最大可选数',
+        value: undefined,
+        props: { min: 1, precision: 0, placeholder: '不填则不限制' }
+      },
+      {
+        type: 'input',
+        field: 'formCreateInfo',
+        title: '提示信息'
+      }
+    ]
+  }
+}