381 lines
10 KiB
Vue
381 lines
10 KiB
Vue
<!-- 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>
|