Files
zkxw/web-admin/src/components/oa-approval/OaPersonSelect.vue
T
2026-07-30 15:02:13 +08:00

381 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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"
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>