GuDingZiChan/frontend/src/components/PersonBindingPicker.vue

242 lines
7.9 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.

<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>