优化考核调研评价人选择

This commit is contained in:
chenpei 2026-07-08 19:37:45 +08:00
parent de727a18e6
commit 83cf1ee2ca
2 changed files with 164 additions and 117 deletions

View File

@ -150,6 +150,16 @@ function matchesPickerSearch(keyword: string, values: unknown[]) {
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
}
function mergeEmployeesById(...groups: Employee[][]) {
const byId = new Map<string, Employee>()
for (const group of groups) {
for (const employee of group) {
if (!byId.has(employee.id)) byId.set(employee.id, employee)
}
}
return Array.from(byId.values())
}
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
const dingtalkToken = getStoredDingtalkPerformanceToken()
const response = await fetch(`${API_BASE}${path}`, {
@ -962,8 +972,9 @@ function CreateSurveyForm({
const [dueAt, setDueAt] = useState('')
const [openNow, setOpenNow] = useState(true)
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
const [evaluatorEmployees, setEvaluatorEmployees] = useState<Employee[]>([])
const [evaluatorDepartments, setEvaluatorDepartments] = useState<string[]>([])
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState<string[]>([])
const [evaluatorDepartmentEmployees, setEvaluatorDepartmentEmployees] = useState<Record<string, Employee[]>>({})
const [itemName, setItemName] = useState('')
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
@ -973,6 +984,22 @@ function CreateSurveyForm({
const selectedTemplate = templateCode || templates[0]?.code || ''
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
const evaluatorEmployees = useMemo(
() => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)),
[manualEvaluatorEmployees, evaluatorDepartmentEmployees],
)
const removeEvaluatorEmployee = (id: string) => {
setManualEvaluatorEmployees((current) => current.filter((employee) => employee.id !== id))
setEvaluatorDepartmentEmployees((current) => {
const next: Record<string, Employee[]> = {}
for (const [department, employees] of Object.entries(current)) {
const filtered = employees.filter((employee) => employee.id !== id)
if (filtered.length > 0) next[department] = filtered
}
return next
})
}
const submit = async () => {
setSaving(true)
@ -1002,14 +1029,14 @@ function CreateSurveyForm({
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
evaluatorDepartmentNames: evaluatorDepartments,
}),
})
setTitle('')
setDescription('')
setTargetEmployees([])
setEvaluatorEmployees([])
setEvaluatorDepartments([])
setManualEvaluatorEmployees([])
setEvaluatorDepartmentValues([])
setEvaluatorDepartmentEmployees({})
setTargetItems([])
setItemName('')
setCreatedSurvey(created)
@ -1045,16 +1072,18 @@ function CreateSurveyForm({
))}
</select>
</label>
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
{surveyKind === 'person_period' ? (
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
<>
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
</>
) : (
<div>
<div className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">{selectedTemplateInfo?.itemName || '调研事项'}</div>
@ -1082,14 +1111,40 @@ function CreateSurveyForm({
items={targetItems.map((item) => ({ id: item.name, label: item.name }))}
onRemove={(id) => setTargetItems((current) => current.filter((item) => item.name !== id))}
/>
<div className="mt-3">
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
</div>
</div>
)}
<EmployeePicker title="评价人" purpose="evaluator" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
<EmployeePicker
title="评价人"
purpose="evaluator"
selected={evaluatorEmployees}
onChange={setManualEvaluatorEmployees}
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
onRemoveEmployee={removeEvaluatorEmployee}
/>
<DepartmentPicker
departments={departments}
selected={evaluatorDepartments}
onChange={setEvaluatorDepartments}
selected={evaluatorDepartmentValues}
onChange={setEvaluatorDepartmentValues}
onDepartmentEmployeesChange={(department, checked, employees) => {
setEvaluatorDepartmentEmployees((current) => {
const next = { ...current }
if (checked) {
next[department] = employees
} else {
delete next[department]
}
return next
})
}}
/>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
@ -1119,11 +1174,15 @@ function EmployeePicker({
purpose,
selected,
onChange,
onAddEmployee,
onRemoveEmployee,
}: {
title: string
purpose?: 'target' | 'evaluator'
selected: Employee[]
onChange: (employees: Employee[]) => void
onAddEmployee?: (employee: Employee) => void
onRemoveEmployee?: (id: string) => void
}) {
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState<Employee[]>([])
@ -1131,26 +1190,38 @@ function EmployeePicker({
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const pickerRef = useRef<HTMLDivElement | null>(null)
const cacheRef = useRef<Map<string, Employee[]>>(new Map())
useEffect(() => {
const timer = window.setTimeout(async () => {
if (!keyword.trim()) {
const trimmedKeyword = keyword.trim()
if (!trimmedKeyword) {
setItems([])
setError(null)
setLoading(false)
setOpen(false)
return
}
const cacheKey = `${purpose || 'default'}:${trimmedKeyword}`
const cached = cacheRef.current.get(cacheKey)
if (cached) {
setItems(cached)
setError(null)
setLoading(false)
setOpen(true)
return
}
setLoading(true)
setError(null)
setOpen(true)
try {
const params = new URLSearchParams({
search: keyword,
search: trimmedKeyword,
limit: '50',
...(purpose ? { purpose } : {}),
})
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
cacheRef.current.set(cacheKey, data)
setItems(data)
} catch (err) {
setItems([])
@ -1177,9 +1248,15 @@ function EmployeePicker({
const selectedIds = new Set(selected.map((employee) => employee.id))
const selectEmployee = (employee: Employee) => {
if (selectedIds.has(employee.id)) return
onChange([...selected, employee])
if (onAddEmployee) {
onAddEmployee(employee)
} else {
onChange([...selected, employee])
}
setError(null)
setOpen(true)
setKeyword('')
setItems([])
setOpen(false)
}
return (
@ -1234,7 +1311,13 @@ function EmployeePicker({
)}
<SelectedChips
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
onRemove={(id) => onChange(selected.filter((employee) => employee.id !== id))}
onRemove={(id) => {
if (onRemoveEmployee) {
onRemoveEmployee(id)
} else {
onChange(selected.filter((employee) => employee.id !== id))
}
}}
/>
</div>
)
@ -1244,13 +1327,18 @@ function DepartmentPicker({
departments,
selected,
onChange,
onDepartmentEmployeesChange,
}: {
departments: DepartmentOption[]
selected: string[]
onChange: (items: string[]) => void
onDepartmentEmployeesChange?: (value: string, checked: boolean, employees: Employee[]) => void
}) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [keyword, setKeyword] = useState('')
const [loadingValue, setLoadingValue] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const employeeCacheRef = useRef<Map<string, Employee[]>>(new Map())
const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase()
@ -1322,12 +1410,43 @@ function DepartmentPicker({
})
}
const toggleValue = (value: string, checked: boolean) => {
onChange(
checked
? Array.from(new Set([...selected, value]))
: selected.filter((item) => item !== value),
)
const getDepartmentEmployees = async (value: string) => {
const cached = employeeCacheRef.current.get(value)
if (cached) return cached
const params = new URLSearchParams({
department: value,
limit: '1000',
purpose: 'evaluator',
})
const employees = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
employeeCacheRef.current.set(value, employees)
return employees
}
const toggleValue = async (value: string, checked: boolean) => {
if (!checked) {
onChange(selected.filter((item) => item !== value))
onDepartmentEmployeesChange?.(value, false, [])
setError(null)
return
}
setLoadingValue(value)
setError(null)
try {
const employees = await getDepartmentEmployees(value)
if (employees.length === 0) {
setError('该部门暂无可选人员')
return
}
onChange(Array.from(new Set([...selected, value])))
onDepartmentEmployeesChange?.(value, true, employees)
} catch (err) {
setError(err instanceof Error ? err.message : '部门人员加载失败')
} finally {
setLoadingValue(null)
}
}
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
@ -1363,10 +1482,16 @@ function DepartmentPicker({
<input
type="checkbox"
checked={selected.includes(department.value)}
onChange={(event) => toggleValue(department.value!, event.target.checked)}
disabled={loadingValue === department.value}
onChange={(event) => {
void toggleValue(department.value!, event.target.checked)
}}
aria-label={`选择${department.name}`}
/>
)}
{department.value && loadingValue === department.value && (
<span className="text-xs text-gray-400 dark:text-gray-500">加载中</span>
)}
</div>
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
</div>
@ -1382,7 +1507,11 @@ function DepartmentPicker({
{selected.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
onClick={() => {
selected.forEach((value) => onDepartmentEmployeesChange?.(value, false, []))
onChange([])
setError(null)
}}
className="text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
清空
@ -1398,13 +1527,16 @@ function DepartmentPicker({
<div className="max-h-64 overflow-y-auto rounded-[10px] border border-gray-200 p-1.5 dark:border-gray-700">
{roots.map((department) => renderNode(department))}
</div>
{error && <div className="mt-2 text-xs text-red-600 dark:text-red-300">{error}</div>}
{selectedLabels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selectedLabels.map((department) => (
<button
key={department.value || department.id}
type="button"
onClick={() => department.value && toggleValue(department.value, false)}
onClick={() => {
if (department.value) void toggleValue(department.value, false)
}}
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
>
{department.fullPath || department.departmentPath || (

View File

@ -469,7 +469,7 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
const ctx = await getPerformanceSurveyContext(session)
const search = clean(params.get('search')) || ''
const department = clean(params.get('department'))
const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 200))
const limit = Math.max(1, Math.min(Number(params.get('limit') || 50), 1000))
const isEvaluatorSearch = clean(params.get('purpose')) === 'evaluator'
const buildWhere = async (includeKeyword: boolean) => {
@ -512,7 +512,8 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
let employees = await queryEmployees()
if (search && employees.length < limit) {
const needsPinyinSearch = search && (employees.length === 0 || /[A-Za-z]/.test(search))
if (needsPinyinSearch && employees.length < limit) {
const candidates = await prisma.performanceSurveyEmployeeProfile.findMany({
where: await buildWhere(false),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
@ -538,92 +539,6 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
employees = [...employees, ...pinyinMatches].slice(0, limit)
}
if (employees.length === 0 && search) {
let matchingUsers = await prisma.user.findMany({
where: {
AND: [
{
OR: [
{ username: { contains: search, mode: 'insensitive' } },
{ realName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ dingtalkUserId: { contains: search, mode: 'insensitive' } },
],
},
{ archivedAt: null },
{
NOT: [
{ status: 'archived' },
{ status: 'suspended' },
],
},
],
},
select: {
id: true,
username: true,
realName: true,
email: true,
dingtalkUserId: true,
department: true,
},
take: 10,
})
if (matchingUsers.length < 10) {
const existingUserIds = new Set(matchingUsers.map((user) => user.id))
const candidates = await prisma.user.findMany({
where: {
AND: [
{ archivedAt: null },
{
NOT: [
{ status: 'archived' },
{ status: 'suspended' },
],
},
],
},
select: {
id: true,
username: true,
realName: true,
email: true,
dingtalkUserId: true,
department: true,
},
take: Math.min(5000, Math.max(1000, limit * 20)),
})
const pinyinUsers = candidates.filter((user) => {
if (existingUserIds.has(user.id)) return false
return matchesSearchKeyword(search, [
user.username,
user.realName,
user.email,
user.dingtalkUserId,
user.department,
])
})
matchingUsers = [...matchingUsers, ...pinyinUsers].slice(0, 10)
}
for (const user of matchingUsers) {
await ensurePerformanceSurveyEmployeeProfileForUser({
userId: user.id,
dingtalkUserId: user.dingtalkUserId,
email: user.email,
username: user.username,
realName: user.realName,
displayName: getDisplayName(user),
department: user.department,
})
}
if (matchingUsers.length > 0) {
employees = await queryEmployees()
}
}
return employees.map(serializeEmployee)
}