feat: 完善考核调研人员搜索与模板逻辑

This commit is contained in:
chenpei 2026-07-04 17:54:14 +08:00
parent beb2676163
commit 01c9af89a6
5 changed files with 594 additions and 111 deletions

View File

@ -71,7 +71,7 @@ export async function GET(request: Request, context: RouteContext) {
}
if (path[0] === 'departments') {
return ok(await listPerformanceSurveyDepartments(session))
return ok(await listPerformanceSurveyDepartments(session, url.searchParams))
}
if (path[0] === 'surveys' && path.length === 1) {

View File

@ -1,9 +1,10 @@
'use client'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { pinyin } from 'pinyin-pro'
import Header from '@/components/Header'
type ApiState<T> = {
@ -44,14 +45,47 @@ type DepartmentOption = {
type Template = {
code: string
name: string
shortName?: string
description: string
surveyType: string
weight: number
weightLabel?: string
maxTotal?: number
achievementLabel?: string
achievementSuffix?: string
questions: Array<{ id: string; title: string; description?: string }>
}
const API_BASE = '/api/performance-survey'
function normalizePickerSearch(value: unknown) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[\s/_\-||,,;;>]+/g, '')
}
function pickerSearchVariants(value: unknown) {
const text = String(value ?? '').trim()
if (!text) return []
const variants = [normalizePickerSearch(text)]
try {
variants.push(
normalizePickerSearch(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
normalizePickerSearch(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
)
} catch {
// 非中文或异常字符按原文匹配即可。
}
return Array.from(new Set(variants.filter(Boolean)))
}
function matchesPickerSearch(keyword: string, values: unknown[]) {
const normalizedKeyword = normalizePickerSearch(keyword)
if (!normalizedKeyword) return true
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
}
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...options,
@ -180,6 +214,7 @@ function Dashboard() {
{surveys.loading && <Panel>加载中...</Panel>}
{surveys.error && <ErrorPanel message={surveys.error} />}
{surveys.data && <DashboardStats data={surveys.data} />}
{surveys.data && tab === 'pending' && <PendingList items={surveys.data.pending || []} />}
{surveys.data && tab === 'created' && (
<CreatedList items={surveys.data.created || []} onRefresh={surveys.refresh} />
@ -190,12 +225,37 @@ function Dashboard() {
onChanged={async () => {
await surveys.refresh()
}}
templateStats={surveys.data?.templateStats || []}
/>
)}
</main>
)
}
function DashboardStats({ data }: { data: any }) {
const created = data.created || []
const templateStats = data.templateStats || []
const responseTotal = created.reduce((sum: number, item: any) => sum + Number(item.counts?.responses || 0), 0)
const openTotal = created.filter((item: any) => item.status === 'open').length
const items = [
{ label: '问卷事项', value: created.length },
{ label: '评价总数', value: responseTotal },
{ label: '模板数量', value: templateStats.length },
{ label: '开放问卷', value: openTotal },
]
return (
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div className="text-sm text-gray-500 dark:text-gray-400">{item.label}</div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">{item.value}</div>
</div>
))}
</div>
)
}
function PendingList({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无待评价" />
@ -329,35 +389,71 @@ function MyResults({ items }: { items: any[] }) {
)
}
function ManagePanel({ onChanged }: { onChanged: () => Promise<void> }) {
function ManagePanel({ onChanged, templateStats }: { onChanged: () => Promise<void>; templateStats: any[] }) {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments', [])
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const notifications = useApi<any[]>('/admin/notifications?limit=30', [])
const [message, setMessage] = useState<string | null>(null)
return (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(340px,0.75fr)]">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已创建考核调研')
await onChanged()
await notifications.refresh()
}}
/>
<div className="space-y-5">
<AdminActions
onMessage={setMessage}
onNotificationsChanged={notifications.refresh}
<div className="space-y-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已创建考核调研')
await onChanged()
await notifications.refresh()
}}
/>
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
<TemplateStatsPanel items={templateStats} />
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]">
<div className="space-y-5">
<AdminActions
onMessage={setMessage}
onNotificationsChanged={notifications.refresh}
/>
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
</div>
<NotificationList state={notifications} />
</div>
</div>
)
}
function TemplateStatsPanel({ items }: { items: any[] }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">模板核算概览</h2>
<div className="mt-4 space-y-3">
{items.map((item) => {
const achievement = item.responseCount > 0 ? item.aggregate?.achievementScore : '暂无'
const suffix = item.responseCount > 0 ? item.achievementSuffix || '' : ''
return (
<div key={item.code} className="grid grid-cols-1 items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:gap-4">
<div className="min-w-0">
<div className="truncate font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{item.responseCount || 0} 份评价</div>
</div>
<div className="sm:w-24 sm:text-right">
<div className="text-xl font-semibold text-gray-900 dark:text-white">{achievement}{suffix}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{item.achievementLabel || '得分'}</div>
</div>
<div className="sm:w-24 sm:text-right">
<div className="text-xl font-semibold text-gray-900 dark:text-white">{item.responseCount > 0 ? item.aggregate?.weightedScore : '暂无'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">权重得分 · {item.weightLabel || `${Math.round(Number(item.weight || 0) * 100)}%`}</div>
</div>
</div>
)
})}
{items.length === 0 && <div className="text-sm text-gray-500 dark:text-gray-400">暂无模板数据</div>}
</div>
</div>
)
}
function CreateSurveyForm({
templates,
departments,
@ -369,7 +465,7 @@ function CreateSurveyForm({
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [surveyKind, setSurveyKind] = useState('person_period')
const [surveyKind, setSurveyKind] = useState('item_course')
const [templateCode, setTemplateCode] = useState('')
const [dueAt, setDueAt] = useState('')
const [openNow, setOpenNow] = useState(true)
@ -378,6 +474,7 @@ function CreateSurveyForm({
const [evaluatorDepartments, setEvaluatorDepartments] = useState<string[]>([])
const [itemName, setItemName] = useState('')
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -387,18 +484,24 @@ function CreateSurveyForm({
const submit = async () => {
setSaving(true)
setError(null)
setCreatedSurvey(null)
try {
await apiRequest('/surveys', {
const pendingItemName = itemName.trim()
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
? [...targetItems, { name: pendingItemName }]
: targetItems
const submitTitle = title.trim() || targetItemsToSubmit[0]?.name || ''
const created = await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
title,
title: submitTitle,
description,
surveyKind,
templateCode: selectedTemplate,
dueAt: dueAt || null,
openNow,
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
targetItems,
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
evaluatorDepartmentNames: evaluatorDepartments,
}),
@ -410,6 +513,7 @@ function CreateSurveyForm({
setEvaluatorDepartments([])
setTargetItems([])
setItemName('')
setCreatedSurvey(created)
await onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败')
@ -418,9 +522,33 @@ function CreateSurveyForm({
}
}
const surveyLink = createdSurvey?.id
? `${typeof window !== 'undefined' ? window.location.origin : ''}/performance-survey/survey/${createdSurvey.id}`
: ''
const qrLink = surveyLink
? `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(surveyLink)}`
: ''
const copySurveyLink = async () => {
if (!surveyLink) return
await navigator.clipboard?.writeText(surveyLink)
}
const downloadQr = async () => {
if (!qrLink) return
const response = await fetch(qrLink)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${createdSurvey?.title || title || '考核调研'}-二维码.png`
link.click()
URL.revokeObjectURL(url)
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">新建考核调研</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">创建考核问卷</h2>
<div className="mt-4 grid gap-4">
<TextInput label="标题" value={title} onChange={setTitle} />
<label className="grid gap-1 text-sm">
@ -446,16 +574,16 @@ function CreateSurveyForm({
>
{(activeTemplates.length > 0 ? activeTemplates : templates).map((template) => (
<option key={template.code} value={template.code}>
{template.name}
{template.name} · {template.weightLabel || `${Math.round(template.weight * 100)}%`}
</option>
))}
</select>
</label>
<TextInput label="说明" value={description} onChange={setDescription} />
<TextInput label="截止时间" value={dueAt} onChange={setDueAt} type="datetime-local" />
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
{surveyKind === 'person_period' ? (
<EmployeePicker title="被考核人" selected={targetEmployees} onChange={setTargetEmployees} />
<EmployeePicker title="被考核人" purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
) : (
<div>
<div className="flex gap-2">
@ -485,7 +613,7 @@ function CreateSurveyForm({
</div>
)}
<EmployeePicker title="评价人" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
<EmployeePicker title="评价人" purpose="evaluator" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
<DepartmentPicker
departments={departments}
selected={evaluatorDepartments}
@ -502,8 +630,29 @@ function CreateSurveyForm({
onClick={submit}
className="rounded-lg bg-gray-900 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900"
>
{saving ? '创建中...' : '创建考核调研'}
{saving ? '创建中...' : '生成问卷二维码'}
</button>
{createdSurvey && surveyLink && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{/* 二维码来自第三方生成服务,无法走本项目的 Next Image 域名白名单。 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{qrLink && <img src={qrLink} alt="问卷二维码" className="h-28 w-28 rounded-md bg-white p-1" />}
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-900 dark:text-white">问卷已生成</div>
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-300">{surveyLink}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={copySurveyLink} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
复制链接
</button>
<button type="button" onClick={downloadQr} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
保存二维码
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
@ -511,52 +660,98 @@ function CreateSurveyForm({
function EmployeePicker({
title,
purpose,
selected,
onChange,
}: {
title: string
purpose?: 'target' | 'evaluator'
selected: Employee[]
onChange: (employees: Employee[]) => void
}) {
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState<Employee[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const pickerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const timer = window.setTimeout(async () => {
if (!keyword.trim()) {
setItems([])
setError(null)
setLoading(false)
setOpen(false)
return
}
setLoading(true)
setError(null)
setOpen(true)
try {
const data = await apiRequest<Employee[]>(`/employees?search=${encodeURIComponent(keyword)}&limit=20`)
const params = new URLSearchParams({
search: keyword,
limit: '50',
...(purpose ? { purpose } : {}),
})
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
setItems(data)
} catch (err) {
setItems([])
setError(err instanceof Error ? err.message : '搜索失败')
} finally {
setLoading(false)
}
}, 250)
return () => window.clearTimeout(timer)
}, [keyword])
}, [keyword, purpose])
useEffect(() => {
const closeWhenOutside = (event: MouseEvent) => {
if (!pickerRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', closeWhenOutside)
return () => document.removeEventListener('mousedown', closeWhenOutside)
}, [])
const selectedIds = new Set(selected.map((employee) => employee.id))
const selectEmployee = (employee: Employee) => {
if (selectedIds.has(employee.id)) return
onChange([...selected, employee])
setError(null)
setOpen(true)
}
return (
<div>
<div ref={pickerRef}>
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{title}</span>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onFocus={() => {
if (keyword.trim()) setOpen(true)
}}
onChange={(event) => {
setKeyword(event.target.value)
if (event.target.value.trim()) setOpen(true)
}}
placeholder="搜索花名、姓名、工号、部门、职级"
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
className="rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
</label>
{keyword && (
{open && keyword && (
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">搜索中...</div>
) : error ? (
<div className="px-3 py-2 text-sm text-red-600">{error}</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">无匹配人员</div>
) : (
@ -565,7 +760,7 @@ function EmployeePicker({
key={employee.id}
type="button"
disabled={selectedIds.has(employee.id)}
onClick={() => onChange([...selected, employee])}
onClick={() => selectEmployee(employee)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-gray-700"
>
<div className="font-medium text-gray-900 dark:text-white">{employee.displayName}</div>
@ -640,8 +835,8 @@ function DepartmentPicker({
department.departmentName,
department.departmentPath,
department.fullPath,
].filter(Boolean).join(' ').toLowerCase()
if (!haystack.includes(normalizedKeyword)) return
].filter(Boolean)
if (!matchesPickerSearch(normalizedKeyword, haystack)) return
let current: DepartmentOption | undefined = department
while (current) {

View File

@ -0,0 +1,55 @@
import {
DEFAULT_PERFORMANCE_SURVEY_TEMPLATES,
calculateAggregateScore,
calculateResponseScores,
} from '@/lib/performance-survey-templates'
describe('performance survey templates', () => {
test('course share template uses the legacy 100-point and 30% weighted scoring rule', () => {
const template = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === 'course_share')!
const scores = Object.fromEntries(template.questions.map((question) => [question.id, 20]))
const result = calculateResponseScores(template, scores)
expect(template.questions).toHaveLength(5)
expect(template.maxTotal).toBe(100)
expect(template.weight).toBe(0.3)
expect(result.totalScore).toBe(100)
expect(result.kpiScore).toBe(120)
expect(result.weightedScore).toBe(36)
})
test('person-period templates use the legacy 120-point and 20% weighted scoring rule', () => {
const template = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === 'cross_department')!
const scores = Object.fromEntries(template.questions.map((question) => [question.id, 20]))
const result = calculateResponseScores(template, scores)
expect(template.questions).toHaveLength(6)
expect(template.maxTotal).toBe(120)
expect(template.weight).toBe(0.2)
expect(result.totalScore).toBe(120)
expect(result.kpiScore).toBe(120)
expect(result.weightedScore).toBe(24)
})
test('aggregate scoring drops one highest and one lowest response when there are at least three', () => {
const template = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === 'cross_department')!
const result = calculateAggregateScore(
[
{ totalScore: 60, createdAt: '2026-07-04T00:00:00.000Z' },
{ totalScore: 100, createdAt: '2026-07-04T00:01:00.000Z' },
{ totalScore: 120, createdAt: '2026-07-04T00:02:00.000Z' },
],
template.scoreBands,
template.weight,
)
expect(result.droppedHighestLowest).toBe(true)
expect(result.effectiveCount).toBe(1)
expect(result.achievementScore).toBe(100)
expect(result.kpiScore).toBe(100)
expect(result.weightedScore).toBe(20)
})
})

View File

@ -1,5 +1,6 @@
import { randomUUID } from 'crypto'
import type { Session } from 'next-auth'
import { pinyin } from 'pinyin-pro'
import { prisma } from '@/lib/prisma'
import {
calculateAggregateScore,
@ -58,6 +59,34 @@ function clean(value: unknown) {
return normalized.length > 0 ? normalized : null
}
function normalizeSearchText(value: unknown) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[\s/_\-||,,;;>]+/g, '')
}
function searchVariants(value: unknown) {
const text = String(value ?? '').trim()
if (!text) return []
const variants = [normalizeSearchText(text)]
try {
variants.push(
normalizeSearchText(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
normalizeSearchText(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
)
} catch {
// 非中文或异常字符按原文匹配即可。
}
return Array.from(new Set(variants.filter(Boolean)))
}
function matchesSearchKeyword(keyword: string, values: unknown[]) {
const normalizedKeyword = normalizeSearchText(keyword)
if (!normalizedKeyword) return true
return values.some((value) => searchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
}
function normalizeList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => clean(item)).filter((item): item is string => Boolean(item))))
@ -345,55 +374,174 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
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 isEvaluatorSearch = clean(params.get('purpose')) === 'evaluator'
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [accountWhere]
if (department) {
const departmentClauses = getDepartmentSelectionWhere([department])
if (departmentClauses.length === 1) {
and.push(departmentClauses[0])
} else if (departmentClauses.length > 1) {
and.push({ OR: departmentClauses })
const buildWhere = async (includeKeyword: boolean) => {
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [accountWhere]
if (department) {
const departmentClauses = getDepartmentSelectionWhere([department])
if (departmentClauses.length === 1) {
and.push(departmentClauses[0])
} else if (departmentClauses.length > 1) {
and.push({ OR: departmentClauses })
}
}
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorSearch) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
if (includeKeyword && search) {
and.push({ OR: [
{ displayName: { contains: search, mode: 'insensitive' } },
{ flowerName: { contains: search, mode: 'insensitive' } },
{ realName: { contains: search, mode: 'insensitive' } },
{ jobNumber: { contains: search, mode: 'insensitive' } },
{ dingEmail: { contains: search, mode: 'insensitive' } },
{ departmentName: { contains: search, mode: 'insensitive' } },
{ departmentPath: { contains: search, mode: 'insensitive' } },
{ positionTitle: { contains: search, mode: 'insensitive' } },
{ positionLevel: { contains: search, mode: 'insensitive' } },
{ levelType: { contains: search, mode: 'insensitive' } },
{ managerName: { contains: search, mode: 'insensitive' } },
] })
}
return { AND: and } as any
}
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
if (search) {
and.push({ OR: [
{ displayName: { contains: search, mode: 'insensitive' } },
{ flowerName: { contains: search, mode: 'insensitive' } },
{ realName: { contains: search, mode: 'insensitive' } },
{ jobNumber: { contains: search, mode: 'insensitive' } },
{ dingEmail: { contains: search, mode: 'insensitive' } },
{ departmentName: { contains: search, mode: 'insensitive' } },
{ departmentPath: { contains: search, mode: 'insensitive' } },
{ positionLevel: { contains: search, mode: 'insensitive' } },
{ levelType: { contains: search, mode: 'insensitive' } },
{ managerName: { contains: search, mode: 'insensitive' } },
] })
}
const where: any = { AND: and }
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where,
const queryEmployees = async () => prisma.performanceSurveyEmployeeProfile.findMany({
where: await buildWhere(true),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: limit,
})
let employees = await queryEmployees()
if (search && employees.length < limit) {
const candidates = await prisma.performanceSurveyEmployeeProfile.findMany({
where: await buildWhere(false),
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
take: Math.min(5000, Math.max(1000, limit * 20)),
})
const existingIds = new Set(employees.map((employee) => employee.id))
const pinyinMatches = candidates.filter((employee) => {
if (existingIds.has(employee.id)) return false
return matchesSearchKeyword(search, [
employee.displayName,
employee.flowerName,
employee.realName,
employee.jobNumber,
employee.dingEmail,
employee.departmentName,
employee.departmentPath,
employee.positionTitle,
employee.positionLevel,
employee.levelType,
employee.managerName,
])
})
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)
}
export async function listPerformanceSurveyDepartments(session: Session | null) {
export async function listPerformanceSurveyDepartments(session: Session | null, params?: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
ensureManager(ctx)
const isEvaluatorPicker = clean(params?.get('purpose')) === 'evaluator'
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [
accountWhere,
{ departmentName: { not: null } },
]
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorPicker) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
@ -494,6 +642,26 @@ export async function listPerformanceSurveys(session: Session | null) {
take: 100,
})
const templates = await listPerformanceSurveyTemplates()
const scopedResponses = await prisma.performanceSurveyResponse.findMany({
where: {
survey: managerWhere as any,
},
include: {
survey: {
select: { templateCode: true },
},
},
orderBy: { createdAt: 'desc' },
take: 5000,
})
const responsesByTemplate = new Map<string, typeof scopedResponses>()
for (const response of scopedResponses) {
const list = responsesByTemplate.get(response.survey.templateCode) || []
list.push(response)
responsesByTemplate.set(response.survey.templateCode, list)
}
const resultTargets = ctx.employee
? await prisma.performanceSurveyTarget.findMany({
where: {
@ -540,6 +708,28 @@ export async function listPerformanceSurveys(session: Session | null) {
targetName: target.targetName,
closedAt: target.survey.closedAt,
})),
templateStats: templates.map((template) => {
const responses = responsesByTemplate.get(template.code) || []
const aggregate = calculateAggregateScore(
responses.map((response) => ({
totalScore: response.totalScore,
createdAt: response.createdAt,
})),
template.scoreBands,
template.weight,
)
return {
code: template.code,
name: template.name,
shortName: template.shortName,
weight: template.weight,
weightLabel: template.weightLabel,
achievementLabel: template.achievementLabel,
achievementSuffix: template.achievementSuffix,
responseCount: responses.length,
aggregate,
}
}),
permissions: {
isSystemAdmin: ctx.isSystemAdmin,
isDepartmentAdmin: ctx.isDepartmentAdmin,
@ -1133,9 +1323,6 @@ async function resolveSurveyEvaluators(body: any, ctx: UserContext) {
await getPerformanceSurveyAccountProfileWhere(),
{ OR: or },
]
if (!ctx.isSystemAdmin && ctx.scopeDepartment) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
const where: any = { AND: and }
const employees = or.length > 0

View File

@ -9,15 +9,23 @@ export type PerformanceSurveyQuestion = {
export type PerformanceSurveyScoreBand = {
min: number
max: number
score: number
score?: number
outMin?: number
outMax?: number
label?: string
}
export type PerformanceSurveyTemplateShape = {
code: string
name: string
shortName?: string
description: string
surveyType: string
weight: number
weightLabel?: string
maxTotal?: number
achievementLabel?: string
achievementSuffix?: string
questions: PerformanceSurveyQuestion[]
scoreBands: PerformanceSurveyScoreBand[]
}
@ -25,59 +33,79 @@ export type PerformanceSurveyTemplateShape = {
export const DEFAULT_PERFORMANCE_SURVEY_TEMPLATES: PerformanceSurveyTemplateShape[] = [
{
code: 'course_share',
name: '课程/分享调研',
name: '课程分享满意度调研',
shortName: '课程分享',
description: '适用于课程培训、经验分享、内部活动后的满意度与效果反馈。',
surveyType: 'item_course',
weight: 1,
weight: 0.3,
weightLabel: '30%',
maxTotal: 100,
achievementLabel: '满意度',
achievementSuffix: '%',
questions: [
{ id: 'content_value', title: '内容价值', description: '内容对工作是否有帮助' },
{ id: 'clarity', title: '表达清晰度', description: '讲解逻辑、重点和案例是否清楚' },
{ id: 'practicality', title: '可落地程度', description: '是否能转化为实际行动' },
{ id: 'interaction', title: '互动与答疑', description: '现场互动和问题回应情况' },
{ id: 'usefulness', title: '内容实用度', description: '听完能用上吗?' },
{ id: 'insight', title: '见解深度', description: '有没有独到洞察?' },
{ id: 'clarity', title: '结构清晰度', description: '听得懂、跟得上吗?' },
{ id: 'caseQuality', title: '案例质量', description: '有真实例支撑吗?' },
{ id: 'interaction', title: '互动参与感', description: '有参与感还是全程被动?' },
],
scoreBands: [
{ min: 0, max: 12, score: 60 },
{ min: 12, max: 15, score: 75 },
{ min: 15, max: 18, score: 90 },
{ min: 18, max: 20, score: 100 },
{ min: 95, max: 100, outMin: 100, outMax: 120, label: '满意度 95% 以上,对应 120-100 分' },
{ min: 89, max: 94, outMin: 85, outMax: 100, label: '满意度 94%-89%,对应 100-85 分' },
{ min: 81, max: 88, outMin: 70, outMax: 84, label: '满意度 88%-81%,对应 84-70 分' },
{ min: 77, max: 80, outMin: 60, outMax: 69, label: '满意度 80%-77%,对应 69-60 分' },
],
},
{
code: 'cross_department',
name: '跨部门协作考核',
name: '跨部门协同满意度调研',
shortName: '跨部门协同',
description: '适用于跨部门项目、流程配合、协同响应等人员周期考核。',
surveyType: 'person_period',
weight: 1,
weight: 0.2,
weightLabel: '20%',
maxTotal: 120,
achievementLabel: '协作得分',
achievementSuffix: '',
questions: [
{ id: 'response_speed', title: '响应速度', description: '需求响应是否及时' },
{ id: 'delivery_quality', title: '交付质量', description: '交付结果是否稳定可靠' },
{ id: 'communication', title: '沟通协同', description: '沟通是否主动、清晰、闭环' },
{ id: 'ownership', title: '责任意识', description: '是否能主动推进问题解决' },
{ id: 'requirementClarity', title: '需求清晰度', description: '目标、需求与验收口径是否清晰?' },
{ id: 'deliveryStandard', title: '交付规范性', description: '交付物是否规范、完整、可复用?' },
{ id: 'responseSpeed', title: '响应时效性', description: '响应是否及时,关键节点是否不拖延?' },
{ id: 'collaborationFit', title: '协作配合度', description: '跨部门配合是否主动、顺畅?' },
{ id: 'communicationEffect', title: '沟通有效性', description: '信息传递是否准确、减少反复?' },
{ id: 'processControl', title: '过程可控性', description: '过程是否有预警、有节奏、有闭环?' },
],
scoreBands: [
{ min: 0, max: 12, score: 60 },
{ min: 12, max: 15, score: 75 },
{ min: 15, max: 18, score: 90 },
{ min: 18, max: 20, score: 100 },
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价,对应 120-100 分' },
{ min: 85, max: 99.9, outMin: 85, outMax: 100, label: '满意,基本达到标准,对应 100-85 分' },
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间,对应 84-70 分' },
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间,对应 69-60 分' },
],
},
{
code: 'work_cooperation',
name: '日常工作配合考核',
name: '工作配合度满意度调研',
shortName: '工作配合度',
description: '适用于常规工作协作、服务支持、团队内互评。',
surveyType: 'person_period',
weight: 1,
weight: 0.2,
weightLabel: '20%',
maxTotal: 120,
achievementLabel: '配合度得分',
achievementSuffix: '',
questions: [
{ id: 'professionalism', title: '专业能力', description: '专业判断和处理问题能力' },
{ id: 'reliability', title: '可靠程度', description: '承诺事项是否稳定完成' },
{ id: 'service_attitude', title: '服务态度', description: '协作态度和服务意识' },
{ id: 'improvement', title: '改进意识', description: '是否持续优化工作方式' },
{ id: 'executionDepth', title: '执行落实度', description: '交办的事放心吗?' },
{ id: 'ownership', title: '主动担责', description: '推一步走一步还是自己跑?' },
{ id: 'reportCommunication', title: '汇报沟通', description: '进度看得见吗?出事早知道吗?' },
{ id: 'adaptability', title: '灵活应变', description: '变了能不能跟着转?' },
{ id: 'teamAlignment', title: '团队补位', description: '别人忙不过来会搭把手吗?' },
{ id: 'emotionalStability', title: '情绪稳定度', description: '压力下解决问题还是制造情绪?' },
],
scoreBands: [
{ min: 0, max: 12, score: 60 },
{ min: 12, max: 15, score: 75 },
{ min: 15, max: 18, score: 90 },
{ min: 18, max: 20, score: 100 },
{ min: 100, max: 120, outMin: 100, outMax: 120, label: '非常满意,并给予高度评价,对应 120-100 分' },
{ min: 85, max: 99.9, outMin: 85, outMax: 100, label: '满意,基本达到标准,对应 100-85 分' },
{ min: 70, max: 84.9, outMin: 70, outMax: 84, label: '评价尚可,还有进步空间,对应 84-70 分' },
{ min: 60, max: 69.9, outMin: 60, outMax: 69, label: '评价一般,有较大进步空间,对应 69-60 分' },
],
},
]
@ -117,14 +145,14 @@ export async function listPerformanceSurveyTemplates() {
orderBy: [{ surveyType: 'asc' }, { createdAt: 'asc' }],
})
return templates.map((template) => ({
return templates.map((template) => withDefaultTemplateMeta({
code: template.code,
name: template.name,
description: template.description,
description: template.description || '',
surveyType: template.surveyType,
weight: template.weight,
questions: template.questions,
scoreBands: template.scoreBands,
questions: template.questions as PerformanceSurveyQuestion[],
scoreBands: template.scoreBands as PerformanceSurveyScoreBand[],
}))
}
@ -139,7 +167,7 @@ export async function getPerformanceSurveyTemplate(code: string) {
throw new Error('考核调研模板不存在')
}
return {
return withDefaultTemplateMeta({
code: template.code,
name: template.name,
description: template.description || '',
@ -147,6 +175,18 @@ export async function getPerformanceSurveyTemplate(code: string) {
weight: template.weight,
questions: template.questions as PerformanceSurveyQuestion[],
scoreBands: template.scoreBands as PerformanceSurveyScoreBand[],
})
}
function withDefaultTemplateMeta(template: PerformanceSurveyTemplateShape): PerformanceSurveyTemplateShape {
const matchedDefault = DEFAULT_PERFORMANCE_SURVEY_TEMPLATES.find((item) => item.code === template.code)
return {
...template,
shortName: matchedDefault?.shortName,
weightLabel: matchedDefault?.weightLabel || `${Math.round(Number(template.weight || 0) * 100)}%`,
maxTotal: matchedDefault?.maxTotal,
achievementLabel: matchedDefault?.achievementLabel,
achievementSuffix: matchedDefault?.achievementSuffix,
}
}
@ -164,7 +204,7 @@ export function calculateResponseScores(
const totalScore =
questionScores.length > 0
? questionScores.reduce((sum, score) => sum + score, 0) / questionScores.length
? questionScores.reduce((sum, score) => sum + score, 0)
: 0
const kpiScore = mapAchievementToKpiScore(totalScore, template.scoreBands)
@ -213,8 +253,14 @@ function mapAchievementToKpiScore(value: number, bands: PerformanceSurveyScoreBa
const normalizedBands = [...bands].sort((left, right) => left.min - right.min)
const matched = normalizedBands.find((band) => value >= band.min && value <= band.max)
if (!matched) {
if (value < normalizedBands[0]?.min) return normalizedBands[0]?.score || 0
return normalizedBands[normalizedBands.length - 1]?.score || 0
if (value < normalizedBands[0]?.min) return 0
const last = normalizedBands[normalizedBands.length - 1]
return last?.outMax ?? last?.score ?? 0
}
if (matched.outMin != null && matched.outMax != null) {
const ratio = (value - matched.min) / (matched.max - matched.min)
return matched.outMin + ratio * (matched.outMax - matched.outMin)
}
const previous = normalizedBands
@ -222,11 +268,11 @@ function mapAchievementToKpiScore(value: number, bands: PerformanceSurveyScoreBa
.sort((left, right) => right.max - left.max)[0]
if (!previous || matched.max === matched.min) {
return matched.score
return matched.score ?? 0
}
const ratio = (value - matched.min) / (matched.max - matched.min)
return previous.score + ratio * (matched.score - previous.score)
return (previous.score ?? 0) + ratio * ((matched.score ?? 0) - (previous.score ?? 0))
}
function roundScore(value: number) {