2026-07-06 17:25:04 +08:00

1377 lines
53 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 ReactNode } from 'react'
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'
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 response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(options?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(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)) {
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[]) : []
useEffect(() => {
if (status === 'unauthenticated') {
const currentPath =
typeof window === 'undefined'
? '/performance-survey'
: `${window.location.pathname}${window.location.search}`
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
}
}, [status, router])
useEffect(() => {
if (path[0] === 'teacher' || path[0] === 'admin') {
router.replace('/performance-survey')
}
}, [path, router])
if (status === 'loading') {
return <LoadingPage />
}
if (!session) {
return null
}
let content = <Dashboard />
if (path[0] === 'survey' && path[1]) {
content = <SurveySubmit surveyId={path[1]} />
}
if (path[0] === 'results' && path[1]) {
content = <SurveyResults surveyId={path[1]} />
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
{content}
</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 tabs = [
{ id: 'pending', label: '待我评价' },
{ id: 'created', label: '我发起的' },
{ id: 'results', label: '我的结果' },
...(canManage ? [{ id: 'manage', label: '管理' }] : []),
]
return (
<main className="mx-auto max-w-7xl px-4 py-6">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">考核调研</h1>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>{me.data?.user?.name || '当前用户'}</span>
{me.data?.employee?.departmentName && <span>{me.data.employee.departmentName}</span>}
{me.data?.employee?.positionLevel && <span>{me.data.employee.positionLevel}</span>}
</div>
</div>
<div className="flex rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-800">
{tabs.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
tab === item.id
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{item.label}
</button>
))}
</div>
</div>
{surveys.loading && <Panel>加载中...</Panel>}
{surveys.error && <ErrorPanel message={surveys.error} />}
{surveys.data && <DashboardStats data={surveys.data} />}
{surveys.data && tab === 'pending' && <PendingList items={surveys.data.pending || []} />}
{surveys.data && tab === 'created' && (
<CreatedList items={surveys.data.created || []} onRefresh={surveys.refresh} />
)}
{surveys.data && tab === 'results' && <MyResults items={surveys.data.results || []} />}
{canManage && tab === 'manage' && (
<ManagePanel
onChanged={async () => {
await surveys.refresh()
}}
templateStats={surveys.data?.templateStats || []}
/>
)}
</main>
)
}
function DashboardStats({ data }: { data: any }) {
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">
{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 <EmptyState title="暂无待评价" />
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<div key={item.assignmentId} className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base 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>
</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">{item.dueAt ? `截止 ${formatDate(item.dueAt)}` : '无截止时间'}</span>
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
className="rounded-md bg-gray-900 px-3 py-1.5 text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900"
>
填写
</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="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<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 [message, setMessage] = useState<string | null>(null)
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={notifications.refresh}
/>
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
</div>
<NotificationList state={notifications} />
</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 }: { state: ApiState<any[]> & { refresh: () => Promise<void> } }) {
const [busyId, setBusyId] = useState<string | null>(null)
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={state.refresh} className="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300">刷新</button>
</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 }: { surveyId: string }) {
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
useEffect(() => {
if (!targetId && targets[0]?.id) setTargetId(targets[0].id)
}, [targets, targetId])
useEffect(() => {
if (template?.questions) {
setScores(
Object.fromEntries(
template.questions.map((question) => [question.id, 16]),
),
)
}
}, [template?.code])
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 <MainPanel>加载中...</MainPanel>
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'
return (
<main className="mx-auto max-w-4xl 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">{detail.data.title}</h1>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
<StatusBadge status={detail.data.status} />
{detail.data.dueAt && <span>截止 {formatDate(detail.data.dueAt)}</span>}
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
{targets.length > 1 && (
<label className="mb-4 grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">评价对象</span>
<select
value={activeTargetId}
onChange={(event) => setTargetId(event.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
))}
</select>
</label>
)}
{activeTarget && (
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
{activeTarget.targetName}
</div>
)}
{submitted ? (
<EmptyState title="你已提交该评价" />
) : detail.data.status !== 'open' ? (
<EmptyState title="该调研未开放或已关闭" />
) : (
<div className="space-y-5">
{template?.questions?.map((question) => (
<div key={question.id}>
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="font-medium text-gray-900 dark:text-white">{question.title}</div>
{question.description && <div className="text-sm text-gray-500 dark:text-gray-400">{question.description}</div>}
</div>
<span className="w-10 text-right text-sm font-semibold text-gray-900 dark:text-white">{scores[question.id] ?? 16}</span>
</div>
<input
type="range"
min={0}
max={20}
value={scores[question.id] ?? 16}
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
className="w-full"
/>
</div>
))}
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">补充评价</span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={4}
className="rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
</label>
{message && <div className="text-sm text-gray-600 dark:text-gray-300">{message}</div>}
<button
disabled={submitting || !activeTargetId}
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"
>
{submitting ? '提交中...' : '提交评价'}
</button>
</div>
)}
</div>
</main>
)
}
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 LoadingPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
加载中...
</div>
)
}
function formatDate(value: string | Date) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', { hour12: false })
}