1933 lines
79 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 { 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
weight: number
weightLabel?: string
maxTotal?: number
achievementLabel?: string
achievementSuffix?: string
questions: Array<{ id: string; title: string; description?: string }>
}
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)))
}
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 load = 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 : '加载失败' })
}
}
useEffect(() => {
void load()
}, deps)
return { ...state, refresh: load }
}
export default function PerformanceSurveyPage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const path = Array.isArray((params as any).path) ? ((params as any).path as string[]) : []
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-[#f6f8fb]">
<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 bg-white">
<PerformanceSurveyMobileHeader session={effectiveSession} />
{content}
</div>
)
}
return (
<div className="min-h-screen overflow-x-hidden bg-gray-50 dark:bg-gray-900">
<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 (
<div className="border-b border-black bg-black px-5 py-4 text-white sm:hidden">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<Image
src="/api/favicon"
alt="由壹点「素材库」"
width={36}
height={36}
unoptimized
className="h-9 w-9 shrink-0 rounded-none object-contain"
/>
<div className="truncate text-[18px] font-semibold tracking-tight">由壹点「素材库」</div>
</div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#303848] bg-[#eef2f7] text-[15px] font-semibold text-[#1677ff] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)]">
{avatar ? (
<Image
src={avatar}
alt={name}
width={44}
height={44}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getSurveyInitials(name)
)}
</div>
</div>
</div>
)
}
function Dashboard() {
const [tab, setTab] = useState('pending')
const surveys = useApi<any>('/surveys', [])
const me = useApi<any>('/me', [])
const canManage = Boolean(me.data?.permissions?.isDepartmentAdmin)
const pendingItems = surveys.data?.pending || []
const createdItems = surveys.data?.created || []
const resultItems = surveys.data?.results || []
const summary = surveys.data?.summary || {}
const pendingTotal = Number(summary.pendingCount ?? pendingItems.length)
const createdTotal = Number(summary.createdCount ?? createdItems.length)
const openCreatedTotal = Number(summary.openCreatedCount ?? createdItems.filter((item: any) => item.status === 'open').length)
const submittedTotal = Number(summary.submittedCount ?? 0)
const resultTotal = Number(summary.resultCount ?? resultItems.length)
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
const avatarUrl = me.data?.user?.avatar || ''
const avatarInitial = String(displayName || '用').trim().charAt(0).toUpperCase() || '用'
const departmentLabel = me.data?.employee?.departmentName || me.data?.user?.department || '未同步部门'
const positionLabel = [me.data?.employee?.positionTitle, me.data?.employee?.positionLevel].filter(Boolean).join(' · ')
const tabs = [
{ id: 'pending', label: '待我评价', count: pendingTotal },
{ id: 'created', label: '我发起的', count: createdTotal },
{ id: 'results', label: '我的结果', count: resultTotal },
...(canManage ? [{ id: 'manage', label: '管理', count: undefined }] : []),
]
return (
<main className="mx-auto max-w-7xl bg-[#f6f8fb] px-4 py-4 pb-24 dark:bg-gray-950 sm:bg-transparent sm:py-6 md:pb-6">
<section className="mb-4 overflow-hidden rounded-[24px] border border-[#e3eaf3] bg-white p-4 shadow-[0_14px_38px_rgba(15,23,42,0.08)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-[20px] sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-[#1677ff]">考核调研</div>
<h1 className="mt-2 text-[1.75rem] font-semibold leading-tight text-[#101828] dark:text-white sm:text-2xl">我的调研</h1>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-[#667085] dark:text-gray-300">
<span className="max-w-[11rem] truncate text-[#101828] dark:text-white">{displayName}</span>
<span className="h-1 w-1 rounded-full bg-[#cbd5e1]" />
<span className="max-w-[13rem] truncate">{departmentLabel}</span>
{positionLabel && (
<>
<span className="h-1 w-1 rounded-full bg-[#cbd5e1]" />
<span className="max-w-full break-words">{positionLabel}</span>
</>
)}
</div>
</div>
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#d8e3f2] bg-[#f8fafc] text-base font-semibold text-[#1677ff] shadow-inner">
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatarUrl} alt={displayName} className="h-full w-full object-cover" />
) : (
avatarInitial
)}
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<DashboardMetric label="待我评价" value={pendingTotal} emphasis />
<DashboardMetric label="进行中问卷" value={openCreatedTotal} />
<DashboardMetric label="我已提交" value={submittedTotal} />
</div>
<div className="mt-4 flex gap-1 overflow-x-auto rounded-2xl bg-[#f1f5f9] p-1 dark:bg-gray-800">
{tabs.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={`min-w-max flex-1 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors sm:flex-none sm:px-4 ${
tab === item.id
? 'bg-white text-[#101828] shadow-sm dark:bg-gray-950 dark:text-white'
: 'text-[#667085] hover:bg-white/70 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{item.label}
{typeof item.count === 'number' && item.count > 0 && (
<span className={`ml-2 hidden rounded-full px-1.5 py-0.5 text-xs sm:inline-flex ${tab === item.id ? 'bg-[#1677ff] text-white' : 'bg-white text-[#667085] dark:bg-gray-900 dark:text-gray-300'}`}>
{item.count}
</span>
)}
</button>
))}
</div>
</section>
{surveys.loading && <Panel>加载中...</Panel>}
{surveys.error && <ErrorPanel message={surveys.error} />}
{surveys.data && <DashboardStats data={surveys.data} activeTab={tab} />}
{surveys.data && tab === 'pending' && <PendingList items={pendingItems} />}
{surveys.data && tab === 'created' && (
<CreatedList items={createdItems} onRefresh={surveys.refresh} />
)}
{surveys.data && tab === 'results' && <MyResults items={resultItems} />}
{canManage && tab === 'manage' && (
<ManagePanel
onChanged={async () => {
await surveys.refresh()
}}
templateStats={surveys.data?.templateStats || []}
/>
)}
</main>
)
}
function DashboardMetric({ label, value, emphasis }: { label: string; value: number; emphasis?: boolean }) {
return (
<div className={`rounded-2xl px-3 py-3 ${emphasis ? 'bg-[#1677ff] text-white shadow-[0_10px_20px_rgba(22,119,255,0.18)]' : 'bg-[#f6f8fb] text-[#101828] dark:bg-gray-800 dark:text-white'}`}>
<div className={`text-xs font-medium ${emphasis ? 'text-white/75' : 'text-[#667085] dark:text-gray-300'}`}>{label}</div>
<div className="mt-1 text-2xl font-semibold leading-none">{value}</div>
</div>
)
}
function DashboardStats({ data, activeTab }: { data: any; activeTab: string }) {
const created = data.created || []
const templateStats = data.templateStats || []
const responseTotal = created.reduce((sum: number, item: any) => sum + Number(item.counts?.responses || 0), 0)
const openTotal = created.filter((item: any) => item.status === 'open').length
const items = [
{ label: '问卷事项', value: created.length },
{ label: '评价总数', value: responseTotal },
{ label: '模板数量', value: templateStats.length },
{ label: '开放问卷', value: openTotal },
]
return (
<div className={`mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4 ${activeTab === 'manage' ? '' : 'hidden sm:grid'}`}>
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div className="text-sm text-gray-500 dark:text-gray-400">{item.label}</div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">{item.value}</div>
</div>
))}
</div>
)
}
function PendingList({ items }: { items: any[] }) {
if (items.length === 0) {
return (
<div className="rounded-[24px] border border-dashed border-gray-200 bg-white px-5 py-8 text-center shadow-[0_12px_34px_rgba(15,23,42,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 text-lg font-semibold text-gray-500 dark:bg-gray-800 dark:text-gray-300">0</div>
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">暂无待评价</h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">新的考核调研会出现在这里。</p>
</div>
)
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<div key={item.assignmentId} className="overflow-hidden rounded-[24px] border border-[#e3eaf3] bg-white p-4 shadow-[0_14px_36px_rgba(15,23,42,0.07)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:p-5 sm:shadow-none">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mb-2 text-xs font-semibold text-blue-600 dark:text-blue-300">待填写</div>
<h2 className="break-words text-[1.35rem] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
<p className="mt-2 break-words text-sm text-[#667085] dark:text-gray-400">评价对象:{item.targetName}</p>
</div>
<div className="shrink-0">
<StatusBadge status={item.status} />
</div>
</div>
<div className="mt-5 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<span className="text-[#667085] dark:text-gray-400">{item.dueAt ? `截止 ${formatDate(item.dueAt)}` : '无截止时间'}</span>
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
className="flex w-full justify-center rounded-2xl bg-[#1677ff] px-5 py-3 font-semibold text-white shadow-[0_10px_22px_rgba(22,119,255,0.22)] hover:bg-[#0f66dd] dark:bg-white dark:text-gray-900 sm:w-auto sm:py-2.5"
>
去评价
</Link>
</div>
</div>
))}
</div>
)
}
function CreatedList({ items, onRefresh }: { items: any[]; onRefresh: () => Promise<void> }) {
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="还没有发起过考核调研" />
}
return (
<>
<div className="grid gap-3 sm:hidden">
{items.map((item) => {
const targetCount = Number(item.counts?.targets || 0)
const assignmentCount = Number(item.counts?.assignments || 0)
const responseCount = Number(item.counts?.responses || 0)
const done = assignmentCount > 0 && responseCount >= assignmentCount
return (
<article
key={item.id}
className="overflow-hidden rounded-[24px] border border-[#e3eaf3] bg-white p-4 shadow-[0_14px_36px_rgba(15,23,42,0.07)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className={`mb-2 text-xs font-semibold ${done ? 'text-emerald-600' : 'text-blue-600'}`}>
{done ? '已全部提交' : item.status === 'open' ? '进行中' : getSurveyStatusLabel(item.status)}
</div>
<h2 className="break-words text-[1.25rem] font-semibold leading-snug text-[#101828]">
{item.title}
</h2>
<p className="mt-2 break-words text-sm font-medium text-[#667085]">
{item.departmentName || '未指定部门'}
</p>
</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
<div className="text-xs font-medium text-[#667085]">对象</div>
<div className="mt-1 text-xl font-semibold leading-none text-[#101828]">{targetCount}</div>
</div>
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
<div className="text-xs font-medium text-[#667085]">评价人</div>
<div className="mt-1 text-xl font-semibold leading-none text-[#101828]">{assignmentCount}</div>
</div>
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
<div className="text-xs font-medium text-[#667085]">已提交</div>
<div className="mt-1 text-xl font-semibold leading-none text-[#101828]">{responseCount}</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-sm font-medium text-[#667085]">
<span>{item.dueAt ? `截止 ${formatDate(item.dueAt)}` : '无截止时间'}</span>
<span>{assignmentCount > 0 ? `${responseCount}/${assignmentCount}` : '0/0'}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{item.status === 'draft' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'open')}
className="min-h-[42px] rounded-2xl border border-[#d8e3f2] px-3 text-sm font-semibold text-[#101828] disabled:opacity-50"
>
发布
</button>
)}
{item.status !== 'closed' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'closed')}
className="min-h-[42px] rounded-2xl border border-[#d8e3f2] px-3 text-sm font-semibold text-[#101828] disabled:opacity-50"
>
关闭
</button>
)}
<Link
href={`/performance-survey/results/${item.id}`}
className="flex min-h-[42px] items-center justify-center rounded-2xl bg-[#101828] px-3 text-sm font-semibold text-white"
>
查看结果
</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}`}
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 MyResults({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无可查看结果" />
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<Link
key={`${item.surveyId}-${item.targetId}`}
href={`/performance-survey/results/${item.surveyId}?targetId=${item.targetId}`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-500"
>
<h2 className="font-semibold text-gray-900 dark:text-white">{item.title}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{item.targetName}</p>
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">{item.closedAt ? formatDate(item.closedAt) : ''}</p>
</Link>
))}
</div>
)
}
function ManagePanel({ onChanged, templateStats }: { onChanged: () => Promise<void>; templateStats: any[] }) {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const notifications = useApi<any[]>('/admin/notifications?limit=30', [])
const notificationStatus = useApi<any>('/admin/notifications/status', [])
const [message, setMessage] = useState<string | null>(null)
const refreshNotifications = async () => {
await Promise.all([notifications.refresh(), notificationStatus.refresh()])
}
return (
<div className="space-y-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已创建考核调研')
await onChanged()
await notifications.refresh()
}}
/>
<TemplateStatsPanel items={templateStats} />
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]">
<div className="space-y-5">
<AdminActions
onMessage={setMessage}
onNotificationsChanged={refreshNotifications}
/>
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
</div>
<NotificationList state={notifications} statusState={notificationStatus} />
</div>
</div>
)
}
function TemplateStatsPanel({ items }: { items: any[] }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">模板核算概览</h2>
<div className="mt-4 space-y-3">
{items.map((item) => {
const achievement = item.responseCount > 0 ? item.aggregate?.achievementScore : '暂无'
const suffix = item.responseCount > 0 ? item.achievementSuffix || '' : ''
return (
<div key={item.code} className="grid grid-cols-1 items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:gap-4">
<div className="min-w-0">
<div className="truncate font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{item.responseCount || 0} 份评价</div>
</div>
<div className="sm:w-24 sm:text-right">
<div className="text-xl font-semibold text-gray-900 dark:text-white">{achievement}{suffix}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{item.achievementLabel || '得分'}</div>
</div>
<div className="sm:w-24 sm:text-right">
<div className="text-xl font-semibold text-gray-900 dark:text-white">{item.responseCount > 0 ? item.aggregate?.weightedScore : '暂无'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">权重得分 · {item.weightLabel || `${Math.round(Number(item.weight || 0) * 100)}%`}</div>
</div>
</div>
)
})}
{items.length === 0 && <div className="text-sm text-gray-500 dark:text-gray-400">暂无模板数据</div>}
</div>
</div>
)
}
function CreateSurveyForm({
templates,
departments,
onCreated,
}: {
templates: Template[]
departments: DepartmentOption[]
onCreated: () => Promise<void>
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [surveyKind, setSurveyKind] = useState('item_course')
const [templateCode, setTemplateCode] = useState('')
const [dueAt, setDueAt] = useState('')
const [openNow, setOpenNow] = useState(true)
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
const [evaluatorEmployees, setEvaluatorEmployees] = useState<Employee[]>([])
const [evaluatorDepartments, setEvaluatorDepartments] = useState<string[]>([])
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 activeTemplates = templates.filter((template) => template.surveyType === surveyKind)
const selectedTemplate = templateCode || activeTemplates[0]?.code || templates[0]?.code || ''
const submit = async () => {
setSaving(true)
setError(null)
setCreatedSurvey(null)
try {
const pendingItemName = itemName.trim()
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
? [...targetItems, { name: pendingItemName }]
: targetItems
const submitTitle = title.trim() || targetItemsToSubmit[0]?.name || ''
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),
evaluatorDepartmentNames: evaluatorDepartments,
}),
})
setTitle('')
setDescription('')
setTargetEmployees([])
setEvaluatorEmployees([])
setEvaluatorDepartments([])
setTargetItems([])
setItemName('')
setCreatedSurvey(created)
await onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败')
} finally {
setSaving(false)
}
}
const surveyLink = createdSurvey?.id
? `${typeof window !== 'undefined' ? window.location.origin : ''}/performance-survey/survey/${createdSurvey.id}`
: ''
const qrLink = surveyLink
? `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(surveyLink)}`
: ''
const copySurveyLink = async () => {
if (!surveyLink) return
await navigator.clipboard?.writeText(surveyLink)
}
const downloadQr = async () => {
if (!qrLink) return
const response = await fetch(qrLink)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${createdSurvey?.title || title || '考核调研'}-二维码.png`
link.click()
URL.revokeObjectURL(url)
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">创建考核问卷</h2>
<div className="mt-4 grid gap-4">
<TextInput label="标题" value={title} onChange={setTitle} />
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">类型</span>
<select
value={surveyKind}
onChange={(event) => {
setSurveyKind(event.target.value)
setTemplateCode('')
}}
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"
>
<option value="person_period">人员周期考核</option>
<option value="item_course">课程/事项/活动调研</option>
</select>
</label>
<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"
>
{(activeTemplates.length > 0 ? activeTemplates : templates).map((template) => (
<option key={template.code} value={template.code}>
{template.name} · {template.weightLabel || `${Math.round(template.weight * 100)}%`}
</option>
))}
</select>
</label>
<TextInput label="说明" value={description} onChange={setDescription} />
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
{surveyKind === 'person_period' ? (
<EmployeePicker title="被考核人" purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
) : (
<div>
<div className="flex gap-2">
<input
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder="课程、事项或活动名称"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
<button
type="button"
onClick={() => {
const name = itemName.trim()
if (!name) return
setTargetItems((current) => [...current, { name }])
setItemName('')
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:text-gray-200"
>
添加
</button>
</div>
<SelectedChips
items={targetItems.map((item) => ({ id: item.name, label: item.name }))}
onRemove={(id) => setTargetItems((current) => current.filter((item) => item.name !== id))}
/>
</div>
)}
<EmployeePicker title="评价人" purpose="evaluator" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
<DepartmentPicker
departments={departments}
selected={evaluatorDepartments}
onChange={setEvaluatorDepartments}
/>
<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-lg bg-gray-900 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900"
>
{saving ? '创建中...' : '生成问卷二维码'}
</button>
{createdSurvey && surveyLink && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{/* 二维码来自第三方生成服务,无法走本项目的 Next Image 域名白名单。 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{qrLink && <img src={qrLink} alt="问卷二维码" className="h-28 w-28 rounded-md bg-white p-1" />}
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-900 dark:text-white">问卷已生成</div>
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-300">{surveyLink}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={copySurveyLink} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
复制链接
</button>
<button type="button" onClick={downloadQr} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
保存二维码
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}
function EmployeePicker({
title,
purpose,
selected,
onChange,
}: {
title: string
purpose?: 'target' | 'evaluator'
selected: Employee[]
onChange: (employees: Employee[]) => 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)
useEffect(() => {
const timer = window.setTimeout(async () => {
if (!keyword.trim()) {
setItems([])
setError(null)
setLoading(false)
setOpen(false)
return
}
setLoading(true)
setError(null)
setOpen(true)
try {
const params = new URLSearchParams({
search: keyword,
limit: '50',
...(purpose ? { purpose } : {}),
})
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
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
onChange([...selected, employee])
setError(null)
setOpen(true)
}
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 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-lg border border-gray-200 dark:border-gray-700">
{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 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="text-xs text-gray-500 dark:text-gray-400">
{[
employee.departmentName,
employee.positionTitle,
employee.positionLevel || employee.levelType,
employee.isLeadership ? 'M层' : employee.levelType === 'P' ? 'P序列' : null,
employee.managerName ? `主管:${employee.managerName}` : null,
employee.jobNumber,
employee.dingEmail,
].filter(Boolean).join(' · ')}
</div>
</button>
))
)}
</div>
)}
<SelectedChips
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
onRemove={(id) => onChange(selected.filter((employee) => employee.id !== id))}
/>
</div>
)
}
function DepartmentPicker({
departments,
selected,
onChange,
}: {
departments: DepartmentOption[]
selected: string[]
onChange: (items: string[]) => void
}) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [keyword, setKeyword] = useState('')
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 toggleValue = (value: string, checked: boolean) => {
onChange(
checked
? Array.from(new Set([...selected, value]))
: selected.filter((item) => item !== value),
)
}
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 className="text-xs text-gray-500 dark:text-gray-400">
{department.employeeCount || 0} 人
</div>
</div>
{department.selectable !== false && department.value && (
<input
type="checkbox"
checked={selected.includes(department.value)}
onChange={(event) => toggleValue(department.value!, event.target.checked)}
aria-label={`选择${department.name}`}
/>
)}
</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={() => onChange([])}
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-72 overflow-y-auto rounded-lg border border-gray-200 p-2 dark:border-gray-700">
{roots.map((department) => renderNode(department))}
</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={() => department.value && 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,
onNotificationsChanged,
}: {
onMessage: (message: string | null) => void
onNotificationsChanged: () => Promise<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} 人` : ''}`)
await onNotificationsChanged()
} catch (error) {
onMessage(error instanceof Error ? error.message : '操作失败')
} finally {
setBusy(null)
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 className="font-semibold text-gray-900 dark:text-white">管理操作</h2>
<div className="mt-3 grid gap-2">
<button
disabled={busy !== null}
onClick={() => run('sync', '/admin/sync-employees', '人员档案已同步')}
className="rounded-lg border border-gray-300 px-3 py-2 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"
>
{busy === 'sync' ? '同步中...' : '同步人员档案'}
</button>
<button
disabled={busy !== null}
onClick={() => run('notice', '/admin/notifications/maintenance', '通知维护已执行')}
className="rounded-lg border border-gray-300 px-3 py-2 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"
>
{busy === 'notice' ? '执行中...' : '执行通知维护'}
</button>
<a
href="/api/performance-survey/admin/export"
className="rounded-lg border border-gray-300 px-3 py-2 text-center text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
导出结果
</a>
</div>
</div>
)
}
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 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 (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900 dark:text-white">钉钉通知</h2>
<button
onClick={async () => {
await Promise.all([state.refresh(), statusState.refresh()])
}}
className="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300"
>
刷新
</button>
</div>
{workNotice && !workNotice.ready && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-100">
钉钉工作通知未就绪:缺少 {workNotice.missing?.join('、') || '必要配置'}
</div>
)}
{workNotice?.ready && (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-100">
钉钉工作通知已就绪
</div>
)}
{Object.keys(counts).length > 0 && (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
{Object.entries(counts).map(([status, count]) => (
<span key={status} className="rounded-md bg-gray-100 px-2 py-1 dark:bg-gray-700">{status}: {String(count)}</span>
))}
</div>
)}
<div className="mt-3 space-y-2">
{state.loading && <div className="text-sm text-gray-500">加载中...</div>}
{state.error && <div className="text-sm text-red-600">{state.error}</div>}
{state.data?.length === 0 && <div className="text-sm text-gray-500">暂无通知记录</div>}
{state.data?.map((item) => (
<div key={item.id} className="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{item.recipientName} · {item.eventType}</div>
{item.lastError && <div className="mt-1 text-xs text-red-600 dark:text-red-300">{item.lastError}</div>}
</div>
<StatusBadge status={item.status} />
</div>
{['retrying', 'exhausted', 'skipped'].includes(item.status) && (
<button
disabled={busyId === item.id}
onClick={() => retry(item.id)}
className="mt-2 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>
)
}
function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) {
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 targets = useMemo(
() => (detail.data?.targets || []).filter((target: any) => target.currentAssignment),
[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 viewer = session?.user || {}
const viewerName = String(viewer.name || '当前用户')
const viewerDepartment = String(viewer.department || viewer.managedDepartment || '素材库成员')
const viewerAvatar = typeof viewer.avatar === 'string' && viewer.avatar
? viewer.avatar
: typeof viewer.image === 'string'
? viewer.image
: ''
const surveyStatusLabel = getSurveyStatusLabel(detail.data.status)
const dueLabel = detail.data.dueAt ? `截止 ${formatDate(detail.data.dueAt)}` : '无截止时间'
return (
<main className="min-h-[100dvh] bg-white text-[#111827]">
<MobileSurveyRangeStyles />
<div className="mx-auto flex min-h-[100dvh] max-w-[430px] flex-col bg-white px-5 pb-[calc(92px+env(safe-area-inset-bottom))] pt-6 sm:my-6 sm:min-h-0 sm:rounded-[28px] sm:border sm:border-[#e5eaf2] sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
<div className="mb-6 flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<SurveyUserAvatar src={viewerAvatar} name={viewerName} />
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold leading-5 text-[#111827]">{viewerName}</div>
<div className="mt-0.5 truncate text-[12px] font-medium leading-4 text-[#9aa3b2]">{viewerDepartment}</div>
</div>
</div>
<span className="shrink-0 rounded-[7px] bg-[#eaf3ff] px-3 py-1.5 text-[13px] font-semibold leading-none text-[#1677ff]">
{surveyStatusLabel}
</span>
</div>
<div className="mb-7">
<h1 className="text-[22px] font-semibold leading-[1.25] tracking-[-0.01em] text-[#111827]">{detail.data.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] font-medium leading-5 text-[#7b8494]">
<span>评价对象:{activeTarget?.targetName || '待选择'}</span>
<span>{dueLabel}</span>
</div>
</div>
{targets.length > 1 && (
<label className="mb-4 grid gap-2 text-sm">
<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 ? (
<div className="rounded-[12px] border border-[#dce6f2] bg-[#f8fbff] px-4 py-10 text-center">
<div className="text-[18px] font-semibold text-[#111827]">你已提交该评价</div>
<Link href="/performance-survey" className="mt-4 inline-flex rounded-[10px] bg-[#1677ff] px-4 py-2 text-sm font-semibold text-white">
返回考核调研
</Link>
</div>
) : detail.data.status !== 'open' ? (
<div className="rounded-[12px] border border-[#dce6f2] bg-[#f8fbff] px-4 py-10 text-center text-[16px] font-semibold text-[#4b5565]">
该调研未开放或已关闭
</div>
) : (
<div className="space-y-3">
{questions.map((question, index) => {
const value = scores[question.id] ?? 16
const scorePercent = Math.max(0, Math.min(100, (value / 20) * 100))
const accentColor = getQuestionAccentColor(index)
return (
<section key={question.id} className="rounded-[10px] border border-[#dfe6f0] bg-white px-3.5 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<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">
<h2 className="text-[15px] font-semibold leading-6 text-[#111827]">
{question.title}
<span className="ml-1 text-[11px] font-medium text-[#98a2b3]">满分20分</span>
</h2>
{question.description && (
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
)}
</div>
<div className="pt-1 text-right">
<span className="text-[18px] font-semibold leading-none" style={{ color: accentColor }}>{value}</span>
<span className="ml-1 text-[11px] font-medium text-[#98a2b3]">/ 20分</span>
</div>
</div>
<input
type="range"
min={0}
max={20}
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">5</span>
<span className="text-center">10</span>
<span className="text-center">15</span>
<span className="text-right">20</span>
</div>
</section>
)
})}
<section className="rounded-[10px] border border-[#dfe6f0] bg-white px-3.5 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<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 max-w-[390px]">
<button
disabled={submitting || !activeTargetId || ratedCount !== questions.length}
onClick={submit}
className="min-h-[50px] w-full rounded-[9px] bg-[#1677ff] px-4 py-2.5 text-[17px] font-semibold text-white shadow-[0_10px_22px_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 SurveyUserAvatar({ src, name }: { src?: string; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name || '用户头像'}
width={44}
height={44}
unoptimized
className="h-11 w-11 rounded-full border border-[#eef2f7] object-cover shadow-[0_4px_12px_rgba(15,23,42,0.08)]"
/>
)
}
return (
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-[#eef2f7] bg-[#f1f6ff] text-[16px] font-semibold text-[#1677ff] shadow-[0_4px_12px_rgba(15,23,42,0.08)]">
{getSurveyInitials(name)}
</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 SurveyResults({ surveyId }: { surveyId: string }) {
const searchParams = useSearchParams()
const query = searchParams.get('targetId') ? `?targetId=${encodeURIComponent(searchParams.get('targetId') || '')}` : ''
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
return (
<main className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-4">
<Link href="/performance-survey" className="text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">返回考核调研</Link>
<h1 className="mt-3 text-2xl font-semibold text-gray-900 dark:text-white">{result.data.survey.title}</h1>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
<StatusBadge status={result.data.survey.status} />
{result.data.survey.closedAt && <span>关闭 {formatDate(result.data.survey.closedAt)}</span>}
</div>
</div>
<div className="grid gap-4">
{result.data.targets.map((target: any) => (
<div key={target.id} className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{target.targetName}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
有效样本 {target.aggregate.effectiveCount}/{target.aggregate.responseCount}
{target.aggregate.droppedHighestLowest ? ' · 已去最高/最低' : ''}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<Metric label="达成分" value={target.aggregate.achievementScore} />
<Metric label="KPI分" value={target.aggregate.kpiScore} />
<Metric label="加权分" value={target.aggregate.weightedScore} />
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{target.responses.map((response: any) => (
<div key={response.id} className="rounded-lg bg-gray-50 p-3 dark:bg-gray-900/40">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-white">{response.respondentName}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{response.totalScore}</span>
</div>
{response.comment && <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{response.comment}</p>}
</div>
))}
{target.responses.length === 0 && <div className="text-sm text-gray-500">暂无评价</div>}
</div>
</div>
))}
</div>
</main>
)
}
function TextInput({
label,
value,
onChange,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
type?: 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)}
className="rounded-lg border border-gray-300 px-3 py-2 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: '已提交',
succeeded: '已发送',
retrying: '重试中',
exhausted: '失败',
skipped: '未发送',
processing: '处理中',
}
return (
<span className="inline-flex rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{map[status] || status}
</span>
)
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-20 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-900/40">
<div className="text-lg font-semibold text-gray-900 dark:text-white">{value}</div>
<div className="text-xs text-gray-500 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-lg border border-dashed border-gray-300 bg-white px-4 py-10 text-center text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
{title}
</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-[#f6f8fb] 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 })
}