1933 lines
79 KiB
TypeScript
1933 lines
79 KiB
TypeScript
'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 })
|
||
}
|