'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' import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order' import { preparePerformanceSurveyImagesInBrowser } from '@/lib/performance-survey-image-browser' import { calculatePerformanceSurveyResponseProgress, calculatePerformanceSurveyWorkItemProgress, } from '@/lib/performance-survey-response-progress' import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping' import { PerformanceSurveyAdminConfig } from '@/app/performance-survey/PerformanceSurveyAdminConfig' import { PerformanceSurveyExportDialog } from '@/app/performance-survey/PerformanceSurveyExportDialog' import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' import { choosePerformanceSurveyExportDestination, sanitizePerformanceSurveyExportFileName, savePerformanceSurveyDashboardWorkbook, type PerformanceSurveyDashboardExportData, } from '@/lib/performance-survey-dashboard-export' 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 departmentPath?: 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 = { templateVersionId?: string templateVersion?: number code: string templateVariant?: 'with_copy' | 'without_copy' templateVariantName?: string templateVariantVersion?: number templateOptionKey?: 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 }> workflow?: { stages: Array<{ key: string label: string order: number reviewers: Array<{ source: 'self' | 'direct_manager' | 'fixed_employee' | 'manual_selection' label: string employeeId?: string employeeName?: string }> }> scoring: { mode: 'final_reviewer' | 'average_reviewers' stageKey?: string reviewerSource?: string stageKeys?: string[] } } } type VisualCaseDraft = { id: string title: string description: string files: File[] } type PerformanceSurveyImageSliceData = { url: string width: number height: number } type PerformanceSurveyImageGroupData = { key: string sourceName: string previewUrl: string previewWidth: number previewHeight: number width: number height: number slices: PerformanceSurveyImageSliceData[] } 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 = 60 const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 200 * 1024 * 1024 const DEFAULT_VISUAL_CASE_COUNT = 3 const ALL_POSITION_GROUPS_FILTER = 'all' const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([ 's3.jiansun.vip', ]) type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage' type PerformanceSurveyWorkspaceTab = 'overview' | 'pending' | 'submitted' | 'created' const PERFORMANCE_SURVEY_WORKSPACE_TABS: PerformanceSurveyWorkspaceTab[] = [ 'overview', 'pending', 'submitted', 'created', ] function normalizePerformanceSurveyDashboardTab(value: unknown): PerformanceSurveyDashboardTab { const tab = String(value || '').trim().toLowerCase() if (tab === 'public') return 'public' if (tab === 'manage' || tab === 'admin') return 'manage' return 'personal' } function normalizePerformanceSurveyWorkspaceTab(value: unknown): PerformanceSurveyWorkspaceTab { const tab = String(value || '').trim().toLowerCase() if (tab === 'results') return 'created' return PERFORMANCE_SURVEY_WORKSPACE_TABS.includes(tab as PerformanceSurveyWorkspaceTab) ? tab as PerformanceSurveyWorkspaceTab : 'overview' } function getLegacyPerformanceSurveyWorkspaceTab(value: unknown): PerformanceSurveyWorkspaceTab | null { const tab = String(value || '').trim().toLowerCase() return ['pending', 'submitted', 'created', 'results'].includes(tab) ? normalizePerformanceSurveyWorkspaceTab(tab) : null } 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}」超过 200MB` return null } async function uploadPerformanceSurveyImageFiles( files: File[], onProgress?: (label: string) => void, ) { const { chunkUpload } = await import('@/lib/chunk-upload') const uploaded: Array<{ uploadId: string mimeType: 'image/webp' width: number height: number kind: 'slice' | 'preview' sourceName: string sourceIndex: number sliceIndex: number sliceCount: number }> = [] const processedImages = await preparePerformanceSurveyImagesInBrowser(files, onProgress) const dingtalkToken = getStoredDingtalkPerformanceToken() const requestHeaders = dingtalkToken ? { Authorization: `Bearer ${dingtalkToken}`, [DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken, } : undefined for (let index = 0; index < processedImages.length; index += 1) { const image = processedImages[index] const file = image.file onProgress?.(`正在上传处理结果 ${index + 1}/${processedImages.length}`) const result = await chunkUpload( file, { materialType: '考核调研-视觉评分', fileName: file.name }, { skipComplete: true, requestHeaders }, ) if (!result?.uploadId) throw new Error(`图片「${file.name}」上传失败,请重试`) uploaded.push({ uploadId: result.uploadId, mimeType: image.mimeType, width: image.width, height: image.height, kind: image.kind, sourceName: image.sourceName, sourceIndex: image.sourceIndex, sliceIndex: image.sliceIndex, sliceCount: image.sliceCount, }) } return uploaded } function formatPerformanceSurveyImageFileSize(bytes: number) { if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB` return `${(bytes / 1024 / 1024).toFixed(1)} MB` } function createVisualCaseDrafts(count = DEFAULT_VISUAL_CASE_COUNT): VisualCaseDraft[] { return Array.from({ length: count }, (_, index) => ({ id: `visual-case-${index + 1}`, title: '', description: '', files: [], })) } function formatDateTimeLocalInput(value?: string | Date | null) { if (!value) return '' const date = value instanceof Date ? value : new Date(value) if (Number.isNaN(date.getTime())) return '' const pad = (part: number) => String(part).padStart(2, '0') return [ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, `${pad(date.getHours())}:${pad(date.getMinutes())}`, ].join('T') } 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 normalizePerformanceSurveyImageGroups(value: unknown): PerformanceSurveyImageGroupData[] { if (!Array.isArray(value) || value.length === 0) return [] const stringUrls = value .filter((item): item is string => typeof item === 'string') .map(normalizePerformanceSurveyImageUrl) .filter(Boolean) if (stringUrls.length === value.length) { return stringUrls.length > 0 ? [{ key: 'legacy-images', sourceName: '案例图片', previewUrl: stringUrls[0], previewWidth: 1200, previewHeight: 560, width: 1200, height: stringUrls.length * 1500, slices: stringUrls.map((url) => ({ url, width: 1200, height: 1500 })), }] : [] } const groups = value.flatMap((item, index): PerformanceSurveyImageGroupData[] => { if (!item || typeof item !== 'object' || Array.isArray(item)) return [] const record = item as Record const width = Math.max(1, Number(record.width) || 1200) const rawSlices = Array.isArray(record.slices) ? record.slices : Array.isArray(record.sliceUrls) ? record.sliceUrls : [] const slices = rawSlices.flatMap((slice): PerformanceSurveyImageSliceData[] => { if (typeof slice === 'string') { const url = normalizePerformanceSurveyImageUrl(slice) return url ? [{ url, width, height: 1500 }] : [] } if (!slice || typeof slice !== 'object' || Array.isArray(slice)) return [] const sliceRecord = slice as Record const url = normalizePerformanceSurveyImageUrl(String(sliceRecord.url || '')) return url ? [{ url, width: Math.max(1, Number(sliceRecord.width) || width), height: Math.max(1, Number(sliceRecord.height) || 1500), }] : [] }) if (slices.length === 0) return [] const previewUrl = normalizePerformanceSurveyImageUrl(String(record.previewUrl || slices[0].url)) return [{ key: `${String(record.sourceName || 'case-image')}-${index}`, sourceName: String(record.sourceName || `案例图片 ${index + 1}`), previewUrl: previewUrl || slices[0].url, previewWidth: Math.max(1, Number(record.previewWidth) || Math.min(width, 720)), previewHeight: Math.max(1, Number(record.previewHeight) || 560), width, height: Math.max(1, Number(record.height) || slices.reduce((sum, slice) => sum + slice.height, 0)), slices, }] }) if (groups.length <= 1) return groups const orderedGroups = sortPerformanceSurveyImagesByName(groups, (group) => group.sourceName) const slices = orderedGroups.flatMap((group) => group.slices) const firstGroup = orderedGroups[0] return [{ key: `ordered-slices-${orderedGroups.map((group) => group.key).join('-')}`, sourceName: `详情切片(${orderedGroups.length} 张)`, previewUrl: firstGroup.previewUrl, previewWidth: firstGroup.previewWidth, previewHeight: firstGroup.previewHeight, width: Math.max(...orderedGroups.map((group) => group.width)), height: slices.reduce((sum, slice) => sum + slice.height, 0), slices, }] } 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() let response: Response try { 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 || {}), }, }) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') throw error throw new Error('网络连接失败,请检查网络后重试') } 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) const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(path[0]) if (legacyWorkspaceTab) query.set('workspace', legacyWorkspaceTab) router.replace(`/performance-survey?${query.toString()}`) }, [legacyPathTab, path, 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]) const canCreateSurvey = Boolean(currentUser.data?.permissions?.canCreate) const permissionsLoading = currentUser.loading || (!currentUser.data && !currentUser.error) 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({ me, permissionsLoading }: { me: any | null; permissionsLoading: boolean }) { 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 [manageConfigurationDirty, setManageConfigurationDirty] = useState(false) const { confirmAction: confirmDashboardNavigation, confirmActionDialog: dashboardNavigationDialog, } = useConfirmActionDialog() const displayName = me?.employee?.flowerName || me?.employee?.displayName || me?.user?.name || '当前用户' const avatar = me?.user?.avatar || me?.user?.image || '' const canCreateSurvey = Boolean(me?.permissions?.canCreate) const permissionsResolved = !permissionsLoading const availableTabIds = useMemo( () => canCreateSurvey ? ['personal', 'public', 'manage'] : ['personal', 'public'], [canCreateSurvey], ) const confirmLeaveManage = useCallback(async () => { if (tab !== 'manage' || !manageConfigurationDirty) return true const result = await confirmDashboardNavigation({ title: '放弃尚未保存的修改?', description: '离开调研管理后,本次尚未保存的配置不会保留。', confirmLabel: '放弃并离开', tone: 'danger', }) return result.confirmed }, [confirmDashboardNavigation, manageConfigurationDirty, tab]) useEffect(() => { if (!permissionsResolved) return let cancelled = false const syncRequestedTab = async () => { if (requestedTab === 'manage' && !canCreateSurvey) { setManageConfigurationDirty(false) setTab('personal') const query = new URLSearchParams(searchParamsKey) query.set('tab', 'personal') query.delete('prefillMonth') query.delete('prefillTemplate') router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) return } if (availableTabIds.includes(requestedTab) && tab !== requestedTab) { if (!await confirmLeaveManage()) { if (cancelled) return const query = new URLSearchParams(searchParamsKey) query.set('tab', tab) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) return } if (cancelled) return setManageConfigurationDirty(false) setTab(requestedTab) return } if (!availableTabIds.includes(tab)) { setManageConfigurationDirty(false) setTab('personal') } } void syncRequestedTab() return () => { cancelled = true } }, [ availableTabIds, canCreateSurvey, confirmLeaveManage, permissionsResolved, requestedTab, router, searchParamsKey, tab, ]) useEffect(() => { if (requestedTabRaw === requestedTab) return const query = new URLSearchParams(searchParamsKey) query.set('tab', requestedTab) const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(requestedTabRaw) if (legacyWorkspaceTab) query.set('workspace', legacyWorkspaceTab) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) }, [requestedTab, requestedTabRaw, router, searchParamsKey]) const selectTab = async (nextTab: PerformanceSurveyDashboardTab) => { if (nextTab === tab || !await confirmLeaveManage()) return setManageConfigurationDirty(false) setTab(nextTab) const query = new URLSearchParams() query.set('tab', nextTab) const workspaceTab = getLegacyPerformanceSurveyWorkspaceTab(searchParams.get('workspace')) if (workspaceTab) query.set('workspace', workspaceTab) const adminWorkspace = searchParams.get('adminWorkspace') if (adminWorkspace) query.set('adminWorkspace', adminWorkspace) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) } const tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> = [ { id: 'personal', label: '个人看板' }, { id: 'public', label: '公共看板' }, ...(canCreateSurvey ? [{ id: 'manage' as const, label: me?.permissions?.isSystemAdmin ? '调研管理' : '发起调研', }] : []), ] const pageTitle = tab === 'public' ? '公共看板' : tab === 'manage' ? me?.permissions?.isSystemAdmin ? '考核调研管理' : '发起调研' : '个人绩效概览' const pageSubtitle = tab === 'personal' ? '过去 12 个月的考核详细数据' : tab === 'public' ? '' : '' return ( <> void selectTab(nextTab)} tabItems={tabItems} />

{pageTitle}

{pageSubtitle &&

{pageSubtitle}

}
{tab === 'personal' && } {tab === 'public' && } {tab === 'manage' && permissionsResolved && canCreateSurvey && ( )}
{dashboardNavigationDialog} ) } 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)} )}
考核调研
{displayName}
返回品牌中心 返回品牌中心
) } function DashboardStats({ data, activeTab, onSelectTab, }: { data: any activeTab: PerformanceSurveyWorkspaceTab onSelectTab: (tab: PerformanceSurveyWorkspaceTab) => void }) { const summary = data.summary || {} const personal = summary.personal || { pendingCount: summary.pendingCount || 0, submittedCount: summary.submittedCount || 0, createdCount: summary.createdCount || 0, } const items: Array<{ id: PerformanceSurveyWorkspaceTab; label: string; value?: number }> = [ { id: 'overview', label: '月度看板' }, { id: 'pending', label: '待我评分', value: personal.pendingCount || 0 }, { id: 'submitted', label: '已处理', value: personal.submittedCount || 0 }, { id: 'created', label: '我发起的', value: personal.createdCount || 0 }, ] return (
{items.map((item) => { const active = activeTab === item.id return ( ) })}
) } function PerformanceSurveyWorkspace({ canCreateSurvey }: { canCreateSurvey: boolean }) { const router = useRouter() const searchParams = useSearchParams() const searchParamsKey = searchParams.toString() const requestedWorkspaceTab = String(searchParams.get('workspace') || '').trim().toLowerCase() const activeTab = normalizePerformanceSurveyWorkspaceTab(requestedWorkspaceTab) const state = useApi('/surveys', []) useEffect(() => { if (requestedWorkspaceTab !== 'results') return const query = new URLSearchParams(searchParamsKey) query.set('tab', 'personal') query.set('workspace', 'created') router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) }, [requestedWorkspaceTab, router, searchParamsKey]) const selectTab = (nextTab: PerformanceSurveyWorkspaceTab) => { const query = new URLSearchParams(searchParamsKey) query.set('tab', 'personal') query.set('workspace', nextTab) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) } return (

任务与记录

查看待评分任务、已处理记录和我发起的调研

{state.loading && activeTab !== 'overview' && 任务加载中...} {state.error && } {activeTab === 'overview' && } {state.data && activeTab === 'pending' && } {state.data && activeTab === 'submitted' && } {state.data && activeTab === 'created' && ( )}
) } function PerformanceDashboardBoard({ view, canCreateSurvey = false, }: { view: 'personal' | 'public' canCreateSurvey?: boolean }) { const router = useRouter() const searchParams = useSearchParams() const [month, setMonth] = useState(searchParams.get('month') || getCurrentMonthValue()) const [department, setDepartment] = useState('') const [employee, setEmployee] = useState(null) const [positionGroupCode, setPositionGroupCode] = useState('') const [itemPage, setItemPage] = useState(1) const [deletingAssessmentKey, setDeletingAssessmentKey] = useState(null) const [removeStatus, setRemoveStatus] = useState('') const removeStatusRef = useRef(null) const [exportDialogOpen, setExportDialogOpen] = useState(false) const [exportBusy, setExportBusy] = useState(false) const [exportError, setExportError] = useState(null) const [exportStatus, setExportStatus] = useState('') const exportStatusRef = useRef(null) const { confirmAction: confirmRemovePublicEmployee, confirmActionDialog: removePublicEmployeeDialog, } = useConfirmActionDialog() const [detailRequest, setDetailRequest] = useState<{ month: string employeeId: string templateCode: string positionGroupCode?: string } | null>(null) const publicDepartmentsPath = view === 'public' ? `/departments?purpose=dashboard&month=${encodeURIComponent(month)}${positionGroupCode && positionGroupCode !== ALL_POSITION_GROUPS_FILTER ? `&positionGroupCode=${encodeURIComponent(positionGroupCode)}` : ''}` : null const departments = useApi(publicDepartmentsPath, [publicDepartmentsPath]) 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) if (positionGroupCode) params.set('positionGroupCode', positionGroupCode) params.set('itemPage', String(itemPage)) const query = params.toString() return `/dashboard${query ? `?${query}` : ''}` }, [view, month, department, employee?.id, positionGroupCode, itemPage]) const dashboard = useApi(dashboardPath, [dashboardPath]) const templates = dashboard.data?.templates || [] const permissions = dashboard.data?.permissions || {} const personalRows = dashboard.data?.personal?.rows || [] const publicRows = dashboard.data?.public?.rows || [] const positionGroups = useMemo( () => dashboard.data?.positionGroups || [], [dashboard.data?.positionGroups], ) const selectedPositionGroup = dashboard.data?.selectedPositionGroup || null const positionMembership = dashboard.data?.positionMembership || null const templatePager = dashboard.data?.templatePager || { page: 1, totalPages: 1, totalItems: 0, start: 0, end: 0 } const allPositionGroupsSelected = dashboard.data?.filters?.positionGroupCode === ALL_POSITION_GROUPS_FILTER const readyPositionGroupCount = positionGroups.filter((group: any) => group.status === 'ready').length const canDeletePublicDashboardAssessment = Boolean( view === 'public' && permissions.canDeletePublicDashboardAssessment ) const effectivePositionGroupCode = String( dashboard.data?.filters?.positionGroupCode || positionGroupCode || selectedPositionGroup?.code || '', ) const exportPositionName = allPositionGroupsSelected ? '全部岗位' : String(selectedPositionGroup?.name || '当前岗位') const deletePublicDashboardAssessment = useCallback(async (row: any, template: any, cell: any) => { const effectivePositionGroupCode = String(cell?.positionGroupCode || selectedPositionGroup?.code || '') if (!effectivePositionGroupCode || effectivePositionGroupCode === ALL_POSITION_GROUPS_FILTER) { setRemoveStatus('当前考核项缺少岗位信息,暂时无法删除。') return } const employeeName = String(row.employee?.displayName || '该人员') const templateName = getTemplateDisplayName(template) const positionName = String( positionGroups.find((group: any) => group.code === effectivePositionGroupCode)?.name || selectedPositionGroup?.name || '当前岗位', ) const assessmentKey = `${row.employee.id}:${template.code}` const result = await confirmRemovePublicEmployee({ title: `彻底删除“${employeeName}”的${templateName}?`, description: ( <> 将彻底删除{employeeName}在{formatMonthLabel(month)}「{positionName}」的“{templateName}” 调研、评分、作品和通知记录。删除后不可恢复,但可以重新发起该月该项调研。 ), confirmLabel: '确认彻底删除', loadingLabel: '删除中...', tone: 'danger', reason: { label: '删除原因', placeholder: '例如:发起有误、重复数据', helperText: '原因会写入操作记录,便于后续核查。', required: true, }, action: async (reason) => { setDeletingAssessmentKey(assessmentKey) try { await apiRequest(`/dashboard/public/assessments/${encodeURIComponent(row.employee.id)}`, { method: 'DELETE', body: JSON.stringify({ month, positionGroupCode: effectivePositionGroupCode, templateCode: template.code, reason, }), }) } finally { setDeletingAssessmentKey(null) } }, }) if (!result.confirmed) return await Promise.all([dashboard.refresh(), departments.refresh()]) setRemoveStatus(`已彻底删除${employeeName}在${formatMonthLabel(month)}的“${templateName}”调研,现在可以重新发起。`) window.requestAnimationFrame(() => removeStatusRef.current?.focus()) }, [ confirmRemovePublicEmployee, dashboard, departments, month, positionGroups, selectedPositionGroup, ]) const exportPublicDashboard = useCallback(async () => { if (!effectivePositionGroupCode) { setExportError('请先选择需要导出的考核岗位。') return } const fileName = sanitizePerformanceSurveyExportFileName( `考核调研_${exportPositionName}_${month}`, ) setExportBusy(true) setExportError(null) setExportStatus('') try { const destination = await choosePerformanceSurveyExportDestination(fileName) if (destination.kind === 'cancelled') return const params = new URLSearchParams({ month, positionGroupCode: effectivePositionGroupCode, }) const exportData = await apiRequest( `/dashboard/export?${params.toString()}`, ) await savePerformanceSurveyDashboardWorkbook(exportData, fileName, destination) setExportDialogOpen(false) setExportStatus( destination.kind === 'file-system' ? `已保存“${fileName}”。` : `已生成“${fileName}”,请在浏览器下载记录中查看。`, ) window.requestAnimationFrame(() => exportStatusRef.current?.focus()) } catch (error) { setExportError(error instanceof Error ? error.message : '导出失败,请稍后重试。') } finally { setExportBusy(false) } }, [effectivePositionGroupCode, exportPositionName, month]) return (
{view === 'public' && (
{ setPositionGroupCode(value) setItemPage(1) setDepartment('') setEmployee(null) }} /> 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.data && (
{allPositionGroupsSelected ? '全部岗位' : selectedPositionGroup?.name || (positionMembership?.status === 'conflict' ? '岗位配置冲突' : '尚未匹配考核岗位')}
{allPositionGroupsSelected ? `${readyPositionGroupCount} 个已生效岗位 · ${templatePager.totalItems} 个考核项` : selectedPositionGroup?.status === 'ready' ? `${templatePager.totalItems} 个考核项` : selectedPositionGroup ? '待管理员完成岗位规则与考核项配置' : positionMembership?.status === 'conflict' ? `同时命中:${(positionMembership.conflictGroupNames || []).join('、')}` : '当前人员没有可用的岗位配置'}
{view === 'public' && effectivePositionGroupCode && ( )}
)} {dashboard.loading && (
看板加载中...
)} {dashboard.error && } {removeStatus && (
{removeStatus}
)} {exportStatus && (
{exportStatus}
)} {dashboard.data && ( <> {view === 'personal' ? ( setDetailRequest({ ...value, positionGroupCode: selectedPositionGroup?.code })} onQuickCreate={canCreateSurvey ? ({ month: targetMonth, templateCode }) => { const params = new URLSearchParams() params.set('tab', 'manage') params.set('prefillMonth', targetMonth) params.set('prefillTemplate', templateCode) router.push(`/performance-survey?${params.toString()}`) } : undefined} /> ) : ( void deletePublicDashboardAssessment(row, template, cell)} onOpenDetail={setDetailRequest} /> )} setDetailRequest(null)} /> )} {removePublicEmployeeDialog} { if (!exportBusy) setExportDialogOpen(false) }} onExport={() => void exportPublicDashboard()} />
) } function PositionGroupSelect({ groups, value, onChange, }: { groups: Array<{ code: string; name: string; status: string; templateCount: number }> value: string onChange: (value: string) => void }) { return ( ) } function TemplatePager({ pager, onChange, }: { pager: { page: number; totalPages: number; totalItems: number; start: number; end: number } onChange: (page: number) => void }) { if (pager.totalItems <= 4) return null return (
{pager.start}-{pager.end} / {pager.totalItems}
) } function MonthlyPersonalLedger({ rows, templates, onOpenDetail, onQuickCreate, }: { rows: any[] templates: any[] onOpenDetail: (value: { month: string; employeeId: string; templateCode: string }) => void onQuickCreate?: (value: { month: string; templateCode: string }) => void }) { if (rows.length === 0) return return (
{rows.map((row) => (
{formatMonthLabel(row.month)}
{row.month === getCurrentMonthValue() && (
本月
)}
{templates.map((template) => ( onQuickCreate({ month: row.month, templateCode: template.code }) : undefined} onClick={() => onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, })} /> ))}
))}
{templates.map((template) => ( ))} {rows.map((row) => ( {templates.map((template) => ( ))} ))}
月份{getTemplateDisplayName(template)}
{formatMonthLabel(row.month)} onQuickCreate({ month: row.month, templateCode: template.code }) : undefined} onClick={() => onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, })} />
) } function MonthlyPublicLedger({ rows, templates, canOpenPublicDetail, canDeleteAssessment, deletingAssessmentKey, onDeleteAssessment, onOpenDetail, }: { rows: any[] templates: any[] canOpenPublicDetail: boolean canDeleteAssessment: boolean deletingAssessmentKey: string | null onDeleteAssessment: (row: any, template: any, cell: any) => void onOpenDetail: (value: { month: string; employeeId: string; templateCode: string; positionGroupCode?: 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(' · ')}
{templates.map((template) => { const cell = row.cells?.[template.code] const hasSurvey = Number(cell?.surveyCount || 0) > 0 return (
onOpenDetail({ month: row.month, employeeId: row.employee.id, templateCode: template.code, positionGroupCode: cell?.positionGroupCode, })} /> {canDeleteAssessment && hasSurvey && ( onDeleteAssessment(row, template, cell)} /> )}
) })}
))}
{templates.map((template) => ( ))} {rows.map((row) => ( {templates.map((template) => { const cell = row.cells?.[template.code] const hasSurvey = Number(cell?.surveyCount || 0) > 0 return ( ) })} ))}
人员 组织 / 职级{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, positionGroupCode: cell?.positionGroupCode, })} /> {canDeleteAssessment && hasSurvey && ( onDeleteAssessment(row, template, cell)} /> )}
) } function PublicLedgerAssessmentDeleteButton({ employeeName, templateName, loading, onClick, }: { employeeName: string templateName: string loading: boolean onClick: () => void }) { const label = `删除${employeeName}的${templateName}调研` return ( ) } function MonthlyScoreCell({ template, cell, compact = false, embedded = false, canOpenOverride, onClick, onQuickCreate, }: { template: any cell: any compact?: boolean embedded?: boolean canOpenOverride?: boolean onClick: () => void onQuickCreate?: () => void }) { const hasScore = cell && cell.score !== null && cell.score !== undefined const hasSurvey = Number(cell?.surveyCount || 0) > 0 const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed)) const maxTotal = Number(cell?.rawMaxTotal || template?.maxTotal || 120) const className = embedded ? `${compact ? 'min-h-[82px]' : 'min-h-[100px]'} px-1 py-2 text-left transition-colors ${canOpen ? 'hover:bg-[#f8faff]' : ''}` : `${compact ? 'min-h-[82px] px-2 py-2' : 'min-h-[108px] 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)}
{hasSurvey && }
{cell?.title || '未发起'}
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'} {hasScore && /{maxTotal}}
) if (!hasSurvey && onQuickCreate) { return ( ) } if (!canOpen) { return
{content}
} return ( ) } function MonthlyTableScoreCell({ cell, showTitle = true, canOpenOverride, onClick, onQuickCreate, }: { cell: any showTitle?: boolean canOpenOverride?: boolean onClick: () => void onQuickCreate?: () => void }) { const hasScore = cell && cell.score !== null && cell.score !== undefined const hasSurvey = Number(cell?.surveyCount || 0) > 0 const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed)) const maxTotal = Number(cell?.rawMaxTotal || 120) const accessibleLabel = `${cell?.title || '考核调研'},${ hasScore ? `得分 ${formatNullableScore(cell.score)},满分 ${maxTotal}` : '待评分' },查看详情` const content = (
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'} {hasScore && /{maxTotal}}
{hasSurvey && showTitle &&
{cell?.title}
}
) if (!hasSurvey && onQuickCreate) { return ( ) } if (!canOpen) { return
{content}
} return ( ) } function EmptyLedger({ title }: { title: string }) { return (
0

{title}

) } function getSurveyStatusMeta(status: unknown) { const value = String(status || '').toLowerCase() if (value === 'open') return { label: '进行中', dot: 'bg-[#0050cb]', text: 'text-[#0050cb]' } if (value === 'closed') return { label: '已结束', dot: 'bg-[#5f5e5e]', text: 'text-[#5f5e5e]' } if (value === 'cancelled') return { label: '已撤销', dot: 'bg-[#b42318]', text: 'text-[#b42318]' } if (value === 'draft') return { label: '草稿', dot: 'bg-[#b26a00]', text: 'text-[#8a5200]' } return { label: '待发布', dot: 'bg-[#8a8a8a]', text: 'text-[#5f5e5e]' } } function InlineSurveyState({ status }: { status: unknown }) { const meta = getSurveyStatusMeta(status) return ( {meta.label} ) } function MonthlyScoreDetailModal({ request, onClose, }: { request: { month: string; employeeId: string; templateCode: string; positionGroupCode?: 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({ month: request.month, employeeId: request.employeeId, templateCode: request.templateCode, }) if (request.positionGroupCode) params.set('positionGroupCode', request.positionGroupCode) 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 primarySurvey = detail.data?.surveys?.[0] || null const workflow = detail.data?.workflow || {} return createPortal((
event.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="performance-survey-detail-title" tabIndex={-1} >
{formatMonthLabel(detail.data?.month || request.month)} · {detail.data?.template?.name || '考核明细'} {primarySurvey && }

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

{detail.loading &&
加载中...
} {detail.error && } {detail.data && (
调研进度
{Number(workflow.submittedCount || responseCount || 0) > 0 && ( Number(workflow.notifiedCount || 0) < Number(workflow.submittedCount || responseCount || 0) || Number(workflow.readCount || 0) < Number(workflow.submittedCount || responseCount || 0) ) && (

评分已完成,但部分通知或查看状态没有记录,不影响已评分结果。

)}
{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 WorkflowCount({ label, value, total }: { label: string; value: number; total: number }) { return (
{label}
{value}/ {total}
) } function WorkflowState({ label, tone = 'muted' }: { label: string; tone?: 'blue' | 'green' | 'red' | 'muted' }) { const toneClass = tone === 'green' ? 'bg-[#149447]' : tone === 'blue' ? 'bg-[#0050cb]' : tone === 'red' ? 'bg-[#ba1a1a]' : 'bg-[#8a8a8a]' return ( {label} ) } function EvaluatorWorkflowList({ items }: { items: any[] }) { if (!items.length) { return
暂无评价人
} return (
{items.map((item) => { const noticeStatus = String(item.notification?.status || '') const readStatus = String(item.notification?.readStatus || 'unknown') const isSubmitted = item.assignmentStatus === 'submitted' const hasNoNotificationRecord = isSubmitted && !item.notification const notice = hasNoNotificationRecord ? { label: '无通知记录', tone: 'muted' as const } : noticeStatus === 'succeeded' ? { label: '已发送', tone: 'blue' as const } : noticeStatus === 'processing' || noticeStatus === 'pending' || noticeStatus === 'retrying' ? { label: '发送中', tone: 'muted' as const } : noticeStatus === 'skipped' || noticeStatus === 'exhausted' ? { label: '发送失败', tone: 'red' as const } : { label: '未发起', tone: 'muted' as const } const read = hasNoNotificationRecord ? { label: '查看状态未知', tone: 'muted' as const } : readStatus === 'read' ? { label: '已查看', tone: 'green' as const } : readStatus === 'unread' ? { label: '未查看', tone: 'muted' as const } : { label: '查看状态未知', tone: 'muted' as const } const submitted = isSubmitted ? { label: '已评分', tone: 'green' as const } : { label: '待评分', tone: 'muted' as const } return (
{item.name}
{item.submittedAt &&
{formatDate(item.submittedAt)}
}
) })}
) } function ScoreParticipantList({ items }: { items: any[] }) { if (!items.length) { return
暂无评分记录
} return (
{items.map((item) => (
{item.name} {item.countsTowardFinal && ( 计入最终分 )}
{formatNullableScore(item.rawScore ?? item.score)} /{Number(item.rawMaxTotal || 120)}
{Array.isArray(item.dimensions) && item.dimensions.length > 0 && (
{item.dimensions.map((dimension: any) => (
{dimension.title} {formatNullableScore(dimension.score)} /{dimension.maxScore}
))}
)} {item.comment &&

{item.comment}

} {item.submittedAt &&
{formatDate(item.submittedAt)}
}
))}
) } function VisualEvaluatorResultList({ responses, workItems, questions, template, }: { responses: any[] workItems: any[] questions: Template['questions'] template?: Template }) { const evaluators = groupVisualScoresByEvaluator(responses, workItems) if (evaluators.length === 0) { return
暂无评分记录
} return (
{evaluators.map((evaluator) => (
{evaluator.respondentName} {['visual_final', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && ( 计入最终分 )} {['self', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && ( 自评 )} {evaluator.workflowRole === 'manager' && ( 主管评分 )}
{evaluator.createdAt &&
提交于 {formatDate(evaluator.createdAt)}
}
{formatResultScore(evaluator.totalScore)}
总分
{evaluator.workItemScores.map((item, itemIndex) => { const itemScore = item.score const rawScores = itemScore?.scores && typeof itemScore.scores === 'object' ? itemScore.scores as Record : {} const dimensions = questions .map((question) => ({ question, value: rawScores[question.id], })) .filter(({ value }) => value !== null && value !== undefined && value !== '') return (
作品 {itemIndex + 1}
{item.workItemTitle}
{itemScore ? `${formatResultScore(itemScore.totalScore)} 分` : '未评分'}
{dimensions.length > 0 && (
{dimensions.map(({ question, value }) => (
{question.title} {formatResultScore(value)} / {getQuestionMaxScore(template, question)}
))}
)} {itemScore?.comment &&

{itemScore.comment}

}
) })}
{evaluator.comment && (
总体评语

{evaluator.comment}

)}
))}
) } 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 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 } return ( <>
{items.map((item) => (

{item.title}

评价对象:{item.targetName}

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

待填写
去评价
))}
{items.map((item) => ( ))}
调研 评价对象 截止 状态 操作
{item.title} {item.targetName} {item.dueAt ? formatDate(item.dueAt) : '无截止'} 待填写 去评价
) } function SubmittedList({ items }: { items: any[] }) { if (items.length === 0) { return } return ( <>
{items.map((item) => (

{item.title}

评价对象:{item.targetName}

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

已提交
查看详情
))}
{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 [actionErrors, setActionErrors] = useState>({}) const changeStatus = async (id: string, status: string) => { const actionLabel = status === 'open' ? '发布' : '关闭' setBusyId(id) setActionErrors((current) => { if (!current[id]) return current const next = { ...current } delete next[id] return next }) try { await apiRequest(`/surveys/${id}`, { method: 'PATCH', body: JSON.stringify({ status }), }) await onRefresh() } catch (error) { const message = error instanceof Error ? error.message : `${actionLabel}失败,请稍后重试` setActionErrors((current) => ({ ...current, [id]: `${actionLabel}失败:${message}`, })) } finally { setBusyId(null) } } const cancelSurvey = async (item: any) => { const reason = window.prompt('请输入撤销原因(必填)')?.trim() if (!reason) return if (!window.confirm(`确认撤销“${item.title}”吗?\n\n撤销原因:${reason}`)) return setBusyId(item.id) try { await apiRequest(`/surveys/${item.id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }), }) await onRefresh() } catch (error) { window.alert(error instanceof Error ? error.message : '撤销失败,请稍后重试') } finally { setBusyId(null) } } if (items.length === 0) { return } return ( <>
{items.map((item) => { const targetCount = Number(item.counts?.targets || 0) const assignmentCount = Number(item.counts?.assignments || 0) const responseCount = Number(item.counts?.responses || 0) const progressText = assignmentCount > 0 ? `已提交 ${responseCount}/${assignmentCount}` : '尚未生成评价任务' const actionError = actionErrors[item.id] const needsWorkItemUpload = actionError?.includes('作品尚未上传完整') return (

{item.title}

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

对象 {targetCount} 人 · 评价任务 {assignmentCount} 份

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

{item.status === 'draft' && ( 继续编辑 )} {item.status === 'draft' && ( )} {item.status !== 'closed' && item.status !== 'cancelled' && ( )} {item.canCancel && ( )} {item.status === 'closed' && item.personalTargetId && ( 查看结果 )}
{actionError && (
{actionError} {needsWorkItemUpload && ( 继续上传作品 )}
)}
) })}
{items.map((item) => { const targetCount = Number(item.counts?.targets || 0) const assignmentCount = Number(item.counts?.assignments || 0) const responseCount = Number(item.counts?.responses || 0) const actionError = actionErrors[item.id] const needsWorkItemUpload = actionError?.includes('作品尚未上传完整') return ( ) })}
调研 状态 完成进度 截止 操作
{item.title}
{item.departmentName || '未指定部门'}
{assignmentCount > 0 ? `已提交 ${responseCount}/${assignmentCount}` : '尚未生成评价任务'}
对象 {targetCount} 人 · 评价任务 {assignmentCount} 份
{item.dueAt ? formatDate(item.dueAt) : '无截止'}
{item.status === 'draft' && ( 继续编辑 )} {item.status === 'draft' && ( )} {item.status !== 'closed' && item.status !== 'cancelled' && ( )} {item.canCancel && ( )} {item.status === 'closed' && item.personalTargetId && ( 查看结果 )}
{actionError && (
{actionError} {needsWorkItemUpload && ( 继续上传作品 )}
)}
) } 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))}
)}
) } type PerformanceSurveyAdminWorkspace = | 'overview' | 'launch' | 'positions' | 'people' | 'templates' | 'operations' const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [ 'overview', 'launch', 'positions', 'people', 'templates', 'operations', ] function ManagePanel({ currentEmployee, isSystemAdmin, configurationDirty, onConfigurationDirtyChange, }: { currentEmployee: Employee | null isSystemAdmin: boolean configurationDirty: boolean onConfigurationDirtyChange: (dirty: boolean) => void }) { const router = useRouter() const searchParams = useSearchParams() const [message, setMessage] = useState(null) const { confirmAction: confirmManageNavigation, confirmActionDialog: manageNavigationDialog, } = useConfirmActionDialog() const requestedWorkspaceValue = String(searchParams.get('adminWorkspace') || '').trim() const requestedWorkspace = ( requestedWorkspaceValue === 'plans' ? 'positions' : requestedWorkspaceValue ) as PerformanceSurveyAdminWorkspace const requestedAdminMonth = String(searchParams.get('adminMonth') || '').trim() const adminMonth = /^\d{4}-\d{2}$/.test(requestedAdminMonth) ? requestedAdminMonth : getCurrentMonthValue() const initialWorkspace = isSystemAdmin && PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) ? requestedWorkspace : isSystemAdmin ? 'overview' : 'launch' const [workspace, setWorkspace] = useState(initialWorkspace) const isLaunchWorkspace = workspace === 'launch' const departments = useApi( isLaunchWorkspace ? '/departments?purpose=evaluator' : null, [isLaunchWorkspace], ) const surveys = useApi( isLaunchWorkspace ? '/surveys' : null, [isLaunchWorkspace], ) const notifications = useApi( isSystemAdmin && workspace === 'operations' ? '/admin/notifications?limit=50' : null, [workspace, isSystemAdmin], ) const notificationStatus = useApi( isSystemAdmin && workspace === 'operations' ? '/admin/notifications/status' : null, [workspace, isSystemAdmin], ) const confirmLeaveConfiguration = useCallback(async () => { if (!configurationDirty) return true const result = await confirmManageNavigation({ title: '放弃尚未保存的修改?', description: '离开当前页面后,本次尚未保存的配置不会保留。', confirmLabel: '放弃并离开', tone: 'danger', }) return result.confirmed }, [configurationDirty, confirmManageNavigation]) useEffect(() => { if (!isSystemAdmin || requestedWorkspaceValue !== 'plans') return const query = new URLSearchParams(searchParams.toString()) query.set('tab', 'manage') query.set('adminWorkspace', 'positions') router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) }, [isSystemAdmin, requestedWorkspaceValue, router, searchParams]) useEffect(() => { if (!isSystemAdmin) { if (configurationDirty) onConfigurationDirtyChange(false) if (workspace !== 'launch') setWorkspace('launch') return } if (!PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) || requestedWorkspace === workspace) { return } let cancelled = false const syncRequestedWorkspace = async () => { if (!await confirmLeaveConfiguration()) { if (cancelled) return const query = new URLSearchParams(searchParams.toString()) query.set('tab', 'manage') query.set('adminWorkspace', workspace) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) return } if (cancelled) return onConfigurationDirtyChange(false) setWorkspace(requestedWorkspace) } void syncRequestedWorkspace() return () => { cancelled = true } }, [ confirmLeaveConfiguration, configurationDirty, isSystemAdmin, onConfigurationDirtyChange, requestedWorkspace, router, searchParams, workspace, ]) const selectWorkspace = async (nextWorkspace: PerformanceSurveyAdminWorkspace) => { if (nextWorkspace === workspace || !await confirmLeaveConfiguration()) return setMessage(null) onConfigurationDirtyChange(false) setWorkspace(nextWorkspace) const query = new URLSearchParams(searchParams.toString()) query.set('tab', 'manage') query.set('adminWorkspace', nextWorkspace) router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) } const selectAdminMonth = (nextMonth: string) => { if (!/^\d{4}-\d{2}$/.test(nextMonth) || nextMonth === adminMonth) return const query = new URLSearchParams(searchParams.toString()) query.set('tab', 'manage') query.set('adminMonth', nextMonth) query.delete('prefillMonth') router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) } return (
{isSystemAdmin && ( )} {workspace === 'launch' ? ( void selectWorkspace(nextWorkspace) : undefined} onCreated={async () => { setMessage('已发布调研') await surveys.refresh() }} /> ) : workspace === 'operations' ? (

运行维护

同步人员档案、检查通知异常并补发消息

) : ( void selectWorkspace(nextWorkspace)} onDirtyChange={onConfigurationDirtyChange} /> )} {manageNavigationDialog} {message && (
{message}
)} {workspace === 'launch' &&

我发起的

回看进度、关闭调研或进入结果页

{surveys.loading && 调研加载中...} {surveys.error && } {surveys.data && ( )}
}
) } 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({ departments, currentEmployee, defaultMonth, onMonthChange, onAdminNavigate, onCreated, }: { departments: DepartmentOption[] currentEmployee: Employee | null defaultMonth?: string onMonthChange?: (month: string) => void onAdminNavigate?: (workspace: PerformanceSurveyAdminWorkspace) => void onCreated: () => Promise }) { const router = useRouter() const searchParams = useSearchParams() const prefillMonth = searchParams.get('prefillMonth') || '' const prefillTemplate = searchParams.get('prefillTemplate') || '' const draftSurveyId = String(searchParams.get('draftId') || '').trim() const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [templateOptionKey, setTemplateOptionKey] = useState(prefillTemplate) const [assessmentMonth, setAssessmentMonth] = useState(prefillMonth || defaultMonth || getCurrentMonthValue()) const [deadlineAt, setDeadlineAt] = useState('') 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 [visualCases, setVisualCases] = useState(() => createVisualCaseDrafts()) const [createdSurvey, setCreatedSurvey] = useState(null) const [saving, setSaving] = useState(false) const [publishingLabel, setPublishingLabel] = useState('') const [error, setError] = useState(null) const hydratedDraftIdRef = useRef(null) const draftState = useApi( draftSurveyId ? `/surveys/${encodeURIComponent(draftSurveyId)}` : null, [draftSurveyId], ) const editingDraft = draftState.data?.status === 'draft' && draftState.data?.canManage ? draftState.data : null const draftLoadIssue = draftSurveyId ? draftState.error || (!draftState.loading && draftState.data?.status !== 'draft' ? '该调研已不是可编辑草稿' : null) || (!draftState.loading && draftState.data && !draftState.data.canManage ? '你没有编辑该草稿的权限' : null) : null const positionGroupsPath = `/position-groups?month=${encodeURIComponent(assessmentMonth || getCurrentMonthValue())}` const positionGroupsState = useApi(positionGroupsPath, [positionGroupsPath]) const availablePositionGroups = positionGroupsState.data?.groups || [] const membershipStatus = positionGroupsState.data?.membership?.status as 'matched' | 'unmatched' | 'conflict' | undefined const selectedPositionGroupCode = membershipStatus === 'matched' ? positionGroupsState.data?.membership?.groupCode || '' : '' const selectedPositionGroup = availablePositionGroups.find((group: any) => group.code === selectedPositionGroupCode) const templatesPath = selectedPositionGroup?.status === 'ready' ? `/templates?month=${encodeURIComponent(assessmentMonth)}&positionGroupCode=${encodeURIComponent(selectedPositionGroupCode)}` : null const templatesState = useApi(templatesPath, [templatesPath]) const templates = useMemo(() => templatesState.data || [], [templatesState.data]) useEffect(() => { if (!draftSurveyId || !editingDraft || hydratedDraftIdRef.current === editingDraft.id) return const draftTemplate = editingDraft.template as Template | undefined const draftTargets = Array.isArray(editingDraft.targets) ? editingDraft.targets : [] const targetEmployeesFromDraft = draftTargets .filter((target: any) => target.targetType === 'employee' && target.targetSnapshot?.id) .map((target: any) => target.targetSnapshot as Employee) .filter((employee: Employee) => employee.id !== currentEmployee?.id) const manualEvaluatorsFromDraft = draftTargets .flatMap((target: any) => Array.isArray(target.assignments) ? target.assignments : []) .filter((assignment: any) => ( assignment.reviewerSource === 'manual_selection' && assignment.evaluatorSnapshot?.id )) .map((assignment: any) => assignment.evaluatorSnapshot as Employee) .filter((employee: Employee) => employee.id !== currentEmployee?.id) const existingWorkItemCount = draftTargets.reduce( (sum: number, target: any) => sum + (Array.isArray(target.workItems) ? target.workItems.length : 0), 0, ) const requiredWorkItemCount = Math.max( 1, Number(draftTemplate?.requiredWorkItemCount || DEFAULT_VISUAL_CASE_COUNT), ) setTitle(String(editingDraft.title || '')) setDescription(String(editingDraft.description || '')) setAssessmentMonth(String(editingDraft.assessmentMonth || defaultMonth || getCurrentMonthValue())) setDeadlineAt(formatDateTimeLocalInput(editingDraft.dueAt)) setTemplateOptionKey( String(draftTemplate?.templateOptionKey || draftTemplate?.code || editingDraft.templateCode || ''), ) setTargetEmployees(targetEmployeesFromDraft) setManualEvaluatorEmployees(mergeEmployeesById(manualEvaluatorsFromDraft)) setEvaluatorDepartmentValues([]) setEvaluatorDepartmentEmployees({}) setVisualCases(createVisualCaseDrafts(Math.max(0, requiredWorkItemCount - existingWorkItemCount))) setCreatedSurvey(null) setError(null) hydratedDraftIdRef.current = editingDraft.id }, [currentEmployee?.id, defaultMonth, draftSurveyId, editingDraft]) useEffect(() => { if (draftSurveyId) return if (prefillMonth && /^\d{4}-\d{2}$/.test(prefillMonth)) setAssessmentMonth(prefillMonth) else if (defaultMonth && /^\d{4}-\d{2}$/.test(defaultMonth)) setAssessmentMonth(defaultMonth) if (prefillTemplate) { const matched = templates.find((template) => template.templateOptionKey === prefillTemplate) || templates.find((template) => template.code === prefillTemplate) if (matched) setTemplateOptionKey(matched.templateOptionKey || matched.code) } }, [defaultMonth, draftSurveyId, prefillMonth, prefillTemplate, templates]) const selectedTemplate = templateOptionKey || templates[0]?.templateOptionKey || templates[0]?.code || '' const selectedTemplateInfo = templates.find((template) => (template.templateOptionKey || template.code) === selectedTemplate) || (editingDraft?.template as Template | undefined) const selectedTemplateCode = selectedTemplateInfo?.code || '' const surveyKind = selectedTemplateInfo?.surveyType || 'person_period' const visualTemplateSelected = isVisualTemplate(selectedTemplateInfo) const workflowStages = selectedTemplateInfo?.workflow?.stages || [] const requiresManualEvaluators = workflowStages.some((stage) => ( stage.reviewers.some((reviewer) => reviewer.source === 'manual_selection') )) const visualRequiredCount = Math.max( 1, Number(selectedTemplateInfo?.requiredWorkItemCount || DEFAULT_VISUAL_CASE_COUNT), ) const existingVisualWorkItems = useMemo( () => editingDraft ? (editingDraft.targets || []).flatMap((target: any) => target.workItems || []) : [], [editingDraft], ) const remainingVisualCaseCount = Math.max(0, visualRequiredCount - existingVisualWorkItems.length) const effectiveTargetEmployees = useMemo( () => surveyKind === 'item_course' ? [] : mergeEmployeesById(currentEmployee ? [currentEmployee] : [], targetEmployees), [currentEmployee, surveyKind, targetEmployees], ) const currentTargetSelected = Boolean( currentEmployee?.id && effectiveTargetEmployees.some((employee) => employee.id === currentEmployee.id), ) const canUploadVisualCases = visualTemplateSelected && ( currentTargetSelected || Boolean(editingDraft?.canManage) ) const createdUploadTarget = visualTemplateSelected ? (createdSurvey?.targets || []).find((target: any) => target.canUploadWorkItems) : null const delegatedTargetCount = surveyKind === 'item_course' ? 0 : effectiveTargetEmployees.filter((employee) => employee.id !== currentEmployee?.id).length const evaluatorEmployees = useMemo( () => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)) .filter((employee) => employee.id !== currentEmployee?.id), [currentEmployee?.id, manualEvaluatorEmployees, evaluatorDepartmentEmployees], ) const publishValidationIssues: string[] = [] if (!selectedTemplate) publishValidationIssues.push('选择考评模板') if (!assessmentMonth) publishValidationIssues.push('选择考核月份') if (surveyKind === 'item_course' && targetItems.length === 0 && !itemName.trim()) { publishValidationIssues.push(`填写${selectedTemplateInfo?.itemName || '课程、事项或活动'}名称`) } if (surveyKind !== 'item_course' && !currentEmployee?.id) { publishValidationIssues.push('当前账户需要先匹配考核人员档案') } if (surveyKind !== 'item_course' && effectiveTargetEmployees.length === 0) { publishValidationIssues.push('确认考核对象') } if ( !editingDraft && requiresManualEvaluators && evaluatorEmployees.length === 0 ) publishValidationIssues.push('至少选择 1 名评分人') if (!editingDraft && membershipStatus === 'unmatched') { publishValidationIssues.push('联系管理员配置本人考核岗位') } if (!editingDraft && membershipStatus === 'conflict') { publishValidationIssues.push('联系管理员处理本人岗位重复匹配') } if (!editingDraft && membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready') { publishValidationIssues.push('联系管理员完成本人岗位考核项配置') } if (draftSurveyId && (draftState.loading || draftLoadIssue)) { publishValidationIssues.push(draftState.loading ? '等待草稿加载完成' : draftLoadIssue || '草稿不可编辑') } if (visualTemplateSelected && canUploadVisualCases) { const requiredNewCaseCount = editingDraft ? remainingVisualCaseCount : visualRequiredCount const visibleVisualCases = visualCases.slice(0, requiredNewCaseCount) if (visibleVisualCases.length < requiredNewCaseCount) { publishValidationIssues.push(`准备剩余 ${requiredNewCaseCount} 个案例作品`) } visibleVisualCases.forEach((draft, index) => { const validationError = validatePerformanceSurveyImageFiles(draft.files) if (validationError) publishValidationIssues.push(`案例 ${index + 1}:${validationError}`) }) } const publishDisabled = saving || Boolean(createdSurvey) || publishValidationIssues.length > 0 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 () => { if (publishValidationIssues.length > 0) { setError(`还需完成:${publishValidationIssues.join(';')}`) return } setSaving(true) setPublishingLabel('正在创建调研') setError(null) setCreatedSurvey(null) let createdForAttempt: any | null = 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' && !currentEmployee?.id) { throw new Error('当前账户尚未匹配考核人员档案,暂时无法发起调研') } if (surveyKind !== 'item_course' && effectiveTargetEmployees.length === 0) { throw new Error('请至少选择 1 个考核对象') } if (!editingDraft && requiresManualEvaluators && evaluatorEmployees.length === 0) { throw new Error('请至少选择 1 名评分人') } const requiredNewVisualCaseCount = editingDraft ? remainingVisualCaseCount : visualRequiredCount const visualCasesToSubmit = visualCases.slice(0, requiredNewVisualCaseCount) if (visualTemplateSelected && canUploadVisualCases) { if (visualCasesToSubmit.length < requiredNewVisualCaseCount) { throw new Error(`请提交剩余 ${requiredNewVisualCaseCount} 个案例作品`) } for (let index = 0; index < visualCasesToSubmit.length; index += 1) { const validationError = validatePerformanceSurveyImageFiles(visualCasesToSubmit[index].files) if (validationError) { throw new Error(`案例 ${index + 1}:${validationError}`) } } } const selectedAssessmentMonth = assessmentMonth || getCurrentMonthValue() const submitTitle = title.trim() || targetItemsToSubmit[0]?.name || `${formatMonthLabel(selectedAssessmentMonth)} · ${getTemplateDisplayName(selectedTemplateInfo)}` const created = editingDraft ? await apiRequest(`/surveys/${editingDraft.id}`, { method: 'PATCH', body: JSON.stringify({ title: submitTitle, description, assessmentMonth: selectedAssessmentMonth, dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null, }), }) : await apiRequest('/surveys', { method: 'POST', body: JSON.stringify({ title: submitTitle, description, surveyKind, templateCode: selectedTemplateCode, templateVersionId: selectedTemplateInfo?.templateVersionId || null, assessmentMonth: selectedAssessmentMonth, dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null, openNow: visualTemplateSelected ? false : openNow, targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id), targetItems: targetItemsToSubmit, evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id), }), }) createdForAttempt = created if (!editingDraft) setCreatedSurvey(created) let uploadTarget: any | null = null if (visualTemplateSelected && canUploadVisualCases) { uploadTarget = (created.targets || []).find((target: any) => ( target.canCreateWorkItem || target.canUploadWorkItems || target.workItemUploadRemaining > 0 )) if (!uploadTarget?.id) throw new Error('未找到当前账户对应的作品上传入口') for (let index = 0; index < visualCasesToSubmit.length; index += 1) { const draft = visualCasesToSubmit[index] setPublishingLabel(`正在上传作品 ${index + 1}/${visualCasesToSubmit.length}`) const images = await uploadPerformanceSurveyImageFiles(draft.files, (label) => { setPublishingLabel(`作品 ${index + 1}/${visualCasesToSubmit.length} · ${label}`) }) await apiRequest(`/surveys/${created.id}/work-items`, { method: 'POST', body: JSON.stringify({ targetId: uploadTarget.id, title: draft.title.trim() || `案例 ${index + 1}`, description: draft.description.trim(), processedImages: images, }), }) } setPublishingLabel('正在确认作品并发布调研') const opened = await apiRequest(`/surveys/${created.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'open' }), }) setCreatedSurvey(opened) } setTitle('') setDescription('') setTemplateOptionKey('') setAssessmentMonth(getCurrentMonthValue()) setDeadlineAt('') setTargetEmployees([]) setManualEvaluatorEmployees([]) setEvaluatorDepartmentValues([]) setEvaluatorDepartmentEmployees({}) setTargetItems([]) setItemName('') setVisualCases(createVisualCaseDrafts()) await onCreated() setCreatedSurvey(null) if (uploadTarget?.id) { router.push(`/performance-survey/survey/${created.id}?targetId=${encodeURIComponent(uploadTarget.id)}`) } else if (editingDraft) { router.push('/performance-survey?tab=personal&workspace=created') } } catch (err) { const message = err instanceof Error ? err.message : '创建失败' setError( createdForAttempt && visualTemplateSelected ? `调研尚未发布,作品没有全部上传完成:${message}` : message, ) } finally { setPublishingLabel('') setSaving(false) } } return (
{draftSurveyId && (
{draftState.loading ? '正在恢复草稿...' : draftLoadIssue ? '无法继续编辑草稿' : '继续编辑未发布草稿'}

{draftState.loading ? '正在读取原有模板、作品和发布设置。' : draftLoadIssue ? `${draftLoadIssue}。请返回“我发起的”刷新后重试。` : `已恢复 ${existingVisualWorkItems.length}/${visualRequiredCount} 个作品;补齐内容后可直接发布。被考核人与评分流程沿用原草稿。`}

)}
{ setAssessmentMonth(value) onMonthChange?.(value) }} type="month" />
考核岗位
{editingDraft ? editingDraft.positionGroup?.name || editingDraft.positionGroupCode || '沿用草稿岗位' : positionGroupsState.loading ? '正在关联人员岗位...' : positionGroupsState.error ? '岗位关联失败' : membershipStatus === 'matched' ? positionGroupsState.data?.membership?.groupName || '已关联岗位' : membershipStatus === 'conflict' ? '岗位配置冲突' : '尚未配置岗位'} 自动关联
{!editingDraft && positionGroupsState.error && (
岗位关联失败:{positionGroupsState.error}
)} {!editingDraft && membershipStatus === 'matched' && (
已按被考核人的人员分配自动关联,发起时无需选择。
)} {!editingDraft && positionGroupsState.data?.membership?.status === 'conflict' && (
当前人员同时命中多个岗位:{(positionGroupsState.data.membership.conflictGroupNames || []).join('、')}
)} {!editingDraft && positionGroupsState.data?.membership?.status === 'unmatched' && (
当前人员尚未匹配考核岗位,请联系系统管理员完成岗位配置
)} {!editingDraft && membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready' && (
当前岗位尚未完成考核项配置,暂时不能发起调研
)}
{!editingDraft && onAdminNavigate && positionGroupsState.data && ( membershipStatus !== 'matched' || selectedPositionGroup?.status !== 'ready' || (selectedPositionGroupCode && !templatesState.loading && templates.length === 0) ) && (
{membershipStatus !== 'matched' ? '当前人员还不能自动关联岗位' : selectedPositionGroup?.status !== 'ready' ? '当前岗位尚未完成配置' : '当前岗位还没有可用考核模板'}

请先补齐人员归属、岗位设置和可用模板,再回来发起调研。

{([ ['positions', '检查岗位设置'], ['people', '检查人员'], ['templates', '检查模板'], ] as const).map(([target, label]) => ( ))}
)}
{surveyKind !== 'item_course' ? (
{selectedTemplateInfo?.ownerRoleName || '被考核人'}
{editingDraft ? (
{(editingDraft.targets || []).map((target: any) => (
{getSurveyInitials(target.targetName)}
{target.targetName}
{[target.targetSnapshot?.departmentName, target.targetSnapshot?.positionLevel || target.targetSnapshot?.levelType] .filter(Boolean) .join(' · ') || '沿用草稿人员'}
已锁定
))}
) : currentEmployee ? (
{getSurveyInitials(currentEmployee.displayName)}
{currentEmployee.displayName}
{[currentEmployee.departmentName, currentEmployee.positionLevel || currentEmployee.levelType] .filter(Boolean) .join(' · ') || '当前登录账户'}
当前账户
) : (
当前账户尚未匹配考核人员档案
)}
{!editingDraft && ( )} {delegatedTargetCount > 0 && (
将向 {delegatedTargetCount} 位被考核人发送钉钉确认
)}
) : (
{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]" />
)}
{editingDraft ? (
评分流程
{workflowStages.map((stage) => (
{stage.order + 1}. {stage.label} · {stage.reviewers.map((reviewer) => reviewer.employeeName || reviewer.label).join('、')}
))}

草稿恢复编辑时沿用已生成的评分人员与阶段设置。

) : !requiresManualEvaluators ? (
评审流程
{workflowStages.map((stage) => (
{stage.order + 1}. {stage.label} · {stage.reviewers.map((reviewer) => reviewer.employeeName || reviewer.label).join('、')}
))}

评分人员按已发布模板解析,前一阶段完成后自动开放下一阶段。

) : ( <>
setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))} onRemoveEmployee={removeEvaluatorEmployee} />
{ setEvaluatorDepartmentEmployees((current) => { const next = { ...current } if (checked) { next[department] = employees } else { delete next[department] } return next }) }} />
)}
{error &&
{error}
}
0 ? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]' : 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]' }`} aria-live="polite" > {publishValidationIssues.length > 0 ? `还需完成:${publishValidationIssues.join(';')}` : visualTemplateSelected ? '配置已完整。发布后先由被考核人完成自评;自评提交后才通知主管和元泽评分' : '配置已完整,可以发布调研'}
{createdSurvey && (
{createdSurvey.status === 'open' ? isVisualTemplate(selectedTemplateInfo) && !currentTargetSelected ? '已发布,已通知被考核人提交作品' : isVisualTemplate(selectedTemplateInfo) ? '已发布,当前等待被考核人完成自评;自评后将自动通知主管和元泽' : '已发布' : '尚未发布,请继续上传作品;作品完整后才能发布'} {createdUploadTarget?.id && ( {createdSurvey.status === 'open' ? '查看作品' : '继续上传'} )}
)}
) } function PerformanceSurveyImageDropzone({ files, disabled, ariaLabel, onFiles, }: { files: File[] disabled?: boolean ariaLabel: string onFiles: (files: File[]) => void }) { const inputRef = useRef(null) const dragDepthRef = useRef(0) const [dragging, setDragging] = useState(false) useEffect(() => { if (files.length === 0 && inputRef.current) inputRef.current.value = '' }, [files.length]) const addFiles = (selected: FileList | File[]) => { if (disabled) return const next = Array.from(selected) if (next.length > 0) onFiles(next) } return (
{ addFiles(event.currentTarget.files || []) event.currentTarget.value = '' }} />
) } function PerformanceSurveySelectedImageFiles({ files, onRemove, }: { files: File[] onRemove: (index: number) => void }) { if (files.length === 0) return null return (
{files.map((file, index) => (
图 {file.name} {formatPerformanceSurveyImageFileSize(file.size)}
))}
) } function VisualCaseDraftUploader({ expanded, enabled, requiredCount, totalRequiredCount = requiredCount, completedOffset = 0, existingItems = [], cases, onChange, }: { expanded: boolean enabled: boolean requiredCount: number totalRequiredCount?: number completedOffset?: number existingItems?: any[] cases: VisualCaseDraft[] onChange: (cases: VisualCaseDraft[]) => void }) { const visibleCases = cases.slice(0, requiredCount) const completedCount = visibleCases.filter((item) => item.files.length > 0).length const updateCase = (index: number, next: VisualCaseDraft) => { onChange(cases.map((item, itemIndex) => itemIndex === index ? next : item)) } return (

作品上传

{completedOffset + completedCount}/{totalRequiredCount}
{!enabled ? (
由被考核人通过钉钉提交作品
) : ( <> {existingItems.length > 0 && (
{existingItems.map((item: any, index: number) => (
{item.title || `案例 ${index + 1}`}
{item.description && (
{item.description}
)}
已上传
))}
)} {visibleCases.length > 0 ? (
{visibleCases.map((draft, index) => ( updateCase(index, next)} /> ))}
) : (
所有作品均已上传,可以保存并发布草稿。
)} )}
) } function VisualCaseDraftEditor({ index, draft, disabled, onChange, }: { index: number draft: VisualCaseDraft disabled: boolean onChange: (draft: VisualCaseDraft) => void }) { const [message, setMessage] = useState(null) const addFiles = (selectedFiles: File[]) => { const deduplicated = [...draft.files, ...selectedFiles].filter((file, fileIndex, items) => { const key = `${file.name}-${file.size}-${file.lastModified}` return items.findIndex((candidate) => `${candidate.name}-${candidate.size}-${candidate.lastModified}` === key) === fileIndex }) const orderedFiles = sortPerformanceSurveyImagesByName(deduplicated, (file) => file.name) const validationError = validatePerformanceSurveyImageFiles(orderedFiles) if (validationError) { setMessage(validationError) return } setMessage(null) onChange({ ...draft, files: orderedFiles }) } return (
案例 {index + 1} 0 ? 'text-[#0050cb]' : 'text-[#98a2b3]'}`}> {draft.files.length > 1 ? `${draft.files.length} 张切片` : draft.files.length === 1 ? '1 张图片' : '待上传'}
onChange({ ...draft, title: event.target.value })} placeholder="案例名称" aria-label={`案例 ${index + 1} 名称`} disabled={disabled} className="h-9 border border-[#d9e0ea] bg-white px-2.5 text-[13px] outline-none transition-colors focus:border-[#0050cb] disabled:bg-[#f3f5f8]" />