2026-07-13 20:21:16 +08:00

5693 lines
234 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 { calculatePerformanceSurveyResponseProgress } from '@/lib/performance-survey-response-progress'
import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping'
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
positionTitle?: string | null
positionLevel?: string | null
levelType?: string | null
isLeadership?: boolean | null
managerName?: string | null
dingtalkUserId?: string | null
}
type DepartmentOption = {
id: string
name: string
parentId?: string | null
parentName?: string | null
employeeCount?: number
selectable?: boolean
value?: string | null
departmentName?: string | null
departmentPath?: string | null
fullPath?: string | null
}
type Template = {
code: string
name: string
shortName?: string
description: string
surveyType: string
ownerRoleName?: string
itemName?: string
itemNamePlaceholder?: string
itemNoteName?: string
itemNotePlaceholder?: string
defaultCategory?: string
respondentNameLabel?: string
respondentDepartmentLabel?: string
respondentNameFallback?: string
dataSource?: string
weight: number
weightLabel?: string
maxTotal?: number
questionMaxScore?: number
requiredWorkItemCount?: number
workItemName?: string
workItemImageRequired?: boolean
achievementLabel?: string
achievementSuffix?: string
trimNote?: string
trimmedNote?: string
belowBand?: string
questions: Array<{ id: string; title: string; description?: string; maxScore?: number }>
}
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 PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([
's3.jiansun.vip',
's3.yydd.ai',
's3.1080p.hk',
])
type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage'
type PerformanceSurveyWorkspaceTab = 'overview' | 'pending' | 'submitted' | 'created' | 'results'
const PERFORMANCE_SURVEY_WORKSPACE_TABS: PerformanceSurveyWorkspaceTab[] = [
'overview',
'pending',
'submitted',
'created',
'results',
]
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()
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: string }> = []
const orderedFiles = sortPerformanceSurveyImagesByName(files, (file) => file.name)
const dingtalkToken = getStoredDingtalkPerformanceToken()
const requestHeaders = dingtalkToken
? {
Authorization: `Bearer ${dingtalkToken}`,
[DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken,
}
: undefined
for (let index = 0; index < orderedFiles.length; index += 1) {
const file = orderedFiles[index]
onProgress?.(`正在上传图片 ${index + 1}/${orderedFiles.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: file.type })
}
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 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()
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(options?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(dingtalkToken
? {
Authorization: `Bearer ${dingtalkToken}`,
[DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken,
}
: {}),
...(options?.headers || {}),
},
})
const contentType = response.headers.get('content-type') || ''
const payload = contentType.includes('application/json') ? await response.json() : await response.text()
if (!response.ok || (payload && typeof payload === 'object' && payload.success === false)) {
if (response.status === 401 && dingtalkToken) {
clearStoredDingtalkPerformanceToken()
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT))
}
throw new Error('钉钉考核调研访问凭证已过期,请从钉钉最新通知重新进入')
}
throw new Error(
payload && typeof payload === 'object'
? payload.error || payload.message || '请求失败'
: String(payload || '请求失败'),
)
}
return (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) as T
}
function useApi<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])
let content = <Dashboard me={currentUser.data} />
if (isSurveySubmitPage && path[1]) {
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
}
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="/"
className="shrink-0 text-[12px] font-semibold text-white/85 transition-colors hover:text-white"
aria-label="返回素材库首页"
>
由壹点「素材库」
</Link>
</div>
</header>
)
}
function Dashboard({ me }: { me: any | null }) {
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 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 availableTabIds = useMemo(
() => canCreateSurvey ? ['personal', 'public', 'manage'] : ['personal', 'public'],
[canCreateSurvey],
)
useEffect(() => {
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
setTab(requestedTab)
return
}
if (!availableTabIds.includes(tab)) {
setTab('personal')
}
}, [availableTabIds, requestedTab, tab])
useEffect(() => {
if (requestedTabRaw === requestedTab) return
const query = new URLSearchParams(searchParamsKey)
query.set('tab', requestedTab)
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(requestedTabRaw)
if (legacyWorkspaceTab) query.set('workspace', legacyWorkspaceTab)
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
}, [requestedTab, requestedTabRaw, router, searchParamsKey])
const selectTab = (nextTab: PerformanceSurveyDashboardTab) => {
setTab(nextTab)
const query = new URLSearchParams()
query.set('tab', nextTab)
const workspaceTab = getLegacyPerformanceSurveyWorkspaceTab(searchParams.get('workspace'))
if (workspaceTab) query.set('workspace', workspaceTab)
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: '发起调研' }] : []),
]
const pageTitle = tab === 'public' ? '公共看板' : tab === 'manage' ? '发起调研' : '个人绩效概览'
const pageSubtitle = tab === 'personal'
? '过去 12 个月的考核详细数据'
: tab === 'public'
? ''
: ''
return (
<>
<PerformanceSurveyDashboardHeader
activeTab={tab}
avatar={avatar}
displayName={displayName}
onSelectTab={selectTab}
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' && (
<ManagePanel currentEmployee={me?.employee || null} />
)}
</main>
<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={() => 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="/"
className="hidden justify-self-end text-[13px] font-semibold text-white/90 transition-colors hover:text-white sm:block"
aria-label="返回素材库首页"
>
由壹点「素材库」
</Link>
<Link
href="/"
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,
resultCount: summary.resultCount || 0,
}
const scope = summary.scope || null
const items: Array<{ id: PerformanceSurveyWorkspaceTab; label: string; value?: number; scopeValue?: number }> = [
{ id: 'overview', label: '月度看板' },
{ id: 'pending', label: '待我评分', value: personal.pendingCount || 0, scopeValue: scope?.pendingCount },
{ id: 'submitted', label: '已处理', value: personal.submittedCount || 0, scopeValue: scope?.submittedCount },
{ id: 'created', label: '我发起的', value: personal.createdCount || 0, scopeValue: scope?.createdCount },
{ id: 'results', label: '我的结果', value: personal.resultCount || 0, scopeValue: scope?.resultCount },
]
return (
<div className="grid grid-cols-6 gap-2 sm:grid-cols-5" role="tablist" aria-label="任务与记录">
{items.map((item, index) => {
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 ${index < 3 ? 'col-span-2' : 'col-span-3'} 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 sm:col-span-1 ${
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 activeTab = normalizePerformanceSurveyWorkspaceTab(searchParams.get('workspace'))
const state = useApi<any>('/surveys', [])
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>
{state.data && <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"
/>
)}
{state.data && activeTab === 'results' && <MyResults items={state.data.results || []} />}
</div>
</section>
)
}
function PerformanceDashboardBoard({
view,
canCreateSurvey = false,
}: {
view: 'personal' | 'public'
canCreateSurvey?: boolean
}) {
const router = useRouter()
const [month, setMonth] = useState(getCurrentMonthValue())
const [department, setDepartment] = useState('')
const [employee, setEmployee] = useState<Employee | null>(null)
const [detailRequest, setDetailRequest] = useState<{
month: string
employeeId: string
templateCode: string
} | null>(null)
const publicDepartmentsPath = view === 'public'
? `/departments?purpose=dashboard&month=${encodeURIComponent(month)}`
: 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)
const query = params.toString()
return `/dashboard${query ? `?${query}` : ''}`
}, [view, month, department, employee?.id])
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 || []).filter((row: any) => row.hasData)
return (
<section className="mb-5 space-y-4">
{view === 'public' && (
<div className="border-b border-[#e2e2e2] pb-5">
<div className="grid gap-3 sm:grid-cols-[auto_150px_auto_minmax(220px,0.8fr)_minmax(260px,1fr)_auto] sm:items-center">
<div className="hidden text-[13px] font-semibold text-[#5f5e5e] sm:block">统计周期</div>
<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.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} />}
{dashboard.data && (
<>
{view === 'personal' ? (
<MonthlyPersonalLedger
rows={personalRows}
templates={templates}
onOpenDetail={setDetailRequest}
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)}
onOpenDetail={setDetailRequest}
/>
)}
<MonthlyScoreDetailModal
request={detailRequest}
onClose={() => setDetailRequest(null)}
/>
</>
)}
</section>
)
}
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,
onOpenDetail,
}: {
rows: any[]
templates: any[]
canOpenPublicDetail: boolean
onOpenDetail: (value: { month: string; employeeId: string; templateCode: 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>
<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) => (
<MonthlyScoreCell
key={template.code}
compact
template={template}
cell={row.cells?.[template.code]}
canOpenOverride={canOpenPublicDetail}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
))}
</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 items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#f4f3f3] text-[12px] font-bold text-[#1a1c1c]">{getSurveyInitials(row.employee.displayName)}</div>
<div>
<div className="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) => (
<td key={template.code} className="px-3 py-3 text-center">
<MonthlyTableScoreCell
cell={row.cells?.[template.code]}
showTitle={false}
canOpenOverride={canOpenPublicDetail}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
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 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 ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</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 accessibleLabel = `${cell?.title || '考核调研'},${
hasScore ? `得分 ${formatNullableScore(cell.score)},满分 120` : '待评分'
},查看详情`
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 ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</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 } | 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(request)
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="当前得分" value={detail.data.score === null || detail.data.score === undefined ? '待评分' : `${formatNullableScore(detail.data.score)} / 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-sm font-semibold text-[#1677ff]">{formatNullableScore(item.score)}</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 changeStatus = async (id: string, status: string) => {
setBusyId(id)
try {
await apiRequest(`/surveys/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
await onRefresh()
} 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}`
: '尚未生成评价任务'
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' && (
<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"
>
发布
</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"
>
关闭
</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>
)}
<Link
href={`/performance-survey/results/${item.id}?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>
</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)
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' && (
<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"
>
发布
</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"
>
关闭
</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>
)}
<Link
href={`/performance-survey/results/${item.id}?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>
</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>
)
}
function MyResults({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无结果" description="已归档的考核结果会显示在这里" />
}
return (
<>
<div className="grid gap-3 lg:hidden">
{items.map((item) => (
<article key={`${item.surveyId}-${item.targetId}`} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="text-[18px] font-bold text-[#1a1c1c]">{formatMonthLabel(item.assessmentMonth)}</div>
<span className="rounded bg-[#e8f0ff] px-2 py-1 text-[12px] font-bold text-[#0050cb]">
{getTemplateDisplayName({ code: item.templateCode, name: item.templateName })}
</span>
</div>
<h2 className="mt-3 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>评价对象:{item.targetName}</p>
<p>{item.closedAt ? `${formatDate(item.closedAt)} 归档` : '已归档'}</p>
</div>
<div className="mt-4 flex justify-end">
<Link
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=personal&workspace=results')}`}
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 lg:block">
<table className="w-full min-w-[840px] border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="w-[130px] px-4 py-3">考核月份</th>
<th scope="col" className="px-4 py-3">考核项目 / 调研</th>
<th scope="col" className="w-[160px] px-4 py-3">评价对象</th>
<th scope="col" className="w-[190px] px-4 py-3">归档时间</th>
<th scope="col" className="w-[150px] px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[#c2c6d8]">
{items.map((item) => (
<tr key={`${item.surveyId}-${item.targetId}`} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
<td className="px-4 py-4 text-[16px] font-bold">{formatMonthLabel(item.assessmentMonth)}</td>
<td className="px-4 py-4">
<div className="mb-1 inline-flex rounded bg-[#e8f0ff] px-2 py-1 text-[12px] font-bold text-[#0050cb]">
{getTemplateDisplayName({ code: item.templateCode, name: item.templateName })}
</div>
<div className="font-bold">{item.title}</div>
</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.closedAt ? formatDate(item.closedAt) : '已归档'}</td>
<td className="px-4 py-4 text-right">
<Link
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=personal&workspace=results')}`}
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 ManagePanel({ currentEmployee }: { currentEmployee: Employee | null }) {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const surveys = useApi<any>('/surveys', [])
const [message, setMessage] = useState<string | null>(null)
return (
<div className="grid gap-4">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
currentEmployee={currentEmployee}
onCreated={async () => {
setMessage('已发布调研')
await surveys.refresh()
}}
/>
{message && (
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
{message}
</div>
)}
<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({
templates,
departments,
currentEmployee,
onCreated,
}: {
templates: Template[]
departments: DepartmentOption[]
currentEmployee: Employee | null
onCreated: () => Promise<void>
}) {
const router = useRouter()
const searchParams = useSearchParams()
const prefillMonth = searchParams.get('prefillMonth') || ''
const prefillTemplate = searchParams.get('prefillTemplate') || ''
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [templateCode, setTemplateCode] = useState(prefillTemplate)
const [assessmentMonth, setAssessmentMonth] = useState(prefillMonth || 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)
useEffect(() => {
if (prefillMonth && /^\d{4}-\d{2}$/.test(prefillMonth)) setAssessmentMonth(prefillMonth)
if (prefillTemplate && templates.some((template) => template.code === prefillTemplate)) {
setTemplateCode(prefillTemplate)
}
}, [prefillMonth, prefillTemplate, templates])
const selectedTemplate = templateCode || templates[0]?.code || ''
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
const visualTemplateSelected = isVisualTemplate(selectedTemplateInfo)
const visualRequiredCount = Math.max(
1,
Number(selectedTemplateInfo?.requiredWorkItemCount || DEFAULT_VISUAL_CASE_COUNT),
)
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 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)),
[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 (!visualTemplateSelected && evaluatorEmployees.length === 0) publishValidationIssues.push('至少选择 1 名评分人')
if (visualTemplateSelected && currentTargetSelected) {
const visibleVisualCases = visualCases.slice(0, visualRequiredCount)
if (visibleVisualCases.length < visualRequiredCount) {
publishValidationIssues.push(`准备 ${visualRequiredCount} 个案例作品`)
}
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 (!visualTemplateSelected && evaluatorEmployees.length === 0) {
throw new Error('请至少选择 1 名评分人')
}
const visualCasesToSubmit = visualCases.slice(0, visualRequiredCount)
if (visualTemplateSelected && currentTargetSelected) {
if (visualCasesToSubmit.length < visualRequiredCount) {
throw new Error(`请提交 ${visualRequiredCount} 个案例作品`)
}
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 = await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
title: submitTitle,
description,
surveyKind,
templateCode: selectedTemplate,
assessmentMonth: selectedAssessmentMonth,
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
openNow,
targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
}),
})
createdForAttempt = created
setCreatedSurvey(created)
let uploadTarget: any | null = null
if (visualTemplateSelected && currentTargetSelected) {
uploadTarget = (created.targets || []).find((target: any) => target.canUploadWorkItems)
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(),
images,
}),
})
}
}
setTitle('')
setDescription('')
setTemplateCode('')
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)}`)
}
} 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)]">
<div>
<div className="border border-[#eeeeee] bg-white p-4 sm:p-6">
<div className="space-y-6">
<TextInput label="考核月份" value={assessmentMonth} onChange={setAssessmentMonth} type="month" />
<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
value={selectedTemplate}
onChange={(event) => setTemplateCode(event.target.value)}
className="h-10 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
>
{templates.map((template) => (
<option key={template.code} value={template.code}>
{getTemplateDisplayName(template)}
</option>
))}
</select>
</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>
{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>
<EmployeePicker
title="添加其他被考核人(可选)"
purpose="target"
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>
{visualTemplateSelected ? (
<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 text-[14px] font-bold text-[#1a1c1c]">系统自动选择元泽和被考核人的直属主管</div>
<p className="mt-2 text-[12px] leading-5 text-[#5f5e5e]">被考核人先完成自评,之后才会通知元泽和主管;最终评分以元泽提交的结果为准。</p>
</div>
) : (
<>
<div>
<EmployeePicker
title="评分人员"
purpose="evaluator"
selected={evaluatorEmployees}
onChange={setManualEvaluatorEmployees}
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
onRemoveEmployee={removeEvaluatorEmployee}
/>
</div>
<div className="lg:col-span-2">
<DepartmentPicker
departments={departments}
selected={evaluatorDepartmentValues}
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={currentTargetSelected}
requiredCount={visualRequiredCount}
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 || '发布中...' : 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>
{isVisualTemplate(selectedTemplateInfo) && !currentTargetSelected
? '已发布,已通知被考核人提交作品'
: '已发布'}
</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"
>
上传作品
</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/*"
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]">或点击选择 · 单张长图,或最多 60 张有序切片;单张 200MB</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,
cases,
onChange,
}: {
expanded: boolean
enabled: boolean
requiredCount: number
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]">{completedCount}/{requiredCount}</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>
) : (
<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={index}
draft={draft}
disabled={!expanded}
onChange={(next) => updateCase(index, next)}
/>
))}
</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,
selected,
excludedIds = [],
onChange,
onAddEmployee,
onRemoveEmployee,
}: {
title: string
purpose?: 'target' | 'evaluator'
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'}:${trimmedKeyword}`
const cached = cacheRef.current.get(cacheKey)
if (cached) {
setItems(cached)
setError(null)
setLoading(false)
setOpen(true)
return
}
setLoading(true)
setError(null)
setOpen(true)
try {
const params = new URLSearchParams({
search: trimmedKeyword,
limit: '50',
...(purpose ? { purpose } : {}),
})
const data = await apiRequest<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])
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,
onChange,
onDepartmentEmployeesChange,
}: {
departments: DepartmentOption[]
selected: 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 employees = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
employeeCacheRef.current.set(value, employees)
return employees
}
const toggleValue = async (value: string, checked: boolean) => {
if (!checked) {
onChange(selected.filter((item) => item !== value))
onDepartmentEmployeesChange?.(value, false, [])
setError(null)
return
}
setLoadingValue(value)
setError(null)
try {
const employees = await getDepartmentEmployees(value)
if (employees.length === 0) {
setError('该部门暂无可选人员')
return
}
onChange(Array.from(new Set([...selected, value])))
onDepartmentEmployeesChange?.(value, true, employees)
} catch (err) {
setError(err instanceof Error ? err.message : '部门人员加载失败')
} finally {
setLoadingValue(null)
}
}
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
const children = childrenByParent.get(department.id) || []
const hasChildren = children.length > 0
const isExpanded = expanded.has(department.id) || Boolean(keyword.trim())
const isVisible = !keyword.trim() || visibleIds.has(department.id)
if (!isVisible) return null
return (
<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 run = async (key: string, path: string, success: string) => {
setBusy(key)
onMessage(null)
try {
const data = await apiRequest<any>(path, { method: 'POST', body: JSON.stringify({}) })
onMessage(`${success}${data?.upserted != null ? `:${data.upserted} 人` : ''}`)
} catch (error) {
onMessage(error instanceof Error ? error.message : '操作失败')
} finally {
setBusy(null)
}
}
return (
<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 grid grid-cols-2 gap-2">
<button
disabled={busy !== null}
onClick={() => run('sync', '/admin/sync-employees', '人员档案已同步')}
className="rounded-[10px] border border-[#dbe5f2] px-2 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{busy === 'sync' ? '同步中' : '人员同步'}
</button>
<a
href="/api/performance-survey/admin/export"
className="rounded-[10px] border border-[#dbe5f2] px-2 py-2 text-center text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
导出数据
</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 workNotice = statusState.data?.workNotice
const counts = statusState.data?.counts || {}
const records = state.data || []
const visibleRecords = records.slice(0, 6)
const total = records.length
const sentCount = Number(counts.sent || counts.succeeded || 0)
const failedCount = Number(counts.exhausted || counts.skipped || counts.failed || 0)
const retry = async (id: string) => {
setBusyId(id)
try {
await apiRequest(`/admin/notifications/${id}/retry`, { method: 'POST', body: JSON.stringify({}) })
await state.refresh()
} finally {
setBusyId(null)
}
}
return (
<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 items-center justify-between">
<div>
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">钉钉消息</h2>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{total} 条 · {sentCount} 已发送 · {failedCount} 异常</div>
</div>
<button
onClick={async () => {
await Promise.all([state.refresh(), statusState.refresh()])
}}
className="rounded-[10px] border border-[#dbe5f2] px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200"
>
刷新
</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 ? '已接入' : '未接入'}
</div>
)}
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
{state.loading && <div className="text-sm text-gray-500">加载中...</div>}
{state.error && <div className="text-sm text-red-600">{state.error}</div>}
{records.length === 0 && !state.loading && <div className="py-3 text-sm text-gray-500">暂无消息记录</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-medium text-gray-900 dark:text-white">{item.title}</div>
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
{item.recipientName || '收件人缺失'} · {getNotificationEventLabel(item.eventType)}
</div>
{item.lastError && (
<details className="mt-1 hidden text-xs text-gray-500 dark:text-gray-400 sm:block">
<summary className="cursor-pointer list-none text-red-500">详情</summary>
<div className="mt-1 break-words text-red-500">{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="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
补发
</button>
)}
</div>
</div>
</div>
)
})}
</div>
</section>
)
}
function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) {
const router = useRouter()
const searchParams = useSearchParams()
const targetIdParam = searchParams.get('targetId')
const detail = useApi<any>(`/surveys/${surveyId}`, [surveyId])
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>
<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-2 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 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?.achievementScore)}</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 && detail.data?.status === 'open')
const canScore = Boolean(activeTarget?.currentAssignment && detail.data?.status === 'open')
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
const completedScores = workItems.filter((item: any) => item.scores?.length > 0).length
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>{canUpload ? '案例提交' : '案例评分'}</span>
<span className="text-[#101828]">{canUpload ? `${workItems.length}/${requiredCount}` : `${completedScores}/${workItems.length || requiredCount}`}</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, ((canUpload ? workItems.length : completedScores) / Math.max(1, canUpload ? requiredCount : workItems.length || requiredCount)) * 100)}%` }}
/>
</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>
)}
{canUpload && (
<WorkItemUploadForm
key={activeTargetId}
surveyId={surveyId}
targetId={activeTargetId}
disabled={workItems.length >= requiredCount}
onUploaded={detail.refresh}
/>
)}
{workItems.length === 0 && (
detail.data?.status === 'open' ? (
<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}`}
surveyId={surveyId}
item={item}
index={index}
template={template}
canScore={canScore && !submitted}
onScored={detail.refresh}
/>
))}
</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-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="/"
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,
disabled,
onUploaded,
}: {
surveyId: string
targetId: string
disabled: boolean
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 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) {
setMessage(validationError)
return
}
setFiles(orderedFiles)
setMessage(null)
}
const submit = async () => {
if (disabled) return
const validationError = validatePerformanceSurveyImageFiles(files)
if (validationError) {
setMessage(validationError)
return
}
setBusy(true)
setMessage(null)
try {
const images = await uploadPerformanceSurveyImageFiles(files, setMessage)
setMessage('图片上传完成,正在生成评分预览')
await apiRequest(`/surveys/${surveyId}/work-items`, {
method: 'POST',
body: JSON.stringify({
targetId,
title: title.trim() || '案例作品',
description: description.trim(),
images,
}),
})
setTitle('')
setDescription('')
setFiles([])
await onUploaded()
} catch (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="案例名称"
disabled={disabled}
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="案例说明(可选)"
disabled={disabled}
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={disabled}
ariaLabel="添加案例图片"
onFiles={addFiles}
/>
<PerformanceSurveySelectedImageFiles
files={files}
onRemove={(fileIndex) => {
setFiles((current) => current.filter((_, index) => index !== fileIndex))
setMessage(null)
}}
/>
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
<button
type="button"
disabled={busy || disabled || files.length === 0}
onClick={submit}
className="rounded-[12px] bg-[#1677ff] px-4 py-2.5 text-sm font-semibold text-white disabled:bg-[#9ec9ff]"
>
{disabled ? '案例已满' : busy ? '上传中...' : '保存案例'}
</button>
</div>
</section>
)
}
function VisualWorkItemCard({
surveyId,
item,
index,
template,
canScore,
onScored,
}: {
surveyId: string
item: any
index: number
template: Template | undefined
canScore: boolean
onScored: () => Promise<void>
}) {
const existingScore = item.scores?.[0]
const questions = template?.questions || []
const [scores, setScores] = useState<Record<string, number>>({})
const [comment, setComment] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
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)
const submit = async () => {
if (!allRated) {
setMessage('请逐项完成评分后再提交')
return
}
setBusy(true)
setMessage(null)
try {
await apiRequest(`/surveys/${surveyId}/work-items/${item.id}/scores`, {
method: 'POST',
body: JSON.stringify({ scores, comment }),
})
await onScored()
} catch (error) {
setMessage(error instanceof Error ? error.message : '评分失败')
} finally {
setBusy(false)
}
}
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) 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-2 w-full"
style={{ '--score-percent': `${percent}%`, '--score-color': accentColor } as CSSProperties}
/>
</div>
)
})}
<textarea
value={comment}
onChange={(event) => setComment(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"
/>
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
<button
type="button"
disabled={busy || !allRated}
onClick={submit}
className="w-full rounded-[14px] bg-[#1677ff] px-4 py-3 text-[15px] font-semibold text-white disabled:bg-[#9ec9ff]"
>
{busy ? '提交中...' : '提交本案例评分'}
</button>
</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)
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="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={() => setExpandedKeys((current) => new Set(current).add(group.key))}
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]">
{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={() => setExpandedKeys((current) => {
const next = new Set(current)
next.delete(group.key)
return next
})}
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) {
const score = Number(value ?? 0)
if (!Number.isFinite(score)) return '0'
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>
{aggregate.rawAverage != null && <span>{visualTemplate ? '权威原始分' : '原始均分'} {formatResultScore(aggregate.rawAverage)}</span>}
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:min-w-[220px]">
<ResultMetric label={visualTemplate ? '权威最终分' : aggregate.achievementLabel || '达成分'} value={aggregate.achievementScore} emphasis={visualTemplate} />
<ResultMetric label={visualTemplate ? '折算KPI分' : 'KPI分'} value={aggregate.kpiScore} />
</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>
)}
{aggregate.band && aggregate.responseCount > 0 && (
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054]">
{aggregate.band}
</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>
)}
{aggregate.responseCount > 0 && aggregate.trimNote && (
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">
{aggregate.trimNote}
{aggregate.removedHigh && aggregate.removedLow && (
<span>;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore}</span>
)}
</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="/"
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)
}