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>
This commit is contained in:
zlwuandCursor committed 2026-07-30 12:18:19 +08:00
1 parent 85322c39e4
commit cf069c74ec
13 files changed
+618 -15

No files matched your search

@@ -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>
@@ -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">
@@ -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 {
@@ -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(' 且 ')
}