4198 lines
167 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'
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 }>
}
const API_BASE = '/api/performance-survey'
const DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY = 'performanceSurveyDingtalkToken'
const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session'
const DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT = 'performance-survey:dingtalk-session-expired'
const PERFORMANCE_SURVEY_MAX_IMAGE_COUNT = 12
const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 20 * 1024 * 1024
const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([
's3.jiansun.vip',
's3.yydd.ai',
's3.1080p.hk',
])
type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage'
function normalizePerformanceSurveyDashboardTab(value: unknown): PerformanceSurveyDashboardTab {
const tab = String(value || '').trim().toLowerCase()
if (tab === 'public') return 'public'
if (tab === 'manage' || tab === 'created' || tab === 'admin') return 'manage'
return 'personal'
}
function getLegacyPerformanceSurveyPathTab(path: string[]): PerformanceSurveyDashboardTab | null {
if (path.length !== 1) return null
const segment = String(path[0] || '').trim().toLowerCase()
if (!['personal', 'public', 'manage', 'pending', 'submitted', 'results', 'created', 'teacher', 'admin'].includes(segment)) {
return null
}
return normalizePerformanceSurveyDashboardTab(segment)
}
function getModalFocusableElements(container: HTMLElement | null) {
if (!container) return []
return Array.from(container.querySelectorAll<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}」超过 20MB`
return null
}
function normalizePerformanceSurveyImageUrl(url?: string | null): string {
const trimmed = String(url || '').trim()
if (!trimmed) return ''
if (!/^https?:\/\//i.test(trimmed)) return trimmed
try {
const parsed = new URL(trimmed)
if (PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS.has(parsed.hostname.toLowerCase())) {
return `/api/proxy-media?url=${encodeURIComponent(trimmed)}`
}
return trimmed
} catch {
return trimmed
}
}
function getStoredDingtalkPerformanceToken() {
if (typeof window === 'undefined') return null
try {
return (
window.sessionStorage.getItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY) ||
window.localStorage.getItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY)
)
} catch {
return null
}
}
function clearStoredDingtalkPerformanceToken() {
if (typeof window === 'undefined') return
try {
window.sessionStorage.removeItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY)
window.localStorage.removeItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY)
} catch {
// 浏览器存储不可用时没有可清理的本地凭证。
}
}
function decodeStoredDingtalkPerformanceSession() {
const token = getStoredDingtalkPerformanceToken()
const encodedPayload = token?.split('.')[0]
if (!encodedPayload) return null
try {
const normalized = encodedPayload.replace(/-/g, '+').replace(/_/g, '/')
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
const binary = window.atob(normalized + padding)
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
const payload = JSON.parse(new TextDecoder().decode(bytes))
return {
id: String(payload.userId || ''),
name: String(payload.name || ''),
email: String(payload.email || ''),
avatar: payload.avatar ? String(payload.avatar) : null,
role: String(payload.role || 'USER'),
department: String(payload.department || ''),
managedDepartment: payload.managedDepartment ? String(payload.managedDepartment) : null,
isSuperAdmin: Boolean(payload.isSuperAdmin),
}
} catch {
return null
}
}
function normalizePickerSearch(value: unknown) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[\s/_\-||,,;;>]+/g, '')
}
function pickerSearchVariants(value: unknown) {
const text = String(value ?? '').trim()
if (!text) return []
const variants = [normalizePickerSearch(text)]
try {
variants.push(
normalizePickerSearch(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
normalizePickerSearch(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
)
} catch {
// 非中文或异常字符按原文匹配即可。
}
return Array.from(new Set(variants.filter(Boolean)))
}
function matchesPickerSearch(keyword: string, values: unknown[]) {
const normalizedKeyword = normalizePickerSearch(keyword)
if (!normalizedKeyword) return true
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
}
function mergeEmployeesById(...groups: Employee[][]) {
const byId = new Map<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)
router.replace(`/performance-survey?${query.toString()}`)
}, [legacyPathTab, 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 />
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() {
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 me = useApi<any>('/me', [])
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
const avatar = me.data?.user?.avatar || me.data?.user?.image || ''
const availableTabIds = useMemo(
() => ['personal', 'public', 'manage'],
[],
)
useEffect(() => {
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
setTab(requestedTab)
return
}
if (!availableTabIds.includes(tab)) {
setTab('personal')
}
}, [availableTabIds, requestedTab, tab])
useEffect(() => {
if (requestedTabRaw === requestedTab) return
const query = new URLSearchParams(searchParamsKey)
query.set('tab', requestedTab)
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
}, [requestedTab, requestedTabRaw, router, searchParamsKey])
const selectTab = (nextTab: PerformanceSurveyDashboardTab) => {
setTab(nextTab)
router.replace(`/performance-survey?tab=${encodeURIComponent(nextTab)}`, { scroll: false })
}
const tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> = [
{ id: 'personal', label: '个人看板' },
{ id: 'public', label: '公共看板' },
{ id: 'manage', label: '发起考核' },
]
const pageTitle = tab === 'public' ? '公共看板' : tab === 'manage' ? '新建考核计划' : '个人绩效概览'
const pageSubtitle = tab === 'personal'
? '过去 12 个月的考核详细数据'
: tab === 'public'
? ''
: ''
return (
<>
<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 !== 'manage' && (
<PerformanceDashboardBoard view={tab === 'public' ? 'public' : 'personal'} />
)}
{tab === 'manage' && (
<ManagePanel />
)}
</main>
<nav className="fixed inset-x-0 bottom-0 z-30 grid h-[68px] grid-cols-3 border-t border-[#e2e2e2] bg-white sm:hidden">
{tabItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectTab(item.id)}
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="truncate text-[16px] font-bold">考核调研</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)}
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: string
onSelectTab: (tab: string) => void
}) {
const summary = data.summary || {}
const personal = summary.personal || {
pendingCount: summary.pendingCount || 0,
submittedCount: summary.submittedCount || 0,
createdCount: summary.createdCount || 0,
resultCount: summary.resultCount || 0,
}
const scope = summary.scope || null
const scopeLabel = summary.scopeMode === 'system' ? '全局' : '架构'
const items = [
{ id: 'pending', label: '待处理', value: personal.pendingCount || 0, scopeValue: scope?.pendingCount },
{ id: 'submitted', label: '已处理', value: personal.submittedCount || 0, scopeValue: scope?.submittedCount },
{ id: 'created', label: '我发起的', value: personal.createdCount || 0, scopeValue: scope?.createdCount },
{ id: 'results', label: '我的结果', value: personal.resultCount || 0, scopeValue: scope?.resultCount },
]
return (
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
{items.map((item) => {
const active = activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onSelectTab(item.id)}
className={`rounded-[14px] border px-4 py-4 text-left transition-colors sm:rounded-lg sm:p-5 ${
active
? 'border-[#0052d9] bg-[#0052d9] text-white shadow-[0_12px_26px_rgba(0,82,217,0.22)]'
: 'border-[#e5eaf2] bg-white text-[#101828] shadow-[0_8px_18px_rgba(45,43,80,0.05)] hover:border-[#b8d0ff] dark:border-gray-700 dark:bg-gray-900 dark:text-white'
}`}
>
<div className={`text-[13px] font-semibold ${active ? 'text-white/80' : 'text-[#667085] dark:text-gray-400'}`}>
{item.label}
</div>
<div className="mt-2 text-[28px] font-semibold leading-none">{item.value}</div>
{typeof item.scopeValue === 'number' && (
<div className={`mt-2 text-[11px] font-semibold ${active ? 'text-white/70' : 'text-[#98a2b3] dark:text-gray-500'}`}>
{scopeLabel} {item.scopeValue}
</div>
)}
</button>
)
})}
</div>
)
}
function PerformanceDashboardBoard({ view }: { view: 'personal' | 'public' }) {
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 departments = useApi<DepartmentOption[]>(view === 'public' ? '/departments?purpose=evaluator' : null, [view])
const dashboardPath = useMemo(() => {
const params = new URLSearchParams()
params.set('view', view)
if (month) params.set('month', month)
if (department) params.set('department', department)
if (employee?.id) params.set('employeeId', employee.id)
const query = params.toString()
return `/dashboard${query ? `?${query}` : ''}`
}, [view, month, department, employee?.id])
const dashboard = useApi<any>(dashboardPath, [dashboardPath])
const templates = dashboard.data?.templates || []
const permissions = dashboard.data?.permissions || {}
const personalRows = (dashboard.data?.personal?.rows || []).filter((row: any) => row.hasData)
const publicRows = (dashboard.data?.public?.rows || []).filter((row: any) => row.hasData)
return (
<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 && <ErrorPanel message={dashboard.error} />}
{dashboard.data && (
<>
{view === 'personal' ? (
<MonthlyPersonalLedger
rows={personalRows}
templates={templates}
onOpenDetail={setDetailRequest}
/>
) : (
<MonthlyPublicLedger
rows={publicRows}
templates={templates}
canOpenPublicDetail={Boolean(permissions.canOpenPublicDetail)}
onOpenDetail={setDetailRequest}
/>
)}
<MonthlyScoreDetailModal
request={detailRequest}
onClose={() => setDetailRequest(null)}
/>
</>
)}
</section>
)
}
function MonthlyPersonalLedger({
rows,
templates,
onOpenDetail,
}: {
rows: any[]
templates: any[]
onOpenDetail: (value: { month: string; employeeId: 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="border border-[#c2c6d8] bg-white p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[20px] font-bold text-[#1a1c1c]">{formatMonthLabel(row.month)}</div>
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">CURRENT</div>
</div>
<div className="grid grid-cols-2 gap-2">
{templates.map((template) => (
<MonthlyScoreCell
key={template.code}
template={template}
cell={row.cells?.[template.code]}
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">{getTemplateDisplayName(template)}</th>
))}
<th className="w-[140px] px-4 py-4 text-right">综合评分</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">{row.month}</td>
{templates.map((template) => (
<td key={template.code} className="px-4 py-4">
<MonthlyTableScoreCell
cell={row.cells?.[template.code]}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
</td>
))}
<td className="px-4 py-4 text-right text-[22px] font-bold text-[#0050cb]">{formatAverageScore(row)}</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 justify-between 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>
<span className="text-[18px] font-bold text-[#0050cb]">{formatAverageScore(row)}</span>
</div>
<div className="grid grid-cols-4 gap-1">
{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-[220px] px-5 py-4">人员</th>
<th className="w-[240px] px-4 py-4">组织 / 职级</th>
{templates.map((template) => (
<th key={template.code} className="px-4 py-4 text-center">{getTemplateDisplayName(template)}</th>
))}
<th className="w-[120px] px-4 py-4 text-right">综合评分</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-5 py-4">
<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-4 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-4 py-4 text-center">
<MonthlyTableScoreCell
cell={row.cells?.[template.code]}
canOpenOverride={canOpenPublicDetail}
onClick={() => onOpenDetail({
month: row.month,
employeeId: row.employee.id,
templateCode: template.code,
})}
/>
</td>
))}
<td className="px-4 py-4 text-right text-[20px] font-bold text-[#0050cb]">{formatAverageScore(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function MonthlyScoreCell({
template,
cell,
compact = false,
canOpenOverride,
onClick,
}: {
template: any
cell: any
compact?: boolean
canOpenOverride?: boolean
onClick: () => void
}) {
const hasScore = cell && cell.score !== null && cell.score !== undefined
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
const className = `${compact ? 'min-h-[58px] px-2 py-2' : 'min-h-[86px] px-3 py-3'} border text-left transition-colors ${
canOpen
? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]'
: 'border-[#e2e2e2] bg-[#f9f9f9]'
}`
const content = (
<>
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
<div className="mt-2 flex items-baseline gap-1">
<span className={`${compact ? 'text-[17px]' : 'text-[24px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
</div>
</>
)
if (!canOpen) {
return <div className={className}>{content}</div>
}
return (
<button type="button" onClick={onClick} className={className}>
{content}
</button>
)
}
function MonthlyTableScoreCell({
cell,
canOpenOverride,
onClick,
}: {
cell: any
canOpenOverride?: boolean
onClick: () => void
}) {
const hasScore = cell && cell.score !== null && cell.score !== undefined
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
const content = (
<>
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
</>
)
if (!canOpen) {
return <div className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left">{content}</div>
}
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left 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 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 surveyCount = Number(detail.data?.surveyCount || 0)
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="text-[12px] font-semibold text-[#5f5e5e]">评估详情报告</div>
<h2 id="performance-survey-detail-title" className="mt-2 break-words text-[22px] font-bold leading-tight text-[#1a1c1c] sm:text-[24px]">
{detail.data?.template?.fullName || detail.data?.template?.name || '考核明细'}
</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] transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c]"
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 && <ErrorPanel message={detail.error} />}
{detail.data && (
<div className="space-y-7">
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4">
<DetailMetaItem label="评估周期" value={detail.data.month || request.month} />
<DetailMetaItem label="被评估人" value={detail.data.employee?.displayName || '-'} />
<DetailMetaItem label="评分人数" value={responseCount || '-'} />
<DetailMetaItem label="记录数" value={surveyCount || '-'} />
</div>
<section className="space-y-3">
<DetailSectionTitle>核心评分</DetailSectionTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DetailScoreCard label="最终分" value={formatNullableScore(detail.data.score)} suffix="/120" strong />
<DetailScoreCard label="评分人数" value={responseCount || 0} suffix="人" />
<DetailScoreCard label="月度记录" value={surveyCount || 0} suffix="条" />
</div>
</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.imageUrls || []} variant="review" />
<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 className="sticky bottom-0 border-t border-[#e2e2e2] bg-white px-4 pb-[calc(12px+env(safe-area-inset-bottom))] pt-3 sm:px-5 sm:pb-3">
<button
type="button"
onClick={onClose}
className="h-11 w-full rounded-lg border border-[#c2c6d8] text-[13px] font-semibold text-[#1a1c1c] transition-colors hover:bg-[#eeeeee]"
>
关闭详情
</button>
</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 DetailScoreCard({
label,
value,
suffix,
strong = false,
}: {
label: string
value: ReactNode
suffix?: string
strong?: boolean
}) {
return (
<div className="rounded-lg border border-[#e2e2e2] bg-[#f9f9f9] p-4">
<div className="mb-2 block text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
<div className="flex items-baseline gap-1">
<span className={`${strong ? 'text-[32px] leading-10' : 'text-[24px] leading-8'} font-bold text-[#1a1c1c]`}>{value}</span>
{suffix && <span className="text-[12px] font-medium text-[#5f5e5e]">{suffix}</span>}
</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}</div>
<div className="shrink-0 text-sm font-semibold text-[#1677ff]">{formatNullableScore(item.score)}</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 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 formatAverageScore(row: any) {
const values = Object.values(row?.cells || {})
.map((cell: any) => Number(cell?.score))
.filter((value) => Number.isFinite(value))
if (values.length === 0) return '-'
const average = values.reduce((sum, value) => sum + value, 0) / values.length
return formatNullableScore(average)
}
function formatNullableScore(value: unknown) {
if (value === null || value === undefined || value === '') return '-'
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '-'
return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1)
}
function SingleEmployeeFilter({
value,
onChange,
}: {
value: Employee | null
onChange: (value: Employee | null) => void
}) {
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState<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 (
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无待处理</h2>
</div>
)
}
return (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<article key={item.assignmentId} className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
<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-3 py-1 text-[12px] font-semibold text-[#0052d9] dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-200">待填写</span>
</div>
<div className="mt-6 flex justify-end text-sm">
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-7 font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)] hover:bg-[#003da6] dark:bg-white dark:text-gray-900"
>
去评价 -&gt;
</Link>
</div>
</article>
))}
</div>
)
}
function SubmittedList({ items }: { items: any[] }) {
if (items.length === 0) {
return (
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无已处理</h2>
</div>
)
}
return (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<article key={item.assignmentId} className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
<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-3 py-1 text-[12px] font-semibold text-[#039855] dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200">已提交</span>
</div>
<div className="mt-6 flex justify-end text-sm">
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] border border-[#dbe5f2] bg-white px-7 font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] hover:bg-[#f8fbff] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
查看详情
</Link>
</div>
</article>
))}
</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)
}
}
if (items.length === 0) {
return <EmptyState title={emptyTitle} />
}
return (
<>
<div className="grid gap-5 sm:hidden">
{items.map((item) => {
const assignmentCount = Number(item.counts?.assignments || 0)
const responseCount = Number(item.counts?.responses || 0)
const done = assignmentCount > 0 && responseCount >= assignmentCount
const progressText = assignmentCount > 0 ? `${responseCount}/${assignmentCount}` : '0/0'
return (
<article
key={item.id}
className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828]">
{item.title}
</h2>
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085]">
<p className="break-words">{item.departmentName || '未指定部门'}</p>
<p>{item.dueAt ? formatDate(item.dueAt) : '无截止'} · {progressText}</p>
</div>
</div>
<span className={`shrink-0 rounded-full border px-3 py-1 text-[12px] font-semibold ${
done
? 'border-[#b9ead0] bg-[#ecfdf3] text-[#039855]'
: item.status === 'open'
? 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]'
: 'border-[#d8dced] bg-[#f5f7fb] text-[#667085]'
}`}>
{done ? '已全部提交' : getSurveyStatusLabel(item.status)}
</span>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
{item.status === 'draft' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'open')}
className="min-h-[40px] rounded-[10px] border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] disabled:opacity-50"
>
发布
</button>
)}
{item.status !== 'closed' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'closed')}
className="min-h-[40px] rounded-[10px] border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] disabled:opacity-50"
>
关闭
</button>
)}
<Link
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-6 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)]"
>
结果 -&gt;
</Link>
</div>
</article>
)
})}
</div>
<div className="hidden overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800 sm:block">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/40">
<tr>
{['调研', '状态', '对象/评价/提交', '截止', '操作'].map((item) => (
<th key={item} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
{item}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{item.title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{item.departmentName || '未指定部门'}</div>
</td>
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{item.counts?.targets || 0} / {item.counts?.assignments || 0} / {item.counts?.responses || 0}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{item.dueAt ? formatDate(item.dueAt) : '-'}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
{item.status === 'draft' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'open')}
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
发布
</button>
)}
{item.status !== 'closed' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'closed')}
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
关闭
</button>
)}
<Link
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
className="rounded-md bg-gray-900 px-2.5 py-1 text-sm text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900"
>
结果
</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 && <Panel>加载中...</Panel>}
{state.error && <ErrorPanel 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-9 items-center gap-2 rounded px-2 py-1.5 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-6 w-6 shrink-0 items-center justify-center rounded border text-xs ${
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-w-0 flex-1 truncate 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)}
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)]">
<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 (
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无结果</h2>
</div>
)
}
return (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<article
key={`${item.surveyId}-${item.targetId}`}
className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white">{item.title}</h2>
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
<p className="break-words">{item.targetName}</p>
<p>{item.closedAt ? formatDate(item.closedAt) : '已关闭'}</p>
</div>
</div>
<span className="shrink-0 rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-3 py-1 text-[12px] font-semibold text-[#0052d9] dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-200">可查看</span>
</div>
<div className="mt-6 flex justify-end text-sm">
<Link
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=personal')}`}
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-7 font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)] hover:bg-[#003da6] dark:bg-white dark:text-gray-900"
>
看结果 -&gt;
</Link>
</div>
</article>
))}
</div>
)
}
function ManagePanel() {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const [message, setMessage] = useState<string | null>(null)
return (
<div className="grid gap-4">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已发布考核')
}}
/>
{message && (
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
{message}
</div>
)}
</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,
onCreated,
}: {
templates: Template[]
departments: DepartmentOption[]
onCreated: () => Promise<void>
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [templateCode, setTemplateCode] = useState('')
const [dueAt, setDueAt] = useState(getCurrentMonthValue())
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 [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const selectedTemplate = templateCode || templates[0]?.code || ''
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
const evaluatorEmployees = useMemo(
() => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)),
[manualEvaluatorEmployees, evaluatorDepartmentEmployees],
)
const removeEvaluatorEmployee = (id: string) => {
setManualEvaluatorEmployees((current) => current.filter((employee) => employee.id !== id))
setEvaluatorDepartmentEmployees((current) => {
const next: Record<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 () => {
setSaving(true)
setError(null)
setCreatedSurvey(null)
try {
const pendingItemName = itemName.trim()
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
? [...targetItems, { name: pendingItemName }]
: targetItems
if (surveyKind === 'item_course' && targetItemsToSubmit.length === 0) {
throw new Error(`请填写${selectedTemplateInfo?.itemName || '课程、事项或活动'}名称`)
}
if (surveyKind !== 'item_course' && targetEmployees.length === 0) {
throw new Error(`请至少选择 1 个${selectedTemplateInfo?.ownerRoleName || '被考核人'}`)
}
const assessmentMonth = dueAt || getCurrentMonthValue()
const submitTitle = title.trim()
|| targetItemsToSubmit[0]?.name
|| `${selectedTemplateInfo?.shortName || selectedTemplateInfo?.name || '考核'}-${assessmentMonth}`
const created = await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
title: submitTitle,
description,
surveyKind,
templateCode: selectedTemplate,
dueAt: assessmentMonth ? `${assessmentMonth}-01T00:00:00` : null,
openNow,
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
}),
})
setTitle('')
setDescription('')
setDueAt(getCurrentMonthValue())
setTargetEmployees([])
setManualEvaluatorEmployees([])
setEvaluatorDepartmentValues([])
setEvaluatorDepartmentEmployees({})
setTargetItems([])
setItemName('')
setCreatedSurvey(created)
await onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败')
} finally {
setSaving(false)
}
}
return (
<section className="grid gap-10 sm:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-6">
<div className="border border-[#eeeeee] bg-white p-6">
<div className="space-y-6">
<TextInput label="考核月份" value={dueAt} onChange={setDueAt} type="month" />
<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>
{error && <div className="border border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-sm font-semibold text-[#93000a]">{error}</div>}
<button
disabled={saving}
onClick={submit}
className="h-12 w-full rounded bg-[#0050cb] px-5 text-[15px] font-bold text-white hover:bg-[#003fa4] disabled:opacity-50"
>
{saving ? '发布中...' : '发布考核计划'}
</button>
{createdSurvey && (
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
已发布
</div>
)}
</div>
<div className="border border-[#eeeeee] bg-white p-6">
<div className="grid gap-8 lg:grid-cols-2">
<div>
{surveyKind !== 'item_course' ? (
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人员'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
) : (
<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>
<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>
</section>
)
}
function EmployeePicker({
title,
purpose,
selected,
onChange,
onAddEmployee,
onRemoveEmployee,
}: {
title: string
purpose?: 'target' | 'evaluator'
selected: Employee[]
onChange: (employees: Employee[]) => void
onAddEmployee?: (employee: Employee) => void
onRemoveEmployee?: (id: string) => void
}) {
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState<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))
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-9 items-center gap-2 px-2 py-1.5 text-sm transition-colors hover:bg-[#f9f9f9]"
style={{ paddingLeft: `${8 + depth * 18}px` }}
>
<button
type="button"
onClick={() => hasChildren && toggle(department.id)}
className={`flex h-6 w-6 shrink-0 items-center justify-center border text-xs ${
hasChildren
? 'border-[#e2e2e2] text-[#5f5e5e] hover:bg-[#f4f3f3]'
: 'border-transparent text-transparent'
}`}
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
disabled={!hasChildren}
>
{hasChildren ? (isExpanded ? '-' : '+') : ''}
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-[#1a1c1c]">{department.name}</div>
</div>
{department.selectable !== false && department.value && (
<input
type="checkbox"
checked={selected.includes(department.value)}
disabled={loadingValue === department.value}
onChange={(event) => {
void toggleValue(department.value!, event.target.checked)
}}
aria-label={`选择${department.name}`}
/>
)}
{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><ErrorPanel message={detail.error} /></MainPanel>
if (!detail.data) return null
const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId)
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
const scoreValues = questions.map((question) => Number(scores[question.id] ?? 0))
const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0)
const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length
const surveyStatusLabel = getSurveyStatusLabel(detail.data.status)
const dueLabel = detail.data.dueAt ? `截止 ${formatDate(detail.data.dueAt)}` : '无截止时间'
const viewer = session?.user || {}
const viewerName = String(viewer.name || viewer.realName || viewer.username || '当前用户')
const viewerAvatar = typeof viewer.avatar === 'string' && viewer.avatar
? viewer.avatar
: typeof viewer.image === 'string'
? viewer.image
: ''
const goToSurveyTab = (nextTab: PerformanceSurveyDashboardTab) => {
setAccountMenuOpen(false)
router.push(`/performance-survey?tab=${encodeURIComponent(nextTab)}`)
}
const changeTarget = (nextTargetId: string) => {
setScores({})
setComment('')
setMessage(null)
setAccountMenuOpen(false)
setTargetId(nextTargetId)
}
if (isVisualTemplate(template)) {
return (
<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-[28px] 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-[20px] 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]">{ratedCount}/{questions.length || 0} · {scoreTotal} 分</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: `${questions.length > 0 ? Math.min(100, (ratedCount / questions.length) * 100) : 0}%` }}
/>
</div>
</div>
</section>
{targets.length > 1 && (
<label className="mb-4 grid gap-2 rounded-[20px] 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' ? (
<div className="rounded-[24px] border border-[#dce6f2] bg-white px-4 py-10 text-center text-[16px] font-semibold text-[#4b5565] shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
该调研未开放或已关闭
</div>
) : (
<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-[20px] 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-[20px] 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-[18px] 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 || 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-[20px] 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"
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-[20px] 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-[20px] 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-[28px] 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-[20px] 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-[20px] 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 && (
<div className="rounded-[20px] border border-dashed border-[#d9e0ea] bg-white px-4 py-10 text-center text-[15px] font-semibold text-[#667085]">
{canUpload ? '请先上传案例图片' : '等待被考核人提交案例'}
</div>
)}
<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>
<Link
href="/performance-survey?tab=personal"
className="rounded-[14px] bg-[#1677ff] px-5 py-3 text-[15px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)]"
>
返回看板
</Link>
</div>
</div>
</div>
</main>
)
}
function WorkItemUploadForm({
surveyId,
targetId,
disabled,
onUploaded,
}: {
surveyId: string
targetId: string
disabled: boolean
onUploaded: () => Promise<void>
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null)
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 selectedPreviews = useMemo(
() => files.map((file) => ({ name: file.name, url: URL.createObjectURL(file) })),
[files],
)
useEffect(() => {
return () => {
selectedPreviews.forEach((item) => URL.revokeObjectURL(item.url))
}
}, [selectedPreviews])
const submit = async () => {
if (disabled) return
const validationError = validatePerformanceSurveyImageFiles(files)
if (validationError) {
setMessage(validationError)
return
}
setBusy(true)
setMessage(null)
try {
const formData = new FormData()
formData.set('targetId', targetId)
formData.set('title', title.trim() || '案例作品')
formData.set('description', description.trim())
files.forEach((file) => formData.append('images', file))
await apiRequest(`/surveys/${surveyId}/work-items`, {
method: 'POST',
body: formData,
})
setTitle('')
setDescription('')
setFiles([])
if (fileInputRef.current) fileInputRef.current.value = ''
await onUploaded()
} catch (error) {
setMessage(error instanceof Error ? error.message : '上传失败')
} finally {
setBusy(false)
}
}
return (
<section className="mb-3 rounded-[20px] 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]"
/>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
disabled={disabled}
onChange={(event) => {
const selectedFiles = Array.from(event.target.files || [])
const validationError = validatePerformanceSurveyImageFiles(selectedFiles)
if (validationError) {
setFiles([])
setMessage(validationError)
event.currentTarget.value = ''
return
}
setFiles(selectedFiles)
setMessage(null)
}}
className="text-sm text-[#667085] file:mr-3 file:rounded-full file:border-0 file:bg-[#eef6ff] file:px-3 file:py-2 file:text-sm file:font-semibold file:text-[#1677ff]"
/>
{selectedPreviews.length > 0 && (
<div className="rounded-[14px] border border-[#e5edf6] bg-[#f6f8fb] p-2">
<div className="mb-2 text-[12px] font-semibold text-[#667085]">已选择 {selectedPreviews.length} 张图片</div>
<ImagePreviewGrid images={selectedPreviews.map((item) => item.url)} variant="gallery" />
</div>
)}
{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-[20px] 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.imageUrls || []} variant={canScore ? 'review' : 'gallery'} />
{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 ImagePreviewGrid({
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: '已关闭',
}
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))
} else if (parsed.pathname === '/performance-survey' && parsed.searchParams.has('tab')) {
parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(parsed.searchParams.get('tab')))
}
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 questions = template?.questions || []
const totalResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.responseCount || 0), 0)
const effectiveResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.effectiveCount || 0), 0)
return (
<main className="mx-auto max-w-[430px] bg-white px-5 py-6 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-6xl sm:bg-transparent sm:py-6 md:pb-6">
<section className="mb-5 rounded-[18px] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)] dark:border dark:border-gray-700 dark:bg-gray-900">
<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] dark:bg-gray-800 dark:text-gray-100"
>
返回
</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] dark:text-white sm:text-3xl">
{survey.title}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
<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="评价" value={totalResponses} />
<ResultMetric label="有效" value={effectiveResponses} />
</div>
</div>
</section>
<div className="grid gap-5">
{targets.map((target: any) => {
const aggregate = target.aggregate || {}
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-[18px] bg-white px-5 py-5 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-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-[#667085] dark:text-gray-400">评价对象</div>
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828] dark:text-white">{target.targetName}</h2>
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
<span>有效 {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0}</span>
{aggregate.rawAverage != null && <span>原始均分 {formatResultScore(aggregate.rawAverage)}</span>}
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:min-w-[220px]">
<ResultMetric label={aggregate.achievementLabel || '达成分'} value={aggregate.achievementScore} />
<ResultMetric label="KPI分" value={aggregate.kpiScore} />
</div>
</div>
{aggregate.band && aggregate.responseCount > 0 && (
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054] dark:bg-gray-800 dark:text-gray-200">
{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 dark:bg-gray-800">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-sm font-semibold text-[#101828] dark:text-white">{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] dark:bg-gray-700">
<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] dark:bg-gray-800 dark:text-gray-300">
{aggregate.trimNote}
{aggregate.removedHigh && aggregate.removedLow && (
<span>;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore}</span>
)}
</div>
)}
<div className="mt-5">
<h3 className="text-sm font-semibold text-[#101828] dark:text-white">评价明细</h3>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{target.responses.map((response: any) => (
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-[#101828] dark:text-white">{response.respondentName}</span>
<span className="text-sm font-semibold text-[#667085] dark:text-gray-300">{formatResultScore(response.totalScore)} 分</span>
</div>
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054] dark:text-gray-300">{response.comment}</p>}
</div>
))}
{target.responses.length === 0 && (
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">暂无评价</div>
)}
</div>
</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: '已关闭',
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 dark:bg-gray-700 dark:text-gray-200">
{map[status] || 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] dark:bg-gray-800 dark:text-white'
}`}>
<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] dark:text-gray-400'}`}>{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 MainPanel({ children }: { children: ReactNode }) {
return <main className="mx-auto max-w-4xl px-4 py-6"><Panel>{children}</Panel></main>
}
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 EmptyState({ title }: { title: string }) {
return (
<div className="rounded-[18px] border border-dashed border-[#d9ddeb] bg-white px-5 py-10 text-center shadow-[0_12px_30px_rgba(45,43,80,0.06)] dark:border-gray-700 dark:bg-gray-900">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
</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-[26px] 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-[18px] 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 date.toLocaleString('zh-CN', { hour12: false })
}