242 lines
7.9 KiB
Vue
242 lines
7.9 KiB
Vue
<template>
|
||
<div class="space-y-3">
|
||
<div
|
||
v-if="modelValue"
|
||
class="flex items-start justify-between gap-3 rounded-lg border border-border bg-bg-tertiary px-4 py-3"
|
||
>
|
||
<div class="min-w-0">
|
||
<p class="text-xs text-text-tertiary">当前绑定</p>
|
||
<p class="mt-1 truncate text-sm font-medium text-text-primary">{{ selectedLabel }}</p>
|
||
<p v-if="selectedMeta" class="mt-1 text-xs text-text-secondary">{{ selectedMeta }}</p>
|
||
<div v-if="selectedPerson" class="mt-2 flex flex-wrap gap-2">
|
||
<span class="badge" :class="selectedAvailability.badgeClass">{{ selectedAvailability.label }}</span>
|
||
<span class="badge" :class="selectedPerson.dingtalk_ready ? 'badge-success' : 'badge-warning'">
|
||
{{ selectedPerson.dingtalk_ready ? '钉钉已就绪' : '钉钉未就绪' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn-secondary shrink-0" @click="clearSelection">
|
||
{{ clearLabel }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="field-stack">
|
||
<input
|
||
v-model="keyword"
|
||
type="search"
|
||
class="input"
|
||
autocomplete="off"
|
||
placeholder="输入花名、姓名、工号或部门搜索"
|
||
/>
|
||
<p class="field-hint">输入关键词后才查询花名册,每次加载 {{ PAGE_SIZE }} 人。</p>
|
||
</div>
|
||
|
||
<div
|
||
v-if="normalizedKeyword"
|
||
class="overflow-hidden rounded-lg border border-border bg-bg-secondary"
|
||
>
|
||
<div v-if="loading && results.length === 0" class="px-4 py-5 text-center text-sm text-text-secondary">
|
||
正在搜索员工...
|
||
</div>
|
||
|
||
<div v-else-if="errorMessage && results.length === 0" class="px-4 py-4 text-sm text-status-error">
|
||
<p>{{ errorMessage }}</p>
|
||
<button type="button" class="btn-secondary mt-3" @click="search(true)">重新搜索</button>
|
||
</div>
|
||
|
||
<div v-else-if="hasSearched && results.length === 0" class="px-4 py-5 text-center text-sm text-text-secondary">
|
||
没有找到匹配的员工
|
||
</div>
|
||
|
||
<div v-else-if="results.length > 0" class="divide-y divide-border">
|
||
<button
|
||
v-for="person in results"
|
||
:key="person.id"
|
||
type="button"
|
||
class="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-bg-tertiary disabled:cursor-not-allowed disabled:opacity-55"
|
||
:disabled="!isSelectable(person)"
|
||
@click="selectPerson(person)"
|
||
>
|
||
<span class="min-w-0">
|
||
<span class="block truncate text-sm font-medium text-text-primary">{{ formatName(person) }}</span>
|
||
<span class="mt-1 block truncate text-xs text-text-secondary">{{ formatMeta(person) }}</span>
|
||
</span>
|
||
<span :class="['badge shrink-0', getAvailability(person).badgeClass]">
|
||
{{ getAvailability(person).label }}
|
||
</span>
|
||
</button>
|
||
|
||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||
<p class="text-xs text-text-tertiary">已加载 {{ results.length }} / {{ total }} 人</p>
|
||
<button
|
||
v-if="hasMore"
|
||
type="button"
|
||
class="btn-secondary"
|
||
:disabled="loading"
|
||
@click="search(false)"
|
||
>
|
||
{{ loading ? '加载中...' : '加载更多' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { getUserPersonOptions, type UserPersonOption } from '@/api/users'
|
||
import { getUserPersonOptionAvailability } from '@/utils/userPersonOption'
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
const props = withDefaults(defineProps<{
|
||
modelValue: string
|
||
selectedPerson?: UserPersonOption | null
|
||
currentUserId?: number | string | null
|
||
clearLabel?: string
|
||
}>(), {
|
||
selectedPerson: null,
|
||
currentUserId: null,
|
||
clearLabel: '取消绑定'
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
(event: 'update:modelValue', value: string): void
|
||
(event: 'update:selectedPerson', value: UserPersonOption | null): void
|
||
}>()
|
||
|
||
const keyword = ref('')
|
||
const results = ref<UserPersonOption[]>([])
|
||
const page = ref(0)
|
||
const total = ref(0)
|
||
const loading = ref(false)
|
||
const hasSearched = ref(false)
|
||
const errorMessage = ref('')
|
||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||
let requestSequence = 0
|
||
|
||
const normalizedKeyword = computed(() => keyword.value.trim())
|
||
const hasMore = computed(() => results.value.length < total.value)
|
||
|
||
const formatName = (person: UserPersonOption) => {
|
||
if (person.nickname && person.real_name && person.nickname !== person.real_name) {
|
||
return `${person.nickname}(${person.real_name})`
|
||
}
|
||
return person.nickname || person.real_name || `员工 #${person.id}`
|
||
}
|
||
|
||
const formatMeta = (person: UserPersonOption) => {
|
||
const department = person.main_department?.name
|
||
const parts = [department, person.job_number ? `工号 ${person.job_number}` : ''].filter(Boolean)
|
||
if (person.is_bound && person.bound_user) {
|
||
parts.push(`已绑定 ${person.bound_user.name}(${person.bound_user.username})`)
|
||
}
|
||
return parts.join(' · ') || (person.status === 'active' ? '在职员工' : '已停用员工')
|
||
}
|
||
|
||
const selectedLabel = computed(() => {
|
||
if (props.selectedPerson?.id === Number(props.modelValue)) {
|
||
return formatName(props.selectedPerson)
|
||
}
|
||
return `员工 #${props.modelValue}`
|
||
})
|
||
|
||
const selectedMeta = computed(() => {
|
||
if (props.selectedPerson?.id !== Number(props.modelValue)) return ''
|
||
return formatMeta(props.selectedPerson)
|
||
})
|
||
|
||
const getAvailability = (person: UserPersonOption) =>
|
||
getUserPersonOptionAvailability(person, props.currentUserId)
|
||
|
||
const selectedAvailability = computed(() => props.selectedPerson
|
||
? getAvailability(props.selectedPerson)
|
||
: { selectable: false, label: '绑定状态未知', badgeClass: 'badge-neutral' })
|
||
|
||
const isSelectable = (person: UserPersonOption) => getAvailability(person).selectable
|
||
|
||
const resetResults = () => {
|
||
requestSequence += 1
|
||
results.value = []
|
||
page.value = 0
|
||
total.value = 0
|
||
loading.value = false
|
||
hasSearched.value = false
|
||
errorMessage.value = ''
|
||
}
|
||
|
||
const search = async (reset: boolean) => {
|
||
const searchKeyword = normalizedKeyword.value
|
||
if (!searchKeyword || loading.value) return
|
||
|
||
const targetPage = reset ? 1 : page.value + 1
|
||
const requestId = ++requestSequence
|
||
if (reset) {
|
||
results.value = []
|
||
page.value = 0
|
||
total.value = 0
|
||
}
|
||
loading.value = true
|
||
errorMessage.value = ''
|
||
|
||
try {
|
||
const response = await getUserPersonOptions({
|
||
page: targetPage,
|
||
pageSize: PAGE_SIZE,
|
||
keyword: searchKeyword,
|
||
bound: 'all'
|
||
})
|
||
if (requestId !== requestSequence || searchKeyword !== normalizedKeyword.value) return
|
||
|
||
const list = response.data?.list || []
|
||
const merged = reset ? list : [...results.value, ...list]
|
||
results.value = Array.from(new Map(merged.map((person) => [person.id, person])).values())
|
||
page.value = response.data?.page || targetPage
|
||
total.value = response.data?.total || 0
|
||
hasSearched.value = true
|
||
} catch (error) {
|
||
if (requestId !== requestSequence) return
|
||
console.error('搜索员工失败', error)
|
||
errorMessage.value = '员工搜索失败,请稍后重试。'
|
||
hasSearched.value = true
|
||
} finally {
|
||
if (requestId === requestSequence) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const selectPerson = (person: UserPersonOption) => {
|
||
if (!isSelectable(person)) return
|
||
emit('update:modelValue', String(person.id))
|
||
emit('update:selectedPerson', person)
|
||
keyword.value = ''
|
||
resetResults()
|
||
}
|
||
|
||
const clearSelection = () => {
|
||
emit('update:modelValue', '')
|
||
emit('update:selectedPerson', null)
|
||
}
|
||
|
||
watch(normalizedKeyword, (value) => {
|
||
if (debounceTimer) {
|
||
clearTimeout(debounceTimer)
|
||
debounceTimer = null
|
||
}
|
||
resetResults()
|
||
if (!value) return
|
||
|
||
debounceTimer = setTimeout(() => {
|
||
debounceTimer = null
|
||
search(true)
|
||
}, 300)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (debounceTimer) clearTimeout(debounceTimer)
|
||
requestSequence += 1
|
||
})
|
||
</script>
|