'use client' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import Image from 'next/image' import Link from 'next/link' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { useSession } from 'next-auth/react' import { pinyin } from 'pinyin-pro' import { createPortal } from 'react-dom' type ApiState = { loading: boolean data: T | null error: string | null } type Employee = { id: string displayName: string flowerName?: string | null realName?: string | null dingEmail?: string | null jobNumber?: string | null departmentName?: string | null positionTitle?: string | null positionLevel?: string | null levelType?: string | null isLeadership?: boolean | null managerName?: string | null dingtalkUserId?: string | null } type DepartmentOption = { id: string name: string parentId?: string | null parentName?: string | null employeeCount?: number selectable?: boolean value?: string | null departmentName?: string | null departmentPath?: string | null fullPath?: string | null } type Template = { code: string name: string shortName?: string description: string surveyType: string ownerRoleName?: string itemName?: string itemNamePlaceholder?: string itemNoteName?: string itemNotePlaceholder?: string defaultCategory?: string respondentNameLabel?: string respondentDepartmentLabel?: string respondentNameFallback?: string dataSource?: string weight: number weightLabel?: string maxTotal?: number questionMaxScore?: number requiredWorkItemCount?: number workItemName?: string workItemImageRequired?: boolean achievementLabel?: string achievementSuffix?: string trimNote?: string trimmedNote?: string belowBand?: string questions: Array<{ id: string; title: string; description?: string; maxScore?: number }> } const API_BASE = '/api/performance-survey' const DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY = 'performanceSurveyDingtalkToken' const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session' const DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT = 'performance-survey:dingtalk-session-expired' const PERFORMANCE_SURVEY_MAX_IMAGE_COUNT = 12 const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 20 * 1024 * 1024 const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([ 's3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk', ]) type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage' function normalizePerformanceSurveyDashboardTab(value: unknown): PerformanceSurveyDashboardTab { const tab = String(value || '').trim().toLowerCase() if (tab === 'public') return 'public' if (tab === 'manage' || tab === 'created' || tab === 'admin') return 'manage' return 'personal' } function getLegacyPerformanceSurveyPathTab(path: string[]): PerformanceSurveyDashboardTab | null { if (path.length !== 1) return null const segment = String(path[0] || '').trim().toLowerCase() if (!['personal', 'public', 'manage', 'pending', 'submitted', 'results', 'created', 'teacher', 'admin'].includes(segment)) { return null } return normalizePerformanceSurveyDashboardTab(segment) } function getModalFocusableElements(container: HTMLElement | null) { if (!container) return [] return Array.from(container.querySelectorAll( 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', )).filter((element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true') } function keepFocusInsideModal(event: KeyboardEvent, container: HTMLElement | null) { if (event.key !== 'Tab') return const focusable = getModalFocusableElements(container) if (focusable.length === 0) { event.preventDefault() container?.focus() return } const first = focusable[0] const last = focusable[focusable.length - 1] const activeElement = document.activeElement if (event.shiftKey && (activeElement === first || !container?.contains(activeElement))) { event.preventDefault() last.focus() } else if (!event.shiftKey && activeElement === last) { event.preventDefault() first.focus() } } function validatePerformanceSurveyImageFiles(files: File[]) { if (files.length === 0) return '请至少选择 1 张图片' if (files.length > PERFORMANCE_SURVEY_MAX_IMAGE_COUNT) { return `单个案例最多上传 ${PERFORMANCE_SURVEY_MAX_IMAGE_COUNT} 张图片` } const invalidType = files.find((file) => !file.type.startsWith('image/')) if (invalidType) return `文件「${invalidType.name}」不是图片` const oversized = files.find((file) => file.size > PERFORMANCE_SURVEY_MAX_IMAGE_BYTES) if (oversized) return `图片「${oversized.name}」超过 20MB` return null } function normalizePerformanceSurveyImageUrl(url?: string | null): string { const trimmed = String(url || '').trim() if (!trimmed) return '' if (!/^https?:\/\//i.test(trimmed)) return trimmed try { const parsed = new URL(trimmed) if (PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS.has(parsed.hostname.toLowerCase())) { return `/api/proxy-media?url=${encodeURIComponent(trimmed)}` } return trimmed } catch { return trimmed } } function getStoredDingtalkPerformanceToken() { if (typeof window === 'undefined') return null try { return ( window.sessionStorage.getItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY) || window.localStorage.getItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY) ) } catch { return null } } function clearStoredDingtalkPerformanceToken() { if (typeof window === 'undefined') return try { window.sessionStorage.removeItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY) window.localStorage.removeItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY) } catch { // 浏览器存储不可用时没有可清理的本地凭证。 } } function decodeStoredDingtalkPerformanceSession() { const token = getStoredDingtalkPerformanceToken() const encodedPayload = token?.split('.')[0] if (!encodedPayload) return null try { const normalized = encodedPayload.replace(/-/g, '+').replace(/_/g, '/') const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)) const binary = window.atob(normalized + padding) const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) const payload = JSON.parse(new TextDecoder().decode(bytes)) return { id: String(payload.userId || ''), name: String(payload.name || ''), email: String(payload.email || ''), avatar: payload.avatar ? String(payload.avatar) : null, role: String(payload.role || 'USER'), department: String(payload.department || ''), managedDepartment: payload.managedDepartment ? String(payload.managedDepartment) : null, isSuperAdmin: Boolean(payload.isSuperAdmin), } } catch { return null } } 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))) } function mergeEmployeesById(...groups: Employee[][]) { const byId = new Map() 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(path: string, options?: RequestInit): Promise { const dingtalkToken = getStoredDingtalkPerformanceToken() const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { ...(options?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(dingtalkToken ? { Authorization: `Bearer ${dingtalkToken}`, [DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken, } : {}), ...(options?.headers || {}), }, }) const contentType = response.headers.get('content-type') || '' const payload = contentType.includes('application/json') ? await response.json() : await response.text() if (!response.ok || (payload && typeof payload === 'object' && payload.success === false)) { if (response.status === 401 && dingtalkToken) { clearStoredDingtalkPerformanceToken() if (typeof window !== 'undefined') { window.dispatchEvent(new Event(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT)) } throw new Error('钉钉考核调研访问凭证已过期,请从钉钉最新通知重新进入') } throw new Error( payload && typeof payload === 'object' ? payload.error || payload.message || '请求失败' : String(payload || '请求失败'), ) } return (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) as T } function useApi(path: string | null, deps: unknown[] = []): ApiState & { refresh: () => Promise } { const [state, setState] = useState>({ loading: Boolean(path), data: null, error: null }) const requestSequenceRef = useRef(0) const abortControllerRef = useRef(null) const depsKey = JSON.stringify(deps) const load = useCallback(async () => { const requestSequence = requestSequenceRef.current + 1 requestSequenceRef.current = requestSequence abortControllerRef.current?.abort() if (!path) { abortControllerRef.current = null setState({ loading: false, data: null, error: null }) return } const abortController = new AbortController() abortControllerRef.current = abortController setState({ loading: true, data: null, error: null }) try { const data = await apiRequest(path, { signal: abortController.signal }) if (requestSequence !== requestSequenceRef.current) return setState({ loading: false, data, error: null }) } catch (error) { if (abortController.signal.aborted || requestSequence !== requestSequenceRef.current) return setState({ loading: false, data: null, error: error instanceof Error ? error.message : '加载失败' }) } finally { if (requestSequence === requestSequenceRef.current && abortControllerRef.current === abortController) { abortControllerRef.current = null } } }, [path]) useEffect(() => { void load() return () => { requestSequenceRef.current += 1 abortControllerRef.current?.abort() abortControllerRef.current = null } }, [load, depsKey]) return { ...state, refresh: load } } export default function PerformanceSurveyPage() { const { data: session, status } = useSession() const router = useRouter() const params = useParams() const searchParams = useSearchParams() const rawPath = (params as any).path const path = useMemo(() => (Array.isArray(rawPath) ? (rawPath as string[]) : []), [rawPath]) const searchParamsKey = searchParams.toString() const legacyPathTab = useMemo(() => getLegacyPerformanceSurveyPathTab(path), [path]) const [dingtalkSessionChecked, setDingtalkSessionChecked] = useState(false) const [hasDingtalkPerformanceSession, setHasDingtalkPerformanceSession] = useState(false) const [dingtalkUserProfile, setDingtalkUserProfile] = useState(null) const canLoadCurrentUser = Boolean(session || hasDingtalkPerformanceSession) const currentUser = useApi(canLoadCurrentUser ? '/me' : null, [canLoadCurrentUser]) useEffect(() => { const hasToken = Boolean(getStoredDingtalkPerformanceToken()) setHasDingtalkPerformanceSession(hasToken) setDingtalkUserProfile(hasToken ? decodeStoredDingtalkPerformanceSession() : null) setDingtalkSessionChecked(true) }, []) useEffect(() => { const handleExpiredSession = () => { setHasDingtalkPerformanceSession(false) setDingtalkUserProfile(null) } window.addEventListener(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT, handleExpiredSession) return () => window.removeEventListener(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT, handleExpiredSession) }, []) useEffect(() => { if (status === 'unauthenticated' && dingtalkSessionChecked && !hasDingtalkPerformanceSession) { const currentPath = typeof window === 'undefined' ? '/performance-survey' : `${window.location.pathname}${window.location.search}` router.push(`/login?redirect=${encodeURIComponent(currentPath)}`) } }, [status, dingtalkSessionChecked, hasDingtalkPerformanceSession, router]) useEffect(() => { if (!legacyPathTab) return const query = new URLSearchParams(searchParamsKey) query.set('tab', legacyPathTab) router.replace(`/performance-survey?${query.toString()}`) }, [legacyPathTab, router, searchParamsKey]) if (status === 'loading' || (status === 'unauthenticated' && !dingtalkSessionChecked)) { return (
) } if (!session && !hasDingtalkPerformanceSession) { return null } const currentUserProfile = currentUser.data?.user || null const sessionUser = session?.user as any const effectiveSession = ({ ...(session || { expires: '' }), user: { ...(sessionUser || {}), id: currentUserProfile?.id || sessionUser?.id || dingtalkUserProfile?.id || '', name: currentUserProfile?.name || session?.user?.name || dingtalkUserProfile?.name || '当前用户', email: currentUserProfile?.email || session?.user?.email || dingtalkUserProfile?.email || '', avatar: currentUserProfile?.avatar || sessionUser?.avatar || dingtalkUserProfile?.avatar || null, role: currentUserProfile?.role || sessionUser?.role || dingtalkUserProfile?.role || 'USER', department: currentUserProfile?.department || sessionUser?.department || dingtalkUserProfile?.department || '', managedDepartment: currentUserProfile?.managedDepartment || sessionUser?.managedDepartment || dingtalkUserProfile?.managedDepartment || null, isSuperAdmin: currentUserProfile?.isSuperAdmin ?? sessionUser?.isSuperAdmin ?? dingtalkUserProfile?.isSuperAdmin ?? false, }, } as any) const isSurveySubmitPage = path[0] === 'survey' && Boolean(path[1]) const isResultsPage = path[0] === 'results' && Boolean(path[1]) let content = if (isSurveySubmitPage && path[1]) { content = } if (isResultsPage && path[1]) { content = } if (isSurveySubmitPage) { return (
{content}
) } return (
{isResultsPage && } {content}
) } function PerformanceSurveyMobileHeader({ session }: { session: any }) { const user = session?.user || {} const name = String(user.name || '当前用户') const avatar = typeof user.avatar === 'string' && user.avatar ? user.avatar : typeof user.image === 'string' ? user.image : '' return (
{avatar ? ( {name} ) : ( getSurveyInitials(name) )}
考核调研
由壹点「素材库」
) } function Dashboard() { const router = useRouter() const searchParams = useSearchParams() const requestedTabRaw = searchParams.get('tab') || 'personal' const requestedTab = normalizePerformanceSurveyDashboardTab(requestedTabRaw) const searchParamsKey = searchParams.toString() const [tab, setTab] = useState(requestedTab) const me = useApi('/me', []) const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户' const avatar = me.data?.user?.avatar || me.data?.user?.image || '' const availableTabIds = useMemo( () => ['personal', 'public', 'manage'], [], ) useEffect(() => { if (availableTabIds.includes(requestedTab) && tab !== requestedTab) { setTab(requestedTab) return } if (!availableTabIds.includes(tab)) { setTab('personal') } }, [availableTabIds, requestedTab, tab]) useEffect(() => { if (requestedTabRaw === requestedTab) return const query = new URLSearchParams(searchParamsKey) query.set('tab', requestedTab) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) }, [requestedTab, requestedTabRaw, router, searchParamsKey]) const selectTab = (nextTab: PerformanceSurveyDashboardTab) => { setTab(nextTab) router.replace(`/performance-survey?tab=${encodeURIComponent(nextTab)}`, { scroll: false }) } const tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> = [ { id: 'personal', label: '个人看板' }, { id: 'public', label: '公共看板' }, { id: 'manage', label: '发起考核' }, ] const pageTitle = tab === 'public' ? '公共看板' : tab === 'manage' ? '新建考核计划' : '个人绩效概览' const pageSubtitle = tab === 'personal' ? '过去 12 个月的考核详细数据' : tab === 'public' ? '' : '' return ( <>

{pageTitle}

{pageSubtitle &&

{pageSubtitle}

}
{tab !== 'manage' && ( )} {tab === 'manage' && ( )}
) } function PerformanceSurveyDashboardHeader({ activeTab, avatar, displayName, onSelectTab, tabItems, }: { activeTab: PerformanceSurveyDashboardTab avatar: string displayName: string onSelectTab: (tab: PerformanceSurveyDashboardTab) => void tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> }) { return (
{avatar ? ( {displayName} ) : ( {getSurveyInitials(displayName)} )}
考核调研
由壹点「素材库」 由壹点
) } function DashboardStats({ data, activeTab, onSelectTab, }: { data: any activeTab: string onSelectTab: (tab: string) => void }) { const summary = data.summary || {} const personal = summary.personal || { pendingCount: summary.pendingCount || 0, submittedCount: summary.submittedCount || 0, createdCount: summary.createdCount || 0, resultCount: summary.resultCount || 0, } const scope = summary.scope || null const scopeLabel = summary.scopeMode === 'system' ? '全局' : '架构' const items = [ { id: 'pending', label: '待处理', value: personal.pendingCount || 0, scopeValue: scope?.pendingCount }, { id: 'submitted', label: '已处理', value: personal.submittedCount || 0, scopeValue: scope?.submittedCount }, { id: 'created', label: '我发起的', value: personal.createdCount || 0, scopeValue: scope?.createdCount }, { id: 'results', label: '我的结果', value: personal.resultCount || 0, scopeValue: scope?.resultCount }, ] return (
{items.map((item) => { const active = activeTab === item.id return ( ) })}
) } function PerformanceDashboardBoard({ view }: { view: 'personal' | 'public' }) { const [month, setMonth] = useState(getCurrentMonthValue()) const [department, setDepartment] = useState('') const [employee, setEmployee] = useState(null) const [detailRequest, setDetailRequest] = useState<{ month: string employeeId: string templateCode: string } | null>(null) const departments = useApi(view === 'public' ? '/departments?purpose=evaluator' : null, [view]) const dashboardPath = useMemo(() => { const params = new URLSearchParams() params.set('view', view) if (month) params.set('month', month) if (department) params.set('department', department) if (employee?.id) params.set('employeeId', employee.id) const query = params.toString() return `/dashboard${query ? `?${query}` : ''}` }, [view, month, department, employee?.id]) const dashboard = useApi(dashboardPath, [dashboardPath]) const templates = dashboard.data?.templates || [] const permissions = dashboard.data?.permissions || {} const personalRows = (dashboard.data?.personal?.rows || []).filter((row: any) => row.hasData) const publicRows = (dashboard.data?.public?.rows || []).filter((row: any) => row.hasData) return (
{view === 'public' && (
统计周期
setMonth(event.target.value)} className="h-10 rounded border border-[#e2e2e2] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]" />
组织架构
setDepartment(value)} /> {(department || employee) && ( )}
)} {dashboard.loading && (
看板加载中...
)} {dashboard.error && } {dashboard.data && ( <> {view === 'personal' ? ( ) : ( )} setDetailRequest(null)} /> )}
) } function MonthlyPersonalLedger({ rows, templates, onOpenDetail, }: { rows: any[] templates: any[] onOpenDetail: (value: { month: string; employeeId: string; templateCode: string }) => void }) { if (rows.length === 0) return return (
{rows.map((row) => (
{formatMonthLabel(row.month)}
CURRENT
{templates.map((template) => ( onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, })} /> ))}
))}
{templates.map((template) => ( ))} {rows.map((row) => ( {templates.map((template) => ( ))} ))}
月份{getTemplateDisplayName(template)}综合评分
{row.month} onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, })} /> {formatAverageScore(row)}
) } function MonthlyPublicLedger({ rows, templates, canOpenPublicDetail, onOpenDetail, }: { rows: any[] templates: any[] canOpenPublicDetail: boolean onOpenDetail: (value: { month: string; employeeId: string; templateCode: string }) => void }) { if (rows.length === 0) return return (
{rows.map((row) => (
{row.employee.displayName}
{[row.employee.departmentName, row.employee.positionLevel || row.employee.levelType].filter(Boolean).join(' · ')}
{formatAverageScore(row)}
{templates.map((template) => ( onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, })} /> ))}
))}
{templates.map((template) => ( ))} {rows.map((row) => ( {templates.map((template) => ( ))} ))}
人员 组织 / 职级{getTemplateDisplayName(template)}综合评分
{getSurveyInitials(row.employee.displayName)}
{row.employee.displayName}
{row.employee.jobNumber &&
{row.employee.jobNumber}
}
{row.employee.departmentName || '-'}
{row.employee.positionLevel || row.employee.levelType || '-'}
onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, })} /> {formatAverageScore(row)}
) } function MonthlyScoreCell({ template, cell, compact = false, canOpenOverride, onClick, }: { template: any cell: any compact?: boolean canOpenOverride?: boolean onClick: () => void }) { const hasScore = cell && cell.score !== null && cell.score !== undefined const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed)) const className = `${compact ? 'min-h-[58px] px-2 py-2' : 'min-h-[86px] px-3 py-3'} border text-left transition-colors ${ canOpen ? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]' : 'border-[#e2e2e2] bg-[#f9f9f9]' }` const content = ( <>
{getTemplateDisplayName(template)}
{hasScore ? formatNullableScore(cell.score) : '-'} {hasScore && /120}
) if (!canOpen) { return
{content}
} return ( ) } function MonthlyTableScoreCell({ cell, canOpenOverride, onClick, }: { cell: any canOpenOverride?: boolean onClick: () => void }) { const hasScore = cell && cell.score !== null && cell.score !== undefined const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed)) const content = ( <> {hasScore ? formatNullableScore(cell.score) : '-'} {hasScore && /120} ) if (!canOpen) { return
{content}
} return ( ) } function EmptyLedger({ title }: { title: string }) { return (
0

{title}

) } function MonthlyScoreDetailModal({ request, onClose, }: { request: { month: string; employeeId: string; templateCode: string } | null onClose: () => void }) { const [portalReady, setPortalReady] = useState(false) const dialogRef = useRef(null) const closeButtonRef = useRef(null) const detailPath = useMemo(() => { if (!request) return null const params = new URLSearchParams(request) return `/dashboard/detail?${params.toString()}` }, [request]) const detail = useApi(detailPath, [detailPath]) useEffect(() => { setPortalReady(true) }, []) useEffect(() => { if (!request) return const previousOverflow = document.body.style.overflow const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null document.body.style.overflow = 'hidden' const focusFrame = window.requestAnimationFrame(() => closeButtonRef.current?.focus()) const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() onClose() return } keepFocusInsideModal(event, dialogRef.current) } window.addEventListener('keydown', handleKeyDown) return () => { window.cancelAnimationFrame(focusFrame) document.body.style.overflow = previousOverflow window.removeEventListener('keydown', handleKeyDown) previousFocus?.focus() } }, [onClose, request]) if (!request || !portalReady) return null const isVisualDetail = Boolean(detail.data?.template?.isVisual || detail.data?.workItems?.length) const responseCount = Number(detail.data?.responseCount || detail.data?.participants?.length || 0) const surveyCount = Number(detail.data?.surveyCount || 0) return createPortal((
event.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="performance-survey-detail-title" tabIndex={-1} >
评估详情报告

{detail.data?.template?.fullName || detail.data?.template?.name || '考核明细'}

{detail.loading &&
加载中...
} {detail.error && } {detail.data && (
核心评分
{isVisualDetail && (
视觉评分作品 {detail.data.workItems?.length > 0 ? (
{detail.data.workItems.map((item: any, index: number) => (
作品 {index + 1}

{item.title}

{item.description &&

{item.description}

}
{formatNullableScore(item.averageScore)}
))}
) : (
暂无作品图片
)}
)}
详细评价内容
)}
), document.body) } function DetailMetaItem({ label, value }: { label: string; value: ReactNode }) { return (
{label}
{value}
) } function DetailSectionTitle({ children }: { children: ReactNode }) { return (

{children}

) } function DetailScoreCard({ label, value, suffix, strong = false, }: { label: string value: ReactNode suffix?: string strong?: boolean }) { return (
{label}
{value} {suffix && {suffix}}
) } function ScoreParticipantList({ items }: { items: any[] }) { if (!items.length) { return
暂无评分记录
} return (
{items.map((item) => (
{item.name}
{formatNullableScore(item.score)}
{item.comment &&

{item.comment}

} {item.submittedAt &&
{formatDate(item.submittedAt)}
}
))}
) } function MetricCard({ label, value }: { label: string; value: ReactNode }) { return (
{label}
{value}
) } function TemplateSectionCard({ item }: { item: any }) { return (
{item.name}
{item.count || 0}
调研
{formatNullableScore(item.averageScore)}
均分
已完成 {item.completedCount || 0} · 待完成 {item.pendingCount || 0}
) } function getTemplateDisplayName(template: any) { const name = String(template?.shortName || template?.name || '') return name .replace(/满意度调研/g, '') .replace(/考核/g, '') .replace(/\s*[·\-]\s*\d+%/g, '') .replace(/\s*\d+%/g, '') .trim() || name || '-' } function formatMonthLabel(value: unknown) { const text = String(value || '') const match = text.match(/^(\d{4})-(\d{2})/) if (!match) return text return `${match[1]}年${Number(match[2])}月` } function formatAverageScore(row: any) { const values = Object.values(row?.cells || {}) .map((cell: any) => Number(cell?.score)) .filter((value) => Number.isFinite(value)) if (values.length === 0) return '-' const average = values.reduce((sum, value) => sum + value, 0) / values.length return formatNullableScore(average) } function formatNullableScore(value: unknown) { if (value === null || value === undefined || value === '') return '-' const numberValue = Number(value) if (!Number.isFinite(numberValue)) return '-' return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1) } function SingleEmployeeFilter({ value, onChange, }: { value: Employee | null onChange: (value: Employee | null) => void }) { const [keyword, setKeyword] = useState('') const [items, setItems] = useState([]) const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) useEffect(() => { const trimmed = keyword.trim() if (!trimmed) { setItems([]) setOpen(false) return } const timer = window.setTimeout(async () => { setLoading(true) setOpen(true) try { const params = new URLSearchParams({ search: trimmed, limit: '20', purpose: 'evaluator' }) setItems(await apiRequest(`/employees?${params.toString()}`)) } finally { setLoading(false) } }, 250) return () => window.clearTimeout(timer) }, [keyword]) return (
setKeyword(event.target.value)} onFocus={() => keyword.trim() && setOpen(true)} placeholder={value ? value.displayName : '筛选人员'} className="h-10 w-full rounded border border-[#e2e2e2] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]" /> {value && ( )} {open && (
{loading ? (
搜索中...
) : items.length === 0 ? (
无匹配人员
) : ( items.map((employee) => ( )) )}
)}
) } function PendingList({ items }: { items: any[] }) { if (items.length === 0) { return (
0

暂无待处理

) } return (
{items.map((item) => (

{item.title}

{item.targetName}

{item.dueAt ? formatDate(item.dueAt) : '无截止'}

待填写
去评价 ->
))}
) } function SubmittedList({ items }: { items: any[] }) { if (items.length === 0) { return (
0

暂无已处理

) } return (
{items.map((item) => (

{item.title}

{item.targetName}

{item.submittedAt ? formatDate(item.submittedAt) : '已提交'}

已提交
查看详情
))}
) } function CreatedList({ items, onRefresh, emptyTitle = '还没有发起过考核调研', resultBackHref = '/performance-survey?tab=manage', }: { items: any[] onRefresh: () => Promise emptyTitle?: string resultBackHref?: string }) { const [busyId, setBusyId] = useState(null) const changeStatus = async (id: string, status: string) => { setBusyId(id) try { await apiRequest(`/surveys/${id}`, { method: 'PATCH', body: JSON.stringify({ status }), }) await onRefresh() } finally { setBusyId(null) } } if (items.length === 0) { return } return ( <>
{items.map((item) => { const assignmentCount = Number(item.counts?.assignments || 0) const responseCount = Number(item.counts?.responses || 0) const done = assignmentCount > 0 && responseCount >= assignmentCount const progressText = assignmentCount > 0 ? `${responseCount}/${assignmentCount}` : '0/0' return (

{item.title}

{item.departmentName || '未指定部门'}

{item.dueAt ? formatDate(item.dueAt) : '无截止'} · {progressText}

{done ? '已全部提交' : getSurveyStatusLabel(item.status)}
{item.status === 'draft' && ( )} {item.status !== 'closed' && ( )} 结果 ->
) })}
{['调研', '状态', '对象/评价/提交', '截止', '操作'].map((item) => ( ))} {items.map((item) => ( ))}
{item}
{item.title}
{item.departmentName || '未指定部门'}
{item.counts?.targets || 0} / {item.counts?.assignments || 0} / {item.counts?.responses || 0} {item.dueAt ? formatDate(item.dueAt) : '-'}
{item.status === 'draft' && ( )} {item.status !== 'closed' && ( )} 结果
) } function getCurrentMonthValue() { const date = new Date() return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` } function getDepartmentFilterValue(department: DepartmentOption) { return department.value || department.departmentPath || department.departmentName || department.name } function getDepartmentFilterLabel(department: DepartmentOption) { return department.fullPath || department.departmentPath || department.departmentName || department.name } function ManagedSurveyList({ canManage, departments, refreshKey, }: { canManage: boolean departments: DepartmentOption[] refreshKey: number }) { const router = useRouter() const searchParams = useSearchParams() const [month, setMonth] = useState(searchParams.get('month') || getCurrentMonthValue()) const [department, setDepartment] = useState(searchParams.get('department') || '') const managedPath = useMemo(() => { const params = new URLSearchParams() if (month) params.set('month', month) if (department) params.set('department', department) const query = params.toString() return `/surveys${query ? `?${query}` : ''}` }, [month, department]) const state = useApi(canManage ? managedPath : null, [canManage, managedPath, refreshKey]) const resultBackHref = useMemo(() => { const params = new URLSearchParams() params.set('tab', 'manage') if (month) params.set('month', month) if (department) params.set('department', department) return `/performance-survey?${params.toString()}` }, [month, department]) const updateFilterUrl = (nextMonth: string, nextDepartment: string) => { const params = new URLSearchParams(searchParams.toString()) params.set('tab', 'manage') if (nextMonth) params.set('month', nextMonth) else params.delete('month') if (nextDepartment) params.set('department', nextDepartment) else params.delete('department') router.replace(`/performance-survey?${params.toString()}`, { scroll: false }) } const surveys = state.data?.managed || [] return (

架构调研

{ setDepartment(nextDepartment) updateFilterUrl(month, nextDepartment) }} /> { const nextMonth = event.target.value setMonth(nextMonth) updateFilterUrl(nextMonth, department) }} className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />
{state.loading && 加载中...} {state.error && } {state.data && }
) } function DepartmentFilterTree({ departments, value, onChange, }: { departments: DepartmentOption[] value: string onChange: (value: string) => void }) { const [open, setOpen] = useState(false) const [keyword, setKeyword] = useState('') const [expanded, setExpanded] = useState>(new Set()) const selectedLabel = useMemo(() => { if (!value) return '全部组织' const selected = departments.find((department) => String(getDepartmentFilterValue(department) || '') === value) return selected ? getDepartmentFilterLabel(selected) : value }, [departments, value]) const { roots, childrenByParent, visibleIds } = useMemo(() => { const normalizedKeyword = keyword.trim().toLowerCase() const byId = new Map() const children = new Map() departments.forEach((department) => { byId.set(department.id, department) const parentKey = department.parentId || '' const list = children.get(parentKey) || [] list.push(department) children.set(parentKey, list) }) children.forEach((list) => { list.sort((a, b) => { const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0) if (countDiff !== 0) return countDiff return a.name.localeCompare(b.name, 'zh-CN') }) }) const matchedIds = new Set() if (normalizedKeyword) { departments.forEach((department) => { const haystack = [ department.name, department.parentName, department.departmentName, department.departmentPath, department.fullPath, ].filter(Boolean) if (!matchesPickerSearch(normalizedKeyword, haystack)) return let current: DepartmentOption | undefined = department while (current) { matchedIds.add(current.id) current = current.parentId ? byId.get(current.parentId) : undefined } }) } return { roots: children.get('') || [], childrenByParent: children, visibleIds: matchedIds, } }, [departments, keyword]) const toggle = (id: string) => { setExpanded((current) => { const next = new Set(current) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const choose = (nextValue: string) => { onChange(nextValue) setOpen(false) setKeyword('') } const renderNode = (department: DepartmentOption, depth = 0): ReactNode => { const children = childrenByParent.get(department.id) || [] const hasChildren = children.length > 0 const isExpanded = expanded.has(department.id) || Boolean(keyword.trim()) const isVisible = !keyword.trim() || visibleIds.has(department.id) const optionValue = String(getDepartmentFilterValue(department) || '') const selectable = Boolean(optionValue) const selected = selectable && optionValue === value if (!isVisible) return null return (
{selected && 已选}
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
) } return (
{open && (
setKeyword(event.target.value)} placeholder="搜索组织" className="mb-2 h-9 w-full rounded border border-[#eeeeee] px-3 text-sm text-[#1a1c1c] outline-none focus:border-[#0050cb]" />
{roots.map((department) => renderNode(department))}
)}
) } function MyResults({ items }: { items: any[] }) { if (items.length === 0) { return (
0

暂无结果

) } return (
{items.map((item) => (

{item.title}

{item.targetName}

{item.closedAt ? formatDate(item.closedAt) : '已关闭'}

可查看
看结果 ->
))}
) } function ManagePanel() { const templates = useApi('/templates', []) const departments = useApi('/departments?purpose=evaluator', []) const [message, setMessage] = useState(null) return (
{ setMessage('已发布考核') }} /> {message && (
{message}
)}
) } function TemplateStatsPanel({ items }: { items: any[] }) { return (

模板

{items.map((item) => { const achievement = item.responseCount > 0 ? `${item.aggregate?.achievementScore}${item.achievementSuffix || ''}` : '-' return (
{item.shortName || item.name}
{item.responseCount || 0} 份
{achievement}
均分
) })} {items.length === 0 &&
暂无模板
}
) } function CreateSurveyForm({ templates, departments, onCreated, }: { templates: Template[] departments: DepartmentOption[] onCreated: () => Promise }) { const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [templateCode, setTemplateCode] = useState('') const [dueAt, setDueAt] = useState(getCurrentMonthValue()) const openNow = true const [targetEmployees, setTargetEmployees] = useState([]) const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState([]) const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState([]) const [evaluatorDepartmentEmployees, setEvaluatorDepartmentEmployees] = useState>({}) const [itemName, setItemName] = useState('') const [targetItems, setTargetItems] = useState>([]) const [createdSurvey, setCreatedSurvey] = useState(null) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) 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 = {} 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) setError(null) setCreatedSurvey(null) try { const pendingItemName = itemName.trim() const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName ? [...targetItems, { name: pendingItemName }] : targetItems if (surveyKind === 'item_course' && targetItemsToSubmit.length === 0) { throw new Error(`请填写${selectedTemplateInfo?.itemName || '课程、事项或活动'}名称`) } if (surveyKind !== 'item_course' && targetEmployees.length === 0) { throw new Error(`请至少选择 1 个${selectedTemplateInfo?.ownerRoleName || '被考核人'}`) } const assessmentMonth = dueAt || getCurrentMonthValue() const submitTitle = title.trim() || targetItemsToSubmit[0]?.name || `${selectedTemplateInfo?.shortName || selectedTemplateInfo?.name || '考核'}-${assessmentMonth}` const created = await apiRequest('/surveys', { method: 'POST', body: JSON.stringify({ title: submitTitle, description, surveyKind, templateCode: selectedTemplate, dueAt: assessmentMonth ? `${assessmentMonth}-01T00:00:00` : null, openNow, targetEmployeeIds: targetEmployees.map((employee) => employee.id), targetItems: targetItemsToSubmit, evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id), }), }) setTitle('') setDescription('') setDueAt(getCurrentMonthValue()) setTargetEmployees([]) setManualEvaluatorEmployees([]) setEvaluatorDepartmentValues([]) setEvaluatorDepartmentEmployees({}) setTargetItems([]) setItemName('') setCreatedSurvey(created) await onCreated() } catch (err) { setError(err instanceof Error ? err.message : '创建失败') } finally { setSaving(false) } } return (
{error &&
{error}
} {createdSurvey && (
已发布
)}
{surveyKind !== 'item_course' ? ( ) : (
{selectedTemplateInfo?.itemName || '调研事项'}
setItemName(event.target.value)} placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写名称'} className="h-10 w-full rounded border border-[#eeeeee] px-3 text-[14px] font-semibold outline-none transition-colors focus:border-[#0050cb]" />
)}
setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))} onRemoveEmployee={removeEvaluatorEmployee} />
{ setEvaluatorDepartmentEmployees((current) => { const next = { ...current } if (checked) { next[department] = employees } else { delete next[department] } return next }) }} />
) } function EmployeePicker({ title, 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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [open, setOpen] = useState(false) const pickerRef = useRef(null) const cacheRef = useRef>(new Map()) useEffect(() => { const timer = window.setTimeout(async () => { 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: trimmedKeyword, limit: '50', ...(purpose ? { purpose } : {}), }) const data = await apiRequest(`/employees?${params.toString()}`) cacheRef.current.set(cacheKey, data) setItems(data) } catch (err) { setItems([]) setError(err instanceof Error ? err.message : '搜索失败') } finally { setLoading(false) } }, 250) return () => window.clearTimeout(timer) }, [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 if (onAddEmployee) { onAddEmployee(employee) } else { onChange([...selected, employee]) } setError(null) setKeyword('') setItems([]) setOpen(false) } return (
{open && keyword && (
{loading ? (
搜索中...
) : error ? (
{error}
) : items.length === 0 ? (
无匹配人员
) : ( items.map((employee) => ( )) )}
)} ({ id: employee.id, label: employee.displayName }))} onRemove={(id) => { if (onRemoveEmployee) { onRemoveEmployee(id) } else { onChange(selected.filter((employee) => employee.id !== id)) } }} />
) } 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>(new Set()) const [keyword, setKeyword] = useState('') const [loadingValue, setLoadingValue] = useState(null) const [error, setError] = useState(null) const employeeCacheRef = useRef>(new Map()) const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => { const normalizedKeyword = keyword.trim().toLowerCase() const byId = new Map() const children = new Map() departments.forEach((department) => { byId.set(department.id, department) const parentKey = department.parentId || '' const list = children.get(parentKey) || [] list.push(department) children.set(parentKey, list) }) children.forEach((list) => { list.sort((a, b) => { const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0) if (countDiff !== 0) return countDiff return a.name.localeCompare(b.name, 'zh-CN') }) }) const values = new Map() departments.forEach((department) => { if (department.value) values.set(department.value, department) }) const matchedIds = new Set() if (normalizedKeyword) { departments.forEach((department) => { const haystack = [ department.name, department.parentName, department.departmentName, department.departmentPath, department.fullPath, ].filter(Boolean) if (!matchesPickerSearch(normalizedKeyword, haystack)) return let current: DepartmentOption | undefined = department while (current) { matchedIds.add(current.id) current = current.parentId ? byId.get(current.parentId) : undefined } }) } return { roots: children.get('') || [], childrenByParent: children, optionByValue: values, visibleIds: matchedIds, } }, [departments, keyword]) const selectedLabels = selected .map((value) => optionByValue.get(value)) .filter((item): item is DepartmentOption => Boolean(item)) const toggle = (id: string) => { setExpanded((current) => { const next = new Set(current) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) } 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(`/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 => { const children = childrenByParent.get(department.id) || [] const hasChildren = children.length > 0 const isExpanded = expanded.has(department.id) || Boolean(keyword.trim()) const isVisible = !keyword.trim() || visibleIds.has(department.id) if (!isVisible) return null return (
{department.name}
{department.selectable !== false && department.value && ( { void toggleValue(department.value!, event.target.checked) }} aria-label={`选择${department.name}`} /> )} {department.value && loadingValue === department.value && ( 加载中 )}
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
) } if (departments.length === 0) return null return (
部门
{selected.length > 0 && ( )}
setKeyword(event.target.value)} placeholder="搜索部门" className="mb-2 h-10 w-full rounded border border-[#eeeeee] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]" />
{roots.map((department) => renderNode(department))}
{error &&
{error}
} {selectedLabels.length > 0 && (
{selectedLabels.map((department) => ( ))}
)}
) } function AdminActions({ onMessage, }: { onMessage: (message: string | null) => void }) { const [busy, setBusy] = useState(null) const run = async (key: string, path: string, success: string) => { setBusy(key) onMessage(null) try { const data = await apiRequest(path, { method: 'POST', body: JSON.stringify({}) }) onMessage(`${success}${data?.upserted != null ? `:${data.upserted} 人` : ''}`) } catch (error) { onMessage(error instanceof Error ? error.message : '操作失败') } finally { setBusy(null) } } return (

维护

导出数据
) } function getNotificationEventLabel(eventType?: string | null) { const map: Record = { 'survey.assignment.created': '待评价', 'survey.assignment.due_reminder': '截止提醒', 'survey.creator.completed': '完成提醒', 'survey.result.available': '结果可看', } return map[String(eventType || '')] || '系统消息' } function NotificationList({ state, statusState, }: { state: ApiState & { refresh: () => Promise } statusState: ApiState & { refresh: () => Promise } }) { const [busyId, setBusyId] = useState(null) const workNotice = statusState.data?.workNotice const counts = statusState.data?.counts || {} const records = state.data || [] const visibleRecords = records.slice(0, 6) const total = records.length const sentCount = Number(counts.sent || counts.succeeded || 0) const failedCount = Number(counts.exhausted || counts.skipped || counts.failed || 0) const retry = async (id: string) => { setBusyId(id) try { await apiRequest(`/admin/notifications/${id}/retry`, { method: 'POST', body: JSON.stringify({}) }) await state.refresh() } finally { setBusyId(null) } } return (

钉钉消息

{total} 条 · {sentCount} 已发送 · {failedCount} 异常
{workNotice && (
{workNotice.ready ? '已接入' : '未接入'}
)}
{state.loading &&
加载中...
} {state.error &&
{state.error}
} {records.length === 0 && !state.loading &&
暂无消息记录
} {visibleRecords.map((item) => { const isRetryable = ['retrying', 'exhausted', 'skipped'].includes(item.status) return (
{item.title}
{item.recipientName || '收件人缺失'} · {getNotificationEventLabel(item.eventType)}
{item.lastError && (
详情
{item.lastError}
)}
{isRetryable && ( )}
) })}
) } function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) { const router = useRouter() const searchParams = useSearchParams() const targetIdParam = searchParams.get('targetId') const detail = useApi(`/surveys/${surveyId}`, [surveyId]) const [targetId, setTargetId] = useState(targetIdParam || '') const [scores, setScores] = useState>({}) const [comment, setComment] = useState('') const [message, setMessage] = useState(null) const [submitting, setSubmitting] = useState(false) const [accountMenuOpen, setAccountMenuOpen] = useState(false) const targets = useMemo( () => (detail.data?.targets || []).filter((target: any) => target.currentAssignment || target.canUploadWorkItems), [detail.data], ) const activeTargetId = targetId || targets[0]?.id || '' const template = detail.data?.template as Template | undefined const questions = useMemo(() => template?.questions || [], [template]) useEffect(() => { if (!targetId && targets[0]?.id) setTargetId(targets[0].id) }, [targets, targetId]) useEffect(() => { setScores({}) setComment('') setMessage(null) setAccountMenuOpen(false) }, [activeTargetId, questions]) const submit = async () => { const hasUnratedQuestion = questions.some((question) => !Number.isFinite(scores[question.id])) if (questions.length === 0 || hasUnratedQuestion) { setMessage('请逐项完成评分后再提交') return } setSubmitting(true) setMessage(null) try { await apiRequest(`/surveys/${surveyId}/responses`, { method: 'POST', body: JSON.stringify({ targetId: activeTargetId, scores, comment }), }) setMessage('提交成功') await detail.refresh() } catch (error) { setMessage(error instanceof Error ? error.message : '提交失败') } finally { setSubmitting(false) } } if (detail.loading) return if (detail.error) return if (!detail.data) return null const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId) const submitted = activeTarget?.currentAssignment?.status === 'submitted' const scoreValues = questions.map((question) => Number(scores[question.id] ?? 0)) const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0) const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length const surveyStatusLabel = getSurveyStatusLabel(detail.data.status) const dueLabel = detail.data.dueAt ? `截止 ${formatDate(detail.data.dueAt)}` : '无截止时间' const viewer = session?.user || {} const viewerName = String(viewer.name || viewer.realName || viewer.username || '当前用户') const viewerAvatar = typeof viewer.avatar === 'string' && viewer.avatar ? viewer.avatar : typeof viewer.image === 'string' ? viewer.image : '' const goToSurveyTab = (nextTab: PerformanceSurveyDashboardTab) => { setAccountMenuOpen(false) router.push(`/performance-survey?tab=${encodeURIComponent(nextTab)}`) } const changeTarget = (nextTargetId: string) => { setScores({}) setComment('') setMessage(null) setAccountMenuOpen(false) setTargetId(nextTargetId) } if (isVisualTemplate(template)) { return ( ) } return (
考核调研问卷 {surveyStatusLabel}

{detail.data.title}

评价对象:{activeTarget?.targetName || '待选择'}

{dueLabel}

填写进度 {ratedCount}/{questions.length || 0} · {scoreTotal} 分
0 ? Math.min(100, (ratedCount / questions.length) * 100) : 0}%` }} />
{targets.length > 1 && ( )} {submitted ? ( ) : detail.data.status !== 'open' ? (
该调研未开放或已关闭
) : (
{questions.map((question, index) => { const maxScore = getQuestionMaxScore(template, question) const isRated = Number.isFinite(scores[question.id]) const value = isRated ? scores[question.id] : 0 const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100)) const accentColor = getQuestionAccentColor(index) return (
{index + 1}
问题 {index + 1}

{question.title}

{question.description && (

{question.description}

)}
{isRated ? value : '未评分'} {isRated && /{maxScore}}
{ if (!isRated) setScores((current) => ({ ...current, [question.id]: 0 })) }} onKeyDown={(event) => { if (!isRated && (event.key === 'ArrowLeft' || event.key === 'Home')) { setScores((current) => ({ ...current, [question.id]: 0 })) } }} onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))} className="survey-score-range mt-4 w-full" style={{ '--score-percent': `${scorePercent}%`, '--score-color': accentColor } as CSSProperties} />
0 {Math.round(maxScore * 0.25)} {Math.round(maxScore * 0.5)} {Math.round(maxScore * 0.75)} {maxScore}
) })}