3285 lines
136 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 Header from '@/components/Header'
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'
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()
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 depsKey = JSON.stringify(deps)
const load = useCallback(async () => {
if (!path) return
setState((current) => ({ ...current, loading: true, error: null }))
try {
const data = await apiRequest<T>(path)
setState({ loading: false, data, error: null })
} catch (error) {
setState({ loading: false, data: null, error: error instanceof Error ? error.message : '加载失败' })
}
}, [path])
useEffect(() => {
void load()
}, [load, depsKey])
return { ...state, refresh: load }
}
export default function PerformanceSurveyPage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const rawPath = (params as any).path
const path = useMemo(() => (Array.isArray(rawPath) ? (rawPath as string[]) : []), [rawPath])
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(() => {
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 (path[0] === 'teacher' || path[0] === 'admin') {
router.replace('/performance-survey')
}
}, [path, router])
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])
let content = <Dashboard />
if (isSurveySubmitPage && path[1]) {
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
}
if (path[0] === 'results' && 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 dark:bg-gray-900 sm:bg-gray-50">
<div className="hidden sm:block">
<Header session={effectiveSession} />
</div>
<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 bg-[#30313a] text-white sm:hidden">
<div className="mx-auto flex h-[72px] max-w-[430px] items-center justify-between gap-4 px-5">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-[#eef2f7] text-[14px] font-semibold text-[#1677ff] shadow-[0_8px_18px_rgba(0,0,0,0.18)]">
{avatar ? (
<Image
src={avatar}
alt={name}
width={44}
height={44}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getSurveyInitials(name)
)}
</div>
<div className="truncate text-[20px] font-semibold tracking-tight">考核调研</div>
</div>
<div className="shrink-0 text-[14px] font-semibold text-[#dce4ff]">由壹点「素材库」</div>
</div>
</header>
)
}
function Dashboard() {
const router = useRouter()
const searchParams = useSearchParams()
const requestedTab = searchParams.get('tab') || 'pending'
const [tab, setTab] = useState(() => (
['pending', 'submitted', 'created', 'results', 'manage'].includes(requestedTab) ? requestedTab : 'pending'
))
const surveys = useApi<any>('/surveys', [])
const me = useApi<any>('/me', [])
const canManage = Boolean(me.data?.permissions?.isDepartmentAdmin)
const pendingItems = surveys.data?.pending || []
const submittedItems = surveys.data?.submitted || []
const createdItems = surveys.data?.created || []
const resultItems = surveys.data?.results || []
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
const departmentLabel = me.data?.employee?.departmentName || me.data?.user?.department || '未同步部门'
const positionLabel = [me.data?.employee?.positionTitle, me.data?.employee?.positionLevel].filter(Boolean).join(' · ')
const availableTabIds = useMemo(
() => ['pending', 'submitted', 'created', 'results', 'manage'],
[],
)
useEffect(() => {
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
setTab(requestedTab)
return
}
if (!availableTabIds.includes(tab)) {
setTab('pending')
}
}, [availableTabIds, requestedTab, tab])
const selectTab = (nextTab: string) => {
setTab(nextTab)
router.replace(`/performance-survey?tab=${encodeURIComponent(nextTab)}`, { scroll: false })
}
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-7xl sm:bg-transparent sm:py-6 md:pb-6">
<section className="mb-6 rounded-[14px] 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 sm:rounded-[20px] sm:p-5">
<div className="flex items-start justify-between gap-3">
<h1 className="text-[25px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-2xl">我的调研</h1>
<button
type="button"
onClick={() => selectTab('manage')}
className={`shrink-0 rounded-full px-3 py-1.5 text-[13px] font-semibold transition-colors ${
tab === 'manage'
? 'bg-[#0052d9] text-white'
: 'bg-[#f3f6fb] text-[#101828] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100'
}`}
>
{canManage ? '管理' : '发起'}
</button>
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-[14px] font-semibold leading-5 text-[#111827] dark:text-gray-200">
<span className="max-w-full truncate">{displayName}</span>
<span className="text-[#98a2b3]">·</span>
<span className="max-w-full truncate">{departmentLabel}</span>
{positionLabel && (
<>
<span className="text-[#98a2b3]">·</span>
<span className="max-w-full break-words">{positionLabel}</span>
</>
)}
</div>
</section>
{surveys.loading && <Panel>加载中...</Panel>}
{surveys.error && <ErrorPanel message={surveys.error} />}
{surveys.data && (
<PerformanceDashboardBoard
canManage={canManage}
onSelectTab={selectTab}
/>
)}
{surveys.data && tab === 'pending' && <PendingList items={pendingItems} />}
{surveys.data && tab === 'submitted' && <SubmittedList items={submittedItems} />}
{surveys.data && tab === 'created' && (
<CreatedList items={createdItems} onRefresh={surveys.refresh} resultBackHref="/performance-survey?tab=created" />
)}
{surveys.data && tab === 'results' && <MyResults items={resultItems} />}
{surveys.data && tab === 'manage' && (
<ManagePanel
canManage={canManage}
onChanged={async () => {
await surveys.refresh()
}}
templateStats={surveys.data?.templateStats || []}
/>
)}
</main>
)
}
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({
canManage,
onSelectTab,
}: {
canManage: boolean
onSelectTab: (tab: string) => void
}) {
const [month, setMonth] = useState(getCurrentMonthValue())
const [department, setDepartment] = useState('')
const [templateCode, setTemplateCode] = useState('')
const [employee, setEmployee] = useState<Employee | null>(null)
const [expandedPersonId, setExpandedPersonId] = useState<string | null>(null)
const departments = useApi<DepartmentOption[]>(canManage ? '/departments?purpose=evaluator' : null, [canManage])
const templates = useApi<Template[]>('/templates', [])
const dashboardPath = useMemo(() => {
const params = new URLSearchParams()
if (month) params.set('month', month)
if (department) params.set('department', department)
if (templateCode) params.set('templateCode', templateCode)
if (employee?.id) params.set('employeeId', employee.id)
const query = params.toString()
return `/dashboard${query ? `?${query}` : ''}`
}, [month, department, templateCode, employee?.id])
const dashboard = useApi<any>(dashboardPath, [dashboardPath])
const personal = dashboard.data?.personal
const people = dashboard.data?.people || []
const templateSections = dashboard.data?.templates || []
const scopeMode = dashboard.data?.permissions?.scopeMode || 'self'
const scopeLabel = scopeMode === 'system' ? '全局' : scopeMode === 'organization' ? '架构' : '个人'
return (
<section className="mb-5 space-y-4">
<div 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-wrap items-center justify-between gap-3">
<div>
<h2 className="text-[18px] font-semibold text-[#101828] dark:text-white">当月看板</h2>
<div className="mt-1 text-xs font-medium text-[#667085] dark:text-gray-400">{scopeLabel} · {month}</div>
</div>
<button
type="button"
onClick={() => onSelectTab(canManage ? 'manage' : 'created')}
className="rounded-full bg-[#f3f6fb] px-3 py-1.5 text-[13px] font-semibold text-[#101828] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100"
>
发起调研
</button>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-[140px_minmax(180px,1fr)_minmax(160px,0.7fr)]">
<input
type="month"
value={month}
onChange={(event) => setMonth(event.target.value)}
className="rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
{canManage ? (
<DepartmentFilterTree
departments={departments.data || []}
value={department}
onChange={(value) => setDepartment(value)}
/>
) : (
<div className="rounded-lg border border-[#eef1f6] px-3 py-2 text-sm text-[#667085] dark:border-gray-700">仅本人</div>
)}
<select
value={templateCode}
onChange={(event) => setTemplateCode(event.target.value)}
className="rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="">全部模板</option>
{(templates.data || []).map((template) => (
<option key={template.code} value={template.code}>{template.shortName || template.name}</option>
))}
</select>
</div>
{canManage && (
<div className="mt-3">
<SingleEmployeeFilter value={employee} onChange={setEmployee} />
</div>
)}
</div>
{dashboard.loading && <Panel>看板加载中...</Panel>}
{dashboard.error && <ErrorPanel message={dashboard.error} />}
{dashboard.data && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<MetricCard label="调研数量" value={personal?.surveyCount || 0} />
<MetricCard label="个人均分" value={formatNullableScore(personal?.totalAverage)} />
<MetricCard label="模板数量" value={(personal?.templates || []).filter((item: any) => item.count > 0).length} />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{templateSections.map((item: any) => (
<TemplateSectionCard key={item.code} item={item} />
))}
</div>
<div 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="mb-3 flex items-center justify-between">
<h2 className="text-[18px] font-semibold text-[#101828] dark:text-white">人员总看板</h2>
<span className="text-xs font-semibold text-[#667085] dark:text-gray-400">{people.length} 人</span>
</div>
<div className="divide-y divide-[#eef1f6] dark:divide-gray-800">
{people.length === 0 && <div className="py-8 text-center text-sm font-medium text-[#667085]">暂无数据</div>}
{people.map((row: any) => {
const expanded = expandedPersonId === row.employee.id
return (
<div key={row.employee.id} className="py-3">
<button
type="button"
disabled={!row.canExpand}
onClick={() => setExpandedPersonId(expanded ? null : row.employee.id)}
className="grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-left disabled:cursor-default"
>
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-[#101828] dark:text-white">{row.employee.displayName}</div>
<div className="mt-1 truncate text-xs font-medium text-[#667085] dark:text-gray-400">
{[row.employee.departmentName, row.employee.positionTitle, row.employee.positionLevel || row.employee.levelType].filter(Boolean).join(' · ')}
</div>
</div>
<div className="text-right">
<div className="text-[18px] font-semibold text-[#101828] dark:text-white">{formatNullableScore(row.totalAverage)}</div>
<div className="text-xs text-[#667085]">{row.surveyCount} 条</div>
</div>
</button>
{expanded && (
<div className="mt-3 space-y-3 rounded-[12px] bg-[#f8fbff] p-3 dark:bg-gray-950">
{row.templates.map((template: any) => (
<div key={template.code}>
<div className="flex items-center justify-between text-sm font-semibold text-[#101828] dark:text-white">
<span>{template.name}</span>
<span>{formatNullableScore(template.averageScore)}</span>
</div>
<div className="mt-2 grid gap-2">
{template.surveys.map((survey: any) => (
<Link
key={`${survey.surveyId}-${survey.targetId}`}
href={`/performance-survey/results/${survey.surveyId}?targetId=${encodeURIComponent(survey.targetId)}&back=${encodeURIComponent(`/performance-survey?tab=created&month=${month}`)}`}
className="rounded-[10px] border border-[#dbe5f2] bg-white px-3 py-2 text-sm hover:bg-[#f6f9ff] dark:border-gray-700 dark:bg-gray-900"
>
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 break-words font-semibold text-[#101828] dark:text-white">{survey.title}</span>
<span className="shrink-0 text-[#1677ff]">{formatNullableScore(survey.score)}</span>
</div>
<div className="mt-1 text-xs text-[#667085]">{survey.templateName} · {survey.responseCount}/{survey.assignmentCount}</div>
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
</>
)}
</section>
)
}
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 formatNullableScore(value: unknown) {
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: 'target' })
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="w-full rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
{value && (
<button
type="button"
onClick={() => {
onChange(null)
setKeyword('')
}}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full px-2 py-1 text-xs font-semibold text-[#667085] hover:bg-[#f3f6fb]"
>
清除
</button>
)}
{open && (
<div className="absolute z-20 mt-2 max-h-56 w-full overflow-y-auto rounded-[10px] border border-[#dbe5f2] bg-white shadow-[0_14px_34px_rgba(15,23,42,0.12)] dark:border-gray-700 dark:bg-gray-900">
{loading ? (
<div className="px-3 py-2 text-sm text-[#667085]">搜索中...</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-[#667085]">无匹配人员</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-[#f6f9ff] dark:hover:bg-gray-800"
>
<div className="font-semibold text-[#101828] dark:text-white">{employee.displayName}</div>
<div className="mt-0.5 truncate text-xs text-[#667085]">{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=created',
}: {
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-md px-2 py-1.5 text-sm ${
selected ? 'bg-[#edf4ff] text-[#0052d9]' : 'text-gray-800 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700'
}`}
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-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600'
: '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-lg border border-gray-300 bg-white px-3 text-left text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
<span className="truncate">{selectedLabel}</span>
<span className="text-gray-400">{open ? '收起' : '选择'}</span>
</button>
{open && (
<div className="absolute left-0 right-0 z-30 mt-2 rounded-[12px] border border-gray-200 bg-white p-2 shadow-[0_18px_42px_rgba(15,23,42,0.16)] dark:border-gray-700 dark:bg-gray-900">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索组织"
className="mb-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-950 dark:text-white"
/>
<button
type="button"
onClick={() => choose('')}
className={`mb-1 flex min-h-9 w-full items-center rounded-md px-3 text-left text-sm font-medium ${
!value ? 'bg-[#edf4ff] text-[#0052d9]' : 'text-gray-800 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700'
}`}
>
全部组织
</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=results')}`}
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({
canManage,
onChanged,
templateStats,
}: {
canManage: boolean
onChanged: () => Promise<void>
templateStats: any[]
}) {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const notifications = useApi<any[]>(canManage ? '/admin/notifications?limit=30' : null, [canManage])
const notificationStatus = useApi<any>(canManage ? '/admin/notifications/status' : null, [canManage])
const [message, setMessage] = useState<string | null>(null)
const [managedRefreshKey, setManagedRefreshKey] = useState(0)
return (
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已创建考核调研')
setManagedRefreshKey((value) => value + 1)
await onChanged()
if (canManage) await Promise.all([notifications.refresh(), notificationStatus.refresh()])
}}
/>
<TemplateStatsPanel items={templateStats} />
</div>
{canManage && (
<ManagedSurveyList
canManage={canManage}
departments={departments.data || []}
refreshKey={managedRefreshKey}
/>
)}
<div className={canManage ? 'grid gap-4 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]' : 'grid gap-4'}>
<div className="space-y-4">
{canManage && (
<AdminActions
onMessage={setMessage}
/>
)}
{message && (
<div className="rounded-[14px] bg-white px-4 py-3 text-sm font-medium text-[#0052d9] shadow-[0_8px_18px_rgba(45,43,80,0.06)] dark:bg-gray-900 dark:text-blue-200">
{message}
</div>
)}
</div>
{canManage && <NotificationList state={notifications} statusState={notificationStatus} />}
</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('')
const [openNow, setOpenNow] = useState(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 submitTitle = title.trim()
|| targetItemsToSubmit[0]?.name
|| (surveyKind === 'visual_case' ? `${selectedTemplateInfo?.shortName || '视觉评分'}-${getCurrentMonthValue()}` : '')
const created = await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
title: submitTitle,
description,
surveyKind,
templateCode: selectedTemplate,
dueAt: dueAt || null,
openNow,
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
}),
})
setTitle('')
setDescription('')
setTargetEmployees([])
setManualEvaluatorEmployees([])
setEvaluatorDepartmentValues([])
setEvaluatorDepartmentEmployees({})
setTargetItems([])
setItemName('')
setCreatedSurvey(created)
await onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败')
} finally {
setSaving(false)
}
}
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-4 grid gap-3.5">
<TextInput
label="调研标题"
value={title}
onChange={setTitle}
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写这次调研的名称'}
/>
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">模板</span>
<select
value={selectedTemplate}
onChange={(event) => setTemplateCode(event.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
{templates.map((template) => (
<option key={template.code} value={template.code}>
{template.name}
</option>
))}
</select>
</label>
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
{surveyKind !== 'item_course' ? (
<>
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
</>
) : (
<div>
<div className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">{selectedTemplateInfo?.itemName || '调研事项'}</div>
<input
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写要评价的课程、事项或活动名称'}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
<div className="mt-3">
<TextInput
label={selectedTemplateInfo?.itemNoteName || '备注'}
value={description}
onChange={setDescription}
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
/>
</div>
</div>
)}
<EmployeePicker
title="评价人"
purpose="evaluator"
selected={evaluatorEmployees}
onChange={setManualEvaluatorEmployees}
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
onRemoveEmployee={removeEvaluatorEmployee}
/>
<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
})
}}
/>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" checked={openNow} onChange={(event) => setOpenNow(event.target.checked)} />
创建后立即发布
</label>
{error && <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{error}</div>}
<button
disabled={saving}
onClick={submit}
className="rounded-[10px] bg-[#0052d9] px-4 py-2.5 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.18)] hover:bg-[#003da6] disabled:opacity-50 dark:bg-white dark:text-gray-900"
>
{saving ? '创建中...' : '创建'}
</button>
{createdSurvey && (
<div className="rounded-[12px] border border-blue-100 bg-blue-50 px-3 py-2.5 text-sm font-medium text-blue-800 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-100">
已创建,系统会按评价人发送钉钉通知。
</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-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{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="rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
</label>
{open && keyword && (
<div className="mt-2 max-h-56 overflow-y-auto rounded-[10px] border border-gray-200 bg-white shadow-[0_10px_20px_rgba(15,23,42,0.06)] dark:border-gray-700 dark:bg-gray-900">
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">搜索中...</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-gray-500">无匹配人员</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 hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-gray-700"
>
<div className="font-medium text-gray-900 dark:text-white">{employee.displayName}</div>
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
{[
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 rounded-md px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
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-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600'
: '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-medium text-gray-800 dark:text-gray-100">{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-gray-400 dark:text-gray-500">加载中</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-sm font-medium text-gray-700 dark:text-gray-300">部门</div>
{selected.length > 0 && (
<button
type="button"
onClick={() => {
selected.forEach((value) => onDepartmentEmployeesChange?.(value, false, []))
onChange([])
setError(null)
}}
className="text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
清空
</button>
)}
</div>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索部门"
className="mb-2 w-full 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"
/>
<div className="max-h-64 overflow-y-auto rounded-[10px] border border-gray-200 p-1.5 dark:border-gray-700">
{roots.map((department) => renderNode(department))}
</div>
{error && <div className="mt-2 text-xs text-red-600 dark:text-red-300">{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="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
>
{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(() => {
if (questions.length > 0) {
setScores(
Object.fromEntries(
questions.map((question) => [question.id, 16]),
),
)
}
}, [questions])
const submit = async () => {
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] ?? 16))
const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0)
const ratedCount = questions.filter((question) => typeof scores[question.id] === 'number').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: string) => {
setAccountMenuOpen(false)
router.push(`/performance-survey?tab=${encodeURIComponent(nextTab)}`)
}
if (isVisualTemplate(template)) {
return (
<VisualCaseSurvey
surveyId={surveyId}
detail={detail}
template={template}
targets={targets}
activeTarget={activeTarget}
activeTargetId={activeTargetId}
setTargetId={setTargetId}
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) => 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>
)}
{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 value = scores[question.id] ?? Math.min(16, maxScore)
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 }}>{value}</span>
<span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>
</div>
</div>
<input
type="range"
min={0}
max={maxScore}
value={value}
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('pending')}
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('submitted')}
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('results')}
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 || 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=submitted"
className="shrink-0 rounded-full border border-[#dbe5f2] bg-[#f8fbff] px-3 py-1.5 text-[12px] font-semibold text-[#101828]"
>
返回已处理
</Link>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
<div className="text-[11px] font-semibold text-[#667085]">总分</div>
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.totalScore)}</div>
</div>
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
<div className="text-[11px] font-semibold text-[#667085]">达成分</div>
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.achievementScore)}</div>
</div>
</div>
</section>
{questions.map((question, index) => {
const maxScore = getQuestionMaxScore(template, question)
const value = scoreOf(question.id)
const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100))
const accentColor = getQuestionAccentColor(index)
return (
<section key={question.id} className="rounded-[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
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={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={submitted ? '/performance-survey?tab=submitted' : '/performance-survey?tab=pending'}
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 [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 submit = async () => {
if (disabled) return
if (files.length === 0) {
setMessage('请至少选择 1 张图片')
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([])
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
type="file"
accept="image/*"
multiple
disabled={disabled}
onChange={(event) => setFiles(Array.from(event.target.files || []))}
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]"
/>
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
<button
type="button"
disabled={busy || disabled}
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 [scores, setScores] = useState<Record<string, number>>(() => Object.fromEntries((template?.questions || []).map((question) => [question.id, Math.min(6, getQuestionMaxScore(template, question))])))
const [comment, setComment] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const total = (template?.questions || []).reduce((sum, question) => sum + Number(scores[question.id] || 0), 0)
const submit = async () => {
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)} 分` : `${total} 分`}
</span>
</div>
<ImagePreviewGrid images={item.imageUrls || []} />
{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">
{(template?.questions || []).map((question, questionIndex) => {
const maxScore = getQuestionMaxScore(template, question)
const value = scores[question.id] ?? Math.min(6, maxScore)
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-[16px] font-semibold" style={{ color: accentColor }}>{value}/{maxScore}</div>
</div>
<input
type="range"
min={0}
max={maxScore}
value={value}
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}
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 }: { images: string[] }) {
if (images.length === 0) return null
return (
<div className="mt-4 grid grid-cols-2 gap-2">
{images.map((url, index) => (
<a
key={`${url}-${index}`}
href={url}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-[14px] border border-[#e5edf6] bg-[#f6f8fb]"
>
<Image
src={url}
alt={`案例图片 ${index + 1}`}
width={320}
height={240}
unoptimized
className="aspect-[4/3] h-auto w-full object-cover"
/>
</a>
))}
</div>
)
}
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) {
if (!value) return '/performance-survey?tab=results'
if (!value.startsWith('/performance-survey')) return '/performance-survey?tab=results'
if (value.startsWith('//')) return '/performance-survey?tab=results'
return value
}
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-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
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"
/>
</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="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
>
{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::-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);
}
`}</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 })
}