2026-09-04 11:50:50 +08:00

7087 lines
295 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<T> = {
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<HTMLElement>(
'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<string, unknown>
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<string, unknown>
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<string, Employee>()
for (const group of groups) {
for (const employee of group) {
if (!byId.has(employee.id)) byId.set(employee.id, employee)
}
}
return Array.from(byId.values())
}
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
const dingtalkToken = getStoredDingtalkPerformanceToken()
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<T>(path: string | null, deps: unknown[] = []): ApiState<T> & { refresh: () => Promise<void> } {
const [state, setState] = useState<ApiState<T>>({ loading: Boolean(path), data: null, error: null })
const requestSequenceRef = useRef(0)
const abortControllerRef = useRef<AbortController | null>(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<T>(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<any>(null)
const canLoadCurrentUser = Boolean(session || hasDingtalkPerformanceSession)
const currentUser = useApi<any>(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 (
<div className="min-h-screen bg-white dark:bg-gray-900">
<PerformanceSurveyMobileHeader session={{ user: dingtalkUserProfile || {} }} />
<LoadingPage />
</div>
)
}
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 = <Dashboard me={currentUser.data} permissionsLoading={permissionsLoading} />
if (isSurveySubmitPage && path[1]) {
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} canCreateSurvey={canCreateSurvey} />
}
if (isResultsPage && path[1]) {
content = <SurveyResults surveyId={path[1]} />
}
if (isSurveySubmitPage) {
return (
<div className="min-h-screen overflow-x-hidden bg-white dark:bg-gray-900">
{content}
</div>
)
}
return (
<div className="min-h-screen overflow-x-hidden bg-white text-[#1a1c1c]">
{isResultsPage && <PerformanceSurveyMobileHeader session={effectiveSession} />}
{content}
</div>
)
}
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 (
<header className="sticky top-0 z-30 border-b border-[#eeeeee] bg-[#2f3131] text-white">
<div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-[#f4f4f4] text-[12px] font-semibold text-[#0050cb]">
{avatar ? (
<Image
src={avatar}
alt={name}
width={32}
height={32}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getSurveyInitials(name)
)}
</div>
<div className="truncate text-[16px] font-bold tracking-tight">考核调研</div>
</div>
<Link
href="/"
target="_top"
className="shrink-0 text-[12px] font-semibold text-white/85 transition-colors hover:text-white"
aria-label="返回品牌中心首页"
>
返回品牌中心
</Link>
</div>
</header>
)
}
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<PerformanceSurveyDashboardTab>(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 (
<>
<PerformanceSurveyDashboardHeader
activeTab={tab}
avatar={avatar}
displayName={displayName}
onSelectTab={(nextTab) => void selectTab(nextTab)}
tabItems={tabItems}
/>
<main className="mx-auto w-full max-w-[1200px] bg-white px-4 py-8 pb-24 text-[#1a1c1c] sm:px-6 sm:pb-10">
<section className="mb-6">
<h1 className="text-[24px] font-bold leading-tight text-[#1a1c1c] sm:text-[22px]">{pageTitle}</h1>
{pageSubtitle && <p className="mt-2 text-[14px] font-medium text-[#5f5e5e]">{pageSubtitle}</p>}
</section>
{tab === 'personal' && <PerformanceSurveyWorkspace canCreateSurvey={canCreateSurvey} />}
{tab === 'public' && <PerformanceDashboardBoard view="public" />}
{tab === 'manage' && permissionsResolved && canCreateSurvey && (
<ManagePanel
currentEmployee={me?.employee || null}
isSystemAdmin={Boolean(me?.permissions?.isSystemAdmin)}
configurationDirty={manageConfigurationDirty}
onConfigurationDirtyChange={setManageConfigurationDirty}
/>
)}
</main>
{dashboardNavigationDialog}
<nav className={`fixed inset-x-0 bottom-0 z-30 grid h-[68px] ${tabItems.length === 3 ? 'grid-cols-3' : 'grid-cols-2'} border-t border-[#e2e2e2] bg-white sm:hidden`}>
{tabItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => void selectTab(item.id)}
aria-current={tab === item.id ? 'page' : undefined}
className={`flex items-center justify-center text-[12px] font-semibold ${
tab === item.id ? 'text-[#0050cb]' : 'text-[#5f5e5e]'
}`}
>
{item.label}
</button>
))}
</nav>
</>
)
}
function PerformanceSurveyDashboardHeader({
activeTab,
avatar,
displayName,
onSelectTab,
tabItems,
}: {
activeTab: PerformanceSurveyDashboardTab
avatar: string
displayName: string
onSelectTab: (tab: PerformanceSurveyDashboardTab) => void
tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }>
}) {
return (
<header className="bg-[#1f2937] text-white shadow-sm">
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(220px,1fr)_auto_minmax(220px,1fr)] sm:px-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-white">
{avatar ? (
<Image
src={avatar}
alt={displayName}
width={36}
height={36}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span className="text-[12px] font-bold">{getSurveyInitials(displayName)}</span>
)}
</div>
<div className="min-w-0">
<div className="truncate text-[16px] font-bold">考核调研</div>
<div className="truncate text-[11px] font-medium text-white/70" title={displayName}>{displayName}</div>
</div>
</div>
<nav className="hidden h-full items-center justify-center gap-9 sm:flex">
{tabItems.map((item) => {
const active = activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onSelectTab(item.id)}
aria-current={active ? 'page' : undefined}
className={`h-full border-b-2 px-1 text-[13px] font-bold transition-colors ${
active
? 'border-white text-white'
: 'border-transparent text-[#b3c5ff] hover:text-white'
}`}
>
{item.label}
</button>
)
})}
</nav>
<Link
href="/"
target="_top"
className="hidden justify-self-end text-[13px] font-semibold text-white/90 transition-colors hover:text-white sm:block"
aria-label="返回品牌中心首页"
>
返回品牌中心
</Link>
<Link
href="/"
target="_top"
className="justify-self-end text-[12px] font-semibold text-[#b3c5ff] transition-colors hover:text-white sm:hidden"
aria-label="返回品牌中心首页"
>
返回品牌中心
</Link>
</div>
</header>
)
}
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 (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="tablist" aria-label="任务与记录">
{items.map((item) => {
const active = activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onSelectTab(item.id)}
role="tab"
id={`performance-survey-workspace-tab-${item.id}`}
aria-controls="performance-survey-workspace-panel"
aria-selected={active}
tabIndex={active ? 0 : -1}
onKeyDown={(event) => {
const currentIndex = items.findIndex((candidate) => candidate.id === item.id)
const lastIndex = items.length - 1
let nextIndex = currentIndex
if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = lastIndex
else return
event.preventDefault()
const nextTab = items[nextIndex].id
onSelectTab(nextTab)
window.requestAnimationFrame(() => document.getElementById(`performance-survey-workspace-tab-${nextTab}`)?.focus())
}}
className={`flex min-h-11 items-center justify-between gap-2 rounded border px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 ${
active
? 'border-[#0052d9] bg-[#eef5ff] text-[#0052d9]'
: 'border-[#e2e2e2] bg-white text-[#5f5e5e] hover:border-[#b8d0ff] hover:text-[#1a1c1c]'
}`}
>
<span className="truncate text-[12px] font-bold sm:text-[13px]">{item.label}</span>
{typeof item.value === 'number' && (
<span className={`flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[12px] font-bold ${active ? 'bg-[#0052d9] text-white' : 'bg-[#f4f3f3] text-[#5f5e5e]'}`}>{item.value}</span>
)}
</button>
)
})}
</div>
)
}
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<any>('/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 (
<section className="mb-8 border-b border-[#e2e2e2] pb-8" aria-labelledby="performance-survey-workspace-title">
<div className="mb-3 flex items-end justify-between gap-4">
<div>
<h2 id="performance-survey-workspace-title" className="text-[16px] font-bold text-[#1a1c1c]">任务与记录</h2>
<p className="mt-1 text-[12px] font-medium text-[#727687]">查看待评分任务、已处理记录和我发起的调研</p>
</div>
<button
type="button"
onClick={() => state.refresh()}
className="shrink-0 text-[12px] font-bold text-[#0050cb] hover:text-[#003fa4]"
>
刷新
</button>
</div>
<DashboardStats data={state.data || {}} activeTab={activeTab} onSelectTab={selectTab} />
<div
id="performance-survey-workspace-panel"
className="mt-4"
role="tabpanel"
aria-labelledby={`performance-survey-workspace-tab-${activeTab}`}
>
{state.loading && activeTab !== 'overview' && <DashboardPanel>任务加载中...</DashboardPanel>}
{state.error && <DashboardErrorPanel message={state.error} />}
{activeTab === 'overview' && <PerformanceDashboardBoard view="personal" canCreateSurvey={canCreateSurvey} />}
{state.data && activeTab === 'pending' && <PendingList items={state.data.pending || []} />}
{state.data && activeTab === 'submitted' && <SubmittedList items={state.data.submitted || []} />}
{state.data && activeTab === 'created' && (
<CreatedList
items={state.data.created || []}
onRefresh={state.refresh}
resultBackHref="/performance-survey?tab=personal&workspace=created"
/>
)}
</div>
</section>
)
}
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<Employee | null>(null)
const [positionGroupCode, setPositionGroupCode] = useState('')
const [itemPage, setItemPage] = useState(1)
const [deletingAssessmentKey, setDeletingAssessmentKey] = useState<string | null>(null)
const [removeStatus, setRemoveStatus] = useState('')
const removeStatusRef = useRef<HTMLDivElement>(null)
const [exportDialogOpen, setExportDialogOpen] = useState(false)
const [exportBusy, setExportBusy] = useState(false)
const [exportError, setExportError] = useState<string | null>(null)
const [exportStatus, setExportStatus] = useState('')
const exportStatusRef = useRef<HTMLDivElement>(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<DepartmentOption[]>(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<any>(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<PerformanceSurveyDashboardExportData>(
`/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 (
<section className="mb-5 space-y-4">
{view === 'public' && (
<div className="border-b border-[#e2e2e2] pb-5">
<div className="grid gap-3 md:grid-cols-[minmax(190px,0.8fr)_150px_minmax(220px,0.9fr)_minmax(240px,1fr)_auto] md:items-center">
<PositionGroupSelect
groups={positionGroups}
value={positionGroupCode || (allPositionGroupsSelected ? ALL_POSITION_GROUPS_FILTER : selectedPositionGroup?.code) || ''}
onChange={(value) => {
setPositionGroupCode(value)
setItemPage(1)
setDepartment('')
setEmployee(null)
}}
/>
<input
type="month"
value={month}
onChange={(event) => 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]"
/>
<div className="hidden text-[13px] font-semibold text-[#5f5e5e] sm:block">组织架构</div>
<DepartmentFilterTree
departments={departments.data || []}
value={department}
onChange={(value) => setDepartment(value)}
/>
<SingleEmployeeFilter value={employee} onChange={setEmployee} />
{(department || employee) && (
<button
type="button"
onClick={() => {
setDepartment('')
setEmployee(null)
}}
className="h-10 rounded border border-[#e2e2e2] bg-white px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#f9f9f9]"
>
清除
</button>
)}
</div>
</div>
)}
{dashboard.data && (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#eeeeee] pb-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<div className="min-w-0">
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">
{allPositionGroupsSelected
? '全部岗位'
: selectedPositionGroup?.name || (positionMembership?.status === 'conflict' ? '岗位配置冲突' : '尚未匹配考核岗位')}
</div>
<div className="mt-0.5 text-[12px] text-[#787777]">
{allPositionGroupsSelected
? `${readyPositionGroupCount} 个已生效岗位 · ${templatePager.totalItems} 个考核项`
: selectedPositionGroup?.status === 'ready'
? `${templatePager.totalItems} 个考核项`
: selectedPositionGroup
? '待管理员完成岗位规则与考核项配置'
: positionMembership?.status === 'conflict'
? `同时命中:${(positionMembership.conflictGroupNames || []).join('、')}`
: '当前人员没有可用的岗位配置'}
</div>
</div>
{view === 'public' && effectivePositionGroupCode && (
<button
type="button"
disabled={templatePager.totalItems === 0}
onClick={() => {
setExportError(null)
setExportDialogOpen(true)
}}
className="inline-flex min-h-10 shrink-0 items-center justify-center rounded border border-[#c9c9c9] bg-white px-4 text-[12px] font-bold text-[#1a1c1c] transition-colors hover:border-[#1a1c1c] hover:bg-[#f7f7f7] focus:outline-none focus:ring-2 focus:ring-[#0050cb] disabled:cursor-not-allowed disabled:opacity-45"
title={templatePager.totalItems === 0 ? '当前岗位暂无考核项' : '导出当前岗位和月份的全部人员、全部考核项得分'}
>
导出
</button>
)}
</div>
<TemplatePager
pager={templatePager}
onChange={setItemPage}
/>
</div>
)}
{dashboard.loading && (
<div className="border border-[#e2e2e2] bg-white px-5 py-8 text-sm font-semibold text-[#5f5e5e]">
看板加载中...
</div>
)}
{dashboard.error && <DashboardErrorPanel message={dashboard.error} />}
{removeStatus && (
<div
ref={removeStatusRef}
role="status"
aria-live="polite"
tabIndex={-1}
className="rounded border border-[#b9dfc8] bg-[#f3fbf6] px-4 py-3 text-[13px] font-semibold text-[#176b3a] outline-none focus:ring-2 focus:ring-[#ccebd8]"
>
{removeStatus}
</div>
)}
{exportStatus && (
<div
ref={exportStatusRef}
role="status"
aria-live="polite"
tabIndex={-1}
className="rounded border border-[#b9dfc8] bg-[#f3fbf6] px-4 py-3 text-[13px] font-semibold text-[#176b3a] outline-none focus:ring-2 focus:ring-[#ccebd8]"
>
{exportStatus}
</div>
)}
{dashboard.data && (
<>
{view === 'personal' ? (
<MonthlyPersonalLedger
rows={personalRows}
templates={templates}
onOpenDetail={(value) => 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}
/>
) : (
<MonthlyPublicLedger
rows={publicRows}
templates={templates}
canOpenPublicDetail={Boolean(permissions.canOpenPublicDetail)}
canDeleteAssessment={canDeletePublicDashboardAssessment}
deletingAssessmentKey={deletingAssessmentKey}
onDeleteAssessment={(row, template, cell) => void deletePublicDashboardAssessment(row, template, cell)}
onOpenDetail={setDetailRequest}
/>
)}
<MonthlyScoreDetailModal
request={detailRequest}
onClose={() => setDetailRequest(null)}
/>
</>
)}
{removePublicEmployeeDialog}
<PerformanceSurveyExportDialog
open={exportDialogOpen}
positionName={exportPositionName}
monthLabel={formatMonthLabel(month)}
employeeSummary={department || employee?.id ? '岗位全部人员' : `${publicRows.length} 人`}
assessmentCount={templatePager.totalItems}
busy={exportBusy}
error={exportError}
onClose={() => {
if (!exportBusy) setExportDialogOpen(false)
}}
onExport={() => void exportPublicDashboard()}
/>
</section>
)
}
function PositionGroupSelect({
groups,
value,
onChange,
}: {
groups: Array<{ code: string; name: string; status: string; templateCount: number }>
value: string
onChange: (value: string) => void
}) {
return (
<label className="grid min-w-0 gap-1">
<span className="sr-only">考核岗位</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-10 min-w-0 rounded border border-[#e2e2e2] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
>
<option value="">选择考核岗位</option>
<option value={ALL_POSITION_GROUPS_FILTER}>全部岗位</option>
{groups.map((group) => (
<option key={group.code} value={group.code}>
{group.name}{group.status === 'ready' ? ` · ${group.templateCount}项` : ' · 待配置'}
</option>
))}
</select>
</label>
)
}
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 (
<div className="flex h-9 shrink-0 items-center border border-[#e2e2e2] bg-white" aria-label="考核项分页">
<button
type="button"
aria-label="上一组考核项"
title="上一组"
disabled={pager.page <= 1}
onClick={() => onChange(Math.max(1, pager.page - 1))}
className="flex h-9 w-9 items-center justify-center border-r border-[#eeeeee] text-[16px] font-bold text-[#1a1c1c] hover:bg-[#f7f7f7] disabled:cursor-not-allowed disabled:text-[#b8b8b8]"
>
←
</button>
<span className="min-w-[88px] px-3 text-center text-[12px] font-semibold tabular-nums text-[#5f5e5e]">
{pager.start}-{pager.end} / {pager.totalItems}
</span>
<button
type="button"
aria-label="下一组考核项"
title="下一组"
disabled={pager.page >= pager.totalPages}
onClick={() => onChange(Math.min(pager.totalPages, pager.page + 1))}
className="flex h-9 w-9 items-center justify-center border-l border-[#eeeeee] text-[16px] font-bold text-[#1a1c1c] hover:bg-[#f7f7f7] disabled:cursor-not-allowed disabled:text-[#b8b8b8]"
>
→
</button>
</div>
)
}
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 <EmptyLedger title="暂无月度考核" />
return (
<div>
<div className="space-y-4 sm:hidden">
{rows.map((row) => (
<article
key={row.month}
className={`rounded-lg border bg-white p-4 ${
row.month === getCurrentMonthValue() ? 'border-[#0050cb]' : 'border-[#e2e2e2]'
}`}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[20px] font-bold text-[#1a1c1c]">{formatMonthLabel(row.month)}</div>
{row.month === getCurrentMonthValue() && (
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">本月</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{templates.map((template) => (
<MonthlyScoreCell
key={template.code}
embedded
template={template}
cell={row.cells?.[template.code]}
onQuickCreate={onQuickCreate ? () => onQuickCreate({ month: row.month, templateCode: template.code }) : undefined}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
))}
</div>
</article>
))}
</div>
<div className="hidden overflow-hidden rounded border border-[#c2c6d8] bg-white sm:block">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold uppercase tracking-tight text-[#5f5e5e]">
<th className="w-[160px] px-6 py-4">月份</th>
{templates.map((template) => (
<th key={template.code} className="px-4 py-4 text-center">{getTemplateDisplayName(template)}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[#c2c6d8]">
{rows.map((row) => (
<tr key={row.month} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
<td className="px-6 py-5 text-[16px] font-bold">{formatMonthLabel(row.month)}</td>
{templates.map((template) => (
<td key={template.code} className="px-4 py-4">
<MonthlyTableScoreCell
cell={row.cells?.[template.code]}
onQuickCreate={onQuickCreate ? () => onQuickCreate({ month: row.month, templateCode: template.code }) : undefined}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
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 <EmptyLedger title="暂无人员数据" />
return (
<div>
<div className="space-y-0 sm:hidden">
{rows.map((row) => (
<article
key={row.employee.id}
className="border-b border-[#c2c6d8] bg-white py-4"
>
<div className="mb-3 flex min-w-0 items-center gap-3">
<div className="min-w-0">
<div className="truncate text-[16px] font-bold text-[#1a1c1c]">{row.employee.displayName}</div>
<div className="mt-1 truncate text-[12px] text-[#5f5e5e]">
{[row.employee.departmentName, row.employee.positionLevel || row.employee.levelType].filter(Boolean).join(' · ')}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{templates.map((template) => {
const cell = row.cells?.[template.code]
const hasSurvey = Number(cell?.surveyCount || 0) > 0
return (
<div key={template.code} className="min-w-0 border border-[#e2e2e2] bg-white">
<MonthlyScoreCell
compact
embedded
template={template}
cell={cell}
canOpenOverride={canOpenPublicDetail}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
positionGroupCode: cell?.positionGroupCode,
})}
/>
{canDeleteAssessment && hasSurvey && (
<PublicLedgerAssessmentDeleteButton
employeeName={row.employee.displayName}
templateName={getTemplateDisplayName(template)}
loading={deletingAssessmentKey === `${row.employee.id}:${template.code}`}
onClick={() => onDeleteAssessment(row, template, cell)}
/>
)}
</div>
)
})}
</div>
</article>
))}
</div>
<div className="hidden overflow-hidden rounded border border-[#e2e2e2] bg-white sm:block">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className="border-b border-[#e2e2e2] bg-[#f9f9f9] text-[12px] font-bold uppercase tracking-tight text-[#5f5e5e]">
<th className="w-[200px] px-4 py-3">人员</th>
<th className="w-[200px] px-4 py-3">组织 / 职级</th>
{templates.map((template) => (
<th key={template.code} className="px-3 py-3 text-center">{getTemplateDisplayName(template)}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[#e2e2e2]">
{rows.map((row) => (
<tr key={row.employee.id} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
<td className="px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#f4f3f3] text-[12px] font-bold text-[#1a1c1c]">{getSurveyInitials(row.employee.displayName)}</div>
<div className="min-w-0">
<div className="truncate text-[15px] font-bold">{row.employee.displayName}</div>
{row.employee.jobNumber && <div className="mt-0.5 text-[11px] text-[#5f5e5e]">{row.employee.jobNumber}</div>}
</div>
</div>
</td>
<td className="px-4 py-3 text-[13px] font-medium text-[#5f5e5e]">
<div className="truncate">{row.employee.departmentName || '-'}</div>
<div className="mt-1 truncate">{row.employee.positionLevel || row.employee.levelType || '-'}</div>
</td>
{templates.map((template) => {
const cell = row.cells?.[template.code]
const hasSurvey = Number(cell?.surveyCount || 0) > 0
return (
<td key={template.code} className="px-3 py-3 text-center">
<MonthlyTableScoreCell
cell={cell}
showTitle={false}
canOpenOverride={canOpenPublicDetail}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
positionGroupCode: cell?.positionGroupCode,
})}
/>
{canDeleteAssessment && hasSurvey && (
<PublicLedgerAssessmentDeleteButton
employeeName={row.employee.displayName}
templateName={getTemplateDisplayName(template)}
loading={deletingAssessmentKey === `${row.employee.id}:${template.code}`}
onClick={() => onDeleteAssessment(row, template, cell)}
/>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function PublicLedgerAssessmentDeleteButton({
employeeName,
templateName,
loading,
onClick,
}: {
employeeName: string
templateName: string
loading: boolean
onClick: () => void
}) {
const label = `删除${employeeName}的${templateName}调研`
return (
<button
type="button"
aria-label={label}
title={label}
disabled={loading}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
className="inline-flex min-h-11 w-full items-center justify-center gap-1 border-t border-[#f0d7d4] px-2 text-[11px] font-bold text-[#b42318] transition-colors hover:bg-[#fff5f5] focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#ffd5d0] disabled:cursor-wait disabled:opacity-45"
>
{loading ? (
<span>删除中...</span>
) : (
<>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7h16m-10 4v6m4-6v6M9 7l1-3h4l1 3m3 0-1 13H7L6 7" />
</svg>
<span>删除</span>
</>
)}
</button>
)
}
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 = (
<>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
{hasSurvey && <InlineSurveyState status={cell?.status} />}
</div>
<div className={`${compact ? 'line-clamp-2 text-[10px] leading-4' : 'truncate text-[12px]'} mt-1.5 font-semibold text-[#1a1c1c]`}>
{cell?.title || '未发起'}
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className={`${compact ? 'text-[16px]' : 'text-[22px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey && canOpen ? 'text-[#0050cb]' : hasSurvey ? 'text-[#1a1c1c]' : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/{maxTotal}</span>}
</div>
</>
)
if (!hasSurvey && onQuickCreate) {
return (
<button
type="button"
onClick={onQuickCreate}
aria-label={`发起${getTemplateDisplayName(template)}`}
className={`${className} group cursor-pointer hover:border-[#0050cb] hover:bg-[#f8faff]`}
>
{content}
<div className="mt-2 text-[11px] font-bold text-[#0050cb] group-hover:text-[#003fa4]">+ 快捷发起</div>
</button>
)
}
if (!canOpen) {
return <div className={className}>{content}</div>
}
return (
<button type="button" onClick={onClick} className={className}>
{content}
</button>
)
}
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 = (
<div className="min-w-0">
<div className="flex items-baseline justify-center gap-1">
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey && canOpen ? 'text-[#0050cb]' : hasSurvey ? 'text-[#1a1c1c]' : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/{maxTotal}</span>}
</div>
{hasSurvey && showTitle && <div className="mt-1 truncate text-[10px] font-medium text-[#5f5e5e]" title={cell?.title || ''}>{cell?.title}</div>}
</div>
)
if (!hasSurvey && onQuickCreate) {
return (
<button
type="button"
onClick={onQuickCreate}
aria-label="快捷发起考核调研"
className="group inline-flex min-h-12 w-full min-w-[74px] flex-col items-center justify-center text-center transition-colors hover:bg-[#f8faff]"
>
{content}
<span className="mt-1 text-[10px] font-bold text-[#0050cb] group-hover:text-[#003fa4]">+ 发起</span>
</button>
)
}
if (!canOpen) {
return <div className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center">{content}</div>
}
return (
<button
type="button"
onClick={onClick}
aria-label={accessibleLabel}
className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center transition-colors hover:text-[#003fa4]"
>
{content}
</button>
)
}
function EmptyLedger({ title }: { title: string }) {
return (
<div className="border border-dashed border-[#c2c6d8] bg-white px-5 py-12 text-center">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#f4f3f3] text-lg font-bold text-[#727687]">0</div>
<h2 className="mt-4 text-[18px] font-bold text-[#1a1c1c]">{title}</h2>
</div>
)
}
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 (
<span className={`inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold ${meta.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</span>
)
}
function MonthlyScoreDetailModal({
request,
onClose,
}: {
request: { month: string; employeeId: string; templateCode: string; positionGroupCode?: string } | null
onClose: () => void
}) {
const [portalReady, setPortalReady] = useState(false)
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeButtonRef = useRef<HTMLButtonElement | null>(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<any>(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((
<div
className="fixed inset-0 z-[1000] flex items-stretch justify-center bg-white p-0 sm:items-center sm:bg-black/40 sm:p-6 sm:backdrop-blur-[2px]"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
className="relative flex h-[100dvh] w-full flex-col overflow-hidden bg-white sm:h-auto sm:max-h-[calc(100dvh-48px)] sm:max-w-2xl sm:rounded-lg sm:shadow-2xl"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="performance-survey-detail-title"
tabIndex={-1}
>
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#e2e2e2] bg-white px-4 pb-4 pt-[calc(16px+env(safe-area-inset-top))] sm:px-5 sm:pt-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
<span>{formatMonthLabel(detail.data?.month || request.month)}</span>
<span>·</span>
<span>{detail.data?.template?.name || '考核明细'}</span>
{primarySurvey && <InlineSurveyState status={primarySurvey.status} />}
</div>
<h2 id="performance-survey-detail-title" className="mt-2 break-words text-[22px] font-bold leading-tight text-[#1a1c1c] sm:text-[24px]">
{primarySurvey?.title || detail.data?.template?.fullName || '考核明细'}
</h2>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-[28px] font-light leading-none text-[#5f5e5e] outline-none transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c] focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
aria-label="关闭详情"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-5 sm:px-5">
{detail.loading && <div className="text-sm font-medium text-[#5f5e5e]">加载中...</div>}
{detail.error && <DashboardErrorPanel message={detail.error} />}
{detail.data && (
<div className="space-y-7">
<div className="grid grid-cols-2 gap-5 border-b border-[#e2e2e2] pb-5 sm:grid-cols-4">
<DetailMetaItem label="被评估人" value={detail.data.employee?.displayName || '-'} />
<DetailMetaItem label="发起人" value={primarySurvey?.creatorName || '-'} />
<DetailMetaItem label="截止时间" value={primarySurvey?.dueAt ? formatDate(primarySurvey.dueAt) : '未设置'} />
<DetailMetaItem label={detail.data?.template?.isVisual ? '最终得分' : '当前得分'} value={detail.data.score === null || detail.data.score === undefined ? '待评分' : `${formatNullableScore(detail.data.score)} / ${Number(detail.data.rawMaxTotal || 120)}`} />
</div>
<section className="space-y-4">
<DetailSectionTitle>调研进度</DetailSectionTitle>
<div className="grid grid-cols-3 border-y border-[#e2e2e2] py-4 text-center">
<WorkflowCount label="通知" value={workflow.notifiedCount || 0} total={workflow.assignmentCount || 0} />
<WorkflowCount label="已查看" value={workflow.readCount || 0} total={workflow.assignmentCount || 0} />
<WorkflowCount label="已评分" value={workflow.submittedCount || responseCount || 0} total={workflow.assignmentCount || 0} />
</div>
{Number(workflow.submittedCount || responseCount || 0) > 0 && (
Number(workflow.notifiedCount || 0) < Number(workflow.submittedCount || responseCount || 0) ||
Number(workflow.readCount || 0) < Number(workflow.submittedCount || responseCount || 0)
) && (
<p className="border-l-2 border-[#8aaeff] bg-[#f8faff] px-3 py-2 text-[12px] font-medium leading-5 text-[#5f5e5e]">
评分已完成,但部分通知或查看状态没有记录,不影响已评分结果。
</p>
)}
<EvaluatorWorkflowList items={detail.data.evaluators || []} />
</section>
{isVisualDetail && (
<section className="space-y-3">
<DetailSectionTitle>视觉评分作品</DetailSectionTitle>
{detail.data.workItems?.length > 0 ? (
<div className="space-y-3">
{detail.data.workItems.map((item: any, index: number) => (
<section key={item.id} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-[#0050cb]">作品 {index + 1}</div>
<h3 className="mt-1 break-words text-[16px] font-bold text-[#1a1c1c]">{item.title}</h3>
{item.description && <p className="mt-1 whitespace-pre-wrap text-sm leading-5 text-[#5f5e5e]">{item.description}</p>}
</div>
<div className="shrink-0 rounded bg-[#f4f3f3] px-3 py-2 text-[18px] font-bold text-[#0050cb]">{formatNullableScore(item.averageScore)}</div>
</div>
<ImagePreviewGrid images={item.imageGroups || item.imageUrls || []} variant="long" />
<ScoreParticipantList items={item.scores || []} />
</section>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-[#c2c6d8] bg-[#f9f9f9] px-4 py-8 text-center text-sm font-semibold text-[#5f5e5e]">
暂无作品图片
</div>
)}
</section>
)}
<section className="space-y-3">
<DetailSectionTitle>详细评价内容</DetailSectionTitle>
<ScoreParticipantList items={detail.data.participants || []} />
</section>
</div>
)}
</div>
</div>
</div>
), document.body)
}
function DetailMetaItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="space-y-1">
<div className="text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
<div className="break-words text-[15px] font-semibold text-[#1a1c1c]">{value}</div>
</div>
)
}
function DetailSectionTitle({ children }: { children: ReactNode }) {
return (
<h3 className="border-b border-[#e2e2e2] pb-1 text-[12px] font-semibold text-[#5f5e5e]">
{children}
</h3>
)
}
function WorkflowCount({ label, value, total }: { label: string; value: number; total: number }) {
return (
<div className="border-r border-[#e2e2e2] last:border-r-0">
<div className="text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
<div className="mt-1 text-[20px] font-bold text-[#1a1c1c]">
{value}<span className="ml-1 text-[12px] font-medium text-[#727687]">/ {total}</span>
</div>
</div>
)
}
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 (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11px] font-semibold text-[#5f5e5e]">
<span className={`h-1.5 w-1.5 rounded-full ${toneClass}`} />
{label}
</span>
)
}
function EvaluatorWorkflowList({ items }: { items: any[] }) {
if (!items.length) {
return <div className="border border-dashed border-[#c2c6d8] px-4 py-6 text-center text-sm font-semibold text-[#5f5e5e]">暂无评价人</div>
}
return (
<div className="divide-y divide-[#eeeeee] border-y border-[#eeeeee]">
{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 (
<div key={item.id} className="grid gap-3 py-3 sm:grid-cols-[minmax(120px,1fr)_repeat(3,auto)] sm:items-center sm:gap-6">
<div className="min-w-0">
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">{item.name}</div>
{item.submittedAt && <div className="mt-1 text-[11px] text-[#727687]">{formatDate(item.submittedAt)}</div>}
</div>
<div className="grid grid-cols-3 gap-3 sm:contents">
<WorkflowState {...notice} />
<WorkflowState {...read} />
<WorkflowState {...submitted} />
</div>
</div>
)
})}
</div>
)
}
function ScoreParticipantList({ items }: { items: any[] }) {
if (!items.length) {
return <div className="rounded-lg border border-[#eeeeee] bg-[#f9f9f9] px-4 py-4 text-sm font-semibold text-[#5f5e5e]">暂无评分记录</div>
}
return (
<div className="overflow-hidden rounded-lg border border-[#eeeeee] bg-white">
{items.map((item) => (
<div key={item.id} className="border-b border-[#eeeeee] px-4 py-3 last:border-b-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-sm font-bold text-[#1a1c1c]">
{item.name}
{item.countsTowardFinal && (
<span className="ml-2 inline-flex rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[10px] font-semibold text-[#0050cb]">计入最终分</span>
)}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-[#1677ff]">
{formatNullableScore(item.rawScore ?? item.score)}
<span className="ml-0.5 text-[11px] font-medium text-[#727687]">/{Number(item.rawMaxTotal || 120)}</span>
</div>
</div>
</div>
{Array.isArray(item.dimensions) && item.dimensions.length > 0 && (
<div className="mt-3 grid gap-x-5 gap-y-2 border-t border-[#eeeeee] pt-3 sm:grid-cols-2">
{item.dimensions.map((dimension: any) => (
<div key={dimension.id} className="flex min-w-0 items-center justify-between gap-3 text-[13px]">
<span className="truncate text-[#5f5e5e]">{dimension.title}</span>
<span className="shrink-0 font-semibold text-[#1a1c1c]">
{formatNullableScore(dimension.score)}
<span className="ml-0.5 text-[11px] font-medium text-[#727687]">/{dimension.maxScore}</span>
</span>
</div>
))}
</div>
)}
{item.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-5 text-[#5f5e5e]">{item.comment}</p>}
{item.submittedAt && <div className="mt-2 text-xs text-[#727687]">{formatDate(item.submittedAt)}</div>}
</div>
))}
</div>
)
}
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 <div className="rounded-[12px] bg-[#f8fafc] px-4 py-4 text-sm text-[#667085]">暂无评分记录</div>
}
return (
<div className="grid gap-3">
{evaluators.map((evaluator) => (
<section key={evaluator.id} className="overflow-hidden rounded-[14px] border border-[#e5edf6] bg-white">
<div className="flex items-start justify-between gap-3 border-b border-[#e5edf6] bg-[#f8fafc] px-4 py-3.5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-[#101828]">{evaluator.respondentName}</span>
{['visual_final', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && (
<span className="rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[11px] font-semibold text-[#0050cb]">计入最终分</span>
)}
{['self', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && (
<span className="rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">自评</span>
)}
{evaluator.workflowRole === 'manager' && (
<span className="rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">主管评分</span>
)}
</div>
{evaluator.createdAt && <div className="mt-1 text-[11px] text-[#98a2b3]">提交于 {formatDate(evaluator.createdAt)}</div>}
</div>
<div className="shrink-0 text-right">
<div className="text-[18px] font-semibold text-[#0052d9]">{formatResultScore(evaluator.totalScore)}</div>
<div className="text-[11px] font-medium text-[#667085]">总分</div>
</div>
</div>
<div className="divide-y divide-[#edf1f6] px-4">
{evaluator.workItemScores.map((item, itemIndex) => {
const itemScore = item.score
const rawScores = itemScore?.scores && typeof itemScore.scores === 'object'
? itemScore.scores as Record<string, unknown>
: {}
const dimensions = questions
.map((question) => ({
question,
value: rawScores[question.id],
}))
.filter(({ value }) => value !== null && value !== undefined && value !== '')
return (
<div key={item.workItemId} className="py-3.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-[#667085]">作品 {itemIndex + 1}</div>
<div className="mt-0.5 break-words text-sm font-semibold text-[#101828]">{item.workItemTitle}</div>
</div>
<div className={`shrink-0 text-sm font-semibold ${itemScore ? 'text-[#0052d9]' : 'text-[#98a2b3]'}`}>
{itemScore ? `${formatResultScore(itemScore.totalScore)} 分` : '未评分'}
</div>
</div>
{dimensions.length > 0 && (
<div className="mt-3 grid gap-x-4 gap-y-2 rounded-[10px] bg-[#f8fafc] px-3 py-2.5 sm:grid-cols-2">
{dimensions.map(({ question, value }) => (
<div key={question.id} className="flex items-center justify-between gap-3 text-[12px]">
<span className="truncate text-[#667085]">{question.title}</span>
<span className="shrink-0 font-semibold text-[#344054]">
{formatResultScore(value)} / {getQuestionMaxScore(template, question)}
</span>
</div>
))}
</div>
)}
{itemScore?.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#475467]">{itemScore.comment}</p>}
</div>
)
})}
</div>
{evaluator.comment && (
<div className="border-t border-[#e5edf6] bg-[#fbfcfe] px-4 py-3">
<div className="text-[11px] font-semibold text-[#667085]">总体评语</div>
<p className="mt-1 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{evaluator.comment}</p>
</div>
)}
</section>
))}
</div>
)
}
function MetricCard({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="rounded-[14px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_8px_18px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900">
<div className="text-[13px] font-semibold text-[#667085] dark:text-gray-400">{label}</div>
<div className="mt-2 text-[28px] font-semibold leading-none text-[#101828] dark:text-white">{value}</div>
</div>
)
}
function TemplateSectionCard({ item }: { item: any }) {
return (
<div className="rounded-[14px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_8px_18px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900">
<div className="truncate text-[15px] font-semibold text-[#101828] dark:text-white">{item.name}</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div>
<div className="text-[26px] font-semibold leading-none text-[#101828] dark:text-white">{item.count || 0}</div>
<div className="mt-1 text-xs font-medium text-[#667085] dark:text-gray-400">调研</div>
</div>
<div className="text-right">
<div className="text-[20px] font-semibold leading-none text-[#1677ff]">{formatNullableScore(item.averageScore)}</div>
<div className="mt-1 text-xs font-medium text-[#667085] dark:text-gray-400">均分</div>
</div>
</div>
<div className="mt-3 text-xs font-medium text-[#667085] dark:text-gray-400">
已完成 {item.completedCount || 0} · 待完成 {item.pendingCount || 0}
</div>
</div>
)
}
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<Employee[]>([])
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<Employee[]>(`/employees?${params.toString()}`))
} finally {
setLoading(false)
}
}, 250)
return () => window.clearTimeout(timer)
}, [keyword])
return (
<div className="relative">
<input
value={keyword}
onChange={(event) => 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 && (
<button
type="button"
onClick={() => {
onChange(null)
setKeyword('')
}}
className="absolute right-2 top-1/2 -translate-y-1/2 px-2 py-1 text-[12px] font-semibold text-[#5f5e5e] hover:text-[#1a1c1c]"
>
清除
</button>
)}
{open && (
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto border border-[#e2e2e2] bg-white shadow-[0_10px_24px_rgba(0,0,0,0.08)]">
{loading ? (
<div className="px-3 py-2 text-sm text-[#5f5e5e]">搜索中...</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-[#5f5e5e]">无匹配人员</div>
) : (
items.map((employee) => (
<button
key={employee.id}
type="button"
onClick={() => {
onChange(employee)
setKeyword('')
setOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-[#f9f9f9]"
>
<div className="font-semibold text-[#1a1c1c]">{employee.displayName}</div>
<div className="mt-0.5 truncate text-xs text-[#5f5e5e]">{employee.departmentName || ''}</div>
</button>
))
)}
</div>
)}
</div>
)
}
function PendingList({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无待处理" description="当前没有需要你评分的调研" />
}
return (
<>
<div className="grid gap-3 md:hidden">
{items.map((item) => (
<article key={item.assignmentId} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">{item.title}</h2>
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
<p className="break-words">评价对象:{item.targetName}</p>
<p>截止:{item.dueAt ? formatDate(item.dueAt) : '无截止'}</p>
</div>
</div>
<span className="shrink-0 rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-2.5 py-1 text-[12px] font-bold text-[#0052d9]">待填写</span>
</div>
<div className="mt-4 flex justify-end">
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
aria-label={`去评价:${item.title}`}
className="inline-flex min-h-11 items-center justify-center rounded bg-[#0050cb] px-5 text-sm font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
去评价
</Link>
</div>
</article>
))}
</div>
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white md:block">
<table className="w-full min-w-[720px] border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="px-4 py-3">调研</th>
<th scope="col" className="w-[180px] px-4 py-3">评价对象</th>
<th scope="col" className="w-[180px] px-4 py-3">截止</th>
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
<th scope="col" className="w-[110px] px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[#c2c6d8]">
{items.map((item) => (
<tr key={item.assignmentId} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
<td className="px-4 py-4 font-bold">{item.title}</td>
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.targetName}</td>
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.dueAt ? formatDate(item.dueAt) : '无截止'}</td>
<td className="px-4 py-4">
<span className="inline-flex rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-2.5 py-1 text-[12px] font-bold text-[#0052d9]">待填写</span>
</td>
<td className="px-4 py-4 text-right">
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
aria-label={`去评价:${item.title}`}
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
去评价
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
function SubmittedList({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无已处理" description="完成评分后,记录会显示在这里" />
}
return (
<>
<div className="grid gap-3 md:hidden">
{items.map((item) => (
<article key={item.assignmentId} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">{item.title}</h2>
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
<p className="break-words">评价对象:{item.targetName}</p>
<p>提交:{item.submittedAt ? formatDate(item.submittedAt) : '已提交'}</p>
</div>
</div>
<span className="shrink-0 rounded-full border border-[#b9ead0] bg-[#ecfdf3] px-2.5 py-1 text-[12px] font-bold text-[#039855]">已提交</span>
</div>
<div className="mt-4 flex justify-end">
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
aria-label={`查看详情:${item.title}`}
className="inline-flex min-h-11 items-center justify-center rounded border border-[#c2c6d8] bg-white px-5 text-sm font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
查看详情
</Link>
</div>
</article>
))}
</div>
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white md:block">
<table className="w-full min-w-[720px] border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="px-4 py-3">调研</th>
<th scope="col" className="w-[180px] px-4 py-3">评价对象</th>
<th scope="col" className="w-[180px] px-4 py-3">提交时间</th>
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
<th scope="col" className="w-[120px] px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[#c2c6d8]">
{items.map((item) => (
<tr key={item.assignmentId} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
<td className="px-4 py-4 font-bold">{item.title}</td>
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.targetName}</td>
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.submittedAt ? formatDate(item.submittedAt) : '已提交'}</td>
<td className="px-4 py-4">
<span className="inline-flex rounded-full border border-[#b9ead0] bg-[#ecfdf3] px-2.5 py-1 text-[12px] font-bold text-[#039855]">已提交</span>
</td>
<td className="px-4 py-4 text-right">
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
aria-label={`查看详情:${item.title}`}
className="inline-flex min-h-9 items-center justify-center rounded border border-[#c2c6d8] bg-white px-4 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
查看详情
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
function CreatedList({
items,
onRefresh,
emptyTitle = '还没有发起过考核调研',
resultBackHref = '/performance-survey?tab=manage',
}: {
items: any[]
onRefresh: () => Promise<void>
emptyTitle?: string
resultBackHref?: string
}) {
const [busyId, setBusyId] = useState<string | null>(null)
const [actionErrors, setActionErrors] = useState<Record<string, string>>({})
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 <EmptyState title={emptyTitle} description="发起调研后,可在这里查看进度和结果" />
}
return (
<>
<div className="grid gap-3 lg:hidden">
{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 (
<article
key={item.id}
className="rounded-lg border border-[#e2e2e2] bg-white p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">
{item.title}
</h2>
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
<p className="break-words">{item.departmentName || '未指定部门'}</p>
<p>对象 {targetCount} 人 · 评价任务 {assignmentCount} 份</p>
<p>{progressText} · {item.dueAt ? `截止 ${formatDate(item.dueAt)}` : '无截止'}</p>
</div>
</div>
<DashboardStatusBadge status={item.status} />
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
{item.status === 'draft' && (
<Link
href={`/performance-survey?tab=manage&adminWorkspace=launch&draftId=${encodeURIComponent(item.id)}`}
aria-label={`继续编辑草稿:${item.title}`}
className="inline-flex min-h-11 items-center justify-center rounded border border-[#c2c6d8] bg-white px-4 text-sm font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
继续编辑
</Link>
)}
{item.status === 'draft' && (
<button
type="button"
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'open')}
aria-label={`发布调研:${item.title}`}
className="min-h-11 rounded border border-[#b8d0ff] bg-[#edf4ff] px-4 text-sm font-bold text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
{busyId === item.id ? '发布中...' : '发布'}
</button>
)}
{item.status !== 'closed' && item.status !== 'cancelled' && (
<button
type="button"
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'closed')}
aria-label={`关闭调研:${item.title}`}
className="min-h-11 rounded border border-[#c2c6d8] bg-white px-4 text-sm font-bold text-[#1a1c1c] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
{busyId === item.id ? '处理中...' : '关闭'}
</button>
)}
{item.canCancel && (
<button
type="button"
disabled={busyId === item.id}
onClick={() => cancelSurvey(item)}
aria-label={`撤销调研:${item.title}`}
className="min-h-11 rounded border border-[#f0b8b8] bg-white px-4 text-sm font-bold text-[#b42318] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2 disabled:opacity-50"
>
撤销
</button>
)}
{item.status === 'closed' && item.personalTargetId && (
<Link
href={`/performance-survey/results/${item.id}?targetId=${encodeURIComponent(item.personalTargetId)}&back=${encodeURIComponent(resultBackHref)}`}
aria-label={`查看结果:${item.title}`}
className="inline-flex min-h-11 items-center justify-center rounded bg-[#0050cb] px-5 text-sm font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
查看结果
</Link>
)}
</div>
{actionError && (
<div
role="alert"
className="mt-3 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-[13px] font-medium leading-5 text-[#b42318]"
>
{actionError}
{needsWorkItemUpload && (
<Link
href={`/performance-survey/survey/${item.id}`}
className="ml-2 inline-flex min-h-8 items-center rounded border border-[#f0b8b8] bg-white px-2.5 font-bold text-[#b42318] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
>
继续上传作品
</Link>
)}
</div>
)}
</article>
)
})}
</div>
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white lg:block">
<table className="w-full min-w-[920px] border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="px-4 py-3">调研</th>
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
<th scope="col" className="w-[210px] px-4 py-3">完成进度</th>
<th scope="col" className="w-[180px] px-4 py-3">截止</th>
<th scope="col" className="w-[270px] px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[#c2c6d8]">
{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 (
<tr key={item.id} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
<td className="px-4 py-4">
<div className="font-bold">{item.title}</div>
<div className="mt-1 text-[12px] font-medium text-[#5f5e5e]">{item.departmentName || '未指定部门'}</div>
</td>
<td className="px-4 py-4"><DashboardStatusBadge status={item.status} /></td>
<td className="px-4 py-4">
<div className="text-[13px] font-bold text-[#1a1c1c]">
{assignmentCount > 0 ? `已提交 ${responseCount}/${assignmentCount}` : '尚未生成评价任务'}
</div>
<div className="mt-1 text-[12px] font-medium text-[#5f5e5e]">对象 {targetCount} 人 · 评价任务 {assignmentCount} 份</div>
</td>
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.dueAt ? formatDate(item.dueAt) : '无截止'}</td>
<td className="px-4 py-4">
<div className="flex flex-wrap justify-end gap-2">
{item.status === 'draft' && (
<Link
href={`/performance-survey?tab=manage&adminWorkspace=launch&draftId=${encodeURIComponent(item.id)}`}
aria-label={`继续编辑草稿:${item.title}`}
className="inline-flex min-h-9 items-center justify-center rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
继续编辑
</Link>
)}
{item.status === 'draft' && (
<button
type="button"
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'open')}
aria-label={`发布调研:${item.title}`}
className="min-h-9 rounded border border-[#b8d0ff] bg-[#edf4ff] px-3 text-[13px] font-bold text-[#0050cb] hover:bg-[#dae8ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
{busyId === item.id ? '发布中...' : '发布'}
</button>
)}
{item.status !== 'closed' && item.status !== 'cancelled' && (
<button
type="button"
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'closed')}
aria-label={`关闭调研:${item.title}`}
className="min-h-9 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
{busyId === item.id ? '处理中...' : '关闭'}
</button>
)}
{item.canCancel && (
<button
type="button"
disabled={busyId === item.id}
onClick={() => cancelSurvey(item)}
aria-label={`撤销调研:${item.title}`}
className="min-h-9 rounded border border-[#f0b8b8] bg-white px-3 text-[13px] font-bold text-[#b42318] hover:bg-[#fff5f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2 disabled:opacity-50"
>
撤销
</button>
)}
{item.status === 'closed' && item.personalTargetId && (
<Link
href={`/performance-survey/results/${item.id}?targetId=${encodeURIComponent(item.personalTargetId)}&back=${encodeURIComponent(resultBackHref)}`}
aria-label={`查看结果:${item.title}`}
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-3 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
查看结果
</Link>
)}
</div>
{actionError && (
<div
role="alert"
className="mt-2 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-left text-[12px] font-medium leading-5 text-[#b42318]"
>
{actionError}
{needsWorkItemUpload && (
<Link
href={`/performance-survey/survey/${item.id}`}
className="ml-2 inline-flex min-h-8 items-center rounded border border-[#f0b8b8] bg-white px-2.5 font-bold text-[#b42318] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
>
继续上传作品
</Link>
)}
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)
}
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<any>(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 (
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">架构调研</h2>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,320px)_150px_auto]">
<DepartmentFilterTree
departments={departments}
value={department}
onChange={(nextDepartment) => {
setDepartment(nextDepartment)
updateFilterUrl(month, nextDepartment)
}}
/>
<input
type="month"
value={month}
onChange={(event) => {
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"
/>
<button
type="button"
onClick={() => {
const nextMonth = getCurrentMonthValue()
setMonth(nextMonth)
updateFilterUrl(nextMonth, department)
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
>
当月
</button>
</div>
</div>
<div className="mt-4">
{state.loading && <DashboardPanel>加载中...</DashboardPanel>}
{state.error && <DashboardErrorPanel message={state.error} />}
{state.data && <CreatedList items={surveys} onRefresh={state.refresh} emptyTitle="暂无架构调研" resultBackHref={resultBackHref} />}
</div>
</section>
)
}
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<Set<string>>(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<string, DepartmentOption>()
const children = new Map<string, DepartmentOption[]>()
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<string>()
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 (
<div key={department.id}>
<div
className={`flex min-h-11 items-center gap-1 rounded px-1 py-1 text-sm ${
selected ? 'bg-[#dae1ff] text-[#0050cb]' : 'text-[#1a1c1c] hover:bg-[#f9f9f9]'
}`}
style={{ paddingLeft: `${8 + depth * 18}px` }}
>
<button
type="button"
onClick={() => hasChildren && toggle(department.id)}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded border text-sm ${
hasChildren
? 'border-[#e2e2e2] text-[#5f5e5e] hover:bg-[#f4f3f3]'
: 'border-transparent text-transparent'
}`}
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
disabled={!hasChildren}
>
{hasChildren ? (isExpanded ? '-' : '+') : ''}
</button>
<button
type="button"
disabled={!selectable}
onClick={() => selectable && choose(optionValue)}
className="min-h-9 min-w-0 flex-1 truncate rounded px-2 text-left font-medium disabled:cursor-default"
>
{department.name}
</button>
{selected && <span className="text-xs font-semibold">已选</span>}
</div>
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
</div>
)
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
aria-haspopup="dialog"
className="flex h-10 w-full items-center justify-between gap-2 rounded border border-[#eeeeee] bg-white px-3 text-left text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors hover:border-[#c2c6d8]"
>
<span className="truncate">{selectedLabel}</span>
<span className="text-xs text-[#5f5e5e]">{open ? '收起' : '选择'}</span>
</button>
{open && (
<div className="absolute left-0 right-0 z-30 mt-2 border border-[#eeeeee] bg-white p-2 shadow-[0_10px_28px_rgba(0,0,0,0.08)]" role="dialog" aria-label="选择组织">
<input
value={keyword}
onChange={(event) => 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]"
/>
<button
type="button"
onClick={() => choose('')}
className={`mb-1 flex min-h-9 w-full items-center rounded px-3 text-left text-sm font-semibold ${
!value ? 'bg-[#dae1ff] text-[#0050cb]' : 'text-[#1a1c1c] hover:bg-[#f9f9f9]'
}`}
>
全部组织
</button>
<div className="max-h-72 overflow-y-auto">
{roots.map((department) => renderNode(department))}
</div>
</div>
)}
</div>
)
}
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<string | null>(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<PerformanceSurveyAdminWorkspace>(initialWorkspace)
const isLaunchWorkspace = workspace === 'launch'
const departments = useApi<DepartmentOption[]>(
isLaunchWorkspace ? '/departments?purpose=evaluator' : null,
[isLaunchWorkspace],
)
const surveys = useApi<any>(
isLaunchWorkspace ? '/surveys' : null,
[isLaunchWorkspace],
)
const notifications = useApi<any[]>(
isSystemAdmin && workspace === 'operations' ? '/admin/notifications?limit=50' : null,
[workspace, isSystemAdmin],
)
const notificationStatus = useApi<any>(
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 (
<div className="grid gap-4">
{isSystemAdmin && (
<nav
aria-label="考核调研管理功能"
className="flex max-w-full items-center gap-1 overflow-x-auto border-b border-[#e2e2e2] bg-white pb-2"
>
{([
['overview', '管理概览'],
['positions', '岗位'],
['people', '人员分配'],
['templates', '模板'],
['launch', '发起调研'],
['operations', '运行维护'],
] as const).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => void selectWorkspace(id)}
aria-current={workspace === id ? 'page' : undefined}
className={`min-h-11 shrink-0 rounded px-3 text-[12px] font-bold transition-colors ${
workspace === id
? 'bg-[#1a1c1c] text-white'
: 'text-[#5f5e5e] hover:bg-[#f4f4f4] hover:text-[#1a1c1c]'
}`}
>
{label}
</button>
))}
</nav>
)}
{workspace === 'launch' ? (
<CreateSurveyForm
departments={departments.data || []}
currentEmployee={currentEmployee}
defaultMonth={adminMonth}
onMonthChange={selectAdminMonth}
onAdminNavigate={isSystemAdmin ? (nextWorkspace) => void selectWorkspace(nextWorkspace) : undefined}
onCreated={async () => {
setMessage('已发布调研')
await surveys.refresh()
}}
/>
) : workspace === 'operations' ? (
<section className="border border-[#e2e2e2] bg-white" aria-labelledby="performance-survey-operations-title">
<div className="border-b border-[#e2e2e2] px-4 py-4">
<h2 id="performance-survey-operations-title" className="text-[18px] font-bold text-[#1a1c1c]">运行维护</h2>
<p className="mt-1 text-[12px] font-medium text-[#727687]">同步人员档案、检查通知异常并补发消息</p>
</div>
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<AdminActions onMessage={setMessage} />
<NotificationList state={notifications} statusState={notificationStatus} />
</div>
</section>
) : (
<PerformanceSurveyAdminConfig
mode={workspace}
request={apiRequest}
month={adminMonth}
onMonthChange={selectAdminMonth}
onNavigate={(nextWorkspace) => void selectWorkspace(nextWorkspace)}
onDirtyChange={onConfigurationDirtyChange}
/>
)}
{manageNavigationDialog}
{message && (
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
{message}
</div>
)}
{workspace === 'launch' && <section className="mt-4 border-t border-[#e2e2e2] pt-6" aria-labelledby="created-survey-list-title">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 id="created-survey-list-title" className="text-[18px] font-bold text-[#1a1c1c]">我发起的</h2>
<p className="mt-1 text-[12px] font-medium text-[#727687]">回看进度、关闭调研或进入结果页</p>
</div>
<button
type="button"
onClick={() => surveys.refresh()}
className="inline-flex min-h-9 items-center rounded px-2 text-[12px] font-bold text-[#0050cb] hover:bg-[#f4f7ff] hover:text-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
刷新
</button>
</div>
{surveys.loading && <DashboardPanel>调研加载中...</DashboardPanel>}
{surveys.error && <DashboardErrorPanel message={surveys.error} />}
{surveys.data && (
<CreatedList
items={surveys.data.created || []}
onRefresh={surveys.refresh}
resultBackHref="/performance-survey?tab=manage"
/>
)}
</section>}
</div>
)
}
function TemplateStatsPanel({ items }: { items: any[] }) {
return (
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">模板</h2>
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
{items.map((item) => {
const achievement = item.responseCount > 0 ? `${item.aggregate?.achievementScore}${item.achievementSuffix || ''}` : '-'
return (
<div key={item.code} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{item.responseCount || 0} 份
</div>
</div>
<div className="text-right">
<div className="text-[18px] font-semibold text-gray-900 dark:text-white">{achievement}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">均分</div>
</div>
</div>
)
})}
{items.length === 0 && <div className="py-3 text-sm text-gray-500 dark:text-gray-400">暂无模板</div>}
</div>
</section>
)
}
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<void>
}) {
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<Employee[]>([])
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState<string[]>([])
const [evaluatorDepartmentEmployees, setEvaluatorDepartmentEmployees] = useState<Record<string, Employee[]>>({})
const [itemName, setItemName] = useState('')
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
const [visualCases, setVisualCases] = useState<VisualCaseDraft[]>(() => createVisualCaseDrafts())
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
const [saving, setSaving] = useState(false)
const [publishingLabel, setPublishingLabel] = useState('')
const [error, setError] = useState<string | null>(null)
const hydratedDraftIdRef = useRef<string | null>(null)
const draftState = useApi<any>(
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<any>(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<Template[]>(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<string, Employee[]> = {}
for (const [department, employees] of Object.entries(current)) {
const filtered = employees.filter((employee) => employee.id !== id)
if (filtered.length > 0) next[department] = filtered
}
return next
})
}
const submit = async () => {
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<any>(`/surveys/${editingDraft.id}`, {
method: 'PATCH',
body: JSON.stringify({
title: submitTitle,
description,
assessmentMonth: selectedAssessmentMonth,
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
}),
})
: await apiRequest<any>('/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<any>(`/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 (
<section className="grid gap-6 lg:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.25fr)]">
{draftSurveyId && (
<div
className={`lg:col-span-2 ${
draftLoadIssue
? 'border border-[#ffdad6] bg-[#fff6f4] text-[#93000a]'
: 'border border-[#b3c5ff] bg-[#f8faff] text-[#0050cb]'
} px-4 py-3`}
role={draftLoadIssue ? 'alert' : 'status'}
aria-live="polite"
>
<div className="text-[14px] font-bold">
{draftState.loading
? '正在恢复草稿...'
: draftLoadIssue
? '无法继续编辑草稿'
: '继续编辑未发布草稿'}
</div>
<p className="mt-1 text-[12px] font-medium leading-5">
{draftState.loading
? '正在读取原有模板、作品和发布设置。'
: draftLoadIssue
? `${draftLoadIssue}。请返回“我发起的”刷新后重试。`
: `已恢复 ${existingVisualWorkItems.length}/${visualRequiredCount} 个作品;补齐内容后可直接发布。被考核人与评分流程沿用原草稿。`}
</p>
</div>
)}
<div>
<div className="border border-[#eeeeee] bg-white p-4 sm:p-6">
<div className="space-y-6">
<TextInput
label="考核月份"
value={assessmentMonth}
onChange={(value) => {
setAssessmentMonth(value)
onMonthChange?.(value)
}}
type="month"
/>
<div className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考核岗位</span>
<div
aria-live="polite"
className="flex min-h-11 min-w-0 items-center justify-between gap-3 rounded border border-[#eeeeee] bg-[#f9f9f9] px-3 py-2"
>
<span className="min-w-0 break-words text-[14px] font-semibold text-[#1a1c1c]">
{editingDraft
? editingDraft.positionGroup?.name || editingDraft.positionGroupCode || '沿用草稿岗位'
: positionGroupsState.loading
? '正在关联人员岗位...'
: positionGroupsState.error
? '岗位关联失败'
: membershipStatus === 'matched'
? positionGroupsState.data?.membership?.groupName || '已关联岗位'
: membershipStatus === 'conflict'
? '岗位配置冲突'
: '尚未配置岗位'}
</span>
<span className="shrink-0 text-[11px] font-semibold text-[#5f5e5e]">自动关联</span>
</div>
{!editingDraft && positionGroupsState.error && (
<div
role="alert"
className="flex flex-wrap items-center justify-between gap-2 border border-[#ffdad6] bg-[#fff6f4] px-3 py-2 text-[12px] font-semibold text-[#93000a]"
>
<span className="min-w-0 break-words">岗位关联失败:{positionGroupsState.error}</span>
<button
type="button"
onClick={() => void positionGroupsState.refresh()}
className="min-h-9 shrink-0 rounded border border-[#93000a] px-3 hover:bg-[#93000a] hover:text-white"
>
重新加载
</button>
</div>
)}
{!editingDraft && membershipStatus === 'matched' && (
<div className="text-[12px] leading-5 text-[#5f5e5e]">
已按被考核人的人员分配自动关联,发起时无需选择。
</div>
)}
{!editingDraft && positionGroupsState.data?.membership?.status === 'conflict' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前人员同时命中多个岗位:{(positionGroupsState.data.membership.conflictGroupNames || []).join('、')}
</div>
)}
{!editingDraft && positionGroupsState.data?.membership?.status === 'unmatched' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前人员尚未匹配考核岗位,请联系系统管理员完成岗位配置
</div>
)}
{!editingDraft && membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前岗位尚未完成考核项配置,暂时不能发起调研
</div>
)}
</div>
{!editingDraft && onAdminNavigate && positionGroupsState.data && (
membershipStatus !== 'matched'
|| selectedPositionGroup?.status !== 'ready'
|| (selectedPositionGroupCode && !templatesState.loading && templates.length === 0)
) && (
<div className="border border-[#e4b86a] bg-[#fffaf0] px-3 py-3 text-[12px] leading-5 text-[#6f4700]">
<div className="font-bold">
{membershipStatus !== 'matched'
? '当前人员还不能自动关联岗位'
: selectedPositionGroup?.status !== 'ready'
? '当前岗位尚未完成配置'
: '当前岗位还没有可用考核模板'}
</div>
<p className="mt-1">请先补齐人员归属、岗位设置和可用模板,再回来发起调研。</p>
<div className="mt-3 flex flex-wrap gap-2">
{([
['positions', '检查岗位设置'],
['people', '检查人员'],
['templates', '检查模板'],
] as const).map(([target, label]) => (
<button
key={target}
type="button"
onClick={() => onAdminNavigate(target)}
className="min-h-11 rounded border border-[#6f4700] px-3 text-[11px] font-bold hover:bg-[#6f4700] hover:text-white"
>
{label}
</button>
))}
</div>
</div>
)}
<TextInput label="截止时间(可选)" value={deadlineAt} onChange={setDeadlineAt} type="datetime-local" />
<label className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考评模板</span>
<select
aria-label="考评模板"
value={selectedTemplate}
onChange={(event) => setTemplateOptionKey(event.target.value)}
disabled={Boolean(editingDraft) || !selectedPositionGroupCode || templatesState.loading}
className="h-11 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
>
{templates.length === 0 && <option value="">该岗位暂无可用考核项</option>}
{editingDraft && !templates.some((template) => (template.templateOptionKey || template.code) === selectedTemplate) && (
<option value={selectedTemplate}>
{selectedTemplateInfo?.name || selectedTemplateInfo?.shortName || editingDraft.templateCode}
</option>
)}
{templates.map((template) => (
<option key={template.templateOptionKey || template.code} value={template.templateOptionKey || template.code}>
{template.name || template.shortName || template.code}
</option>
))}
</select>
{editingDraft && (
<div className="text-[12px] leading-5 text-[#5f5e5e]">
草稿已生成评价任务,模板、被考核人与评分流程保持原设置。
</div>
)}
{selectedTemplateInfo?.templateVariant && (
<div className="border border-[#dce5f3] bg-[#f8faff] px-3 py-3">
<div className="text-[12px] font-bold text-[#1a1c1c]">
{selectedTemplateInfo.questions.length} 个评分项 · 模板总分 {selectedTemplateInfo.maxTotal || 0} 分
</div>
<div className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">
{selectedTemplateInfo.questions.map((question) => question.title).join(' / ')}
</div>
</div>
)}
</label>
</div>
</div>
</div>
<div className="border border-[#eeeeee] bg-white p-4 sm:p-6">
<div className="grid gap-8 lg:grid-cols-2">
<div>
{surveyKind !== 'item_course' ? (
<div className="grid gap-4">
<div>
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">
{selectedTemplateInfo?.ownerRoleName || '被考核人'}
</div>
{editingDraft ? (
<div className="mt-2 divide-y divide-[#dce5f3] border border-[#dce5f3] bg-[#f8faff]">
{(editingDraft.targets || []).map((target: any) => (
<div key={target.id} className="flex min-h-12 items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#0050cb] text-[13px] font-bold text-white">
{getSurveyInitials(target.targetName)}
</div>
<div className="min-w-0 flex-1">
<div className="break-words text-[14px] font-bold text-[#1a1c1c]">{target.targetName}</div>
<div className="mt-0.5 break-words text-[12px] text-[#5f5e5e]">
{[target.targetSnapshot?.departmentName, target.targetSnapshot?.positionLevel || target.targetSnapshot?.levelType]
.filter(Boolean)
.join(' · ') || '沿用草稿人员'}
</div>
</div>
<span className="shrink-0 text-[11px] font-bold text-[#5f5e5e]">已锁定</span>
</div>
))}
</div>
) : currentEmployee ? (
<div className="mt-2 flex items-center gap-3 border border-[#dce5f3] bg-[#f8faff] px-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#0050cb] text-[13px] font-bold text-white">
{getSurveyInitials(currentEmployee.displayName)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">{currentEmployee.displayName}</div>
<div className="mt-0.5 truncate text-[12px] text-[#5f5e5e]">
{[currentEmployee.departmentName, currentEmployee.positionLevel || currentEmployee.levelType]
.filter(Boolean)
.join(' · ') || '当前登录账户'}
</div>
</div>
<span className="shrink-0 rounded-full bg-[#e8f0ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">
当前账户
</span>
</div>
) : (
<div className="mt-2 border border-[#ffdad6] bg-[#fff6f4] px-3 py-3 text-[12px] font-semibold text-[#93000a]">
当前账户尚未匹配考核人员档案
</div>
)}
</div>
{!editingDraft && (
<EmployeePicker
title="添加同岗位被考核人(可选)"
purpose="target"
positionGroupCode={selectedPositionGroupCode}
assessmentMonth={assessmentMonth}
selected={targetEmployees}
excludedIds={currentEmployee?.id ? [currentEmployee.id] : []}
onChange={setTargetEmployees}
/>
)}
{delegatedTargetCount > 0 && (
<div className="text-[12px] font-semibold text-[#0050cb]">
将向 {delegatedTargetCount} 位被考核人发送钉钉确认
</div>
)}
</div>
) : (
<div>
<div className="mb-2 text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">{selectedTemplateInfo?.itemName || '调研事项'}</div>
<input
value={itemName}
onChange={(event) => 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]"
/>
</div>
)}
</div>
{editingDraft ? (
<div className="border border-[#dce5f3] bg-[#f8faff] px-4 py-4">
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">评分流程</div>
<div className="mt-2 grid gap-2">
{workflowStages.map((stage) => (
<div key={stage.key} className="text-[13px] font-semibold text-[#1a1c1c]">
{stage.order + 1}. {stage.label} · {stage.reviewers.map((reviewer) => reviewer.employeeName || reviewer.label).join('、')}
</div>
))}
</div>
<p className="mt-2 text-[12px] leading-5 text-[#5f5e5e]">草稿恢复编辑时沿用已生成的评分人员与阶段设置。</p>
</div>
) : !requiresManualEvaluators ? (
<div className="border border-[#dce5f3] bg-[#f8faff] px-4 py-4">
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">评审流程</div>
<div className="mt-2 grid gap-2">
{workflowStages.map((stage) => (
<div key={stage.key} className="text-[13px] font-semibold text-[#1a1c1c]">
{stage.order + 1}. {stage.label} · {stage.reviewers.map((reviewer) => reviewer.employeeName || reviewer.label).join('、')}
</div>
))}
</div>
<p className="mt-2 text-[12px] leading-5 text-[#5f5e5e]">评分人员按已发布模板解析,前一阶段完成后自动开放下一阶段。</p>
</div>
) : (
<>
<div>
<EmployeePicker
title="评分人员"
purpose="evaluator"
selected={evaluatorEmployees}
excludedIds={currentEmployee?.id ? [currentEmployee.id] : []}
onChange={setManualEvaluatorEmployees}
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
onRemoveEmployee={removeEvaluatorEmployee}
/>
</div>
<div className="lg:col-span-2">
<DepartmentPicker
departments={departments}
selected={evaluatorDepartmentValues}
excludedEmployeeIds={currentEmployee?.id ? [currentEmployee.id] : []}
onChange={setEvaluatorDepartmentValues}
onDepartmentEmployeesChange={(department, checked, employees) => {
setEvaluatorDepartmentEmployees((current) => {
const next = { ...current }
if (checked) {
next[department] = employees
} else {
delete next[department]
}
return next
})
}}
/>
</div>
</>
)}
</div>
</div>
<div className={`min-w-0 lg:col-span-2 ${visualTemplateSelected ? '' : 'hidden'}`}>
<VisualCaseDraftUploader
expanded={visualTemplateSelected}
enabled={canUploadVisualCases}
requiredCount={editingDraft ? remainingVisualCaseCount : visualRequiredCount}
totalRequiredCount={visualRequiredCount}
completedOffset={existingVisualWorkItems.length}
existingItems={existingVisualWorkItems}
cases={visualCases}
onChange={setVisualCases}
/>
</div>
<div className="space-y-3 border-t border-[#e2e2e2] pt-5 lg:col-span-2">
{error && <div className="border border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-sm font-semibold text-[#93000a]" role="alert">{error}</div>}
<div
id="performance-survey-publish-readiness"
className={`px-4 py-3 text-[13px] font-semibold ${
publishValidationIssues.length > 0
? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]'
: 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]'
}`}
aria-live="polite"
>
{publishValidationIssues.length > 0
? `还需完成:${publishValidationIssues.join(';')}`
: visualTemplateSelected
? '配置已完整。发布后先由被考核人完成自评;自评提交后才通知主管和元泽评分'
: '配置已完整,可以发布调研'}
</div>
<button
type="button"
disabled={publishDisabled}
aria-describedby="performance-survey-publish-readiness"
onClick={submit}
className="h-12 w-full rounded bg-[#0050cb] px-5 text-[15px] font-bold text-white hover:bg-[#003fa4] disabled:cursor-not-allowed disabled:bg-[#aeb9c9]"
>
{saving
? publishingLabel || '发布中...'
: editingDraft
? remainingVisualCaseCount > 0
? '上传剩余作品并发布'
: '保存修改并发布'
: createdSurvey?.status === 'open'
? '调研已发布'
: createdSurvey
? '调研待完成'
: visualTemplateSelected
? '上传作品并发布'
: '发布调研'}
</button>
{createdSurvey && (
<div className="flex items-center justify-between gap-3 border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
<span>
{createdSurvey.status === 'open'
? isVisualTemplate(selectedTemplateInfo) && !currentTargetSelected
? '已发布,已通知被考核人提交作品'
: isVisualTemplate(selectedTemplateInfo)
? '已发布,当前等待被考核人完成自评;自评后将自动通知主管和元泽'
: '已发布'
: '尚未发布,请继续上传作品;作品完整后才能发布'}
</span>
{createdUploadTarget?.id && (
<Link
href={`/performance-survey/survey/${createdSurvey.id}?targetId=${encodeURIComponent(createdUploadTarget.id)}`}
className="shrink-0 rounded bg-[#0050cb] px-3 py-1.5 text-white"
>
{createdSurvey.status === 'open' ? '查看作品' : '继续上传'}
</Link>
)}
</div>
)}
</div>
</section>
)
}
function PerformanceSurveyImageDropzone({
files,
disabled,
ariaLabel,
onFiles,
}: {
files: File[]
disabled?: boolean
ariaLabel: string
onFiles: (files: File[]) => void
}) {
const inputRef = useRef<HTMLInputElement | null>(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 (
<div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
disabled={disabled}
tabIndex={-1}
aria-hidden="true"
className="hidden"
onChange={(event) => {
addFiles(event.currentTarget.files || [])
event.currentTarget.value = ''
}}
/>
<button
type="button"
disabled={disabled}
aria-label={ariaLabel}
onClick={() => inputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault()
if (disabled) return
dragDepthRef.current += 1
setDragging(true)
}}
onDragOver={(event) => {
event.preventDefault()
if (!disabled) event.dataTransfer.dropEffect = 'copy'
}}
onDragLeave={(event) => {
event.preventDefault()
if (disabled) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
dragDepthRef.current = 0
setDragging(false)
addFiles(event.dataTransfer.files)
}}
className={`flex min-h-24 w-full flex-col items-center justify-center border border-dashed px-4 py-4 text-center transition-[border-color,background-color,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-60 ${
dragging
? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]'
: 'border-[#aebbd0] bg-white hover:border-[#0050cb] hover:bg-[#f7faff]'
}`}
>
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-[20px] font-light ${dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eef5ff] text-[#0050cb]'}`}>↑</span>
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">{dragging ? '松开即可添加' : '拖拽图片到这里'}</span>
<span className="mt-0.5 text-[11px] text-[#98a2b3]">或点击选择 · 支持 PNG/JPG/GIF/WebP;1600px 长图由浏览器切片,多张按数字文件名顺序处理上传;单张 200MB、最大 12,000px / 1.2 亿像素、60 张切片</span>
<span className="mt-0.5 text-[10px] text-[#98a2b3]">请使用桌面版 Chrome 或 Edge</span>
</button>
</div>
)
}
function PerformanceSurveySelectedImageFiles({
files,
onRemove,
}: {
files: File[]
onRemove: (index: number) => void
}) {
if (files.length === 0) return null
return (
<div className="divide-y divide-[#e5eaf2] border border-[#e5eaf2] bg-white">
{files.map((file, index) => (
<div key={`${file.name}-${file.size}-${file.lastModified}`} className="flex min-h-11 items-center gap-3 px-3 py-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-[#eef5ff] text-[11px] font-bold text-[#0050cb]">图</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-semibold text-[#111827]">{file.name}</span>
<span className="block text-[10px] text-[#98a2b3]">{formatPerformanceSurveyImageFileSize(file.size)}</span>
</span>
<button
type="button"
onClick={() => onRemove(index)}
aria-label={`移除图片 ${index + 1}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[18px] leading-none text-[#667085] transition-colors hover:bg-[#f2f4f7] hover:text-[#111827]"
>
×
</button>
</div>
))}
</div>
)
}
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 (
<div
aria-hidden={!expanded}
className={`grid transition-[grid-template-rows,opacity,transform,margin] duration-300 ease-out motion-reduce:transition-none ${
expanded
? 'mt-0 grid-rows-[1fr] translate-y-0 opacity-100'
: '-mt-6 grid-rows-[0fr] -translate-y-2 opacity-0 pointer-events-none'
}`}
>
<div className="min-h-0 overflow-hidden">
<section className="border border-[#dfe6f0] bg-white p-4 shadow-[0_12px_30px_rgba(15,23,42,0.05)]">
<div className="flex items-center justify-between gap-3">
<h2 className="text-[15px] font-bold text-[#111827]">作品上传</h2>
<span className="text-[12px] font-semibold text-[#667085]">
{completedOffset + completedCount}/{totalRequiredCount}
</span>
</div>
{!enabled ? (
<div className="mt-3 border border-dashed border-[#cfd8e6] bg-[#f8faff] px-3 py-4 text-center text-[13px] font-semibold text-[#667085]">
由被考核人通过钉钉提交作品
</div>
) : (
<>
{existingItems.length > 0 && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{existingItems.map((item: any, index: number) => (
<div key={item.id} className="border border-[#d9e7ff] bg-[#f8fbff] px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="break-words text-[13px] font-bold text-[#111827]">
{item.title || `案例 ${index + 1}`}
</div>
{item.description && (
<div className="mt-1 line-clamp-2 break-words text-[11px] leading-4 text-[#667085]">
{item.description}
</div>
)}
</div>
<span className="shrink-0 text-[11px] font-bold text-[#087a3c]">已上传</span>
</div>
</div>
))}
</div>
)}
{visibleCases.length > 0 ? (
<div className="mt-3 grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleCases.map((draft, index) => (
<VisualCaseDraftEditor
key={draft.id}
index={completedOffset + index}
draft={draft}
disabled={!expanded}
onChange={(next) => updateCase(index, next)}
/>
))}
</div>
) : (
<div className="mt-3 border border-[#b9ead0] bg-[#f0fff6] px-3 py-3 text-[13px] font-semibold text-[#087a3c]">
所有作品均已上传,可以保存并发布草稿。
</div>
)}
</>
)}
</section>
</div>
</div>
)
}
function VisualCaseDraftEditor({
index,
draft,
disabled,
onChange,
}: {
index: number
draft: VisualCaseDraft
disabled: boolean
onChange: (draft: VisualCaseDraft) => void
}) {
const [message, setMessage] = useState<string | null>(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 (
<div className="border border-[#e5eaf2] bg-[#fbfcfe] p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[13px] font-bold text-[#111827]">案例 {index + 1}</span>
<span className={`text-[11px] font-semibold ${draft.files.length > 0 ? 'text-[#0050cb]' : 'text-[#98a2b3]'}`}>
{draft.files.length > 1 ? `${draft.files.length} 张切片` : draft.files.length === 1 ? '1 张图片' : '待上传'}
</span>
</div>
<div className="mt-2.5 grid gap-2">
<input
value={draft.title}
onChange={(event) => 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]"
/>
<textarea
value={draft.description}
onChange={(event) => onChange({ ...draft, description: event.target.value })}
placeholder="案例说明(可选)"
aria-label={`案例 ${index + 1} 说明`}
disabled={disabled}
rows={2}
className="resize-none border border-[#d9e0ea] bg-white px-2.5 py-2 text-[13px] outline-none transition-colors focus:border-[#0050cb] disabled:bg-[#f3f5f8]"
/>
<PerformanceSurveyImageDropzone
files={draft.files}
disabled={disabled}
ariaLabel={`为案例 ${index + 1} 添加图片`}
onFiles={addFiles}
/>
</div>
<div className="mt-2.5">
<PerformanceSurveySelectedImageFiles
files={draft.files}
onRemove={(fileIndex) => {
setMessage(null)
onChange({ ...draft, files: draft.files.filter((_, indexToKeep) => indexToKeep !== fileIndex) })
}}
/>
</div>
{message && <div className="mt-2 text-[12px] font-semibold text-[#d92d20]">{message}</div>}
</div>
)
}
function EmployeePicker({
title,
purpose,
positionGroupCode,
assessmentMonth,
selected,
excludedIds = [],
onChange,
onAddEmployee,
onRemoveEmployee,
}: {
title: string
purpose?: 'target' | 'evaluator'
positionGroupCode?: string
assessmentMonth?: string
selected: Employee[]
excludedIds?: string[]
onChange: (employees: Employee[]) => void
onAddEmployee?: (employee: Employee) => void
onRemoveEmployee?: (id: string) => void
}) {
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState<Employee[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const pickerRef = useRef<HTMLDivElement | null>(null)
const cacheRef = useRef<Map<string, Employee[]>>(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'}:${positionGroupCode || 'all'}:${assessmentMonth || ''}:${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 } : {}),
...(positionGroupCode ? { positionGroupCode } : {}),
...(assessmentMonth ? { month: assessmentMonth } : {}),
})
const data = await apiRequest<Employee[]>(`/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, positionGroupCode, assessmentMonth])
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), ...excludedIds])
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 (
<div ref={pickerRef}>
<label className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">{title}</span>
<input
value={keyword}
onFocus={() => {
if (keyword.trim()) setOpen(true)
}}
onChange={(event) => {
setKeyword(event.target.value)
if (event.target.value.trim()) setOpen(true)
}}
placeholder="搜索人员"
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
className="h-10 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
/>
</label>
{open && keyword && (
<div className="mt-2 max-h-56 overflow-y-auto border border-[#eeeeee] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)]">
{loading ? (
<div className="px-3 py-2 text-sm text-[#5f5e5e]">搜索中...</div>
) : error ? (
<div className="px-3 py-2 text-sm text-red-600">{error}</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-[#5f5e5e]">无匹配人员</div>
) : (
items.map((employee) => (
<button
key={employee.id}
type="button"
disabled={selectedIds.has(employee.id)}
onClick={() => selectEmployee(employee)}
className="w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-[#f9f9f9] disabled:opacity-50"
>
<div className="font-semibold text-[#1a1c1c]">{employee.displayName}</div>
<div className="mt-0.5 truncate text-xs text-[#5f5e5e]">
{[
employee.departmentName,
employee.positionTitle,
employee.positionLevel || employee.levelType,
].filter(Boolean).join(' · ')}
</div>
</button>
))
)}
</div>
)}
<SelectedChips
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
onRemove={(id) => {
if (onRemoveEmployee) {
onRemoveEmployee(id)
} else {
onChange(selected.filter((employee) => employee.id !== id))
}
}}
/>
</div>
)
}
function DepartmentPicker({
departments,
selected,
excludedEmployeeIds = [],
onChange,
onDepartmentEmployeesChange,
}: {
departments: DepartmentOption[]
selected: string[]
excludedEmployeeIds?: string[]
onChange: (items: string[]) => void
onDepartmentEmployeesChange?: (value: string, checked: boolean, employees: Employee[]) => void
}) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [keyword, setKeyword] = useState('')
const [loadingValue, setLoadingValue] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const employeeCacheRef = useRef<Map<string, Employee[]>>(new Map())
const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase()
const byId = new Map<string, DepartmentOption>()
const children = new Map<string, DepartmentOption[]>()
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<string, DepartmentOption>()
departments.forEach((department) => {
if (department.value) values.set(department.value, department)
})
const matchedIds = new Set<string>()
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 excludedIds = new Set(excludedEmployeeIds)
const employees = (await apiRequest<Employee[]>(`/employees?${params.toString()}`))
.filter((employee) => !excludedIds.has(employee.id))
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 (
<div key={department.id}>
<div
className="flex min-h-11 items-center gap-1 px-1 py-1 text-sm transition-colors hover:bg-[#f9f9f9]"
style={{ paddingLeft: `${8 + depth * 18}px` }}
>
<button
type="button"
onClick={() => hasChildren && toggle(department.id)}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded border text-sm ${
hasChildren
? 'border-[#e2e2e2] text-[#5f5e5e] hover:bg-[#f4f3f3]'
: 'border-transparent text-transparent'
}`}
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
disabled={!hasChildren}
>
{hasChildren ? (isExpanded ? '-' : '+') : ''}
</button>
{department.selectable !== false && department.value ? (
<label className="flex min-h-9 min-w-0 flex-1 cursor-pointer items-center justify-between gap-3 rounded px-2">
<span className="truncate font-semibold text-[#1a1c1c]">{department.name}</span>
<input
type="checkbox"
checked={selected.includes(department.value)}
disabled={loadingValue === department.value}
onChange={(event) => {
void toggleValue(department.value!, event.target.checked)
}}
className="h-5 w-5 shrink-0"
aria-label={`选择${department.name}`}
/>
</label>
) : (
<div className="min-h-9 min-w-0 flex-1 px-2 py-2">
<div className="truncate font-semibold text-[#1a1c1c]">{department.name}</div>
</div>
)}
{department.value && loadingValue === department.value && (
<span className="text-xs text-[#5f5e5e]">加载中</span>
)}
</div>
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
</div>
)
}
if (departments.length === 0) return null
return (
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">部门</div>
{selected.length > 0 && (
<button
type="button"
onClick={() => {
selected.forEach((value) => onDepartmentEmployeesChange?.(value, false, []))
onChange([])
setError(null)
}}
className="text-xs font-semibold text-[#5f5e5e] hover:text-[#1a1c1c]"
>
清空
</button>
)}
</div>
<input
value={keyword}
onChange={(event) => 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]"
/>
<div className="max-h-64 overflow-y-auto border border-[#eeeeee] bg-white p-1.5">
{roots.map((department) => renderNode(department))}
</div>
{error && <div className="mt-2 text-xs font-semibold text-[#93000a]">{error}</div>}
{selectedLabels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selectedLabels.map((department) => (
<button
key={department.value || department.id}
type="button"
onClick={() => {
if (department.value) void toggleValue(department.value, false)
}}
className="border border-[#e2e2e2] bg-white px-2.5 py-1 text-xs font-semibold text-[#1a1c1c] hover:bg-[#f9f9f9]"
>
{department.fullPath || department.departmentPath || (
department.parentName && department.parentName !== '全部部门'
? `${department.parentName} / ${department.name}`
: department.name
)} ×
</button>
))}
</div>
)}
</div>
)
}
function AdminActions({
onMessage,
}: {
onMessage: (message: string | null) => void
}) {
const [busy, setBusy] = useState<string | null>(null)
const [preview, setPreview] = useState<any | null>(null)
const [error, setError] = useState<string | null>(null)
const run = async (key: string, path: string, success: string) => {
setBusy(key)
setError(null)
onMessage(null)
try {
const data = await apiRequest<any>(path, { method: 'POST', body: JSON.stringify({}) })
onMessage(`${success}${data?.upserted != null ? `:${data.upserted} 人` : ''}`)
if (key === 'sync') setPreview(null)
} catch (error) {
setError(error instanceof Error ? error.message : '操作失败')
} finally {
setBusy(null)
}
}
const previewSync = async () => {
setBusy('preview')
setError(null)
onMessage(null)
try {
setPreview(await apiRequest<any>('/admin/sync-employees/preview', {
method: 'POST',
body: JSON.stringify({ limit: 3000 }),
}))
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : '人员同步影响检查失败')
} finally {
setBusy(null)
}
}
return (
<section className="border border-[#e2e2e2] bg-white px-4 py-4" aria-labelledby="employee-sync-title">
<h3 id="employee-sync-title" className="text-[16px] font-bold text-[#1a1c1c]">数据维护</h3>
<p className="mt-1 text-[12px] font-medium leading-5 text-[#727687]">
先只读检查变更范围,确认后才会更新本地人员档案;历史调研不会被修改。
</p>
{error && (
<div role="alert" className="mt-3 border border-[#ffdad6] bg-[#fff6f4] px-3 py-2 text-[12px] font-semibold leading-5 text-[#93000a]">
{error}
</div>
)}
{preview && (
<section aria-label="人员同步影响预览" className="mt-4 border border-[#c2c6d8]">
<div className="border-b border-[#e2e2e2] bg-[#f7f7f7] px-3 py-3">
<div className="text-[13px] font-bold text-[#1a1c1c]">同步影响预览</div>
<div className="mt-1 text-[11px] leading-5 text-[#5f5e5e]">
数据来源:{preview.source?.name || '上游花名册'}
{preview.last?.latestLocalProfileSyncedAt
? ` · 最近一条本地档案更新于 ${new Date(preview.last.latestLocalProfileSyncedAt).toLocaleString('zh-CN')}`
: ' · 暂无可用的历史同步时间'}
</div>
</div>
<dl className="grid grid-cols-2 divide-x divide-y divide-[#eeeeee] sm:grid-cols-4">
{[
['预计新增', preview.counts?.created ?? 0],
['预计更新', preview.counts?.updated ?? 0],
['无需变化', preview.counts?.unchanged ?? 0],
['资料兜底', preview.counts?.fallback ?? 0],
].map(([label, value]) => (
<div key={String(label)} className="px-3 py-3">
<dt className="text-[11px] font-semibold text-[#727687]">{label}</dt>
<dd className="mt-1 text-[18px] font-bold text-[#1a1c1c]">{String(value)} 人</dd>
</div>
))}
</dl>
<div className="px-3 py-3 text-[11px] leading-5 text-[#5f5e5e]">
<p>
本次检查 {preview.now?.scannedAccounts ?? 0}/{preview.now?.totalAccounts ?? 0} 个素材库账号,
正式同步预计写入 {preview.counts?.plannedProfileWrites ?? 0} 份人员档案。
</p>
{preview.counts?.fallback > 0 && (
<p className="mt-1 font-semibold text-[#7a4b00]">
有 {preview.counts.fallback} 人未匹配上游花名册,将暂用素材库账号资料;建议同步后到“人员分配”确认。
</p>
)}
<p className="mt-1">当前规则不会根据“上游未返回”自动停用人员,因此停用人数不做推测。</p>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-[#e2e2e2] px-3 py-3">
<button
type="button"
disabled={busy !== null}
onClick={() => setPreview(null)}
className="min-h-11 rounded border border-[#c9c9c9] px-3 text-[12px] font-bold text-[#1a1c1c] disabled:opacity-50"
>
取消
</button>
<button
type="button"
disabled={busy !== null}
onClick={() => void run('sync', '/admin/sync-employees', '人员档案已同步')}
className="min-h-11 rounded bg-[#1a1c1c] px-4 text-[12px] font-bold text-white disabled:opacity-50"
>
{busy === 'sync' ? '正在同步...' : '确认正式同步'}
</button>
</div>
</section>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<button
disabled={busy !== null}
onClick={() => void previewSync()}
className="min-h-11 rounded border border-[#c9c9c9] px-3 py-2 text-sm font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7] disabled:opacity-50"
>
{busy === 'preview' ? '检查中...' : preview ? '重新检查影响' : '检查同步影响'}
</button>
<a
href="/api/performance-survey/admin/export"
className="flex min-h-11 items-center justify-center rounded border border-[#c9c9c9] px-3 py-2 text-center text-sm font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7]"
>
导出数据
</a>
</div>
</section>
)
}
function getNotificationEventLabel(eventType?: string | null) {
const map: Record<string, string> = {
'survey.assignment.created': '待评价',
'survey.assignment.due_reminder': '截止提醒',
'survey.creator.completed': '完成提醒',
'survey.result.available': '结果可看',
}
return map[String(eventType || '')] || '系统消息'
}
function NotificationList({
state,
statusState,
}: {
state: ApiState<any[]> & { refresh: () => Promise<void> }
statusState: ApiState<any> & { refresh: () => Promise<void> }
}) {
const [busyId, setBusyId] = useState<string | null>(null)
const [showAll, setShowAll] = useState(false)
const workNotice = statusState.data?.notificationChannel || statusState.data?.workNotice
const counts = statusState.data?.counts || {}
const records = state.data || []
const visibleRecords = showAll ? records : 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 (
<section className="border border-[#e2e2e2] bg-white px-4 py-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-[16px] font-bold text-[#1a1c1c]">钉钉消息</h3>
<div className="mt-1 text-xs font-medium text-[#727687]">{total} 条 · {sentCount} 已发送 · {failedCount} 异常</div>
</div>
<button
onClick={async () => {
await Promise.all([state.refresh(), statusState.refresh()])
}}
className="min-h-11 rounded px-3 py-1.5 text-sm font-bold text-[#0050cb] hover:bg-[#f4f7ff]"
>
刷新
</button>
</div>
{workNotice && (
<div className={`mt-3 inline-flex rounded-full px-3 py-1 text-xs font-semibold ${
workNotice.ready
? 'bg-[#ecfdf3] text-[#039855]'
: 'bg-[#fff7ed] text-[#b45309]'
}`}>
{workNotice.ready
? workNotice.channel === 'robot_oto' ? '机器人单聊已接入' : '工作通知已接入'
: workNotice.channel === 'robot_oto' ? '机器人单聊未接入' : '工作通知未接入'}
</div>
)}
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
{state.loading && <div role="status" aria-live="polite" className="text-sm text-[#727687]">加载中...</div>}
{state.error && <div role="alert" className="text-sm font-semibold text-[#93000a]">{state.error}</div>}
{records.length === 0 && !state.loading && <div className="py-3 text-sm text-[#727687]">暂无消息记录</div>}
{visibleRecords.map((item) => {
const isRetryable = ['retrying', 'exhausted', 'skipped'].includes(item.status)
return (
<div key={item.id} className="py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-bold text-[#1a1c1c]">{item.title}</div>
<div className="mt-1 truncate text-xs text-[#727687]">
{item.recipientName || '收件人缺失'} · {getNotificationEventLabel(item.eventType)}
</div>
{item.lastError && (
<details className="mt-1 hidden text-xs text-[#727687] sm:block">
<summary className="cursor-pointer list-none font-semibold text-[#93000a]">详情</summary>
<div className="mt-1 break-words text-[#93000a]">{item.lastError}</div>
</details>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<StatusBadge status={item.status} />
{isRetryable && (
<button
disabled={busyId === item.id}
onClick={() => retry(item.id)}
className="min-h-11 rounded border border-[#c9c9c9] px-3 py-1 text-xs font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7] disabled:opacity-50"
>
补发
</button>
)}
</div>
</div>
</div>
)
})}
</div>
{records.length > 6 && (
<button
type="button"
onClick={() => setShowAll((current) => !current)}
className="mt-3 min-h-11 w-full rounded border border-[#c9c9c9] px-3 text-[12px] font-bold text-[#1a1c1c] hover:border-[#1a1c1c] hover:bg-[#f7f7f7]"
>
{showAll ? '收起消息记录' : `查看全部 ${records.length} 条消息`}
</button>
)}
</section>
)
}
function SurveySubmit({ surveyId, session, canCreateSurvey }: { surveyId: string; session: any; canCreateSurvey: boolean }) {
const router = useRouter()
const searchParams = useSearchParams()
const targetIdParam = searchParams.get('targetId')
const detailPath = targetIdParam
? `/surveys/${surveyId}?targetId=${encodeURIComponent(targetIdParam)}`
: `/surveys/${surveyId}`
const detail = useApi<any>(detailPath, [detailPath])
const [targetId, setTargetId] = useState(targetIdParam || '')
const [scores, setScores] = useState<Record<string, number>>({})
const [comment, setComment] = useState('')
const [message, setMessage] = useState<string | null>(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 <SurveyOpeningScreen />
if (detail.error) {
return (
<MainPanel>
<SurveyUnavailablePanel title="问卷加载失败" message={detail.error} onRetry={detail.refresh} />
</MainPanel>
)
}
if (!detail.data) return null
if (targets.length === 0) {
return <MainPanel><SurveyUnavailablePanel message="当前账号没有可填写或可提交作品的任务" /></MainPanel>
}
const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId)
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
const submittedScores = activeTarget?.currentAssignment?.response?.scores
const progress = calculatePerformanceSurveyResponseProgress({
questions,
draftScores: scores,
submittedScores: submittedScores && typeof submittedScores === 'object' ? submittedScores : {},
submitted,
})
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 (
<VisualCaseSurvey
surveyId={surveyId}
detail={detail}
template={template}
targets={targets}
activeTarget={activeTarget}
activeTargetId={activeTargetId}
setTargetId={changeTarget}
viewerName={viewerName}
viewerAvatar={viewerAvatar}
dueLabel={dueLabel}
surveyStatusLabel={surveyStatusLabel}
/>
)
}
return (
<main className="min-h-[100dvh] bg-white text-[#111827]">
<MobileSurveyRangeStyles />
<div className="mx-auto flex max-w-[430px] flex-col px-4 pb-[calc(92px+env(safe-area-inset-bottom))] pt-3 sm:my-6 sm:rounded-xl sm:border sm:border-[#e5eaf2] sm:bg-white sm:p-5 sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
<section className="mb-3 overflow-hidden rounded-xl border border-[#dfe6f0] bg-white px-4 py-3.5 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
<div className="flex items-center justify-between gap-3">
<span className="text-[13px] font-semibold text-[#1677ff]">考核调研问卷</span>
<span className="shrink-0 rounded-full bg-[#eef6ff] px-3 py-1 text-[12px] font-semibold text-[#1677ff]">
{surveyStatusLabel}
</span>
</div>
<h1 className="mt-2.5 break-words text-[1.22rem] font-semibold leading-[1.22] tracking-[-0.01em] text-[#101828]">
{detail.data.title}
</h1>
<div className="mt-2.5 space-y-1 text-[12px] font-medium leading-5 text-[#667085]">
<p className="break-words text-[#4b5565]">评价对象:{activeTarget?.targetName || '待选择'}</p>
<p>{dueLabel}</p>
</div>
<div className="mt-3 rounded-2xl bg-[#f6f8fb] px-3.5 py-2.5">
<div className="flex items-center justify-between text-[12px] font-semibold text-[#667085]">
<span>填写进度</span>
<span className="text-[#101828]">{progress.ratedCount}/{progress.totalCount} · {progress.totalScore} 分</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
<div
className="h-full rounded-full bg-[#1677ff] transition-all"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
</section>
{targets.length > 1 && (
<label className="mb-4 grid gap-2 rounded-xl border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<span className="font-semibold text-[#4b5565]">切换评价对象</span>
<select
value={activeTargetId}
onChange={(event) => changeTarget(event.target.value)}
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
>
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
))}
</select>
</label>
)}
{submitted ? (
<SubmittedResponseDetail
questions={questions}
template={template}
response={activeTarget?.currentAssignment?.response}
submittedAt={activeTarget?.currentAssignment?.submittedAt}
/>
) : detail.data.status !== 'open' ? (
<SurveyUnavailablePanel message="该调研未开放或已关闭" />
) : (
<div className="space-y-3">
{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 (
<section key={question.id} className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
<span
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
style={{ backgroundColor: accentColor }}
>
{index + 1}
</span>
<div className="min-w-0">
<div className="text-[11px] font-semibold text-[#98a2b3]">问题 {index + 1}</div>
<h2 className="mt-1 break-words text-[17px] font-semibold leading-6 text-[#111827]">{question.title}</h2>
{question.description && (
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
)}
</div>
<div className="rounded-xl bg-[#f6f8fb] px-2.5 py-1.5 text-right">
<span className="text-[15px] font-semibold leading-none" style={{ color: accentColor }}>{isRated ? value : '未评分'}</span>
{isRated && <span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>}
</div>
</div>
<input
type="range"
min={0}
max={maxScore}
value={value}
data-rated={isRated ? 'true' : 'false'}
aria-label={`${question.title}评分,满分 ${maxScore} 分`}
onPointerDown={() => {
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}
/>
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
<span>0</span>
<span className="text-center">{Math.round(maxScore * 0.25)}</span>
<span className="text-center">{Math.round(maxScore * 0.5)}</span>
<span className="text-center">{Math.round(maxScore * 0.75)}</span>
<span className="text-right">{maxScore}</span>
</div>
</section>
)
})}
<section className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<label className="grid gap-3">
<span className="text-[15px] font-semibold text-[#111827]">补充评价</span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value.slice(0, 300))}
rows={4}
placeholder="可以从优点、待改进或建议等方面补充说明..."
className="min-h-[92px] resize-none rounded-[9px] border border-[#d9e0ea] px-3 py-2.5 text-[14px] leading-6 text-[#111827] outline-none transition placeholder:text-[#c0c7d2] focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
/>
<span className="text-right text-[11px] font-medium text-[#9aa3b2]">{comment.length} / 300</span>
</label>
</section>
{message && <div className="rounded-[10px] bg-[#eef6ff] px-3 py-2 text-sm font-medium text-[#1677ff]">{message}</div>}
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-1">
<div className="mx-auto grid max-w-[390px] grid-cols-[minmax(0,0.42fr)_minmax(0,1fr)] gap-3">
<div className="relative min-w-0">
{accountMenuOpen && (
<div className="absolute bottom-[calc(100%+10px)] left-0 z-30 w-[196px] overflow-hidden rounded-lg border border-[#dbe5f2] bg-white shadow-[0_18px_46px_rgba(15,23,42,0.16)]">
<button
type="button"
onClick={() => goToSurveyTab('personal')}
className="block w-full px-4 py-3 text-left text-[14px] font-semibold text-[#101828] active:bg-[#f3f7fc]"
>
个人看板
</button>
<button
type="button"
onClick={() => goToSurveyTab('public')}
className="block w-full border-t border-[#edf2f7] px-4 py-3 text-left text-[14px] font-semibold text-[#101828] active:bg-[#f3f7fc]"
>
公共看板
</button>
{canCreateSurvey && (
<button
type="button"
onClick={() => goToSurveyTab('manage')}
className="block w-full border-t border-[#edf2f7] px-4 py-3 text-left text-[14px] font-semibold text-[#101828] active:bg-[#f3f7fc]"
>
发起调研
</button>
)}
</div>
)}
<button
type="button"
aria-label="打开账户菜单"
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
onClick={() => setAccountMenuOpen((open) => !open)}
className="flex min-h-[52px] w-full min-w-0 items-center gap-2.5 rounded-[16px] border border-[#dbe5f2] bg-[#f8fbff] px-2.5 text-left shadow-[0_8px_18px_rgba(15,23,42,0.06)] transition active:scale-[0.99]"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#eaf3ff] text-[13px] font-semibold text-[#1677ff]">
{viewerAvatar ? (
<Image
src={viewerAvatar}
alt={viewerName}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getSurveyInitials(viewerName)
)}
</span>
<span className="min-w-0 flex-1 truncate text-[14px] font-semibold leading-5 text-[#101828]">{viewerName}</span>
</button>
</div>
<button
disabled={submitting || !activeTargetId || questions.length === 0 || progress.ratedCount !== questions.length}
onClick={submit}
className="min-h-[52px] w-full rounded-[16px] bg-[#1677ff] px-4 py-2.5 text-[17px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.28)] transition active:scale-[0.99] disabled:bg-[#9ec9ff] disabled:shadow-none"
>
{submitting ? '提交中...' : '提交评价'}
</button>
</div>
</div>
</div>
)}
</div>
</main>
)
}
function SubmittedResponseDetail({
questions,
template,
response,
submittedAt,
}: {
questions: Template['questions']
template?: Template
response?: any
submittedAt?: string | Date | null
}) {
const responseScores = response?.scores && typeof response.scores === 'object'
? response.scores as Record<string, unknown>
: {}
const scoreOf = (questionId: string) => {
const value = Number(responseScores[questionId])
return Number.isFinite(value) ? value : 0
}
const formatScore = (value: unknown) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '-'
return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1)
}
const submittedLabel = submittedAt || response?.createdAt
? formatDate(submittedAt || response?.createdAt)
: '已提交'
return (
<div className="space-y-3">
<section className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[13px] font-semibold text-[#16a34a]">已提交</div>
<div className="mt-1 text-[12px] font-medium text-[#667085]">{submittedLabel}</div>
</div>
<Link
href="/performance-survey?tab=personal&workspace=submitted"
className="shrink-0 rounded-full border border-[#dbe5f2] bg-[#f8fbff] px-3 py-1.5 text-[12px] font-semibold text-[#101828]"
>
返回个人看板
</Link>
</div>
<div className="mt-4 grid grid-cols-1 gap-2">
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
<div className="text-[11px] font-semibold text-[#667085]">得分</div>
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.totalScore)}</div>
</div>
</div>
</section>
{questions.map((question, index) => {
const maxScore = getQuestionMaxScore(template, question)
const value = scoreOf(question.id)
const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100))
const accentColor = getQuestionAccentColor(index)
return (
<section key={question.id} className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
<span
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
style={{ backgroundColor: accentColor }}
>
{index + 1}
</span>
<div className="min-w-0">
<div className="text-[11px] font-semibold text-[#98a2b3]">问题 {index + 1}</div>
<h2 className="mt-1 break-words text-[17px] font-semibold leading-6 text-[#111827]">{question.title}</h2>
{question.description && (
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
)}
</div>
<div className="rounded-xl bg-[#f6f8fb] px-2.5 py-1.5 text-right">
<span className="text-[18px] font-semibold leading-none" style={{ color: accentColor }}>{formatScore(value)}</span>
<span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>
</div>
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
<div
className="h-full rounded-full"
style={{ width: `${scorePercent}%`, backgroundColor: accentColor }}
/>
</div>
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
<span>0</span>
<span className="text-center">{Math.round(maxScore * 0.25)}</span>
<span className="text-center">{Math.round(maxScore * 0.5)}</span>
<span className="text-center">{Math.round(maxScore * 0.75)}</span>
<span className="text-right">{maxScore}</span>
</div>
</section>
)
})}
{response?.comment && (
<section className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="text-[15px] font-semibold text-[#111827]">补充评价</div>
<p className="mt-3 whitespace-pre-wrap text-[14px] leading-6 text-[#4b5565]">{response.comment}</p>
</section>
)}
</div>
)
}
function VisualCaseSurvey({
surveyId,
detail,
template,
targets,
activeTarget,
activeTargetId,
setTargetId,
viewerName,
viewerAvatar,
dueLabel,
surveyStatusLabel,
}: {
surveyId: string
detail: ApiState<any> & { refresh: () => Promise<void> }
template: Template | undefined
targets: any[]
activeTarget: any
activeTargetId: string
setTargetId: (id: string) => void
viewerName: string
viewerAvatar: string
dueLabel: string
surveyStatusLabel: string
}) {
const requiredCount = Number(template?.requiredWorkItemCount || 3)
const workItems = activeTarget?.workItems || []
const canUpload = Boolean(activeTarget?.canUploadWorkItems && ['draft', 'open'].includes(detail.data?.status))
const canCreateWorkItem = activeTarget?.canCreateWorkItem === true
|| (activeTarget?.canCreateWorkItem == null && canUpload && workItems.length < requiredCount)
const showUploadProgress = canUpload
&& (detail.data?.status === 'draft' || workItems.length < requiredCount)
const workflowRole = String(activeTarget?.currentAssignment?.workflowRole || '')
const isSelfWorkflow = workflowRole === 'self' || workflowRole === 'self_visual_final'
const canScore = Boolean(
activeTarget?.currentAssignment
&& detail.data?.status === 'open'
&& detail.data?.canSubmit !== false,
)
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
const [publishing, setPublishing] = useState(false)
const [publishError, setPublishError] = useState<string | null>(null)
const [scoreDrafts, setScoreDrafts] = useState<Record<string, { scores: Record<string, number>; comment: string }>>({})
const [submittingAllScores, setSubmittingAllScores] = useState(false)
const [scoreSubmitMessage, setScoreSubmitMessage] = useState<string | null>(null)
const draftReadyToPublish = detail.data?.status === 'draft'
&& Boolean(detail.data?.canManage)
&& workItems.length >= requiredCount
const questions = template?.questions || []
const scoreProgress = calculatePerformanceSurveyWorkItemProgress({
workItems,
questions,
scoreDrafts,
assignmentId: activeTarget?.currentAssignment?.id,
})
const completedScores = scoreProgress.completedCount
const unscoredWorkItemIdSet = new Set(scoreProgress.unscoredWorkItemIds)
const unscoredWorkItems = workItems.filter((item: any) => unscoredWorkItemIdSet.has(item.id))
const hasUnsubmittedCompletedDraft = scoreProgress.completedCount > scoreProgress.persistedCount
const allUnscoredWorkItemsRated = unscoredWorkItems.length > 0
&& unscoredWorkItems.every((item: any) => questions.every((question) => (
Number.isFinite(scoreDrafts[item.id]?.scores?.[question.id])
)))
const canSubmitAllScores = canScore
&& workItems.length === requiredCount
&& unscoredWorkItems.length > 0
const updateScoreDraft = (workItemId: string, next: { scores: Record<string, number>; comment: string }) => {
setScoreDrafts((current) => ({ ...current, [workItemId]: next }))
setScoreSubmitMessage(null)
}
const publishDraft = async () => {
if (!draftReadyToPublish || publishing) return
setPublishing(true)
setPublishError(null)
try {
await apiRequest(`/surveys/${surveyId}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'open' }),
})
await detail.refresh()
} catch (error) {
setPublishError(error instanceof Error ? error.message : '发布失败')
} finally {
setPublishing(false)
}
}
const submitAllScores = async () => {
if (!canSubmitAllScores || submittingAllScores) return
if (!allUnscoredWorkItemsRated) {
setScoreSubmitMessage('请完成全部案例的每一项评分后再统一提交')
return
}
setSubmittingAllScores(true)
setScoreSubmitMessage(null)
try {
await apiRequest(`/surveys/${surveyId}/work-item-scores`, {
method: 'POST',
body: JSON.stringify({
items: unscoredWorkItems.map((item: any) => ({
workItemId: item.id,
scores: scoreDrafts[item.id].scores,
comment: scoreDrafts[item.id].comment,
})),
finalComment: '',
}),
})
await detail.refresh()
setScoreSubmitMessage('全部案例评分已提交')
} catch (error) {
setScoreSubmitMessage(`提交失败:${error instanceof Error ? error.message : '请重试'}`)
} finally {
setSubmittingAllScores(false)
}
}
return (
<main className="min-h-[100dvh] bg-white text-[#111827]">
<MobileSurveyRangeStyles />
<div className="mx-auto flex max-w-[430px] flex-col px-4 pb-[calc(92px+env(safe-area-inset-bottom))] pt-3 sm:my-6 sm:rounded-xl sm:border sm:border-[#e5eaf2] sm:bg-white sm:p-5 sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
<section className="mb-3 rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
<div className="flex items-center justify-between gap-3">
<span className="text-[13px] font-semibold text-[#1677ff]">{template?.shortName || '视觉评分'}</span>
<span className="shrink-0 rounded-full bg-[#eef6ff] px-3 py-1 text-[12px] font-semibold text-[#1677ff]">
{surveyStatusLabel}
</span>
</div>
<h1 className="mt-2.5 break-words text-[1.22rem] font-semibold leading-[1.22] tracking-[-0.01em] text-[#101828]">
{detail.data?.title}
</h1>
<div className="mt-2.5 space-y-1 text-[12px] font-medium leading-5 text-[#667085]">
<p className="break-words text-[#4b5565]">被考核人:{activeTarget?.targetName || '待选择'}</p>
<p>{dueLabel}</p>
</div>
<div className="mt-3 rounded-2xl bg-[#f6f8fb] px-3.5 py-2.5">
<div className="flex items-center justify-between text-[12px] font-semibold text-[#667085]">
<span>{showUploadProgress ? '案例提交' : '评分完成度'}</span>
<span className="text-[#101828]">
{showUploadProgress
? `${workItems.length}/${requiredCount}`
: `${completedScores}/${requiredCount} · ${formatNullableScore(scoreProgress.totalScore)} 分`}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
<div
className="h-full rounded-full bg-[#1677ff] transition-all"
style={{ width: `${Math.min(100, ((showUploadProgress ? workItems.length : completedScores) / Math.max(1, requiredCount)) * 100)}%` }}
/>
</div>
{!showUploadProgress && hasUnsubmittedCompletedDraft && !submitted && (
<div className="mt-2 text-[11px] font-medium text-[#667085]">已完成的案例评分尚未统一提交</div>
)}
</div>
</section>
{targets.length > 1 && (
<label className="mb-4 grid gap-2 rounded-xl border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<span className="font-semibold text-[#4b5565]">切换对象</span>
<select
value={activeTargetId}
onChange={(event) => setTargetId(event.target.value)}
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
>
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
))}
</select>
</label>
)}
{canCreateWorkItem && (
<WorkItemUploadForm
key={activeTargetId}
surveyId={surveyId}
targetId={activeTargetId}
onUploaded={detail.refresh}
/>
)}
{detail.data?.status === 'draft' && (
<section className="mb-3 rounded-xl border border-[#dfe6f0] bg-[#f8faff] px-4 py-4">
<div className="text-[14px] font-semibold text-[#101828]">
{draftReadyToPublish
? '作品已完整,确认发布后才会通知评分人'
: `当前为未发布草稿,还需上传 ${Math.max(0, requiredCount - workItems.length)} 个案例`}
</div>
{publishError && <div className="mt-2 text-sm font-medium text-[#d92d20]">{publishError}</div>}
{draftReadyToPublish && (
<button
type="button"
disabled={publishing}
onClick={publishDraft}
className="mt-3 w-full rounded-[12px] bg-[#1677ff] px-4 py-2.5 text-sm font-semibold text-white disabled:bg-[#9ec9ff]"
>
{publishing ? '发布中...' : '确认发布调研'}
</button>
)}
</section>
)}
{detail.data?.status === 'open' && activeTarget?.currentAssignment && !submitted && (
<section className={`mb-3 rounded-xl border px-4 py-3 text-[13px] font-medium leading-5 ${
canScore
? 'border-[#d9e7ff] bg-[#f8fbff] text-[#3f4b5e]'
: 'border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]'
}`}>
{isSelfWorkflow
? '当前为被考核人自评阶段。请完成全部案例评分并统一提交;提交后系统才会通知并开放主管与元泽评分。'
: canScore
? '被考核人已完成自评,现在可以评分。最终考核分以元泽评分为准。'
: '等待被考核人完成自评,当前暂不可评分;自评提交后系统会自动开放并通知你。'}
</section>
)}
{workItems.length === 0 && (
['draft', 'open'].includes(detail.data?.status) ? (
<div className="rounded-xl border border-dashed border-[#d9e0ea] bg-white px-4 py-10 text-center text-[15px] font-semibold text-[#667085]">
{canUpload ? '请先上传案例图片' : '等待被考核人提交案例'}
</div>
) : (
<SurveyUnavailablePanel message="该调研未开放或已关闭" />
)
)}
<div className="space-y-3">
{workItems.map((item: any, index: number) => (
<VisualWorkItemCard
key={`${activeTargetId}-${item.id}`}
item={item}
index={index}
template={template}
canScore={canScore && !submitted}
assignmentId={activeTarget?.currentAssignment?.id}
scoreDraft={scoreDrafts[item.id] || { scores: {}, comment: '' }}
onScoreDraftChange={(next) => updateScoreDraft(item.id, next)}
/>
))}
</div>
{canSubmitAllScores && (
<section className="mt-4 rounded-xl border border-[#d9e7ff] bg-[#f8fbff] px-4 py-4">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-semibold text-[#101828]">统一提交全部案例评分</span>
<span className="shrink-0 font-semibold text-[#1677ff]">
{allUnscoredWorkItemsRated ? '评分已完整' : '请完成全部评分'}
</span>
</div>
<p className="mt-1.5 text-[12px] leading-5 text-[#667085]">
将一次性提交 {unscoredWorkItems.length} 个尚未评分的案例。
</p>
{scoreSubmitMessage && (
<div
className={`mt-2 text-sm font-medium ${
scoreSubmitMessage.includes('失败') || scoreSubmitMessage.includes('请完成')
? 'text-[#d92d20]'
: 'text-[#1677ff]'
}`}
aria-live="polite"
>
{scoreSubmitMessage}
</div>
)}
<button
type="button"
disabled={submittingAllScores || !allUnscoredWorkItemsRated}
onClick={submitAllScores}
className="mt-3 w-full rounded-[14px] bg-[#1677ff] px-4 py-3 text-[15px] font-semibold text-white disabled:bg-[#9ec9ff]"
>
{submittingAllScores ? '统一提交中...' : `提交全部案例评分(${unscoredWorkItems.length})`}
</button>
</section>
)}
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-3">
<div className="mx-auto flex max-w-[390px] items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#eaf3ff] text-[13px] font-semibold text-[#1677ff]">
{viewerAvatar ? (
<Image src={viewerAvatar} alt={viewerName} width={40} height={40} unoptimized className="h-full w-full object-cover" />
) : (
getSurveyInitials(viewerName)
)}
</span>
<span className="min-w-0 truncate text-[14px] font-semibold text-[#101828]">{viewerName}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href="/"
target="_top"
className="rounded-[12px] border border-[#dbe5f2] bg-white px-3 py-2.5 text-[13px] font-semibold text-[#101828]"
>
素材库
</Link>
<Link
href="/performance-survey?tab=personal&workspace=pending"
className="rounded-[14px] bg-[#1677ff] px-4 py-3 text-[14px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)]"
>
个人看板
</Link>
</div>
</div>
</div>
</div>
</main>
)
}
function WorkItemUploadForm({
surveyId,
targetId,
onUploaded,
}: {
surveyId: string
targetId: string
onUploaded: () => Promise<void>
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [files, setFiles] = useState<File[]>([])
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [messageTone, setMessageTone] = useState<'progress' | 'success' | 'error'>('progress')
const addFiles = (selectedFiles: File[]) => {
const merged = [...files, ...selectedFiles].filter((file, index, items) => {
const key = `${file.name}-${file.size}-${file.lastModified}`
return items.findIndex((candidate) => `${candidate.name}-${candidate.size}-${candidate.lastModified}` === key) === index
})
const orderedFiles = sortPerformanceSurveyImagesByName(merged, (file) => file.name)
const validationError = validatePerformanceSurveyImageFiles(orderedFiles)
if (validationError) {
setMessageTone('error')
setMessage(validationError)
return
}
setFiles(orderedFiles)
setMessage(null)
}
const submit = async () => {
const validationError = validatePerformanceSurveyImageFiles(files)
if (validationError) {
setMessageTone('error')
setMessage(validationError)
return
}
setBusy(true)
setMessageTone('progress')
setMessage(null)
try {
const processedImages = await uploadPerformanceSurveyImageFiles(files, (label) => {
setMessageTone('progress')
setMessage(label)
})
setMessage('图片处理和上传完成,正在保存作品')
await apiRequest(`/surveys/${surveyId}/work-items`, {
method: 'POST',
body: JSON.stringify({
targetId,
title: title.trim() || '案例作品',
description: description.trim(),
processedImages,
}),
})
setTitle('')
setDescription('')
setFiles([])
await onUploaded()
setMessageTone('success')
setMessage('作品已保存')
} catch (error) {
setMessageTone('error')
setMessage(error instanceof Error ? error.message : '上传失败')
} finally {
setBusy(false)
}
}
return (
<section className="mb-3 rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="text-[15px] font-semibold text-[#111827]">提交案例</div>
<div className="mt-3 grid gap-3">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="案例名称"
className="rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
/>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="案例说明(可选)"
rows={3}
className="resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
/>
<PerformanceSurveyImageDropzone
files={files}
disabled={false}
ariaLabel="添加案例图片"
onFiles={addFiles}
/>
<PerformanceSurveySelectedImageFiles
files={files}
onRemove={(fileIndex) => {
setFiles((current) => current.filter((_, index) => index !== fileIndex))
setMessage(null)
}}
/>
{message && (
<div className={`text-sm font-medium ${
messageTone === 'error'
? 'text-[#d92d20]'
: messageTone === 'success'
? 'text-[#087a3c]'
: 'text-[#1677ff]'
}`}>
{message}
</div>
)}
<button
type="button"
disabled={busy || files.length === 0}
onClick={submit}
className="rounded-[12px] bg-[#1677ff] px-4 py-2.5 text-sm font-semibold text-white disabled:bg-[#9ec9ff]"
>
{busy ? '上传中...' : '保存案例'}
</button>
</div>
</section>
)
}
function VisualWorkItemCard({
item,
index,
template,
canScore,
assignmentId,
scoreDraft,
onScoreDraftChange,
}: {
item: any
index: number
template: Template | undefined
canScore: boolean
assignmentId?: string | null
scoreDraft: { scores: Record<string, number>; comment: string }
onScoreDraftChange: (next: { scores: Record<string, number>; comment: string }) => void
}) {
const existingScore = item.scores?.find((score: any) => score.assignmentId === assignmentId)
const questions = template?.questions || []
const scores = scoreDraft.scores
const comment = scoreDraft.comment
const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length
const allRated = questions.length > 0 && ratedCount === questions.length
const total = questions.reduce((sum, question) => sum + Number(scores[question.id] || 0), 0)
return (
<section className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-[#1677ff]">案例 {index + 1}</div>
<h2 className="mt-1 break-words text-[18px] font-semibold text-[#111827]">{item.title}</h2>
{item.description && <p className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-[#667085]">{item.description}</p>}
</div>
<span className="shrink-0 rounded-xl bg-[#f6f8fb] px-3 py-1.5 text-[15px] font-semibold text-[#101828]">
{existingScore ? `${formatNullableScore(existingScore.totalScore)} 分` : allRated ? `${total} 分` : '待评分'}
</span>
</div>
<ImagePreviewGrid images={item.imageGroups || item.imageUrls || []} variant="long" />
{existingScore ? (
<div className="mt-4 rounded-[14px] bg-[#f6f8fb] px-3 py-3 text-sm font-medium text-[#4b5565]">
已评分:{formatNullableScore(existingScore.totalScore)} 分
{existingScore.comment && <p className="mt-2 whitespace-pre-wrap text-[#667085]">{existingScore.comment}</p>}
</div>
) : canScore ? (
<div className="mt-4 space-y-3">
{questions.map((question, questionIndex) => {
const maxScore = getQuestionMaxScore(template, question)
const isRated = Number.isFinite(scores[question.id])
const value = isRated ? scores[question.id] : 0
const percent = Math.max(0, Math.min(100, (value / maxScore) * 100))
const accentColor = getQuestionAccentColor(questionIndex)
return (
<div key={question.id}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[14px] font-semibold text-[#111827]">{question.title}</div>
{question.description && <div className="mt-0.5 text-[12px] text-[#667085]">{question.description}</div>}
</div>
<div className="text-[14px] font-semibold" style={{ color: accentColor }}>{isRated ? `${value}/${maxScore}` : '未评分'}</div>
</div>
<input
type="range"
min={0}
max={maxScore}
value={value}
data-rated={isRated ? 'true' : 'false'}
aria-label={`${question.title}评分,满分 ${maxScore} 分`}
onPointerDown={() => {
if (!isRated) {
onScoreDraftChange({ scores: { ...scores, [question.id]: 0 }, comment })
}
}}
onKeyDown={(event) => {
if (!isRated && (event.key === 'ArrowLeft' || event.key === 'Home')) {
onScoreDraftChange({ scores: { ...scores, [question.id]: 0 }, comment })
}
}}
onChange={(event) => onScoreDraftChange({
scores: { ...scores, [question.id]: Number(event.target.value) },
comment,
})}
className="survey-score-range mt-2 w-full"
style={{ '--score-percent': `${percent}%`, '--score-color': accentColor } as CSSProperties}
/>
</div>
)
})}
<textarea
value={comment}
onChange={(event) => onScoreDraftChange({
scores,
comment: event.target.value.slice(0, 300),
})}
rows={3}
placeholder="案例评语(可选)"
className="w-full resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
/>
<div className={`rounded-[10px] px-3 py-2 text-[12px] font-medium ${
allRated ? 'bg-[#edf8f1] text-[#087a3c]' : 'bg-[#f6f8fb] text-[#667085]'
}`}>
{allRated ? '本案例评分已完成,等待统一提交' : `还需完成 ${questions.length - ratedCount} 项评分`}
</div>
</div>
) : null}
</section>
)
}
function ProgressiveLongImageGallery({ images }: { images: unknown[] }) {
const groups = useMemo(() => normalizePerformanceSurveyImageGroups(images), [images])
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => new Set())
const [activeGroupIndex, setActiveGroupIndex] = useState<number | null>(null)
const expandScrollPositions = useRef<Map<string, number>>(new Map())
const expandGroup = (groupKey: string) => {
expandScrollPositions.current.set(groupKey, window.scrollY)
setExpandedKeys((current) => new Set(current).add(groupKey))
}
const collapseGroup = (groupKey: string) => {
const scrollTop = expandScrollPositions.current.get(groupKey)
setExpandedKeys((current) => {
const next = new Set(current)
next.delete(groupKey)
return next
})
if (scrollTop == null) return
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
window.scrollTo({ top: scrollTop, behavior: 'auto' })
})
})
}
if (groups.length === 0) return null
return (
<>
<div className="mt-4 space-y-4">
{groups.map((group, groupIndex) => {
const expanded = expandedKeys.has(group.key)
return (
<section
key={group.key}
className={`${expanded ? 'overflow-visible' : 'overflow-hidden'} rounded-[14px] border border-[#e5edf6] bg-white`}
>
{groups.length > 1 && (
<div className="flex items-center justify-between gap-3 border-b border-[#edf1f6] px-3 py-2.5">
<span className="min-w-0 truncate text-[12px] font-semibold text-[#667085]">{group.sourceName}</span>
<span className="shrink-0 text-[11px] text-[#98a2b3]">{group.slices.length} 段</span>
</div>
)}
{!expanded ? (
<div className="relative h-[300px] bg-[#f6f8fb] sm:h-[360px]">
<button
type="button"
onClick={() => setActiveGroupIndex(groupIndex)}
aria-label={`全屏查看${group.sourceName}`}
className="block h-full w-full text-left"
>
<Image
src={group.previewUrl}
alt={`${group.sourceName}预览`}
width={group.previewWidth}
height={group.previewHeight}
unoptimized
{...(groupIndex === 0 ? { priority: true } : { loading: 'lazy' as const })}
className="h-full w-full object-cover object-top"
/>
</button>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white via-white/90 to-transparent" />
<button
type="button"
onClick={() => expandGroup(group.key)}
aria-expanded={false}
className="absolute bottom-4 left-1/2 h-10 -translate-x-1/2 rounded-full bg-[#0050cb] px-5 text-[13px] font-bold text-white shadow-[0_10px_24px_rgba(0,80,203,0.24)] transition-transform hover:scale-[1.02]"
>
加载全部
</button>
</div>
) : (
<div className="bg-[#f6f8fb]">
<div className="sticky top-2 z-10 flex justify-center border-b border-[#e5edf6] bg-white/95 px-3 py-2.5 backdrop-blur">
<button
type="button"
onClick={() => collapseGroup(group.key)}
aria-label={`收起${group.sourceName}全部图片`}
className="h-9 rounded-full border border-[#d9e0ea] bg-white px-5 text-[13px] font-bold text-[#344054] shadow-sm"
>
收起全部
</button>
</div>
{group.slices.map((slice, sliceIndex) => (
<button
key={`${slice.url}-${sliceIndex}`}
type="button"
onClick={() => setActiveGroupIndex(groupIndex)}
aria-label={`全屏查看${group.sourceName}第 ${sliceIndex + 1} 段`}
className="block w-full border-0 bg-transparent p-0 text-left"
>
<Image
src={slice.url}
alt={`${group.sourceName}第 ${sliceIndex + 1} 段`}
width={slice.width}
height={slice.height}
unoptimized
loading="lazy"
className="block h-auto w-full"
/>
</button>
))}
<div className="sticky bottom-3 flex justify-center py-3">
<button
type="button"
onClick={() => collapseGroup(group.key)}
className="h-9 rounded-full border border-[#d9e0ea] bg-white/95 px-5 text-[13px] font-bold text-[#344054] shadow-sm backdrop-blur"
>
收起
</button>
</div>
</div>
)}
</section>
)
})}
</div>
<PerformanceSurveyLongImageViewer
group={activeGroupIndex == null ? null : groups[activeGroupIndex]}
onClose={() => setActiveGroupIndex(null)}
/>
</>
)
}
function PerformanceSurveyLongImageViewer({
group,
onClose,
}: {
group: PerformanceSurveyImageGroupData | null
onClose: () => void
}) {
const [portalReady, setPortalReady] = useState(false)
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => setPortalReady(true), [])
useEffect(() => {
if (!group) 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)
window.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = previousOverflow
previousFocus?.focus()
}
}, [group, onClose])
if (!group || !portalReady) return null
return createPortal((
<div
ref={dialogRef}
className="fixed inset-0 z-[1200] flex flex-col bg-black/[0.92] pt-[env(safe-area-inset-top)] text-white"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={`查看${group.sourceName}`}
tabIndex={-1}
>
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-white/10 px-4" onClick={(event) => event.stopPropagation()}>
<div className="min-w-0 truncate text-sm font-semibold text-white/80">{group.sourceName}</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-[28px] font-light leading-none text-white transition-colors hover:bg-white/20"
aria-label="关闭长图预览"
>
×
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-[calc(20px+env(safe-area-inset-bottom))] sm:px-6" onClick={(event) => event.stopPropagation()}>
<div className="mx-auto max-w-[1200px] bg-white">
{group.slices.map((slice, index) => (
<Image
key={`${slice.url}-${index}`}
src={slice.url}
alt={`${group.sourceName}第 ${index + 1} 段`}
width={slice.width}
height={slice.height}
unoptimized
loading="lazy"
className="block h-auto w-full"
/>
))}
</div>
</div>
</div>
), document.body)
}
function ImagePreviewGrid({
images,
variant = 'default',
}: {
images: unknown[]
variant?: 'default' | 'gallery' | 'review' | 'long'
}) {
if (variant === 'long') return <ProgressiveLongImageGallery images={images} />
const stringImages = images.filter((item): item is string => typeof item === 'string')
return <StandardImagePreviewGrid images={stringImages} variant={variant} />
}
function StandardImagePreviewGrid({
images,
variant = 'default',
}: {
images: string[]
variant?: 'default' | 'gallery' | 'review'
}) {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const displayImages = useMemo(
() => images.map(normalizePerformanceSurveyImageUrl).filter(Boolean),
[images],
)
if (displayImages.length === 0) return null
if (variant === 'review') {
const [firstImage, ...restImages] = displayImages
return (
<>
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => setActiveIndex(0)}
aria-label="查看主要案例图片"
className="block w-full overflow-hidden rounded-[16px] border border-[#e5edf6] bg-[#f6f8fb] text-left"
>
<Image
src={firstImage}
alt="主要案例图片"
width={720}
height={540}
unoptimized
className="aspect-[4/3] h-auto w-full object-cover"
/>
</button>
{restImages.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{restImages.map((url, index) => (
<button
key={`${url}-${index}`}
type="button"
onClick={() => setActiveIndex(index + 1)}
aria-label={`查看案例图片 ${index + 2}`}
className="block w-full overflow-hidden rounded-[12px] border border-[#e5edf6] bg-[#f6f8fb] text-left"
>
<Image
src={url}
alt={`案例图片 ${index + 2}`}
width={220}
height={220}
unoptimized
className="aspect-square h-auto w-full object-cover"
/>
</button>
))}
</div>
)}
</div>
<ImageFullscreenViewer
images={displayImages}
activeIndex={activeIndex}
onChange={setActiveIndex}
onClose={() => setActiveIndex(null)}
/>
</>
)
}
const gridClass = variant === 'gallery'
? 'mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4'
: 'mt-4 grid grid-cols-2 gap-2'
const imageClass = variant === 'gallery'
? 'aspect-square h-auto w-full object-cover transition-transform duration-300 group-hover:scale-105'
: 'aspect-[4/3] h-auto w-full object-cover'
return (
<>
<div className={gridClass}>
{displayImages.map((url, index) => (
<button
key={`${url}-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
aria-label={`查看案例图片 ${index + 1}`}
className="group block w-full overflow-hidden rounded-[14px] border border-[#e5edf6] bg-[#f6f8fb] text-left"
>
<Image
src={url}
alt={`案例图片 ${index + 1}`}
width={320}
height={240}
unoptimized
className={imageClass}
/>
</button>
))}
</div>
<ImageFullscreenViewer
images={displayImages}
activeIndex={activeIndex}
onChange={setActiveIndex}
onClose={() => setActiveIndex(null)}
/>
</>
)
}
function ImageFullscreenViewer({
images,
activeIndex,
onChange,
onClose,
}: {
images: string[]
activeIndex: number | null
onChange: (index: number) => void
onClose: () => void
}) {
const [portalReady, setPortalReady] = useState(false)
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
setPortalReady(true)
}, [])
const currentIndex = activeIndex == null ? null : Math.max(0, Math.min(images.length - 1, activeIndex))
const isOpen = currentIndex != null
const canNavigate = images.length > 1
const goPrevious = useCallback(() => {
if (currentIndex == null || !canNavigate) return
onChange((currentIndex - 1 + images.length) % images.length)
}, [canNavigate, currentIndex, images.length, onChange])
const goNext = useCallback(() => {
if (currentIndex == null || !canNavigate) return
onChange((currentIndex + 1) % images.length)
}, [canNavigate, currentIndex, images.length, onChange])
useEffect(() => {
if (!isOpen) 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())
return () => {
window.cancelAnimationFrame(focusFrame)
document.body.style.overflow = previousOverflow
previousFocus?.focus()
}
}, [isOpen])
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
goPrevious()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
goNext()
return
}
keepFocusInsideModal(event, dialogRef.current)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [goNext, goPrevious, isOpen, onClose])
if (currentIndex == null || !portalReady) return null
const currentImage = images[currentIndex]
return createPortal((
<div
ref={dialogRef}
className="fixed inset-0 z-[1200] flex flex-col bg-black/90 pt-[env(safe-area-inset-top)] text-white"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={`图片预览,第 ${currentIndex + 1} 张,共 ${images.length} 张`}
tabIndex={-1}
>
<div className="flex h-14 shrink-0 items-center justify-between gap-3 px-4" onClick={(event) => event.stopPropagation()}>
<div className="text-sm font-semibold text-white/80">
{currentIndex + 1} / {images.length}
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-[28px] font-light leading-none text-white transition-colors hover:bg-white/20"
aria-label="关闭图片预览"
>
×
</button>
</div>
<div className="relative flex min-h-0 flex-1 items-center justify-center px-3 pb-[calc(16px+env(safe-area-inset-bottom))]">
{canNavigate && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
goPrevious()
}}
className="absolute left-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-2xl font-light text-white transition-colors hover:bg-white/20"
aria-label="上一张图片"
>
‹
</button>
)}
<button
type="button"
onClick={(event) => event.stopPropagation()}
className="flex h-full w-full items-center justify-center"
aria-label="当前图片"
>
<Image
src={currentImage}
alt={`全屏案例图片 ${currentIndex + 1}`}
width={1400}
height={1000}
unoptimized
className="max-h-full max-w-full object-contain"
/>
</button>
{canNavigate && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
goNext()
}}
className="absolute right-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-2xl font-light text-white transition-colors hover:bg-white/20"
aria-label="下一张图片"
>
›
</button>
)}
</div>
</div>
), document.body)
}
function getSurveyInitials(name: string) {
const text = String(name || '').trim()
if (!text) return '用'
const chars = Array.from(text)
if (/^[A-Za-z\s]+$/.test(text)) {
return text
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((item) => item[0]?.toUpperCase())
.join('')
}
return chars.slice(0, 2).join('')
}
function getQuestionAccentColor(index: number) {
const colors = ['#1677ff', '#16a34a', '#1677ff', '#7c3aed', '#f59e0b']
return colors[index % colors.length]
}
function getSurveyStatusLabel(status: string) {
const map: Record<string, string> = {
draft: '草稿',
open: '进行中',
closed: '已关闭',
cancelled: '已撤销',
}
return map[status] || status
}
function getPerformanceSurveyBackHref(value: string | null) {
const fallback = '/performance-survey?tab=personal'
if (!value || value.startsWith('//')) return fallback
try {
const parsed = new URL(value, 'https://performance-survey.local')
if (parsed.origin !== 'https://performance-survey.local') return fallback
if (parsed.pathname !== '/performance-survey' && !parsed.pathname.startsWith('/performance-survey/')) {
return fallback
}
const legacySegment = parsed.pathname.match(/^\/performance-survey\/(personal|public|manage|pending|submitted|results|created|teacher|admin)\/?$/)?.[1]
if (legacySegment) {
parsed.pathname = '/performance-survey'
parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(legacySegment))
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(legacySegment)
if (legacyWorkspaceTab) parsed.searchParams.set('workspace', legacyWorkspaceTab)
} else if (parsed.pathname === '/performance-survey' && parsed.searchParams.has('tab')) {
const requestedTab = parsed.searchParams.get('tab')
parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(requestedTab))
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(requestedTab)
if (legacyWorkspaceTab) parsed.searchParams.set('workspace', legacyWorkspaceTab)
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
return fallback
}
}
function formatResultScore(value: unknown) {
if (value === null || value === undefined || value === '') return '—'
const score = Number(value)
if (!Number.isFinite(score)) return '—'
return Number.isInteger(score) ? String(score) : score.toFixed(1)
}
function getQuestionMaxScore(template: Template | undefined, question: Template['questions'][number]) {
const maxScore = Number(question.maxScore ?? template?.questionMaxScore ?? 20)
return Number.isFinite(maxScore) && maxScore > 0 ? maxScore : 20
}
function isVisualTemplate(template?: Template | null) {
return template?.surveyType === 'visual_case' || template?.code === 'visual_brand_tone'
}
function getScorePercent(value: unknown, maxScore = 20) {
const score = Number(value ?? 0)
if (!Number.isFinite(score)) return 0
return Math.max(0, Math.min(100, (score / Math.max(1, maxScore)) * 100))
}
function SurveyResults({ surveyId }: { surveyId: string }) {
const searchParams = useSearchParams()
const targetId = searchParams.get('targetId')
const query = targetId ? `?targetId=${encodeURIComponent(targetId)}` : ''
const backHref = getPerformanceSurveyBackHref(searchParams.get('back'))
const result = useApi<any>(`/results/${surveyId}${query}`, [surveyId, query])
if (result.loading) return <MainPanel>加载中...</MainPanel>
if (result.error) return <MainPanel><ErrorPanel message={result.error} /></MainPanel>
if (!result.data) return null
const survey = result.data.survey
const targets = result.data.targets || []
const template = result.data.survey.template as Template | undefined
const visualTemplate = Boolean(template?.workItemImageRequired || template?.code === 'visual_brand_tone')
const questions = template?.questions || []
const totalResponses = targets.reduce((sum: number, target: any) => sum + Number(target.responses?.length || 0), 0)
const effectiveResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.effectiveCount || 0), 0)
const isCancelled = survey.status === 'cancelled'
return (
<main className="mx-auto min-h-screen max-w-[430px] bg-[#f6f8fc] px-5 py-6 pb-24 text-[#101828] sm:max-w-6xl sm:py-6 md:pb-6">
<section className="mb-5 rounded-xl border border-[#e3e9f2] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)]">
<Link
href={backHref}
className="inline-flex min-h-[34px] items-center rounded-full bg-[#f3f6fb] px-3 text-[13px] font-semibold text-[#344054] hover:bg-[#e8eef7]"
>
返回
</Link>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-[#0052d9]">调研结果</div>
<h1 className="mt-2 break-words text-[26px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] sm:text-3xl">
{survey.title}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085]">
<StatusBadge status={survey.status} />
{survey.closedAt && <span>关闭 {formatDate(survey.closedAt)}</span>}
</div>
</div>
<div className="grid grid-cols-3 gap-2 sm:min-w-[280px]">
<ResultMetric label="对象" value={targets.length} />
<ResultMetric label={visualTemplate ? '评分记录' : '评价'} value={totalResponses} />
<ResultMetric label={visualTemplate ? '计分记录' : '有效'} value={effectiveResponses} />
</div>
</div>
{isCancelled && (
<div className="mt-4 rounded-[10px] border border-[#fecdca] bg-[#fff6f5] px-4 py-3 text-sm font-medium text-[#b42318]">
此调研已撤销,页面数据仅供留档,不再作为当前考核结果。
</div>
)}
</section>
<div className="grid gap-5">
{targets.map((target: any) => {
const aggregate = target.aggregate || {}
const workItems = Array.isArray(target.workItems) ? target.workItems : []
const responses = Array.isArray(target.responses) ? target.responses : []
const dimensionEntries = questions
.map((question: Template['questions'][number]) => ({
question,
value: aggregate.dimensionAverages?.[question.id],
}))
.filter((item: { value: unknown }) => item.value !== null && item.value !== undefined)
return (
<article key={target.id} className="rounded-xl border border-[#e3e9f2] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-[#667085]">评价对象</div>
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828]">{target.targetName}</h2>
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085]">
<span>{visualTemplate ? '最终评分' : '已评分'} {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0}</span>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:min-w-[150px]">
<ResultMetric label={visualTemplate ? '最终得分' : '当前得分'} value={aggregate.achievementScore} emphasis={visualTemplate} />
</div>
</div>
{visualTemplate && (
<div className="mt-4 rounded-[10px] border border-[#c7d7fe] bg-[#f5f8ff] px-4 py-3 text-[13px] leading-5 text-[#344054]">
最终分只采用指定最终评分人的评分;自评和主管评分保留在下方明细中,仅供参考。
</div>
)}
{dimensionEntries.length > 0 && (
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{dimensionEntries.map(({ question, value }: { question: Template['questions'][number]; value: number }) => {
const maxScore = getQuestionMaxScore(template, question)
return (
<div key={question.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-sm font-semibold text-[#101828]">{question.title}</div>
<div className="shrink-0 text-sm font-semibold text-[#0052d9]">{formatResultScore(value)}/{maxScore}</div>
</div>
<div className="mt-3 h-2 rounded-full bg-[#e8eef7]">
<div className="h-full rounded-full bg-[#0052d9]" style={{ width: `${getScorePercent(value, maxScore)}%` }} />
</div>
</div>
)
})}
</div>
)}
{visualTemplate && workItems.length > 0 && (
<section className="mt-5 space-y-4">
<h3 className="text-sm font-semibold text-[#101828]">视觉评分作品</h3>
{workItems.map((item: any, itemIndex: number) => (
<div key={item.id} className="rounded-[14px] border border-[#e5edf6] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-[#0052d9]">作品 {itemIndex + 1}</div>
<div className="mt-1 break-words text-[16px] font-semibold text-[#101828]">{item.title}</div>
{item.description && <p className="mt-1 whitespace-pre-wrap text-sm text-[#667085]">{item.description}</p>}
</div>
<span className="shrink-0 rounded bg-[#f3f6fb] px-3 py-1.5 text-sm font-semibold text-[#0052d9]">
{formatNullableScore(item.averageScore)} 分{visualTemplate ? '(最终评分)' : ''}
</span>
</div>
<ImagePreviewGrid images={item.imageGroups || item.imageUrls || []} variant="long" />
</div>
))}
</section>
)}
<section className="mt-5">
<h3 className="text-sm font-semibold text-[#101828]">{visualTemplate ? `评分人明细(${responses.length}人)` : '评价明细'}</h3>
<div className="mt-3">
{visualTemplate ? (
<VisualEvaluatorResultList
responses={responses}
workItems={workItems}
questions={questions}
template={template}
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
{responses.map((response: any) => (
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-[#101828]">{response.respondentName}</span>
<span className="text-sm font-semibold text-[#667085]">{formatResultScore(response.totalScore)} 分</span>
</div>
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{response.comment}</p>}
</div>
))}
{responses.length === 0 && (
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">暂无评价</div>
)}
</div>
)}
</div>
</section>
</article>
)
})}
</div>
</main>
)
}
function TextInput({
label,
value,
onChange,
type = 'text',
placeholder,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
placeholder?: string
}) {
return (
<label className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">{label}</span>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="h-10 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
/>
</label>
)
}
function SelectedChips({ items, onRemove }: { items: Array<{ id: string; label: string }>; onRemove: (id: string) => void }) {
if (items.length === 0) return null
return (
<div className="mt-2 flex flex-wrap gap-2">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onRemove(item.id)}
className="border border-[#e2e2e2] bg-white px-2.5 py-1 text-xs font-semibold text-[#1a1c1c] hover:bg-[#f9f9f9]"
>
{item.label} ×
</button>
))}
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: '草稿',
open: '进行中',
closed: '已关闭',
cancelled: '已撤销',
pending: '待提交',
submitted: '已提交',
sent: '已发送',
succeeded: '已发送',
retrying: '重试中',
exhausted: '失败',
skipped: '未发送',
processing: '处理中',
}
return (
<span className="inline-flex rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700">
{map[status] || status}
</span>
)
}
function DashboardStatusBadge({ status }: { status: string }) {
const tones: Record<string, string> = {
draft: 'border-[#f3d29b] bg-[#fff8e8] text-[#9a5b00]',
open: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
closed: 'border-[#d8dced] bg-[#f5f7fb] text-[#5f5e5e]',
cancelled: 'border-[#f0b8b8] bg-[#fff5f5] text-[#b42318]',
pending: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
submitted: 'border-[#b9ead0] bg-[#ecfdf3] text-[#039855]',
}
return (
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[12px] font-bold ${tones[status] || 'border-[#d8dced] bg-[#f5f7fb] text-[#5f5e5e]'}`}>
{getSurveyStatusLabel(status)}
</span>
)
}
function ResultMetric({ label, value, emphasis = false }: { label: string; value: unknown; emphasis?: boolean }) {
return (
<div className={`min-w-0 rounded-[12px] px-3 py-3 text-center ${
emphasis
? 'bg-[#0052d9] text-white'
: 'bg-[#f8fafc] text-[#101828]'
}`}>
<div className="truncate text-[22px] font-semibold leading-none">{formatResultScore(value)}</div>
<div className={`mt-1 truncate text-[11px] font-semibold ${emphasis ? 'text-white/75' : 'text-[#667085]'}`}>{label}</div>
</div>
)
}
function Panel({ children }: { children: ReactNode }) {
return <div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">{children}</div>
}
function DashboardPanel({ children }: { children: ReactNode }) {
return <div className="rounded border border-[#e2e2e2] bg-white p-4 text-[#1a1c1c]">{children}</div>
}
function MainPanel({ children }: { children: ReactNode }) {
return <main className="mx-auto max-w-4xl px-4 py-6"><Panel>{children}</Panel></main>
}
function SurveyUnavailablePanel({
message,
title = '当前无法填写调研',
onRetry,
}: {
message: string
title?: string
onRetry?: () => Promise<void>
}) {
return (
<div className="rounded-xl border border-[#dce6f2] bg-white px-5 py-9 text-center shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<h1 className="text-[18px] font-bold text-[#101828]">{title}</h1>
<p className="mt-2 text-[14px] font-medium leading-6 text-[#667085]">{message}</p>
<div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
{onRetry && (
<button
type="button"
onClick={() => { void onRetry() }}
className="inline-flex min-h-11 items-center justify-center rounded-md bg-[#1677ff] px-5 text-sm font-semibold text-white"
>
重新加载
</button>
)}
<Link
href="/performance-survey?tab=personal&workspace=pending"
className={`inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-semibold ${onRetry ? 'border border-[#dbe5f2] bg-white text-[#101828]' : 'bg-[#1677ff] text-white'}`}
>
返回个人看板
</Link>
<Link
href="/"
target="_top"
className="inline-flex min-h-11 items-center justify-center rounded-md border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828]"
>
返回品牌中心
</Link>
</div>
</div>
)
}
function ErrorPanel({ message }: { message: string }) {
return <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{message}</div>
}
function DashboardErrorPanel({ message }: { message: string }) {
return <div className="rounded border border-[#f0b8b8] bg-[#fff5f5] px-4 py-3 text-sm font-medium text-[#b42318]">{message}</div>
}
function EmptyState({ title, description }: { title: string; description?: string }) {
return (
<div className="rounded border border-dashed border-[#c2c6d8] bg-[#fafafa] px-5 py-7 text-center">
<h2 className="text-[15px] font-bold text-[#1a1c1c]">{title}</h2>
{description && <p className="mt-1.5 text-[12px] font-medium text-[#727687]">{description}</p>}
</div>
)
}
function MobileSurveyRangeStyles() {
return (
<style>{`
.survey-score-range {
height: 24px;
appearance: none;
-webkit-appearance: none;
background: transparent;
}
.survey-score-range:focus {
outline: none;
}
.survey-score-range::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--score-color, #1677ff) 0%,
var(--score-color, #1677ff) var(--score-percent),
#e5eaf2 var(--score-percent),
#e5eaf2 100%
);
}
.survey-score-range::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -7px;
appearance: none;
-webkit-appearance: none;
border: 3px solid var(--score-color, #1677ff);
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
}
.survey-score-range[data-rated="false"]::-webkit-slider-thumb {
border-color: #b8c2cf;
background: #f6f8fb;
box-shadow: none;
}
.survey-score-range::-moz-range-track {
height: 4px;
border-radius: 999px;
background: #e5eaf2;
}
.survey-score-range::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: var(--score-color, #1677ff);
}
.survey-score-range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 3px solid var(--score-color, #1677ff);
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
}
.survey-score-range[data-rated="false"]::-moz-range-thumb {
border-color: #b8c2cf;
background: #f6f8fb;
box-shadow: none;
}
`}</style>
)
}
function SurveyOpeningScreen() {
return (
<main className="min-h-[calc(100dvh-76px)] bg-white text-[#111827]">
<section className="mx-auto max-w-[430px] px-5 py-8">
<div className="overflow-hidden rounded-xl border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
<div className="text-[14px] font-semibold text-[#1677ff]">考核调研</div>
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1>
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">正在准备问卷内容,请稍候。</p>
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
<div className="flex items-center justify-between gap-4">
<span className="text-[15px] font-semibold text-[#101828]">准备问卷内容</span>
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
<div className="h-full w-2/3 animate-pulse rounded-full bg-[#1677ff]" />
</div>
</div>
<div className="mt-7 rounded-xl border border-[#eef2f7] bg-white p-5">
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
<div className="mt-8 space-y-5">
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 w-2/3 rounded-full bg-[#f1f5f9]" />
</div>
</div>
</div>
</div>
</section>
</main>
)
}
function LoadingPage() {
return <SurveyOpeningScreen />
}
function formatDate(value: string | Date) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}