diff --git a/app/api/performance-survey/[...path]/route.ts b/app/api/performance-survey/[...path]/route.ts index 5ebafcc..576a512 100644 --- a/app/api/performance-survey/[...path]/route.ts +++ b/app/api/performance-survey/[...path]/route.ts @@ -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) { diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index d7ad4a1..3aa2bb7 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -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 = { @@ -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(path: string, options?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { ...options, @@ -180,6 +214,7 @@ function Dashboard() { {surveys.loading && 加载中...} {surveys.error && } + {surveys.data && } {surveys.data && tab === 'pending' && } {surveys.data && tab === 'created' && ( @@ -190,12 +225,37 @@ function Dashboard() { onChanged={async () => { await surveys.refresh() }} + templateStats={surveys.data?.templateStats || []} /> )} ) } +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 ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) +} + function PendingList({ items }: { items: any[] }) { if (items.length === 0) { return @@ -329,35 +389,71 @@ function MyResults({ items }: { items: any[] }) { ) } -function ManagePanel({ onChanged }: { onChanged: () => Promise }) { +function ManagePanel({ onChanged, templateStats }: { onChanged: () => Promise; templateStats: any[] }) { const templates = useApi('/templates', []) - const departments = useApi('/departments', []) + const departments = useApi('/departments?purpose=evaluator', []) const notifications = useApi('/admin/notifications?limit=30', []) const [message, setMessage] = useState(null) return ( -
- { - setMessage('已创建考核调研') - await onChanged() - await notifications.refresh() - }} - /> -
- +
+ { + setMessage('已创建考核调研') + await onChanged() + await notifications.refresh() + }} /> - {message && {message}} + +
+
+
+ + {message && {message}} +
) } +function TemplateStatsPanel({ items }: { items: any[] }) { + return ( +
+

模板核算概览

+
+ {items.map((item) => { + const achievement = item.responseCount > 0 ? item.aggregate?.achievementScore : '暂无' + const suffix = item.responseCount > 0 ? item.achievementSuffix || '' : '' + return ( +
+
+
{item.shortName || item.name}
+
{item.responseCount || 0} 份评价
+
+
+
{achievement}{suffix}
+
{item.achievementLabel || '得分'}
+
+
+
{item.responseCount > 0 ? item.aggregate?.weightedScore : '暂无'}
+
权重得分 · {item.weightLabel || `${Math.round(Number(item.weight || 0) * 100)}%`}
+
+
+ ) + })} + {items.length === 0 &&
暂无模板数据
} +
+
+ ) +} + 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([]) const [itemName, setItemName] = useState('') const [targetItems, setTargetItems] = useState>([]) + const [createdSurvey, setCreatedSurvey] = useState(null) const [saving, setSaving] = useState(false) const [error, setError] = useState(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('/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 (
-

新建考核调研

+

创建考核问卷

- + {surveyKind === 'person_period' ? ( - + ) : (
@@ -485,7 +613,7 @@ function CreateSurveyForm({
)} - + - {saving ? '创建中...' : '创建考核调研'} + {saving ? '创建中...' : '生成问卷二维码'} + {createdSurvey && surveyLink && ( +
+
+ {/* 二维码来自第三方生成服务,无法走本项目的 Next Image 域名白名单。 */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {qrLink && 问卷二维码} +
+
问卷已生成
+
{surveyLink}
+
+ + +
+
+
+
+ )}
) @@ -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([]) const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + const pickerRef = useRef(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(`/employees?search=${encodeURIComponent(keyword)}&limit=20`) + const params = new URLSearchParams({ + search: keyword, + limit: '50', + ...(purpose ? { purpose } : {}), + }) + const data = await apiRequest(`/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 ( -
+
- {keyword && ( + {open && keyword && (
{loading ? (
搜索中...
+ ) : error ? ( +
{error}
) : items.length === 0 ? (
无匹配人员
) : ( @@ -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" >
{employee.displayName}
@@ -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) { diff --git a/lib/__tests__/performance-survey-templates.test.ts b/lib/__tests__/performance-survey-templates.test.ts new file mode 100644 index 0000000..2b20120 --- /dev/null +++ b/lib/__tests__/performance-survey-templates.test.ts @@ -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) + }) +}) diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index b5f4986..17e4e42 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -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() + 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 diff --git a/lib/performance-survey-templates.ts b/lib/performance-survey-templates.ts index f3b1fe6..e566fa0 100644 --- a/lib/performance-survey-templates.ts +++ b/lib/performance-survey-templates.ts @@ -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) {