4198 lines
167 KiB
TypeScript
4198 lines
167 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||
import Image from 'next/image'
|
||
import Link from 'next/link'
|
||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||
import { useSession } from 'next-auth/react'
|
||
import { pinyin } from 'pinyin-pro'
|
||
import { createPortal } from 'react-dom'
|
||
|
||
type ApiState<T> = {
|
||
loading: boolean
|
||
data: T | null
|
||
error: string | null
|
||
}
|
||
|
||
type Employee = {
|
||
id: string
|
||
displayName: string
|
||
flowerName?: string | null
|
||
realName?: string | null
|
||
dingEmail?: string | null
|
||
jobNumber?: string | null
|
||
departmentName?: string | null
|
||
positionTitle?: string | null
|
||
positionLevel?: string | null
|
||
levelType?: string | null
|
||
isLeadership?: boolean | null
|
||
managerName?: string | null
|
||
dingtalkUserId?: string | null
|
||
}
|
||
|
||
type DepartmentOption = {
|
||
id: string
|
||
name: string
|
||
parentId?: string | null
|
||
parentName?: string | null
|
||
employeeCount?: number
|
||
selectable?: boolean
|
||
value?: string | null
|
||
departmentName?: string | null
|
||
departmentPath?: string | null
|
||
fullPath?: string | null
|
||
}
|
||
|
||
type Template = {
|
||
code: string
|
||
name: string
|
||
shortName?: string
|
||
description: string
|
||
surveyType: string
|
||
ownerRoleName?: string
|
||
itemName?: string
|
||
itemNamePlaceholder?: string
|
||
itemNoteName?: string
|
||
itemNotePlaceholder?: string
|
||
defaultCategory?: string
|
||
respondentNameLabel?: string
|
||
respondentDepartmentLabel?: string
|
||
respondentNameFallback?: string
|
||
dataSource?: string
|
||
weight: number
|
||
weightLabel?: string
|
||
maxTotal?: number
|
||
questionMaxScore?: number
|
||
requiredWorkItemCount?: number
|
||
workItemName?: string
|
||
workItemImageRequired?: boolean
|
||
achievementLabel?: string
|
||
achievementSuffix?: string
|
||
trimNote?: string
|
||
trimmedNote?: string
|
||
belowBand?: string
|
||
questions: Array<{ id: string; title: string; description?: string; maxScore?: number }>
|
||
}
|
||
|
||
const API_BASE = '/api/performance-survey'
|
||
const DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY = 'performanceSurveyDingtalkToken'
|
||
const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session'
|
||
const DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT = 'performance-survey:dingtalk-session-expired'
|
||
const PERFORMANCE_SURVEY_MAX_IMAGE_COUNT = 12
|
||
const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||
const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([
|
||
's3.jiansun.vip',
|
||
's3.yydd.ai',
|
||
's3.1080p.hk',
|
||
])
|
||
|
||
type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage'
|
||
|
||
function normalizePerformanceSurveyDashboardTab(value: unknown): PerformanceSurveyDashboardTab {
|
||
const tab = String(value || '').trim().toLowerCase()
|
||
if (tab === 'public') return 'public'
|
||
if (tab === 'manage' || tab === 'created' || tab === 'admin') return 'manage'
|
||
return 'personal'
|
||
}
|
||
|
||
function getLegacyPerformanceSurveyPathTab(path: string[]): PerformanceSurveyDashboardTab | null {
|
||
if (path.length !== 1) return null
|
||
const segment = String(path[0] || '').trim().toLowerCase()
|
||
if (!['personal', 'public', 'manage', 'pending', 'submitted', 'results', 'created', 'teacher', 'admin'].includes(segment)) {
|
||
return null
|
||
}
|
||
return normalizePerformanceSurveyDashboardTab(segment)
|
||
}
|
||
|
||
function getModalFocusableElements(container: HTMLElement | null) {
|
||
if (!container) return []
|
||
return Array.from(container.querySelectorAll<HTMLElement>(
|
||
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||
)).filter((element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true')
|
||
}
|
||
|
||
function keepFocusInsideModal(event: KeyboardEvent, container: HTMLElement | null) {
|
||
if (event.key !== 'Tab') return
|
||
const focusable = getModalFocusableElements(container)
|
||
if (focusable.length === 0) {
|
||
event.preventDefault()
|
||
container?.focus()
|
||
return
|
||
}
|
||
|
||
const first = focusable[0]
|
||
const last = focusable[focusable.length - 1]
|
||
const activeElement = document.activeElement
|
||
if (event.shiftKey && (activeElement === first || !container?.contains(activeElement))) {
|
||
event.preventDefault()
|
||
last.focus()
|
||
} else if (!event.shiftKey && activeElement === last) {
|
||
event.preventDefault()
|
||
first.focus()
|
||
}
|
||
}
|
||
|
||
function validatePerformanceSurveyImageFiles(files: File[]) {
|
||
if (files.length === 0) return '请至少选择 1 张图片'
|
||
if (files.length > PERFORMANCE_SURVEY_MAX_IMAGE_COUNT) {
|
||
return `单个案例最多上传 ${PERFORMANCE_SURVEY_MAX_IMAGE_COUNT} 张图片`
|
||
}
|
||
|
||
const invalidType = files.find((file) => !file.type.startsWith('image/'))
|
||
if (invalidType) return `文件「${invalidType.name}」不是图片`
|
||
const oversized = files.find((file) => file.size > PERFORMANCE_SURVEY_MAX_IMAGE_BYTES)
|
||
if (oversized) return `图片「${oversized.name}」超过 20MB`
|
||
return null
|
||
}
|
||
|
||
function normalizePerformanceSurveyImageUrl(url?: string | null): string {
|
||
const trimmed = String(url || '').trim()
|
||
if (!trimmed) return ''
|
||
if (!/^https?:\/\//i.test(trimmed)) return trimmed
|
||
|
||
try {
|
||
const parsed = new URL(trimmed)
|
||
if (PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS.has(parsed.hostname.toLowerCase())) {
|
||
return `/api/proxy-media?url=${encodeURIComponent(trimmed)}`
|
||
}
|
||
return trimmed
|
||
} catch {
|
||
return trimmed
|
||
}
|
||
}
|
||
|
||
function getStoredDingtalkPerformanceToken() {
|
||
if (typeof window === 'undefined') return null
|
||
try {
|
||
return (
|
||
window.sessionStorage.getItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY) ||
|
||
window.localStorage.getItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY)
|
||
)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function clearStoredDingtalkPerformanceToken() {
|
||
if (typeof window === 'undefined') return
|
||
try {
|
||
window.sessionStorage.removeItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY)
|
||
window.localStorage.removeItem(DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY)
|
||
} catch {
|
||
// 浏览器存储不可用时没有可清理的本地凭证。
|
||
}
|
||
}
|
||
|
||
function decodeStoredDingtalkPerformanceSession() {
|
||
const token = getStoredDingtalkPerformanceToken()
|
||
const encodedPayload = token?.split('.')[0]
|
||
if (!encodedPayload) return null
|
||
|
||
try {
|
||
const normalized = encodedPayload.replace(/-/g, '+').replace(/_/g, '/')
|
||
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
|
||
const binary = window.atob(normalized + padding)
|
||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
||
const payload = JSON.parse(new TextDecoder().decode(bytes))
|
||
return {
|
||
id: String(payload.userId || ''),
|
||
name: String(payload.name || ''),
|
||
email: String(payload.email || ''),
|
||
avatar: payload.avatar ? String(payload.avatar) : null,
|
||
role: String(payload.role || 'USER'),
|
||
department: String(payload.department || ''),
|
||
managedDepartment: payload.managedDepartment ? String(payload.managedDepartment) : null,
|
||
isSuperAdmin: Boolean(payload.isSuperAdmin),
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function normalizePickerSearch(value: unknown) {
|
||
return String(value ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\s/_\-||,,;;>]+/g, '')
|
||
}
|
||
|
||
function pickerSearchVariants(value: unknown) {
|
||
const text = String(value ?? '').trim()
|
||
if (!text) return []
|
||
const variants = [normalizePickerSearch(text)]
|
||
try {
|
||
variants.push(
|
||
normalizePickerSearch(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
|
||
normalizePickerSearch(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
|
||
)
|
||
} catch {
|
||
// 非中文或异常字符按原文匹配即可。
|
||
}
|
||
return Array.from(new Set(variants.filter(Boolean)))
|
||
}
|
||
|
||
function matchesPickerSearch(keyword: string, values: unknown[]) {
|
||
const normalizedKeyword = normalizePickerSearch(keyword)
|
||
if (!normalizedKeyword) return true
|
||
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
|
||
}
|
||
|
||
function mergeEmployeesById(...groups: Employee[][]) {
|
||
const byId = new Map<string, Employee>()
|
||
for (const group of groups) {
|
||
for (const employee of group) {
|
||
if (!byId.has(employee.id)) byId.set(employee.id, employee)
|
||
}
|
||
}
|
||
return Array.from(byId.values())
|
||
}
|
||
|
||
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
|
||
const dingtalkToken = getStoredDingtalkPerformanceToken()
|
||
const response = await fetch(`${API_BASE}${path}`, {
|
||
...options,
|
||
headers: {
|
||
...(options?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||
...(dingtalkToken
|
||
? {
|
||
Authorization: `Bearer ${dingtalkToken}`,
|
||
[DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken,
|
||
}
|
||
: {}),
|
||
...(options?.headers || {}),
|
||
},
|
||
})
|
||
const contentType = response.headers.get('content-type') || ''
|
||
const payload = contentType.includes('application/json') ? await response.json() : await response.text()
|
||
|
||
if (!response.ok || (payload && typeof payload === 'object' && payload.success === false)) {
|
||
if (response.status === 401 && dingtalkToken) {
|
||
clearStoredDingtalkPerformanceToken()
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new Event(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT))
|
||
}
|
||
throw new Error('钉钉考核调研访问凭证已过期,请从钉钉最新通知重新进入')
|
||
}
|
||
|
||
throw new Error(
|
||
payload && typeof payload === 'object'
|
||
? payload.error || payload.message || '请求失败'
|
||
: String(payload || '请求失败'),
|
||
)
|
||
}
|
||
|
||
return (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) as T
|
||
}
|
||
|
||
function useApi<T>(path: string | null, deps: unknown[] = []): ApiState<T> & { refresh: () => Promise<void> } {
|
||
const [state, setState] = useState<ApiState<T>>({ loading: Boolean(path), data: null, error: null })
|
||
const requestSequenceRef = useRef(0)
|
||
const abortControllerRef = useRef<AbortController | null>(null)
|
||
const depsKey = JSON.stringify(deps)
|
||
|
||
const load = useCallback(async () => {
|
||
const requestSequence = requestSequenceRef.current + 1
|
||
requestSequenceRef.current = requestSequence
|
||
abortControllerRef.current?.abort()
|
||
|
||
if (!path) {
|
||
abortControllerRef.current = null
|
||
setState({ loading: false, data: null, error: null })
|
||
return
|
||
}
|
||
|
||
const abortController = new AbortController()
|
||
abortControllerRef.current = abortController
|
||
setState({ loading: true, data: null, error: null })
|
||
try {
|
||
const data = await apiRequest<T>(path, { signal: abortController.signal })
|
||
if (requestSequence !== requestSequenceRef.current) return
|
||
setState({ loading: false, data, error: null })
|
||
} catch (error) {
|
||
if (abortController.signal.aborted || requestSequence !== requestSequenceRef.current) return
|
||
setState({ loading: false, data: null, error: error instanceof Error ? error.message : '加载失败' })
|
||
} finally {
|
||
if (requestSequence === requestSequenceRef.current && abortControllerRef.current === abortController) {
|
||
abortControllerRef.current = null
|
||
}
|
||
}
|
||
}, [path])
|
||
|
||
useEffect(() => {
|
||
void load()
|
||
return () => {
|
||
requestSequenceRef.current += 1
|
||
abortControllerRef.current?.abort()
|
||
abortControllerRef.current = null
|
||
}
|
||
}, [load, depsKey])
|
||
|
||
return { ...state, refresh: load }
|
||
}
|
||
|
||
export default function PerformanceSurveyPage() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const params = useParams()
|
||
const searchParams = useSearchParams()
|
||
const rawPath = (params as any).path
|
||
const path = useMemo(() => (Array.isArray(rawPath) ? (rawPath as string[]) : []), [rawPath])
|
||
const searchParamsKey = searchParams.toString()
|
||
const legacyPathTab = useMemo(() => getLegacyPerformanceSurveyPathTab(path), [path])
|
||
const [dingtalkSessionChecked, setDingtalkSessionChecked] = useState(false)
|
||
const [hasDingtalkPerformanceSession, setHasDingtalkPerformanceSession] = useState(false)
|
||
const [dingtalkUserProfile, setDingtalkUserProfile] = useState<any>(null)
|
||
const canLoadCurrentUser = Boolean(session || hasDingtalkPerformanceSession)
|
||
const currentUser = useApi<any>(canLoadCurrentUser ? '/me' : null, [canLoadCurrentUser])
|
||
|
||
useEffect(() => {
|
||
const hasToken = Boolean(getStoredDingtalkPerformanceToken())
|
||
setHasDingtalkPerformanceSession(hasToken)
|
||
setDingtalkUserProfile(hasToken ? decodeStoredDingtalkPerformanceSession() : null)
|
||
setDingtalkSessionChecked(true)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const handleExpiredSession = () => {
|
||
setHasDingtalkPerformanceSession(false)
|
||
setDingtalkUserProfile(null)
|
||
}
|
||
window.addEventListener(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT, handleExpiredSession)
|
||
return () => window.removeEventListener(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT, handleExpiredSession)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated' && dingtalkSessionChecked && !hasDingtalkPerformanceSession) {
|
||
const currentPath =
|
||
typeof window === 'undefined'
|
||
? '/performance-survey'
|
||
: `${window.location.pathname}${window.location.search}`
|
||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
|
||
}
|
||
}, [status, dingtalkSessionChecked, hasDingtalkPerformanceSession, router])
|
||
|
||
useEffect(() => {
|
||
if (!legacyPathTab) return
|
||
const query = new URLSearchParams(searchParamsKey)
|
||
query.set('tab', legacyPathTab)
|
||
router.replace(`/performance-survey?${query.toString()}`)
|
||
}, [legacyPathTab, router, searchParamsKey])
|
||
|
||
if (status === 'loading' || (status === 'unauthenticated' && !dingtalkSessionChecked)) {
|
||
return (
|
||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||
<PerformanceSurveyMobileHeader session={{ user: dingtalkUserProfile || {} }} />
|
||
<LoadingPage />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!session && !hasDingtalkPerformanceSession) {
|
||
return null
|
||
}
|
||
|
||
const currentUserProfile = currentUser.data?.user || null
|
||
const sessionUser = session?.user as any
|
||
const effectiveSession = ({
|
||
...(session || { expires: '' }),
|
||
user: {
|
||
...(sessionUser || {}),
|
||
id: currentUserProfile?.id || sessionUser?.id || dingtalkUserProfile?.id || '',
|
||
name: currentUserProfile?.name || session?.user?.name || dingtalkUserProfile?.name || '当前用户',
|
||
email: currentUserProfile?.email || session?.user?.email || dingtalkUserProfile?.email || '',
|
||
avatar: currentUserProfile?.avatar || sessionUser?.avatar || dingtalkUserProfile?.avatar || null,
|
||
role: currentUserProfile?.role || sessionUser?.role || dingtalkUserProfile?.role || 'USER',
|
||
department: currentUserProfile?.department || sessionUser?.department || dingtalkUserProfile?.department || '',
|
||
managedDepartment:
|
||
currentUserProfile?.managedDepartment ||
|
||
sessionUser?.managedDepartment ||
|
||
dingtalkUserProfile?.managedDepartment ||
|
||
null,
|
||
isSuperAdmin:
|
||
currentUserProfile?.isSuperAdmin ??
|
||
sessionUser?.isSuperAdmin ??
|
||
dingtalkUserProfile?.isSuperAdmin ??
|
||
false,
|
||
},
|
||
} as any)
|
||
|
||
const isSurveySubmitPage = path[0] === 'survey' && Boolean(path[1])
|
||
const isResultsPage = path[0] === 'results' && Boolean(path[1])
|
||
let content = <Dashboard />
|
||
if (isSurveySubmitPage && path[1]) {
|
||
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
|
||
}
|
||
if (isResultsPage && path[1]) {
|
||
content = <SurveyResults surveyId={path[1]} />
|
||
}
|
||
|
||
if (isSurveySubmitPage) {
|
||
return (
|
||
<div className="min-h-screen overflow-x-hidden bg-white dark:bg-gray-900">
|
||
{content}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen overflow-x-hidden bg-white text-[#1a1c1c]">
|
||
{isResultsPage && <PerformanceSurveyMobileHeader session={effectiveSession} />}
|
||
{content}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveyMobileHeader({ session }: { session: any }) {
|
||
const user = session?.user || {}
|
||
const name = String(user.name || '当前用户')
|
||
const avatar = typeof user.avatar === 'string' && user.avatar
|
||
? user.avatar
|
||
: typeof user.image === 'string'
|
||
? user.image
|
||
: ''
|
||
|
||
return (
|
||
<header className="sticky top-0 z-30 border-b border-[#eeeeee] bg-[#2f3131] text-white">
|
||
<div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-[#f4f4f4] text-[12px] font-semibold text-[#0050cb]">
|
||
{avatar ? (
|
||
<Image
|
||
src={avatar}
|
||
alt={name}
|
||
width={32}
|
||
height={32}
|
||
unoptimized
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
getSurveyInitials(name)
|
||
)}
|
||
</div>
|
||
<div className="truncate text-[16px] font-bold tracking-tight">考核调研</div>
|
||
</div>
|
||
<Link
|
||
href="/"
|
||
className="shrink-0 text-[12px] font-semibold text-white/85 transition-colors hover:text-white"
|
||
aria-label="返回素材库首页"
|
||
>
|
||
由壹点「素材库」
|
||
</Link>
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
function Dashboard() {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const requestedTabRaw = searchParams.get('tab') || 'personal'
|
||
const requestedTab = normalizePerformanceSurveyDashboardTab(requestedTabRaw)
|
||
const searchParamsKey = searchParams.toString()
|
||
const [tab, setTab] = useState<PerformanceSurveyDashboardTab>(requestedTab)
|
||
const me = useApi<any>('/me', [])
|
||
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
|
||
const avatar = me.data?.user?.avatar || me.data?.user?.image || ''
|
||
const availableTabIds = useMemo(
|
||
() => ['personal', 'public', 'manage'],
|
||
[],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
|
||
setTab(requestedTab)
|
||
return
|
||
}
|
||
if (!availableTabIds.includes(tab)) {
|
||
setTab('personal')
|
||
}
|
||
}, [availableTabIds, requestedTab, tab])
|
||
|
||
useEffect(() => {
|
||
if (requestedTabRaw === requestedTab) return
|
||
const query = new URLSearchParams(searchParamsKey)
|
||
query.set('tab', requestedTab)
|
||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||
}, [requestedTab, requestedTabRaw, router, searchParamsKey])
|
||
|
||
const selectTab = (nextTab: PerformanceSurveyDashboardTab) => {
|
||
setTab(nextTab)
|
||
router.replace(`/performance-survey?tab=${encodeURIComponent(nextTab)}`, { scroll: false })
|
||
}
|
||
const tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> = [
|
||
{ id: 'personal', label: '个人看板' },
|
||
{ id: 'public', label: '公共看板' },
|
||
{ id: 'manage', label: '发起考核' },
|
||
]
|
||
const pageTitle = tab === 'public' ? '公共看板' : tab === 'manage' ? '新建考核计划' : '个人绩效概览'
|
||
const pageSubtitle = tab === 'personal'
|
||
? '过去 12 个月的考核详细数据'
|
||
: tab === 'public'
|
||
? ''
|
||
: ''
|
||
|
||
return (
|
||
<>
|
||
<PerformanceSurveyDashboardHeader
|
||
activeTab={tab}
|
||
avatar={avatar}
|
||
displayName={displayName}
|
||
onSelectTab={selectTab}
|
||
tabItems={tabItems}
|
||
/>
|
||
<main className="mx-auto w-full max-w-[1200px] bg-white px-4 py-8 pb-24 text-[#1a1c1c] sm:px-6 sm:pb-10">
|
||
<section className="mb-6">
|
||
<h1 className="text-[24px] font-bold leading-tight text-[#1a1c1c] sm:text-[22px]">{pageTitle}</h1>
|
||
{pageSubtitle && <p className="mt-2 text-[14px] font-medium text-[#5f5e5e]">{pageSubtitle}</p>}
|
||
</section>
|
||
|
||
{tab !== 'manage' && (
|
||
<PerformanceDashboardBoard view={tab === 'public' ? 'public' : 'personal'} />
|
||
)}
|
||
{tab === 'manage' && (
|
||
<ManagePanel />
|
||
)}
|
||
</main>
|
||
<nav className="fixed inset-x-0 bottom-0 z-30 grid h-[68px] grid-cols-3 border-t border-[#e2e2e2] bg-white sm:hidden">
|
||
{tabItems.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => selectTab(item.id)}
|
||
className={`flex items-center justify-center text-[12px] font-semibold ${
|
||
tab === item.id ? 'text-[#0050cb]' : 'text-[#5f5e5e]'
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveyDashboardHeader({
|
||
activeTab,
|
||
avatar,
|
||
displayName,
|
||
onSelectTab,
|
||
tabItems,
|
||
}: {
|
||
activeTab: PerformanceSurveyDashboardTab
|
||
avatar: string
|
||
displayName: string
|
||
onSelectTab: (tab: PerformanceSurveyDashboardTab) => void
|
||
tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }>
|
||
}) {
|
||
return (
|
||
<header className="bg-[#1f2937] text-white shadow-sm">
|
||
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(220px,1fr)_auto_minmax(220px,1fr)] sm:px-4">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-white">
|
||
{avatar ? (
|
||
<Image
|
||
src={avatar}
|
||
alt={displayName}
|
||
width={36}
|
||
height={36}
|
||
unoptimized
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<span className="text-[12px] font-bold">{getSurveyInitials(displayName)}</span>
|
||
)}
|
||
</div>
|
||
<div className="truncate text-[16px] font-bold">考核调研</div>
|
||
</div>
|
||
<nav className="hidden h-full items-center justify-center gap-9 sm:flex">
|
||
{tabItems.map((item) => {
|
||
const active = activeTab === item.id
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onSelectTab(item.id)}
|
||
className={`h-full border-b-2 px-1 text-[13px] font-bold transition-colors ${
|
||
active
|
||
? 'border-white text-white'
|
||
: 'border-transparent text-[#b3c5ff] hover:text-white'
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
<Link
|
||
href="/"
|
||
className="hidden justify-self-end text-[13px] font-semibold text-white/90 transition-colors hover:text-white sm:block"
|
||
aria-label="返回素材库首页"
|
||
>
|
||
由壹点「素材库」
|
||
</Link>
|
||
<Link
|
||
href="/"
|
||
className="justify-self-end text-[12px] font-semibold text-[#b3c5ff] transition-colors hover:text-white sm:hidden"
|
||
aria-label="返回素材库首页"
|
||
>
|
||
由壹点
|
||
</Link>
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
function DashboardStats({
|
||
data,
|
||
activeTab,
|
||
onSelectTab,
|
||
}: {
|
||
data: any
|
||
activeTab: string
|
||
onSelectTab: (tab: string) => void
|
||
}) {
|
||
const summary = data.summary || {}
|
||
const personal = summary.personal || {
|
||
pendingCount: summary.pendingCount || 0,
|
||
submittedCount: summary.submittedCount || 0,
|
||
createdCount: summary.createdCount || 0,
|
||
resultCount: summary.resultCount || 0,
|
||
}
|
||
const scope = summary.scope || null
|
||
const scopeLabel = summary.scopeMode === 'system' ? '全局' : '架构'
|
||
const items = [
|
||
{ id: 'pending', label: '待处理', value: personal.pendingCount || 0, scopeValue: scope?.pendingCount },
|
||
{ id: 'submitted', label: '已处理', value: personal.submittedCount || 0, scopeValue: scope?.submittedCount },
|
||
{ id: 'created', label: '我发起的', value: personal.createdCount || 0, scopeValue: scope?.createdCount },
|
||
{ id: 'results', label: '我的结果', value: personal.resultCount || 0, scopeValue: scope?.resultCount },
|
||
]
|
||
|
||
return (
|
||
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
|
||
{items.map((item) => {
|
||
const active = activeTab === item.id
|
||
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onSelectTab(item.id)}
|
||
className={`rounded-[14px] border px-4 py-4 text-left transition-colors sm:rounded-lg sm:p-5 ${
|
||
active
|
||
? 'border-[#0052d9] bg-[#0052d9] text-white shadow-[0_12px_26px_rgba(0,82,217,0.22)]'
|
||
: 'border-[#e5eaf2] bg-white text-[#101828] shadow-[0_8px_18px_rgba(45,43,80,0.05)] hover:border-[#b8d0ff] dark:border-gray-700 dark:bg-gray-900 dark:text-white'
|
||
}`}
|
||
>
|
||
<div className={`text-[13px] font-semibold ${active ? 'text-white/80' : 'text-[#667085] dark:text-gray-400'}`}>
|
||
{item.label}
|
||
</div>
|
||
<div className="mt-2 text-[28px] font-semibold leading-none">{item.value}</div>
|
||
{typeof item.scopeValue === 'number' && (
|
||
<div className={`mt-2 text-[11px] font-semibold ${active ? 'text-white/70' : 'text-[#98a2b3] dark:text-gray-500'}`}>
|
||
{scopeLabel} {item.scopeValue}
|
||
</div>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PerformanceDashboardBoard({ view }: { view: 'personal' | 'public' }) {
|
||
const [month, setMonth] = useState(getCurrentMonthValue())
|
||
const [department, setDepartment] = useState('')
|
||
const [employee, setEmployee] = useState<Employee | null>(null)
|
||
const [detailRequest, setDetailRequest] = useState<{
|
||
month: string
|
||
employeeId: string
|
||
templateCode: string
|
||
} | null>(null)
|
||
const departments = useApi<DepartmentOption[]>(view === 'public' ? '/departments?purpose=evaluator' : null, [view])
|
||
const dashboardPath = useMemo(() => {
|
||
const params = new URLSearchParams()
|
||
params.set('view', view)
|
||
if (month) params.set('month', month)
|
||
if (department) params.set('department', department)
|
||
if (employee?.id) params.set('employeeId', employee.id)
|
||
const query = params.toString()
|
||
return `/dashboard${query ? `?${query}` : ''}`
|
||
}, [view, month, department, employee?.id])
|
||
const dashboard = useApi<any>(dashboardPath, [dashboardPath])
|
||
const templates = dashboard.data?.templates || []
|
||
const permissions = dashboard.data?.permissions || {}
|
||
const personalRows = (dashboard.data?.personal?.rows || []).filter((row: any) => row.hasData)
|
||
const publicRows = (dashboard.data?.public?.rows || []).filter((row: any) => row.hasData)
|
||
|
||
return (
|
||
<section className="mb-5 space-y-4">
|
||
{view === 'public' && (
|
||
<div className="border-b border-[#e2e2e2] pb-5">
|
||
<div className="grid gap-3 sm:grid-cols-[auto_150px_auto_minmax(220px,0.8fr)_minmax(260px,1fr)_auto] sm:items-center">
|
||
<div className="hidden text-[13px] font-semibold text-[#5f5e5e] sm:block">统计周期</div>
|
||
<input
|
||
type="month"
|
||
value={month}
|
||
onChange={(event) => setMonth(event.target.value)}
|
||
className="h-10 rounded border border-[#e2e2e2] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
|
||
/>
|
||
<div className="hidden text-[13px] font-semibold text-[#5f5e5e] sm:block">组织架构</div>
|
||
<DepartmentFilterTree
|
||
departments={departments.data || []}
|
||
value={department}
|
||
onChange={(value) => setDepartment(value)}
|
||
/>
|
||
<SingleEmployeeFilter value={employee} onChange={setEmployee} />
|
||
{(department || employee) && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDepartment('')
|
||
setEmployee(null)
|
||
}}
|
||
className="h-10 rounded border border-[#e2e2e2] bg-white px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#f9f9f9]"
|
||
>
|
||
清除
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{dashboard.loading && (
|
||
<div className="border border-[#e2e2e2] bg-white px-5 py-8 text-sm font-semibold text-[#5f5e5e]">
|
||
看板加载中...
|
||
</div>
|
||
)}
|
||
{dashboard.error && <ErrorPanel message={dashboard.error} />}
|
||
{dashboard.data && (
|
||
<>
|
||
{view === 'personal' ? (
|
||
<MonthlyPersonalLedger
|
||
rows={personalRows}
|
||
templates={templates}
|
||
onOpenDetail={setDetailRequest}
|
||
/>
|
||
) : (
|
||
<MonthlyPublicLedger
|
||
rows={publicRows}
|
||
templates={templates}
|
||
canOpenPublicDetail={Boolean(permissions.canOpenPublicDetail)}
|
||
onOpenDetail={setDetailRequest}
|
||
/>
|
||
)}
|
||
<MonthlyScoreDetailModal
|
||
request={detailRequest}
|
||
onClose={() => setDetailRequest(null)}
|
||
/>
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function MonthlyPersonalLedger({
|
||
rows,
|
||
templates,
|
||
onOpenDetail,
|
||
}: {
|
||
rows: any[]
|
||
templates: any[]
|
||
onOpenDetail: (value: { month: string; employeeId: string; templateCode: string }) => void
|
||
}) {
|
||
if (rows.length === 0) return <EmptyLedger title="暂无月度考核" />
|
||
|
||
return (
|
||
<div>
|
||
<div className="space-y-4 sm:hidden">
|
||
{rows.map((row) => (
|
||
<article
|
||
key={row.month}
|
||
className="border border-[#c2c6d8] bg-white p-4"
|
||
>
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="text-[20px] font-bold text-[#1a1c1c]">{formatMonthLabel(row.month)}</div>
|
||
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">CURRENT</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{templates.map((template) => (
|
||
<MonthlyScoreCell
|
||
key={template.code}
|
||
template={template}
|
||
cell={row.cells?.[template.code]}
|
||
onClick={() => onOpenDetail({
|
||
month: row.month,
|
||
employeeId: row.employee.id,
|
||
templateCode: template.code,
|
||
})}
|
||
/>
|
||
))}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="hidden overflow-hidden rounded border border-[#c2c6d8] bg-white sm:block">
|
||
<table className="w-full table-fixed border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold uppercase tracking-tight text-[#5f5e5e]">
|
||
<th className="w-[160px] px-6 py-4">月份</th>
|
||
{templates.map((template) => (
|
||
<th key={template.code} className="px-4 py-4">{getTemplateDisplayName(template)}</th>
|
||
))}
|
||
<th className="w-[140px] px-4 py-4 text-right">综合评分</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#c2c6d8]">
|
||
{rows.map((row) => (
|
||
<tr key={row.month} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
|
||
<td className="px-6 py-5 text-[16px] font-bold">{row.month}</td>
|
||
{templates.map((template) => (
|
||
<td key={template.code} className="px-4 py-4">
|
||
<MonthlyTableScoreCell
|
||
cell={row.cells?.[template.code]}
|
||
onClick={() => onOpenDetail({
|
||
month: row.month,
|
||
employeeId: row.employee.id,
|
||
templateCode: template.code,
|
||
})}
|
||
/>
|
||
</td>
|
||
))}
|
||
<td className="px-4 py-4 text-right text-[22px] font-bold text-[#0050cb]">{formatAverageScore(row)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MonthlyPublicLedger({
|
||
rows,
|
||
templates,
|
||
canOpenPublicDetail,
|
||
onOpenDetail,
|
||
}: {
|
||
rows: any[]
|
||
templates: any[]
|
||
canOpenPublicDetail: boolean
|
||
onOpenDetail: (value: { month: string; employeeId: string; templateCode: string }) => void
|
||
}) {
|
||
if (rows.length === 0) return <EmptyLedger title="暂无人员数据" />
|
||
|
||
return (
|
||
<div>
|
||
<div className="space-y-0 sm:hidden">
|
||
{rows.map((row) => (
|
||
<article
|
||
key={row.employee.id}
|
||
className="border-b border-[#c2c6d8] bg-white py-4"
|
||
>
|
||
<div className="mb-3 flex min-w-0 items-center justify-between gap-3">
|
||
<div>
|
||
<div className="truncate text-[16px] font-bold text-[#1a1c1c]">{row.employee.displayName}</div>
|
||
<div className="mt-1 truncate text-[12px] text-[#5f5e5e]">
|
||
{[row.employee.departmentName, row.employee.positionLevel || row.employee.levelType].filter(Boolean).join(' · ')}
|
||
</div>
|
||
</div>
|
||
<span className="text-[18px] font-bold text-[#0050cb]">{formatAverageScore(row)}</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1">
|
||
{templates.map((template) => (
|
||
<MonthlyScoreCell
|
||
key={template.code}
|
||
compact
|
||
template={template}
|
||
cell={row.cells?.[template.code]}
|
||
canOpenOverride={canOpenPublicDetail}
|
||
onClick={() => onOpenDetail({
|
||
month: row.month,
|
||
employeeId: row.employee.id,
|
||
templateCode: template.code,
|
||
})}
|
||
/>
|
||
))}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="hidden overflow-hidden rounded border border-[#e2e2e2] bg-white sm:block">
|
||
<table className="w-full table-fixed border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#e2e2e2] bg-[#f9f9f9] text-[12px] font-bold uppercase tracking-tight text-[#5f5e5e]">
|
||
<th className="w-[220px] px-5 py-4">人员</th>
|
||
<th className="w-[240px] px-4 py-4">组织 / 职级</th>
|
||
{templates.map((template) => (
|
||
<th key={template.code} className="px-4 py-4 text-center">{getTemplateDisplayName(template)}</th>
|
||
))}
|
||
<th className="w-[120px] px-4 py-4 text-right">综合评分</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#e2e2e2]">
|
||
{rows.map((row) => (
|
||
<tr key={row.employee.id} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
|
||
<td className="px-5 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#f4f3f3] text-[12px] font-bold text-[#1a1c1c]">{getSurveyInitials(row.employee.displayName)}</div>
|
||
<div>
|
||
<div className="text-[15px] font-bold">{row.employee.displayName}</div>
|
||
{row.employee.jobNumber && <div className="mt-0.5 text-[11px] text-[#5f5e5e]">{row.employee.jobNumber}</div>}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">
|
||
<div className="truncate">{row.employee.departmentName || '-'}</div>
|
||
<div className="mt-1 truncate">{row.employee.positionLevel || row.employee.levelType || '-'}</div>
|
||
</td>
|
||
{templates.map((template) => (
|
||
<td key={template.code} className="px-4 py-4 text-center">
|
||
<MonthlyTableScoreCell
|
||
cell={row.cells?.[template.code]}
|
||
canOpenOverride={canOpenPublicDetail}
|
||
onClick={() => onOpenDetail({
|
||
month: row.month,
|
||
employeeId: row.employee.id,
|
||
templateCode: template.code,
|
||
})}
|
||
/>
|
||
</td>
|
||
))}
|
||
<td className="px-4 py-4 text-right text-[20px] font-bold text-[#0050cb]">{formatAverageScore(row)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MonthlyScoreCell({
|
||
template,
|
||
cell,
|
||
compact = false,
|
||
canOpenOverride,
|
||
onClick,
|
||
}: {
|
||
template: any
|
||
cell: any
|
||
compact?: boolean
|
||
canOpenOverride?: boolean
|
||
onClick: () => void
|
||
}) {
|
||
const hasScore = cell && cell.score !== null && cell.score !== undefined
|
||
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
|
||
const className = `${compact ? 'min-h-[58px] px-2 py-2' : 'min-h-[86px] px-3 py-3'} border text-left transition-colors ${
|
||
canOpen
|
||
? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]'
|
||
: 'border-[#e2e2e2] bg-[#f9f9f9]'
|
||
}`
|
||
const content = (
|
||
<>
|
||
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
|
||
<div className="mt-2 flex items-baseline gap-1">
|
||
<span className={`${compact ? 'text-[17px]' : 'text-[24px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
|
||
{hasScore ? formatNullableScore(cell.score) : '-'}
|
||
</span>
|
||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||
</div>
|
||
</>
|
||
)
|
||
|
||
if (!canOpen) {
|
||
return <div className={className}>{content}</div>
|
||
}
|
||
|
||
return (
|
||
<button type="button" onClick={onClick} className={className}>
|
||
{content}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function MonthlyTableScoreCell({
|
||
cell,
|
||
canOpenOverride,
|
||
onClick,
|
||
}: {
|
||
cell: any
|
||
canOpenOverride?: boolean
|
||
onClick: () => void
|
||
}) {
|
||
const hasScore = cell && cell.score !== null && cell.score !== undefined
|
||
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
|
||
const content = (
|
||
<>
|
||
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
|
||
{hasScore ? formatNullableScore(cell.score) : '-'}
|
||
</span>
|
||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||
</>
|
||
)
|
||
|
||
if (!canOpen) {
|
||
return <div className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left">{content}</div>
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left transition-colors hover:text-[#003fa4]"
|
||
>
|
||
{content}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function EmptyLedger({ title }: { title: string }) {
|
||
return (
|
||
<div className="border border-dashed border-[#c2c6d8] bg-white px-5 py-12 text-center">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#f4f3f3] text-lg font-bold text-[#727687]">0</div>
|
||
<h2 className="mt-4 text-[18px] font-bold text-[#1a1c1c]">{title}</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MonthlyScoreDetailModal({
|
||
request,
|
||
onClose,
|
||
}: {
|
||
request: { month: string; employeeId: string; templateCode: string } | null
|
||
onClose: () => void
|
||
}) {
|
||
const [portalReady, setPortalReady] = useState(false)
|
||
const dialogRef = useRef<HTMLDivElement | null>(null)
|
||
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||
const detailPath = useMemo(() => {
|
||
if (!request) return null
|
||
const params = new URLSearchParams(request)
|
||
return `/dashboard/detail?${params.toString()}`
|
||
}, [request])
|
||
const detail = useApi<any>(detailPath, [detailPath])
|
||
|
||
useEffect(() => {
|
||
setPortalReady(true)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!request) return
|
||
|
||
const previousOverflow = document.body.style.overflow
|
||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||
document.body.style.overflow = 'hidden'
|
||
const focusFrame = window.requestAnimationFrame(() => closeButtonRef.current?.focus())
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
onClose()
|
||
return
|
||
}
|
||
keepFocusInsideModal(event, dialogRef.current)
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
return () => {
|
||
window.cancelAnimationFrame(focusFrame)
|
||
document.body.style.overflow = previousOverflow
|
||
window.removeEventListener('keydown', handleKeyDown)
|
||
previousFocus?.focus()
|
||
}
|
||
}, [onClose, request])
|
||
|
||
if (!request || !portalReady) return null
|
||
|
||
const isVisualDetail = Boolean(detail.data?.template?.isVisual || detail.data?.workItems?.length)
|
||
const responseCount = Number(detail.data?.responseCount || detail.data?.participants?.length || 0)
|
||
const surveyCount = Number(detail.data?.surveyCount || 0)
|
||
|
||
return createPortal((
|
||
<div
|
||
className="fixed inset-0 z-[1000] flex items-stretch justify-center bg-white p-0 sm:items-center sm:bg-black/40 sm:p-6 sm:backdrop-blur-[2px]"
|
||
onClick={onClose}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
ref={dialogRef}
|
||
className="relative flex h-[100dvh] w-full flex-col overflow-hidden bg-white sm:h-auto sm:max-h-[calc(100dvh-48px)] sm:max-w-2xl sm:rounded-lg sm:shadow-2xl"
|
||
onClick={(event) => event.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="performance-survey-detail-title"
|
||
tabIndex={-1}
|
||
>
|
||
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#e2e2e2] bg-white px-4 pb-4 pt-[calc(16px+env(safe-area-inset-top))] sm:px-5 sm:pt-4">
|
||
<div className="min-w-0">
|
||
<div className="text-[12px] font-semibold text-[#5f5e5e]">评估详情报告</div>
|
||
<h2 id="performance-survey-detail-title" className="mt-2 break-words text-[22px] font-bold leading-tight text-[#1a1c1c] sm:text-[24px]">
|
||
{detail.data?.template?.fullName || detail.data?.template?.name || '考核明细'}
|
||
</h2>
|
||
</div>
|
||
<button
|
||
ref={closeButtonRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-[28px] font-light leading-none text-[#5f5e5e] transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c]"
|
||
aria-label="关闭详情"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-4 py-5 sm:px-5">
|
||
{detail.loading && <div className="text-sm font-medium text-[#5f5e5e]">加载中...</div>}
|
||
{detail.error && <ErrorPanel message={detail.error} />}
|
||
{detail.data && (
|
||
<div className="space-y-7">
|
||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4">
|
||
<DetailMetaItem label="评估周期" value={detail.data.month || request.month} />
|
||
<DetailMetaItem label="被评估人" value={detail.data.employee?.displayName || '-'} />
|
||
<DetailMetaItem label="评分人数" value={responseCount || '-'} />
|
||
<DetailMetaItem label="记录数" value={surveyCount || '-'} />
|
||
</div>
|
||
|
||
<section className="space-y-3">
|
||
<DetailSectionTitle>核心评分</DetailSectionTitle>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<DetailScoreCard label="最终分" value={formatNullableScore(detail.data.score)} suffix="/120" strong />
|
||
<DetailScoreCard label="评分人数" value={responseCount || 0} suffix="人" />
|
||
<DetailScoreCard label="月度记录" value={surveyCount || 0} suffix="条" />
|
||
</div>
|
||
</section>
|
||
|
||
{isVisualDetail && (
|
||
<section className="space-y-3">
|
||
<DetailSectionTitle>视觉评分作品</DetailSectionTitle>
|
||
{detail.data.workItems?.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{detail.data.workItems.map((item: any, index: number) => (
|
||
<section key={item.id} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-[12px] font-semibold text-[#0050cb]">作品 {index + 1}</div>
|
||
<h3 className="mt-1 break-words text-[16px] font-bold text-[#1a1c1c]">{item.title}</h3>
|
||
{item.description && <p className="mt-1 whitespace-pre-wrap text-sm leading-5 text-[#5f5e5e]">{item.description}</p>}
|
||
</div>
|
||
<div className="shrink-0 rounded bg-[#f4f3f3] px-3 py-2 text-[18px] font-bold text-[#0050cb]">{formatNullableScore(item.averageScore)}</div>
|
||
</div>
|
||
<ImagePreviewGrid images={item.imageUrls || []} variant="review" />
|
||
<ScoreParticipantList items={item.scores || []} />
|
||
</section>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-[#c2c6d8] bg-[#f9f9f9] px-4 py-8 text-center text-sm font-semibold text-[#5f5e5e]">
|
||
暂无作品图片
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
<section className="space-y-3">
|
||
<DetailSectionTitle>详细评价内容</DetailSectionTitle>
|
||
<ScoreParticipantList items={detail.data.participants || []} />
|
||
</section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="sticky bottom-0 border-t border-[#e2e2e2] bg-white px-4 pb-[calc(12px+env(safe-area-inset-bottom))] pt-3 sm:px-5 sm:pb-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="h-11 w-full rounded-lg border border-[#c2c6d8] text-[13px] font-semibold text-[#1a1c1c] transition-colors hover:bg-[#eeeeee]"
|
||
>
|
||
关闭详情
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
), document.body)
|
||
}
|
||
|
||
function DetailMetaItem({ label, value }: { label: string; value: ReactNode }) {
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
|
||
<div className="break-words text-[15px] font-semibold text-[#1a1c1c]">{value}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DetailSectionTitle({ children }: { children: ReactNode }) {
|
||
return (
|
||
<h3 className="border-b border-[#e2e2e2] pb-1 text-[12px] font-semibold text-[#5f5e5e]">
|
||
{children}
|
||
</h3>
|
||
)
|
||
}
|
||
|
||
function DetailScoreCard({
|
||
label,
|
||
value,
|
||
suffix,
|
||
strong = false,
|
||
}: {
|
||
label: string
|
||
value: ReactNode
|
||
suffix?: string
|
||
strong?: boolean
|
||
}) {
|
||
return (
|
||
<div className="rounded-lg border border-[#e2e2e2] bg-[#f9f9f9] p-4">
|
||
<div className="mb-2 block text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
|
||
<div className="flex items-baseline gap-1">
|
||
<span className={`${strong ? 'text-[32px] leading-10' : 'text-[24px] leading-8'} font-bold text-[#1a1c1c]`}>{value}</span>
|
||
{suffix && <span className="text-[12px] font-medium text-[#5f5e5e]">{suffix}</span>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ScoreParticipantList({ items }: { items: any[] }) {
|
||
if (!items.length) {
|
||
return <div className="rounded-lg border border-[#eeeeee] bg-[#f9f9f9] px-4 py-4 text-sm font-semibold text-[#5f5e5e]">暂无评分记录</div>
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-hidden rounded-lg border border-[#eeeeee] bg-white">
|
||
{items.map((item) => (
|
||
<div key={item.id} className="border-b border-[#eeeeee] px-4 py-3 last:border-b-0">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 text-sm font-bold text-[#1a1c1c]">{item.name}</div>
|
||
<div className="shrink-0 text-sm font-semibold text-[#1677ff]">{formatNullableScore(item.score)}</div>
|
||
</div>
|
||
{item.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-5 text-[#5f5e5e]">{item.comment}</p>}
|
||
{item.submittedAt && <div className="mt-2 text-xs text-[#727687]">{formatDate(item.submittedAt)}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MetricCard({ label, value }: { label: string; value: ReactNode }) {
|
||
return (
|
||
<div className="rounded-[14px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_8px_18px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="text-[13px] font-semibold text-[#667085] dark:text-gray-400">{label}</div>
|
||
<div className="mt-2 text-[28px] font-semibold leading-none text-[#101828] dark:text-white">{value}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TemplateSectionCard({ item }: { item: any }) {
|
||
return (
|
||
<div className="rounded-[14px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_8px_18px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="truncate text-[15px] font-semibold text-[#101828] dark:text-white">{item.name}</div>
|
||
<div className="mt-3 flex items-end justify-between gap-3">
|
||
<div>
|
||
<div className="text-[26px] font-semibold leading-none text-[#101828] dark:text-white">{item.count || 0}</div>
|
||
<div className="mt-1 text-xs font-medium text-[#667085] dark:text-gray-400">调研</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-[20px] font-semibold leading-none text-[#1677ff]">{formatNullableScore(item.averageScore)}</div>
|
||
<div className="mt-1 text-xs font-medium text-[#667085] dark:text-gray-400">均分</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 text-xs font-medium text-[#667085] dark:text-gray-400">
|
||
已完成 {item.completedCount || 0} · 待完成 {item.pendingCount || 0}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getTemplateDisplayName(template: any) {
|
||
const name = String(template?.shortName || template?.name || '')
|
||
return name
|
||
.replace(/满意度调研/g, '')
|
||
.replace(/考核/g, '')
|
||
.replace(/\s*[·\-]\s*\d+%/g, '')
|
||
.replace(/\s*\d+%/g, '')
|
||
.trim() || name || '-'
|
||
}
|
||
|
||
function formatMonthLabel(value: unknown) {
|
||
const text = String(value || '')
|
||
const match = text.match(/^(\d{4})-(\d{2})/)
|
||
if (!match) return text
|
||
return `${match[1]}年${Number(match[2])}月`
|
||
}
|
||
|
||
function formatAverageScore(row: any) {
|
||
const values = Object.values(row?.cells || {})
|
||
.map((cell: any) => Number(cell?.score))
|
||
.filter((value) => Number.isFinite(value))
|
||
if (values.length === 0) return '-'
|
||
const average = values.reduce((sum, value) => sum + value, 0) / values.length
|
||
return formatNullableScore(average)
|
||
}
|
||
|
||
function formatNullableScore(value: unknown) {
|
||
if (value === null || value === undefined || value === '') return '-'
|
||
const numberValue = Number(value)
|
||
if (!Number.isFinite(numberValue)) return '-'
|
||
return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1)
|
||
}
|
||
|
||
function SingleEmployeeFilter({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: Employee | null
|
||
onChange: (value: Employee | null) => void
|
||
}) {
|
||
const [keyword, setKeyword] = useState('')
|
||
const [items, setItems] = useState<Employee[]>([])
|
||
const [open, setOpen] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const trimmed = keyword.trim()
|
||
if (!trimmed) {
|
||
setItems([])
|
||
setOpen(false)
|
||
return
|
||
}
|
||
const timer = window.setTimeout(async () => {
|
||
setLoading(true)
|
||
setOpen(true)
|
||
try {
|
||
const params = new URLSearchParams({ search: trimmed, limit: '20', purpose: 'evaluator' })
|
||
setItems(await apiRequest<Employee[]>(`/employees?${params.toString()}`))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, 250)
|
||
return () => window.clearTimeout(timer)
|
||
}, [keyword])
|
||
|
||
return (
|
||
<div className="relative">
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
onFocus={() => keyword.trim() && setOpen(true)}
|
||
placeholder={value ? value.displayName : '筛选人员'}
|
||
className="h-10 w-full rounded border border-[#e2e2e2] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
|
||
/>
|
||
{value && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(null)
|
||
setKeyword('')
|
||
}}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 px-2 py-1 text-[12px] font-semibold text-[#5f5e5e] hover:text-[#1a1c1c]"
|
||
>
|
||
清除
|
||
</button>
|
||
)}
|
||
{open && (
|
||
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto border border-[#e2e2e2] bg-white shadow-[0_10px_24px_rgba(0,0,0,0.08)]">
|
||
{loading ? (
|
||
<div className="px-3 py-2 text-sm text-[#5f5e5e]">搜索中...</div>
|
||
) : items.length === 0 ? (
|
||
<div className="px-3 py-2 text-sm text-[#5f5e5e]">无匹配人员</div>
|
||
) : (
|
||
items.map((employee) => (
|
||
<button
|
||
key={employee.id}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(employee)
|
||
setKeyword('')
|
||
setOpen(false)
|
||
}}
|
||
className="block w-full px-3 py-2 text-left text-sm hover:bg-[#f9f9f9]"
|
||
>
|
||
<div className="font-semibold text-[#1a1c1c]">{employee.displayName}</div>
|
||
<div className="mt-0.5 truncate text-xs text-[#5f5e5e]">{employee.departmentName || ''}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PendingList({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无待处理</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<article key={item.assignmentId} className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
|
||
<p className="break-words">{item.targetName}</p>
|
||
<p>{item.dueAt ? formatDate(item.dueAt) : '无截止'}</p>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-3 py-1 text-[12px] font-semibold text-[#0052d9] dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-200">待填写</span>
|
||
</div>
|
||
<div className="mt-6 flex justify-end text-sm">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-7 font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)] hover:bg-[#003da6] dark:bg-white dark:text-gray-900"
|
||
>
|
||
去评价 ->
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SubmittedList({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无已处理</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<article key={item.assignmentId} className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
|
||
<p className="break-words">{item.targetName}</p>
|
||
<p>{item.submittedAt ? formatDate(item.submittedAt) : '已提交'}</p>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-[#b9ead0] bg-[#ecfdf3] px-3 py-1 text-[12px] font-semibold text-[#039855] dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200">已提交</span>
|
||
</div>
|
||
<div className="mt-6 flex justify-end text-sm">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] border border-[#dbe5f2] bg-white px-7 font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] hover:bg-[#f8fbff] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||
>
|
||
查看详情
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CreatedList({
|
||
items,
|
||
onRefresh,
|
||
emptyTitle = '还没有发起过考核调研',
|
||
resultBackHref = '/performance-survey?tab=manage',
|
||
}: {
|
||
items: any[]
|
||
onRefresh: () => Promise<void>
|
||
emptyTitle?: string
|
||
resultBackHref?: string
|
||
}) {
|
||
const [busyId, setBusyId] = useState<string | null>(null)
|
||
|
||
const changeStatus = async (id: string, status: string) => {
|
||
setBusyId(id)
|
||
try {
|
||
await apiRequest(`/surveys/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ status }),
|
||
})
|
||
await onRefresh()
|
||
} finally {
|
||
setBusyId(null)
|
||
}
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
return <EmptyState title={emptyTitle} />
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-5 sm:hidden">
|
||
{items.map((item) => {
|
||
const assignmentCount = Number(item.counts?.assignments || 0)
|
||
const responseCount = Number(item.counts?.responses || 0)
|
||
const done = assignmentCount > 0 && responseCount >= assignmentCount
|
||
const progressText = assignmentCount > 0 ? `${responseCount}/${assignmentCount}` : '0/0'
|
||
|
||
return (
|
||
<article
|
||
key={item.id}
|
||
className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)]"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828]">
|
||
{item.title}
|
||
</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085]">
|
||
<p className="break-words">{item.departmentName || '未指定部门'}</p>
|
||
<p>{item.dueAt ? formatDate(item.dueAt) : '无截止'} · {progressText}</p>
|
||
</div>
|
||
</div>
|
||
<span className={`shrink-0 rounded-full border px-3 py-1 text-[12px] font-semibold ${
|
||
done
|
||
? 'border-[#b9ead0] bg-[#ecfdf3] text-[#039855]'
|
||
: item.status === 'open'
|
||
? 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]'
|
||
: 'border-[#d8dced] bg-[#f5f7fb] text-[#667085]'
|
||
}`}>
|
||
{done ? '已全部提交' : getSurveyStatusLabel(item.status)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||
{item.status === 'draft' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'open')}
|
||
className="min-h-[40px] rounded-[10px] border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] disabled:opacity-50"
|
||
>
|
||
发布
|
||
</button>
|
||
)}
|
||
{item.status !== 'closed' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'closed')}
|
||
className="min-h-[40px] rounded-[10px] border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] disabled:opacity-50"
|
||
>
|
||
关闭
|
||
</button>
|
||
)}
|
||
<Link
|
||
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-6 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)]"
|
||
>
|
||
结果 ->
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="hidden overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800 sm:block">
|
||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
<thead className="bg-gray-50 dark:bg-gray-900/40">
|
||
<tr>
|
||
{['调研', '状态', '对象/评价/提交', '截止', '操作'].map((item) => (
|
||
<th key={item} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{item}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||
{items.map((item) => (
|
||
<tr key={item.id}>
|
||
<td className="px-4 py-3">
|
||
<div className="font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400">{item.departmentName || '未指定部门'}</div>
|
||
</td>
|
||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||
{item.counts?.targets || 0} / {item.counts?.assignments || 0} / {item.counts?.responses || 0}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{item.dueAt ? formatDate(item.dueAt) : '-'}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{item.status === 'draft' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'open')}
|
||
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
发布
|
||
</button>
|
||
)}
|
||
{item.status !== 'closed' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'closed')}
|
||
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
关闭
|
||
</button>
|
||
)}
|
||
<Link
|
||
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
|
||
className="rounded-md bg-gray-900 px-2.5 py-1 text-sm text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900"
|
||
>
|
||
结果
|
||
</Link>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function getCurrentMonthValue() {
|
||
const date = new Date()
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||
}
|
||
|
||
function getDepartmentFilterValue(department: DepartmentOption) {
|
||
return department.value || department.departmentPath || department.departmentName || department.name
|
||
}
|
||
|
||
function getDepartmentFilterLabel(department: DepartmentOption) {
|
||
return department.fullPath || department.departmentPath || department.departmentName || department.name
|
||
}
|
||
|
||
function ManagedSurveyList({
|
||
canManage,
|
||
departments,
|
||
refreshKey,
|
||
}: {
|
||
canManage: boolean
|
||
departments: DepartmentOption[]
|
||
refreshKey: number
|
||
}) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const [month, setMonth] = useState(searchParams.get('month') || getCurrentMonthValue())
|
||
const [department, setDepartment] = useState(searchParams.get('department') || '')
|
||
const managedPath = useMemo(() => {
|
||
const params = new URLSearchParams()
|
||
if (month) params.set('month', month)
|
||
if (department) params.set('department', department)
|
||
const query = params.toString()
|
||
return `/surveys${query ? `?${query}` : ''}`
|
||
}, [month, department])
|
||
const state = useApi<any>(canManage ? managedPath : null, [canManage, managedPath, refreshKey])
|
||
const resultBackHref = useMemo(() => {
|
||
const params = new URLSearchParams()
|
||
params.set('tab', 'manage')
|
||
if (month) params.set('month', month)
|
||
if (department) params.set('department', department)
|
||
return `/performance-survey?${params.toString()}`
|
||
}, [month, department])
|
||
|
||
const updateFilterUrl = (nextMonth: string, nextDepartment: string) => {
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
params.set('tab', 'manage')
|
||
if (nextMonth) params.set('month', nextMonth)
|
||
else params.delete('month')
|
||
if (nextDepartment) params.set('department', nextDepartment)
|
||
else params.delete('department')
|
||
router.replace(`/performance-survey?${params.toString()}`, { scroll: false })
|
||
}
|
||
|
||
const surveys = state.data?.managed || []
|
||
|
||
return (
|
||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">架构调研</h2>
|
||
<div className="grid gap-2 sm:grid-cols-[minmax(220px,320px)_150px_auto]">
|
||
<DepartmentFilterTree
|
||
departments={departments}
|
||
value={department}
|
||
onChange={(nextDepartment) => {
|
||
setDepartment(nextDepartment)
|
||
updateFilterUrl(month, nextDepartment)
|
||
}}
|
||
/>
|
||
<input
|
||
type="month"
|
||
value={month}
|
||
onChange={(event) => {
|
||
const nextMonth = event.target.value
|
||
setMonth(nextMonth)
|
||
updateFilterUrl(nextMonth, department)
|
||
}}
|
||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const nextMonth = getCurrentMonthValue()
|
||
setMonth(nextMonth)
|
||
updateFilterUrl(nextMonth, department)
|
||
}}
|
||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
>
|
||
当月
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4">
|
||
{state.loading && <Panel>加载中...</Panel>}
|
||
{state.error && <ErrorPanel message={state.error} />}
|
||
{state.data && <CreatedList items={surveys} onRefresh={state.refresh} emptyTitle="暂无架构调研" resultBackHref={resultBackHref} />}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function DepartmentFilterTree({
|
||
departments,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
departments: DepartmentOption[]
|
||
value: string
|
||
onChange: (value: string) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [keyword, setKeyword] = useState('')
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||
const selectedLabel = useMemo(() => {
|
||
if (!value) return '全部组织'
|
||
const selected = departments.find((department) => String(getDepartmentFilterValue(department) || '') === value)
|
||
return selected ? getDepartmentFilterLabel(selected) : value
|
||
}, [departments, value])
|
||
|
||
const { roots, childrenByParent, visibleIds } = useMemo(() => {
|
||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||
const byId = new Map<string, DepartmentOption>()
|
||
const children = new Map<string, DepartmentOption[]>()
|
||
|
||
departments.forEach((department) => {
|
||
byId.set(department.id, department)
|
||
const parentKey = department.parentId || ''
|
||
const list = children.get(parentKey) || []
|
||
list.push(department)
|
||
children.set(parentKey, list)
|
||
})
|
||
|
||
children.forEach((list) => {
|
||
list.sort((a, b) => {
|
||
const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0)
|
||
if (countDiff !== 0) return countDiff
|
||
return a.name.localeCompare(b.name, 'zh-CN')
|
||
})
|
||
})
|
||
|
||
const matchedIds = new Set<string>()
|
||
if (normalizedKeyword) {
|
||
departments.forEach((department) => {
|
||
const haystack = [
|
||
department.name,
|
||
department.parentName,
|
||
department.departmentName,
|
||
department.departmentPath,
|
||
department.fullPath,
|
||
].filter(Boolean)
|
||
if (!matchesPickerSearch(normalizedKeyword, haystack)) return
|
||
|
||
let current: DepartmentOption | undefined = department
|
||
while (current) {
|
||
matchedIds.add(current.id)
|
||
current = current.parentId ? byId.get(current.parentId) : undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
return {
|
||
roots: children.get('') || [],
|
||
childrenByParent: children,
|
||
visibleIds: matchedIds,
|
||
}
|
||
}, [departments, keyword])
|
||
|
||
const toggle = (id: string) => {
|
||
setExpanded((current) => {
|
||
const next = new Set(current)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const choose = (nextValue: string) => {
|
||
onChange(nextValue)
|
||
setOpen(false)
|
||
setKeyword('')
|
||
}
|
||
|
||
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
|
||
const children = childrenByParent.get(department.id) || []
|
||
const hasChildren = children.length > 0
|
||
const isExpanded = expanded.has(department.id) || Boolean(keyword.trim())
|
||
const isVisible = !keyword.trim() || visibleIds.has(department.id)
|
||
const optionValue = String(getDepartmentFilterValue(department) || '')
|
||
const selectable = Boolean(optionValue)
|
||
const selected = selectable && optionValue === value
|
||
if (!isVisible) return null
|
||
|
||
return (
|
||
<div key={department.id}>
|
||
<div
|
||
className={`flex min-h-9 items-center gap-2 rounded px-2 py-1.5 text-sm ${
|
||
selected ? 'bg-[#dae1ff] text-[#0050cb]' : 'text-[#1a1c1c] hover:bg-[#f9f9f9]'
|
||
}`}
|
||
style={{ paddingLeft: `${8 + depth * 18}px` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => hasChildren && toggle(department.id)}
|
||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs ${
|
||
hasChildren
|
||
? 'border-[#e2e2e2] text-[#5f5e5e] hover:bg-[#f4f3f3]'
|
||
: 'border-transparent text-transparent'
|
||
}`}
|
||
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
|
||
disabled={!hasChildren}
|
||
>
|
||
{hasChildren ? (isExpanded ? '-' : '+') : ''}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!selectable}
|
||
onClick={() => selectable && choose(optionValue)}
|
||
className="min-w-0 flex-1 truncate text-left font-medium disabled:cursor-default"
|
||
>
|
||
{department.name}
|
||
</button>
|
||
{selected && <span className="text-xs font-semibold">已选</span>}
|
||
</div>
|
||
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((current) => !current)}
|
||
className="flex h-10 w-full items-center justify-between gap-2 rounded border border-[#eeeeee] bg-white px-3 text-left text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors hover:border-[#c2c6d8]"
|
||
>
|
||
<span className="truncate">{selectedLabel}</span>
|
||
<span className="text-xs text-[#5f5e5e]">{open ? '收起' : '选择'}</span>
|
||
</button>
|
||
{open && (
|
||
<div className="absolute left-0 right-0 z-30 mt-2 border border-[#eeeeee] bg-white p-2 shadow-[0_10px_28px_rgba(0,0,0,0.08)]">
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索组织"
|
||
className="mb-2 h-9 w-full rounded border border-[#eeeeee] px-3 text-sm text-[#1a1c1c] outline-none focus:border-[#0050cb]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => choose('')}
|
||
className={`mb-1 flex min-h-9 w-full items-center rounded px-3 text-left text-sm font-semibold ${
|
||
!value ? 'bg-[#dae1ff] text-[#0050cb]' : 'text-[#1a1c1c] hover:bg-[#f9f9f9]'
|
||
}`}
|
||
>
|
||
全部组织
|
||
</button>
|
||
<div className="max-h-72 overflow-y-auto">
|
||
{roots.map((department) => renderNode(department))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MyResults({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无结果</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<article
|
||
key={`${item.surveyId}-${item.targetId}`}
|
||
className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white">{item.title}</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
|
||
<p className="break-words">{item.targetName}</p>
|
||
<p>{item.closedAt ? formatDate(item.closedAt) : '已关闭'}</p>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-3 py-1 text-[12px] font-semibold text-[#0052d9] dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-200">可查看</span>
|
||
</div>
|
||
<div className="mt-6 flex justify-end text-sm">
|
||
<Link
|
||
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=personal')}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-7 font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)] hover:bg-[#003da6] dark:bg-white dark:text-gray-900"
|
||
>
|
||
看结果 ->
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ManagePanel() {
|
||
const templates = useApi<Template[]>('/templates', [])
|
||
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
|
||
return (
|
||
<div className="grid gap-4">
|
||
<CreateSurveyForm
|
||
templates={templates.data || []}
|
||
departments={departments.data || []}
|
||
onCreated={async () => {
|
||
setMessage('已发布考核')
|
||
}}
|
||
/>
|
||
{message && (
|
||
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
|
||
{message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TemplateStatsPanel({ items }: { items: any[] }) {
|
||
return (
|
||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">模板</h2>
|
||
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
|
||
{items.map((item) => {
|
||
const achievement = item.responseCount > 0 ? `${item.aggregate?.achievementScore}${item.achievementSuffix || ''}` : '-'
|
||
return (
|
||
<div key={item.code} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[15px] font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{item.responseCount || 0} 份
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-[18px] font-semibold text-gray-900 dark:text-white">{achievement}</div>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">均分</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{items.length === 0 && <div className="py-3 text-sm text-gray-500 dark:text-gray-400">暂无模板</div>}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function CreateSurveyForm({
|
||
templates,
|
||
departments,
|
||
onCreated,
|
||
}: {
|
||
templates: Template[]
|
||
departments: DepartmentOption[]
|
||
onCreated: () => Promise<void>
|
||
}) {
|
||
const [title, setTitle] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [templateCode, setTemplateCode] = useState('')
|
||
const [dueAt, setDueAt] = useState(getCurrentMonthValue())
|
||
const openNow = true
|
||
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
|
||
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
|
||
const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState<string[]>([])
|
||
const [evaluatorDepartmentEmployees, setEvaluatorDepartmentEmployees] = useState<Record<string, Employee[]>>({})
|
||
const [itemName, setItemName] = useState('')
|
||
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
|
||
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const selectedTemplate = templateCode || templates[0]?.code || ''
|
||
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
|
||
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
|
||
const evaluatorEmployees = useMemo(
|
||
() => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)),
|
||
[manualEvaluatorEmployees, evaluatorDepartmentEmployees],
|
||
)
|
||
|
||
const removeEvaluatorEmployee = (id: string) => {
|
||
setManualEvaluatorEmployees((current) => current.filter((employee) => employee.id !== id))
|
||
setEvaluatorDepartmentEmployees((current) => {
|
||
const next: Record<string, Employee[]> = {}
|
||
for (const [department, employees] of Object.entries(current)) {
|
||
const filtered = employees.filter((employee) => employee.id !== id)
|
||
if (filtered.length > 0) next[department] = filtered
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
const submit = async () => {
|
||
setSaving(true)
|
||
setError(null)
|
||
setCreatedSurvey(null)
|
||
try {
|
||
const pendingItemName = itemName.trim()
|
||
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
|
||
? [...targetItems, { name: pendingItemName }]
|
||
: targetItems
|
||
if (surveyKind === 'item_course' && targetItemsToSubmit.length === 0) {
|
||
throw new Error(`请填写${selectedTemplateInfo?.itemName || '课程、事项或活动'}名称`)
|
||
}
|
||
if (surveyKind !== 'item_course' && targetEmployees.length === 0) {
|
||
throw new Error(`请至少选择 1 个${selectedTemplateInfo?.ownerRoleName || '被考核人'}`)
|
||
}
|
||
const assessmentMonth = dueAt || getCurrentMonthValue()
|
||
const submitTitle = title.trim()
|
||
|| targetItemsToSubmit[0]?.name
|
||
|| `${selectedTemplateInfo?.shortName || selectedTemplateInfo?.name || '考核'}-${assessmentMonth}`
|
||
const created = await apiRequest<any>('/surveys', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
title: submitTitle,
|
||
description,
|
||
surveyKind,
|
||
templateCode: selectedTemplate,
|
||
dueAt: assessmentMonth ? `${assessmentMonth}-01T00:00:00` : null,
|
||
openNow,
|
||
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
|
||
targetItems: targetItemsToSubmit,
|
||
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
|
||
}),
|
||
})
|
||
setTitle('')
|
||
setDescription('')
|
||
setDueAt(getCurrentMonthValue())
|
||
setTargetEmployees([])
|
||
setManualEvaluatorEmployees([])
|
||
setEvaluatorDepartmentValues([])
|
||
setEvaluatorDepartmentEmployees({})
|
||
setTargetItems([])
|
||
setItemName('')
|
||
setCreatedSurvey(created)
|
||
await onCreated()
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '创建失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="grid gap-10 sm:grid-cols-[360px_minmax(0,1fr)]">
|
||
<div className="space-y-6">
|
||
<div className="border border-[#eeeeee] bg-white p-6">
|
||
<div className="space-y-6">
|
||
<TextInput label="考核月份" value={dueAt} onChange={setDueAt} type="month" />
|
||
<label className="grid gap-2 text-sm">
|
||
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考评模板</span>
|
||
<select
|
||
value={selectedTemplate}
|
||
onChange={(event) => setTemplateCode(event.target.value)}
|
||
className="h-10 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
|
||
>
|
||
{templates.map((template) => (
|
||
<option key={template.code} value={template.code}>
|
||
{getTemplateDisplayName(template)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="border border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-sm font-semibold text-[#93000a]">{error}</div>}
|
||
<button
|
||
disabled={saving}
|
||
onClick={submit}
|
||
className="h-12 w-full rounded bg-[#0050cb] px-5 text-[15px] font-bold text-white hover:bg-[#003fa4] disabled:opacity-50"
|
||
>
|
||
{saving ? '发布中...' : '发布考核计划'}
|
||
</button>
|
||
{createdSurvey && (
|
||
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
|
||
已发布
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border border-[#eeeeee] bg-white p-6">
|
||
<div className="grid gap-8 lg:grid-cols-2">
|
||
<div>
|
||
{surveyKind !== 'item_course' ? (
|
||
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人员'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
|
||
) : (
|
||
<div>
|
||
<div className="mb-2 text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">{selectedTemplateInfo?.itemName || '调研事项'}</div>
|
||
<input
|
||
value={itemName}
|
||
onChange={(event) => setItemName(event.target.value)}
|
||
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写名称'}
|
||
className="h-10 w-full rounded border border-[#eeeeee] px-3 text-[14px] font-semibold outline-none transition-colors focus:border-[#0050cb]"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<EmployeePicker
|
||
title="评分人员"
|
||
purpose="evaluator"
|
||
selected={evaluatorEmployees}
|
||
onChange={setManualEvaluatorEmployees}
|
||
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
|
||
onRemoveEmployee={removeEvaluatorEmployee}
|
||
/>
|
||
</div>
|
||
<div className="lg:col-span-2">
|
||
<DepartmentPicker
|
||
departments={departments}
|
||
selected={evaluatorDepartmentValues}
|
||
onChange={setEvaluatorDepartmentValues}
|
||
onDepartmentEmployeesChange={(department, checked, employees) => {
|
||
setEvaluatorDepartmentEmployees((current) => {
|
||
const next = { ...current }
|
||
if (checked) {
|
||
next[department] = employees
|
||
} else {
|
||
delete next[department]
|
||
}
|
||
return next
|
||
})
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function EmployeePicker({
|
||
title,
|
||
purpose,
|
||
selected,
|
||
onChange,
|
||
onAddEmployee,
|
||
onRemoveEmployee,
|
||
}: {
|
||
title: string
|
||
purpose?: 'target' | 'evaluator'
|
||
selected: Employee[]
|
||
onChange: (employees: Employee[]) => void
|
||
onAddEmployee?: (employee: Employee) => void
|
||
onRemoveEmployee?: (id: string) => void
|
||
}) {
|
||
const [keyword, setKeyword] = useState('')
|
||
const [items, setItems] = useState<Employee[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [open, setOpen] = useState(false)
|
||
const pickerRef = useRef<HTMLDivElement | null>(null)
|
||
const cacheRef = useRef<Map<string, Employee[]>>(new Map())
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(async () => {
|
||
const trimmedKeyword = keyword.trim()
|
||
if (!trimmedKeyword) {
|
||
setItems([])
|
||
setError(null)
|
||
setLoading(false)
|
||
setOpen(false)
|
||
return
|
||
}
|
||
const cacheKey = `${purpose || 'default'}:${trimmedKeyword}`
|
||
const cached = cacheRef.current.get(cacheKey)
|
||
if (cached) {
|
||
setItems(cached)
|
||
setError(null)
|
||
setLoading(false)
|
||
setOpen(true)
|
||
return
|
||
}
|
||
setLoading(true)
|
||
setError(null)
|
||
setOpen(true)
|
||
try {
|
||
const params = new URLSearchParams({
|
||
search: trimmedKeyword,
|
||
limit: '50',
|
||
...(purpose ? { purpose } : {}),
|
||
})
|
||
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||
cacheRef.current.set(cacheKey, data)
|
||
setItems(data)
|
||
} catch (err) {
|
||
setItems([])
|
||
setError(err instanceof Error ? err.message : '搜索失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, 250)
|
||
|
||
return () => window.clearTimeout(timer)
|
||
}, [keyword, purpose])
|
||
|
||
useEffect(() => {
|
||
const closeWhenOutside = (event: MouseEvent) => {
|
||
if (!pickerRef.current?.contains(event.target as Node)) {
|
||
setOpen(false)
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', closeWhenOutside)
|
||
return () => document.removeEventListener('mousedown', closeWhenOutside)
|
||
}, [])
|
||
|
||
const selectedIds = new Set(selected.map((employee) => employee.id))
|
||
const selectEmployee = (employee: Employee) => {
|
||
if (selectedIds.has(employee.id)) return
|
||
if (onAddEmployee) {
|
||
onAddEmployee(employee)
|
||
} else {
|
||
onChange([...selected, employee])
|
||
}
|
||
setError(null)
|
||
setKeyword('')
|
||
setItems([])
|
||
setOpen(false)
|
||
}
|
||
|
||
return (
|
||
<div ref={pickerRef}>
|
||
<label className="grid gap-2 text-sm">
|
||
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">{title}</span>
|
||
<input
|
||
value={keyword}
|
||
onFocus={() => {
|
||
if (keyword.trim()) setOpen(true)
|
||
}}
|
||
onChange={(event) => {
|
||
setKeyword(event.target.value)
|
||
if (event.target.value.trim()) setOpen(true)
|
||
}}
|
||
placeholder="搜索人员"
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') setOpen(false)
|
||
}}
|
||
className="h-10 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
|
||
/>
|
||
</label>
|
||
{open && keyword && (
|
||
<div className="mt-2 max-h-56 overflow-y-auto border border-[#eeeeee] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)]">
|
||
{loading ? (
|
||
<div className="px-3 py-2 text-sm text-[#5f5e5e]">搜索中...</div>
|
||
) : error ? (
|
||
<div className="px-3 py-2 text-sm text-red-600">{error}</div>
|
||
) : items.length === 0 ? (
|
||
<div className="px-3 py-2 text-sm text-[#5f5e5e]">无匹配人员</div>
|
||
) : (
|
||
items.map((employee) => (
|
||
<button
|
||
key={employee.id}
|
||
type="button"
|
||
disabled={selectedIds.has(employee.id)}
|
||
onClick={() => selectEmployee(employee)}
|
||
className="w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-[#f9f9f9] disabled:opacity-50"
|
||
>
|
||
<div className="font-semibold text-[#1a1c1c]">{employee.displayName}</div>
|
||
<div className="mt-0.5 truncate text-xs text-[#5f5e5e]">
|
||
{[
|
||
employee.departmentName,
|
||
employee.positionTitle,
|
||
employee.positionLevel || employee.levelType,
|
||
].filter(Boolean).join(' · ')}
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
<SelectedChips
|
||
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
|
||
onRemove={(id) => {
|
||
if (onRemoveEmployee) {
|
||
onRemoveEmployee(id)
|
||
} else {
|
||
onChange(selected.filter((employee) => employee.id !== id))
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DepartmentPicker({
|
||
departments,
|
||
selected,
|
||
onChange,
|
||
onDepartmentEmployeesChange,
|
||
}: {
|
||
departments: DepartmentOption[]
|
||
selected: string[]
|
||
onChange: (items: string[]) => void
|
||
onDepartmentEmployeesChange?: (value: string, checked: boolean, employees: Employee[]) => void
|
||
}) {
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||
const [keyword, setKeyword] = useState('')
|
||
const [loadingValue, setLoadingValue] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const employeeCacheRef = useRef<Map<string, Employee[]>>(new Map())
|
||
|
||
const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => {
|
||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||
const byId = new Map<string, DepartmentOption>()
|
||
const children = new Map<string, DepartmentOption[]>()
|
||
|
||
departments.forEach((department) => {
|
||
byId.set(department.id, department)
|
||
const parentKey = department.parentId || ''
|
||
const list = children.get(parentKey) || []
|
||
list.push(department)
|
||
children.set(parentKey, list)
|
||
})
|
||
|
||
children.forEach((list) => {
|
||
list.sort((a, b) => {
|
||
const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0)
|
||
if (countDiff !== 0) return countDiff
|
||
return a.name.localeCompare(b.name, 'zh-CN')
|
||
})
|
||
})
|
||
|
||
const values = new Map<string, DepartmentOption>()
|
||
departments.forEach((department) => {
|
||
if (department.value) values.set(department.value, department)
|
||
})
|
||
|
||
const matchedIds = new Set<string>()
|
||
if (normalizedKeyword) {
|
||
departments.forEach((department) => {
|
||
const haystack = [
|
||
department.name,
|
||
department.parentName,
|
||
department.departmentName,
|
||
department.departmentPath,
|
||
department.fullPath,
|
||
].filter(Boolean)
|
||
if (!matchesPickerSearch(normalizedKeyword, haystack)) return
|
||
|
||
let current: DepartmentOption | undefined = department
|
||
while (current) {
|
||
matchedIds.add(current.id)
|
||
current = current.parentId ? byId.get(current.parentId) : undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
return {
|
||
roots: children.get('') || [],
|
||
childrenByParent: children,
|
||
optionByValue: values,
|
||
visibleIds: matchedIds,
|
||
}
|
||
}, [departments, keyword])
|
||
|
||
const selectedLabels = selected
|
||
.map((value) => optionByValue.get(value))
|
||
.filter((item): item is DepartmentOption => Boolean(item))
|
||
|
||
const toggle = (id: string) => {
|
||
setExpanded((current) => {
|
||
const next = new Set(current)
|
||
if (next.has(id)) {
|
||
next.delete(id)
|
||
} else {
|
||
next.add(id)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
const getDepartmentEmployees = async (value: string) => {
|
||
const cached = employeeCacheRef.current.get(value)
|
||
if (cached) return cached
|
||
|
||
const params = new URLSearchParams({
|
||
department: value,
|
||
limit: '1000',
|
||
purpose: 'evaluator',
|
||
})
|
||
const employees = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||
employeeCacheRef.current.set(value, employees)
|
||
return employees
|
||
}
|
||
|
||
const toggleValue = async (value: string, checked: boolean) => {
|
||
if (!checked) {
|
||
onChange(selected.filter((item) => item !== value))
|
||
onDepartmentEmployeesChange?.(value, false, [])
|
||
setError(null)
|
||
return
|
||
}
|
||
|
||
setLoadingValue(value)
|
||
setError(null)
|
||
try {
|
||
const employees = await getDepartmentEmployees(value)
|
||
if (employees.length === 0) {
|
||
setError('该部门暂无可选人员')
|
||
return
|
||
}
|
||
onChange(Array.from(new Set([...selected, value])))
|
||
onDepartmentEmployeesChange?.(value, true, employees)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '部门人员加载失败')
|
||
} finally {
|
||
setLoadingValue(null)
|
||
}
|
||
}
|
||
|
||
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
|
||
const children = childrenByParent.get(department.id) || []
|
||
const hasChildren = children.length > 0
|
||
const isExpanded = expanded.has(department.id) || Boolean(keyword.trim())
|
||
const isVisible = !keyword.trim() || visibleIds.has(department.id)
|
||
if (!isVisible) return null
|
||
|
||
return (
|
||
<div key={department.id}>
|
||
<div
|
||
className="flex min-h-9 items-center gap-2 px-2 py-1.5 text-sm transition-colors hover:bg-[#f9f9f9]"
|
||
style={{ paddingLeft: `${8 + depth * 18}px` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => hasChildren && toggle(department.id)}
|
||
className={`flex h-6 w-6 shrink-0 items-center justify-center border text-xs ${
|
||
hasChildren
|
||
? 'border-[#e2e2e2] text-[#5f5e5e] hover:bg-[#f4f3f3]'
|
||
: 'border-transparent text-transparent'
|
||
}`}
|
||
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
|
||
disabled={!hasChildren}
|
||
>
|
||
{hasChildren ? (isExpanded ? '-' : '+') : ''}
|
||
</button>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-semibold text-[#1a1c1c]">{department.name}</div>
|
||
</div>
|
||
{department.selectable !== false && department.value && (
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.includes(department.value)}
|
||
disabled={loadingValue === department.value}
|
||
onChange={(event) => {
|
||
void toggleValue(department.value!, event.target.checked)
|
||
}}
|
||
aria-label={`选择${department.name}`}
|
||
/>
|
||
)}
|
||
{department.value && loadingValue === department.value && (
|
||
<span className="text-xs text-[#5f5e5e]">加载中</span>
|
||
)}
|
||
</div>
|
||
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (departments.length === 0) return null
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">部门</div>
|
||
{selected.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
selected.forEach((value) => onDepartmentEmployeesChange?.(value, false, []))
|
||
onChange([])
|
||
setError(null)
|
||
}}
|
||
className="text-xs font-semibold text-[#5f5e5e] hover:text-[#1a1c1c]"
|
||
>
|
||
清空
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索部门"
|
||
className="mb-2 h-10 w-full rounded border border-[#eeeeee] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
|
||
/>
|
||
<div className="max-h-64 overflow-y-auto border border-[#eeeeee] bg-white p-1.5">
|
||
{roots.map((department) => renderNode(department))}
|
||
</div>
|
||
{error && <div className="mt-2 text-xs font-semibold text-[#93000a]">{error}</div>}
|
||
{selectedLabels.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{selectedLabels.map((department) => (
|
||
<button
|
||
key={department.value || department.id}
|
||
type="button"
|
||
onClick={() => {
|
||
if (department.value) void toggleValue(department.value, false)
|
||
}}
|
||
className="border border-[#e2e2e2] bg-white px-2.5 py-1 text-xs font-semibold text-[#1a1c1c] hover:bg-[#f9f9f9]"
|
||
>
|
||
{department.fullPath || department.departmentPath || (
|
||
department.parentName && department.parentName !== '全部部门'
|
||
? `${department.parentName} / ${department.name}`
|
||
: department.name
|
||
)} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AdminActions({
|
||
onMessage,
|
||
}: {
|
||
onMessage: (message: string | null) => void
|
||
}) {
|
||
const [busy, setBusy] = useState<string | null>(null)
|
||
|
||
const run = async (key: string, path: string, success: string) => {
|
||
setBusy(key)
|
||
onMessage(null)
|
||
try {
|
||
const data = await apiRequest<any>(path, { method: 'POST', body: JSON.stringify({}) })
|
||
onMessage(`${success}${data?.upserted != null ? `:${data.upserted} 人` : ''}`)
|
||
} catch (error) {
|
||
onMessage(error instanceof Error ? error.message : '操作失败')
|
||
} finally {
|
||
setBusy(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">维护</h2>
|
||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||
<button
|
||
disabled={busy !== null}
|
||
onClick={() => run('sync', '/admin/sync-employees', '人员档案已同步')}
|
||
className="rounded-[10px] border border-[#dbe5f2] px-2 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
{busy === 'sync' ? '同步中' : '人员同步'}
|
||
</button>
|
||
<a
|
||
href="/api/performance-survey/admin/export"
|
||
className="rounded-[10px] border border-[#dbe5f2] px-2 py-2 text-center text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
导出数据
|
||
</a>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function getNotificationEventLabel(eventType?: string | null) {
|
||
const map: Record<string, string> = {
|
||
'survey.assignment.created': '待评价',
|
||
'survey.assignment.due_reminder': '截止提醒',
|
||
'survey.creator.completed': '完成提醒',
|
||
'survey.result.available': '结果可看',
|
||
}
|
||
return map[String(eventType || '')] || '系统消息'
|
||
}
|
||
|
||
function NotificationList({
|
||
state,
|
||
statusState,
|
||
}: {
|
||
state: ApiState<any[]> & { refresh: () => Promise<void> }
|
||
statusState: ApiState<any> & { refresh: () => Promise<void> }
|
||
}) {
|
||
const [busyId, setBusyId] = useState<string | null>(null)
|
||
const workNotice = statusState.data?.workNotice
|
||
const counts = statusState.data?.counts || {}
|
||
const records = state.data || []
|
||
const visibleRecords = records.slice(0, 6)
|
||
const total = records.length
|
||
const sentCount = Number(counts.sent || counts.succeeded || 0)
|
||
const failedCount = Number(counts.exhausted || counts.skipped || counts.failed || 0)
|
||
|
||
const retry = async (id: string) => {
|
||
setBusyId(id)
|
||
try {
|
||
await apiRequest(`/admin/notifications/${id}/retry`, { method: 'POST', body: JSON.stringify({}) })
|
||
await state.refresh()
|
||
} finally {
|
||
setBusyId(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">钉钉消息</h2>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{total} 条 · {sentCount} 已发送 · {failedCount} 异常</div>
|
||
</div>
|
||
<button
|
||
onClick={async () => {
|
||
await Promise.all([state.refresh(), statusState.refresh()])
|
||
}}
|
||
className="rounded-[10px] border border-[#dbe5f2] px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200"
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
{workNotice && (
|
||
<div className={`mt-3 inline-flex rounded-full px-3 py-1 text-xs font-semibold ${
|
||
workNotice.ready
|
||
? 'bg-[#ecfdf3] text-[#039855]'
|
||
: 'bg-[#fff7ed] text-[#b45309]'
|
||
}`}>
|
||
{workNotice.ready ? '已接入' : '未接入'}
|
||
</div>
|
||
)}
|
||
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
|
||
{state.loading && <div className="text-sm text-gray-500">加载中...</div>}
|
||
{state.error && <div className="text-sm text-red-600">{state.error}</div>}
|
||
{records.length === 0 && !state.loading && <div className="py-3 text-sm text-gray-500">暂无消息记录</div>}
|
||
{visibleRecords.map((item) => {
|
||
const isRetryable = ['retrying', 'exhausted', 'skipped'].includes(item.status)
|
||
return (
|
||
<div key={item.id} className="py-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
|
||
{item.recipientName || '收件人缺失'} · {getNotificationEventLabel(item.eventType)}
|
||
</div>
|
||
{item.lastError && (
|
||
<details className="mt-1 hidden text-xs text-gray-500 dark:text-gray-400 sm:block">
|
||
<summary className="cursor-pointer list-none text-red-500">详情</summary>
|
||
<div className="mt-1 break-words text-red-500">{item.lastError}</div>
|
||
</details>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||
<StatusBadge status={item.status} />
|
||
{isRetryable && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => retry(item.id)}
|
||
className="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
补发
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const targetIdParam = searchParams.get('targetId')
|
||
const detail = useApi<any>(`/surveys/${surveyId}`, [surveyId])
|
||
const [targetId, setTargetId] = useState(targetIdParam || '')
|
||
const [scores, setScores] = useState<Record<string, number>>({})
|
||
const [comment, setComment] = useState('')
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
||
|
||
const targets = useMemo(
|
||
() => (detail.data?.targets || []).filter((target: any) => target.currentAssignment || target.canUploadWorkItems),
|
||
[detail.data],
|
||
)
|
||
const activeTargetId = targetId || targets[0]?.id || ''
|
||
const template = detail.data?.template as Template | undefined
|
||
const questions = useMemo(() => template?.questions || [], [template])
|
||
|
||
useEffect(() => {
|
||
if (!targetId && targets[0]?.id) setTargetId(targets[0].id)
|
||
}, [targets, targetId])
|
||
|
||
useEffect(() => {
|
||
setScores({})
|
||
setComment('')
|
||
setMessage(null)
|
||
setAccountMenuOpen(false)
|
||
}, [activeTargetId, questions])
|
||
|
||
const submit = async () => {
|
||
const hasUnratedQuestion = questions.some((question) => !Number.isFinite(scores[question.id]))
|
||
if (questions.length === 0 || hasUnratedQuestion) {
|
||
setMessage('请逐项完成评分后再提交')
|
||
return
|
||
}
|
||
setSubmitting(true)
|
||
setMessage(null)
|
||
try {
|
||
await apiRequest(`/surveys/${surveyId}/responses`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ targetId: activeTargetId, scores, comment }),
|
||
})
|
||
setMessage('提交成功')
|
||
await detail.refresh()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '提交失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (detail.loading) return <SurveyOpeningScreen />
|
||
if (detail.error) return <MainPanel><ErrorPanel message={detail.error} /></MainPanel>
|
||
if (!detail.data) return null
|
||
|
||
const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId)
|
||
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
|
||
const scoreValues = questions.map((question) => Number(scores[question.id] ?? 0))
|
||
const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0)
|
||
const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length
|
||
const surveyStatusLabel = getSurveyStatusLabel(detail.data.status)
|
||
const dueLabel = detail.data.dueAt ? `截止 ${formatDate(detail.data.dueAt)}` : '无截止时间'
|
||
const viewer = session?.user || {}
|
||
const viewerName = String(viewer.name || viewer.realName || viewer.username || '当前用户')
|
||
const viewerAvatar = typeof viewer.avatar === 'string' && viewer.avatar
|
||
? viewer.avatar
|
||
: typeof viewer.image === 'string'
|
||
? viewer.image
|
||
: ''
|
||
const goToSurveyTab = (nextTab: PerformanceSurveyDashboardTab) => {
|
||
setAccountMenuOpen(false)
|
||
router.push(`/performance-survey?tab=${encodeURIComponent(nextTab)}`)
|
||
}
|
||
const changeTarget = (nextTargetId: string) => {
|
||
setScores({})
|
||
setComment('')
|
||
setMessage(null)
|
||
setAccountMenuOpen(false)
|
||
setTargetId(nextTargetId)
|
||
}
|
||
|
||
if (isVisualTemplate(template)) {
|
||
return (
|
||
<VisualCaseSurvey
|
||
surveyId={surveyId}
|
||
detail={detail}
|
||
template={template}
|
||
targets={targets}
|
||
activeTarget={activeTarget}
|
||
activeTargetId={activeTargetId}
|
||
setTargetId={changeTarget}
|
||
viewerName={viewerName}
|
||
viewerAvatar={viewerAvatar}
|
||
dueLabel={dueLabel}
|
||
surveyStatusLabel={surveyStatusLabel}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<main className="min-h-[100dvh] bg-white text-[#111827]">
|
||
<MobileSurveyRangeStyles />
|
||
<div className="mx-auto flex max-w-[430px] flex-col px-4 pb-[calc(92px+env(safe-area-inset-bottom))] pt-3 sm:my-6 sm:rounded-[28px] sm:border sm:border-[#e5eaf2] sm:bg-white sm:p-5 sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
|
||
<section className="mb-3 overflow-hidden rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-3.5 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[13px] font-semibold text-[#1677ff]">考核调研问卷</span>
|
||
<span className="shrink-0 rounded-full bg-[#eef6ff] px-3 py-1 text-[12px] font-semibold text-[#1677ff]">
|
||
{surveyStatusLabel}
|
||
</span>
|
||
</div>
|
||
<h1 className="mt-2.5 break-words text-[1.22rem] font-semibold leading-[1.22] tracking-[-0.01em] text-[#101828]">
|
||
{detail.data.title}
|
||
</h1>
|
||
<div className="mt-2.5 space-y-1 text-[12px] font-medium leading-5 text-[#667085]">
|
||
<p className="break-words text-[#4b5565]">评价对象:{activeTarget?.targetName || '待选择'}</p>
|
||
<p>{dueLabel}</p>
|
||
</div>
|
||
<div className="mt-3 rounded-2xl bg-[#f6f8fb] px-3.5 py-2.5">
|
||
<div className="flex items-center justify-between text-[12px] font-semibold text-[#667085]">
|
||
<span>填写进度</span>
|
||
<span className="text-[#101828]">{ratedCount}/{questions.length || 0} · {scoreTotal} 分</span>
|
||
</div>
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
|
||
<div
|
||
className="h-full rounded-full bg-[#1677ff] transition-all"
|
||
style={{ width: `${questions.length > 0 ? Math.min(100, (ratedCount / questions.length) * 100) : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{targets.length > 1 && (
|
||
<label className="mb-4 grid gap-2 rounded-[20px] border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<span className="font-semibold text-[#4b5565]">切换评价对象</span>
|
||
<select
|
||
value={activeTargetId}
|
||
onChange={(event) => changeTarget(event.target.value)}
|
||
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
>
|
||
{targets.map((target: any) => (
|
||
<option key={target.id} value={target.id}>{target.targetName}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
{submitted ? (
|
||
<SubmittedResponseDetail
|
||
questions={questions}
|
||
template={template}
|
||
response={activeTarget?.currentAssignment?.response}
|
||
submittedAt={activeTarget?.currentAssignment?.submittedAt}
|
||
/>
|
||
) : detail.data.status !== 'open' ? (
|
||
<div className="rounded-[24px] border border-[#dce6f2] bg-white px-4 py-10 text-center text-[16px] font-semibold text-[#4b5565] shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
该调研未开放或已关闭
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{questions.map((question, index) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
const isRated = Number.isFinite(scores[question.id])
|
||
const value = isRated ? scores[question.id] : 0
|
||
const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100))
|
||
const accentColor = getQuestionAccentColor(index)
|
||
return (
|
||
<section key={question.id} className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
|
||
<span
|
||
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
|
||
style={{ backgroundColor: accentColor }}
|
||
>
|
||
{index + 1}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] font-semibold text-[#98a2b3]">问题 {index + 1}</div>
|
||
<h2 className="mt-1 break-words text-[17px] font-semibold leading-6 text-[#111827]">{question.title}</h2>
|
||
{question.description && (
|
||
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
|
||
)}
|
||
</div>
|
||
<div className="rounded-xl bg-[#f6f8fb] px-2.5 py-1.5 text-right">
|
||
<span className="text-[15px] font-semibold leading-none" style={{ color: accentColor }}>{isRated ? value : '未评分'}</span>
|
||
{isRated && <span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={maxScore}
|
||
value={value}
|
||
data-rated={isRated ? 'true' : 'false'}
|
||
aria-label={`${question.title}评分,满分 ${maxScore} 分`}
|
||
onPointerDown={() => {
|
||
if (!isRated) setScores((current) => ({ ...current, [question.id]: 0 }))
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (!isRated && (event.key === 'ArrowLeft' || event.key === 'Home')) {
|
||
setScores((current) => ({ ...current, [question.id]: 0 }))
|
||
}
|
||
}}
|
||
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
|
||
className="survey-score-range mt-4 w-full"
|
||
style={{ '--score-percent': `${scorePercent}%`, '--score-color': accentColor } as CSSProperties}
|
||
/>
|
||
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
|
||
<span>0</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.25)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.5)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.75)}</span>
|
||
<span className="text-right">{maxScore}</span>
|
||
</div>
|
||
</section>
|
||
)
|
||
})}
|
||
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<label className="grid gap-3">
|
||
<span className="text-[15px] font-semibold text-[#111827]">补充评价</span>
|
||
<textarea
|
||
value={comment}
|
||
onChange={(event) => setComment(event.target.value.slice(0, 300))}
|
||
rows={4}
|
||
placeholder="可以从优点、待改进或建议等方面补充说明..."
|
||
className="min-h-[92px] resize-none rounded-[9px] border border-[#d9e0ea] px-3 py-2.5 text-[14px] leading-6 text-[#111827] outline-none transition placeholder:text-[#c0c7d2] focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
/>
|
||
<span className="text-right text-[11px] font-medium text-[#9aa3b2]">{comment.length} / 300</span>
|
||
</label>
|
||
</section>
|
||
|
||
{message && <div className="rounded-[10px] bg-[#eef6ff] px-3 py-2 text-sm font-medium text-[#1677ff]">{message}</div>}
|
||
|
||
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-1">
|
||
<div className="mx-auto grid max-w-[390px] grid-cols-[minmax(0,0.42fr)_minmax(0,1fr)] gap-3">
|
||
<div className="relative min-w-0">
|
||
{accountMenuOpen && (
|
||
<div className="absolute bottom-[calc(100%+10px)] left-0 z-30 w-[196px] overflow-hidden rounded-[18px] border border-[#dbe5f2] bg-white shadow-[0_18px_46px_rgba(15,23,42,0.16)]">
|
||
<button
|
||
type="button"
|
||
onClick={() => goToSurveyTab('personal')}
|
||
className="block w-full px-4 py-3 text-left text-[14px] font-semibold text-[#101828] active:bg-[#f3f7fc]"
|
||
>
|
||
个人看板
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => goToSurveyTab('public')}
|
||
className="block w-full border-t border-[#edf2f7] px-4 py-3 text-left text-[14px] font-semibold text-[#101828] active:bg-[#f3f7fc]"
|
||
>
|
||
公共看板
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => goToSurveyTab('manage')}
|
||
className="block w-full border-t border-[#edf2f7] px-4 py-3 text-left text-[14px] font-semibold text-[#101828] active:bg-[#f3f7fc]"
|
||
>
|
||
发起考核
|
||
</button>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
aria-label="打开账户菜单"
|
||
aria-haspopup="menu"
|
||
aria-expanded={accountMenuOpen}
|
||
onClick={() => setAccountMenuOpen((open) => !open)}
|
||
className="flex min-h-[52px] w-full min-w-0 items-center gap-2.5 rounded-[16px] border border-[#dbe5f2] bg-[#f8fbff] px-2.5 text-left shadow-[0_8px_18px_rgba(15,23,42,0.06)] transition active:scale-[0.99]"
|
||
>
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#eaf3ff] text-[13px] font-semibold text-[#1677ff]">
|
||
{viewerAvatar ? (
|
||
<Image
|
||
src={viewerAvatar}
|
||
alt={viewerName}
|
||
width={40}
|
||
height={40}
|
||
unoptimized
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
getSurveyInitials(viewerName)
|
||
)}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-[14px] font-semibold leading-5 text-[#101828]">{viewerName}</span>
|
||
</button>
|
||
</div>
|
||
<button
|
||
disabled={submitting || !activeTargetId || questions.length === 0 || ratedCount !== questions.length}
|
||
onClick={submit}
|
||
className="min-h-[52px] w-full rounded-[16px] bg-[#1677ff] px-4 py-2.5 text-[17px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.28)] transition active:scale-[0.99] disabled:bg-[#9ec9ff] disabled:shadow-none"
|
||
>
|
||
{submitting ? '提交中...' : '提交评价'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function SubmittedResponseDetail({
|
||
questions,
|
||
template,
|
||
response,
|
||
submittedAt,
|
||
}: {
|
||
questions: Template['questions']
|
||
template?: Template
|
||
response?: any
|
||
submittedAt?: string | Date | null
|
||
}) {
|
||
const responseScores = response?.scores && typeof response.scores === 'object'
|
||
? response.scores as Record<string, unknown>
|
||
: {}
|
||
const scoreOf = (questionId: string) => {
|
||
const value = Number(responseScores[questionId])
|
||
return Number.isFinite(value) ? value : 0
|
||
}
|
||
const formatScore = (value: unknown) => {
|
||
const numberValue = Number(value)
|
||
if (!Number.isFinite(numberValue)) return '-'
|
||
return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1)
|
||
}
|
||
const submittedLabel = submittedAt || response?.createdAt
|
||
? formatDate(submittedAt || response?.createdAt)
|
||
: '已提交'
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[13px] font-semibold text-[#16a34a]">已提交</div>
|
||
<div className="mt-1 text-[12px] font-medium text-[#667085]">{submittedLabel}</div>
|
||
</div>
|
||
<Link
|
||
href="/performance-survey?tab=personal"
|
||
className="shrink-0 rounded-full border border-[#dbe5f2] bg-[#f8fbff] px-3 py-1.5 text-[12px] font-semibold text-[#101828]"
|
||
>
|
||
返回个人看板
|
||
</Link>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
|
||
<div className="text-[11px] font-semibold text-[#667085]">总分</div>
|
||
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.totalScore)}</div>
|
||
</div>
|
||
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
|
||
<div className="text-[11px] font-semibold text-[#667085]">达成分</div>
|
||
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.achievementScore)}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{questions.map((question, index) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
const value = scoreOf(question.id)
|
||
const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100))
|
||
const accentColor = getQuestionAccentColor(index)
|
||
return (
|
||
<section key={question.id} className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
|
||
<span
|
||
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
|
||
style={{ backgroundColor: accentColor }}
|
||
>
|
||
{index + 1}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] font-semibold text-[#98a2b3]">问题 {index + 1}</div>
|
||
<h2 className="mt-1 break-words text-[17px] font-semibold leading-6 text-[#111827]">{question.title}</h2>
|
||
{question.description && (
|
||
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
|
||
)}
|
||
</div>
|
||
<div className="rounded-xl bg-[#f6f8fb] px-2.5 py-1.5 text-right">
|
||
<span className="text-[18px] font-semibold leading-none" style={{ color: accentColor }}>{formatScore(value)}</span>
|
||
<span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
|
||
<div
|
||
className="h-full rounded-full"
|
||
style={{ width: `${scorePercent}%`, backgroundColor: accentColor }}
|
||
/>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
|
||
<span>0</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.25)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.5)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.75)}</span>
|
||
<span className="text-right">{maxScore}</span>
|
||
</div>
|
||
</section>
|
||
)
|
||
})}
|
||
|
||
{response?.comment && (
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="text-[15px] font-semibold text-[#111827]">补充评价</div>
|
||
<p className="mt-3 whitespace-pre-wrap text-[14px] leading-6 text-[#4b5565]">{response.comment}</p>
|
||
</section>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VisualCaseSurvey({
|
||
surveyId,
|
||
detail,
|
||
template,
|
||
targets,
|
||
activeTarget,
|
||
activeTargetId,
|
||
setTargetId,
|
||
viewerName,
|
||
viewerAvatar,
|
||
dueLabel,
|
||
surveyStatusLabel,
|
||
}: {
|
||
surveyId: string
|
||
detail: ApiState<any> & { refresh: () => Promise<void> }
|
||
template: Template | undefined
|
||
targets: any[]
|
||
activeTarget: any
|
||
activeTargetId: string
|
||
setTargetId: (id: string) => void
|
||
viewerName: string
|
||
viewerAvatar: string
|
||
dueLabel: string
|
||
surveyStatusLabel: string
|
||
}) {
|
||
const requiredCount = Number(template?.requiredWorkItemCount || 3)
|
||
const workItems = activeTarget?.workItems || []
|
||
const canUpload = Boolean(activeTarget?.canUploadWorkItems && detail.data?.status === 'open')
|
||
const canScore = Boolean(activeTarget?.currentAssignment && detail.data?.status === 'open')
|
||
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
|
||
const completedScores = workItems.filter((item: any) => item.scores?.length > 0).length
|
||
|
||
return (
|
||
<main className="min-h-[100dvh] bg-white text-[#111827]">
|
||
<MobileSurveyRangeStyles />
|
||
<div className="mx-auto flex max-w-[430px] flex-col px-4 pb-[calc(92px+env(safe-area-inset-bottom))] pt-3 sm:my-6 sm:rounded-[28px] sm:border sm:border-[#e5eaf2] sm:bg-white sm:p-5 sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
|
||
<section className="mb-3 rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[13px] font-semibold text-[#1677ff]">{template?.shortName || '视觉评分'}</span>
|
||
<span className="shrink-0 rounded-full bg-[#eef6ff] px-3 py-1 text-[12px] font-semibold text-[#1677ff]">
|
||
{surveyStatusLabel}
|
||
</span>
|
||
</div>
|
||
<h1 className="mt-2.5 break-words text-[1.22rem] font-semibold leading-[1.22] tracking-[-0.01em] text-[#101828]">
|
||
{detail.data?.title}
|
||
</h1>
|
||
<div className="mt-2.5 space-y-1 text-[12px] font-medium leading-5 text-[#667085]">
|
||
<p className="break-words text-[#4b5565]">被考核人:{activeTarget?.targetName || '待选择'}</p>
|
||
<p>{dueLabel}</p>
|
||
</div>
|
||
<div className="mt-3 rounded-2xl bg-[#f6f8fb] px-3.5 py-2.5">
|
||
<div className="flex items-center justify-between text-[12px] font-semibold text-[#667085]">
|
||
<span>{canUpload ? '案例提交' : '案例评分'}</span>
|
||
<span className="text-[#101828]">{canUpload ? `${workItems.length}/${requiredCount}` : `${completedScores}/${workItems.length || requiredCount}`}</span>
|
||
</div>
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
|
||
<div
|
||
className="h-full rounded-full bg-[#1677ff] transition-all"
|
||
style={{ width: `${Math.min(100, ((canUpload ? workItems.length : completedScores) / Math.max(1, canUpload ? requiredCount : workItems.length || requiredCount)) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{targets.length > 1 && (
|
||
<label className="mb-4 grid gap-2 rounded-[20px] border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<span className="font-semibold text-[#4b5565]">切换对象</span>
|
||
<select
|
||
value={activeTargetId}
|
||
onChange={(event) => setTargetId(event.target.value)}
|
||
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
>
|
||
{targets.map((target: any) => (
|
||
<option key={target.id} value={target.id}>{target.targetName}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
{canUpload && (
|
||
<WorkItemUploadForm
|
||
key={activeTargetId}
|
||
surveyId={surveyId}
|
||
targetId={activeTargetId}
|
||
disabled={workItems.length >= requiredCount}
|
||
onUploaded={detail.refresh}
|
||
/>
|
||
)}
|
||
|
||
{workItems.length === 0 && (
|
||
<div className="rounded-[20px] border border-dashed border-[#d9e0ea] bg-white px-4 py-10 text-center text-[15px] font-semibold text-[#667085]">
|
||
{canUpload ? '请先上传案例图片' : '等待被考核人提交案例'}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
{workItems.map((item: any, index: number) => (
|
||
<VisualWorkItemCard
|
||
key={`${activeTargetId}-${item.id}`}
|
||
surveyId={surveyId}
|
||
item={item}
|
||
index={index}
|
||
template={template}
|
||
canScore={canScore && !submitted}
|
||
onScored={detail.refresh}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-3">
|
||
<div className="mx-auto flex max-w-[390px] items-center justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#eaf3ff] text-[13px] font-semibold text-[#1677ff]">
|
||
{viewerAvatar ? (
|
||
<Image src={viewerAvatar} alt={viewerName} width={40} height={40} unoptimized className="h-full w-full object-cover" />
|
||
) : (
|
||
getSurveyInitials(viewerName)
|
||
)}
|
||
</span>
|
||
<span className="min-w-0 truncate text-[14px] font-semibold text-[#101828]">{viewerName}</span>
|
||
</div>
|
||
<Link
|
||
href="/performance-survey?tab=personal"
|
||
className="rounded-[14px] bg-[#1677ff] px-5 py-3 text-[15px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)]"
|
||
>
|
||
返回看板
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function WorkItemUploadForm({
|
||
surveyId,
|
||
targetId,
|
||
disabled,
|
||
onUploaded,
|
||
}: {
|
||
surveyId: string
|
||
targetId: string
|
||
disabled: boolean
|
||
onUploaded: () => Promise<void>
|
||
}) {
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||
const [title, setTitle] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [files, setFiles] = useState<File[]>([])
|
||
const [busy, setBusy] = useState(false)
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
const selectedPreviews = useMemo(
|
||
() => files.map((file) => ({ name: file.name, url: URL.createObjectURL(file) })),
|
||
[files],
|
||
)
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
selectedPreviews.forEach((item) => URL.revokeObjectURL(item.url))
|
||
}
|
||
}, [selectedPreviews])
|
||
|
||
const submit = async () => {
|
||
if (disabled) return
|
||
const validationError = validatePerformanceSurveyImageFiles(files)
|
||
if (validationError) {
|
||
setMessage(validationError)
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setMessage(null)
|
||
try {
|
||
const formData = new FormData()
|
||
formData.set('targetId', targetId)
|
||
formData.set('title', title.trim() || '案例作品')
|
||
formData.set('description', description.trim())
|
||
files.forEach((file) => formData.append('images', file))
|
||
await apiRequest(`/surveys/${surveyId}/work-items`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
setTitle('')
|
||
setDescription('')
|
||
setFiles([])
|
||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||
await onUploaded()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '上传失败')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="mb-3 rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="text-[15px] font-semibold text-[#111827]">提交案例</div>
|
||
<div className="mt-3 grid gap-3">
|
||
<input
|
||
value={title}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
placeholder="案例名称"
|
||
disabled={disabled}
|
||
className="rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
|
||
/>
|
||
<textarea
|
||
value={description}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
placeholder="案例说明(可选)"
|
||
disabled={disabled}
|
||
rows={3}
|
||
className="resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
|
||
/>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
disabled={disabled}
|
||
onChange={(event) => {
|
||
const selectedFiles = Array.from(event.target.files || [])
|
||
const validationError = validatePerformanceSurveyImageFiles(selectedFiles)
|
||
if (validationError) {
|
||
setFiles([])
|
||
setMessage(validationError)
|
||
event.currentTarget.value = ''
|
||
return
|
||
}
|
||
setFiles(selectedFiles)
|
||
setMessage(null)
|
||
}}
|
||
className="text-sm text-[#667085] file:mr-3 file:rounded-full file:border-0 file:bg-[#eef6ff] file:px-3 file:py-2 file:text-sm file:font-semibold file:text-[#1677ff]"
|
||
/>
|
||
{selectedPreviews.length > 0 && (
|
||
<div className="rounded-[14px] border border-[#e5edf6] bg-[#f6f8fb] p-2">
|
||
<div className="mb-2 text-[12px] font-semibold text-[#667085]">已选择 {selectedPreviews.length} 张图片</div>
|
||
<ImagePreviewGrid images={selectedPreviews.map((item) => item.url)} variant="gallery" />
|
||
</div>
|
||
)}
|
||
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
|
||
<button
|
||
type="button"
|
||
disabled={busy || disabled || files.length === 0}
|
||
onClick={submit}
|
||
className="rounded-[12px] bg-[#1677ff] px-4 py-2.5 text-sm font-semibold text-white disabled:bg-[#9ec9ff]"
|
||
>
|
||
{disabled ? '案例已满' : busy ? '上传中...' : '保存案例'}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function VisualWorkItemCard({
|
||
surveyId,
|
||
item,
|
||
index,
|
||
template,
|
||
canScore,
|
||
onScored,
|
||
}: {
|
||
surveyId: string
|
||
item: any
|
||
index: number
|
||
template: Template | undefined
|
||
canScore: boolean
|
||
onScored: () => Promise<void>
|
||
}) {
|
||
const existingScore = item.scores?.[0]
|
||
const questions = template?.questions || []
|
||
const [scores, setScores] = useState<Record<string, number>>({})
|
||
const [comment, setComment] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length
|
||
const allRated = questions.length > 0 && ratedCount === questions.length
|
||
const total = questions.reduce((sum, question) => sum + Number(scores[question.id] || 0), 0)
|
||
|
||
const submit = async () => {
|
||
if (!allRated) {
|
||
setMessage('请逐项完成评分后再提交')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setMessage(null)
|
||
try {
|
||
await apiRequest(`/surveys/${surveyId}/work-items/${item.id}/scores`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ scores, comment }),
|
||
})
|
||
await onScored()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '评分失败')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-[12px] font-semibold text-[#1677ff]">案例 {index + 1}</div>
|
||
<h2 className="mt-1 break-words text-[18px] font-semibold text-[#111827]">{item.title}</h2>
|
||
{item.description && <p className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-[#667085]">{item.description}</p>}
|
||
</div>
|
||
<span className="shrink-0 rounded-xl bg-[#f6f8fb] px-3 py-1.5 text-[15px] font-semibold text-[#101828]">
|
||
{existingScore ? `${formatNullableScore(existingScore.totalScore)} 分` : allRated ? `${total} 分` : '待评分'}
|
||
</span>
|
||
</div>
|
||
|
||
<ImagePreviewGrid images={item.imageUrls || []} variant={canScore ? 'review' : 'gallery'} />
|
||
|
||
{existingScore ? (
|
||
<div className="mt-4 rounded-[14px] bg-[#f6f8fb] px-3 py-3 text-sm font-medium text-[#4b5565]">
|
||
已评分:{formatNullableScore(existingScore.totalScore)} 分
|
||
{existingScore.comment && <p className="mt-2 whitespace-pre-wrap text-[#667085]">{existingScore.comment}</p>}
|
||
</div>
|
||
) : canScore ? (
|
||
<div className="mt-4 space-y-3">
|
||
{questions.map((question, questionIndex) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
const isRated = Number.isFinite(scores[question.id])
|
||
const value = isRated ? scores[question.id] : 0
|
||
const percent = Math.max(0, Math.min(100, (value / maxScore) * 100))
|
||
const accentColor = getQuestionAccentColor(questionIndex)
|
||
return (
|
||
<div key={question.id}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-[14px] font-semibold text-[#111827]">{question.title}</div>
|
||
{question.description && <div className="mt-0.5 text-[12px] text-[#667085]">{question.description}</div>}
|
||
</div>
|
||
<div className="text-[14px] font-semibold" style={{ color: accentColor }}>{isRated ? `${value}/${maxScore}` : '未评分'}</div>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={maxScore}
|
||
value={value}
|
||
data-rated={isRated ? 'true' : 'false'}
|
||
aria-label={`${question.title}评分,满分 ${maxScore} 分`}
|
||
onPointerDown={() => {
|
||
if (!isRated) setScores((current) => ({ ...current, [question.id]: 0 }))
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (!isRated && (event.key === 'ArrowLeft' || event.key === 'Home')) {
|
||
setScores((current) => ({ ...current, [question.id]: 0 }))
|
||
}
|
||
}}
|
||
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
|
||
className="survey-score-range mt-2 w-full"
|
||
style={{ '--score-percent': `${percent}%`, '--score-color': accentColor } as CSSProperties}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
<textarea
|
||
value={comment}
|
||
onChange={(event) => setComment(event.target.value.slice(0, 300))}
|
||
rows={3}
|
||
placeholder="案例评语(可选)"
|
||
className="w-full resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
/>
|
||
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
|
||
<button
|
||
type="button"
|
||
disabled={busy || !allRated}
|
||
onClick={submit}
|
||
className="w-full rounded-[14px] bg-[#1677ff] px-4 py-3 text-[15px] font-semibold text-white disabled:bg-[#9ec9ff]"
|
||
>
|
||
{busy ? '提交中...' : '提交本案例评分'}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ImagePreviewGrid({
|
||
images,
|
||
variant = 'default',
|
||
}: {
|
||
images: string[]
|
||
variant?: 'default' | 'gallery' | 'review'
|
||
}) {
|
||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||
const displayImages = useMemo(
|
||
() => images.map(normalizePerformanceSurveyImageUrl).filter(Boolean),
|
||
[images],
|
||
)
|
||
|
||
if (displayImages.length === 0) return null
|
||
|
||
if (variant === 'review') {
|
||
const [firstImage, ...restImages] = displayImages
|
||
return (
|
||
<>
|
||
<div className="mt-4 space-y-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveIndex(0)}
|
||
aria-label="查看主要案例图片"
|
||
className="block w-full overflow-hidden rounded-[16px] border border-[#e5edf6] bg-[#f6f8fb] text-left"
|
||
>
|
||
<Image
|
||
src={firstImage}
|
||
alt="主要案例图片"
|
||
width={720}
|
||
height={540}
|
||
unoptimized
|
||
className="aspect-[4/3] h-auto w-full object-cover"
|
||
/>
|
||
</button>
|
||
{restImages.length > 0 && (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{restImages.map((url, index) => (
|
||
<button
|
||
key={`${url}-${index}`}
|
||
type="button"
|
||
onClick={() => setActiveIndex(index + 1)}
|
||
aria-label={`查看案例图片 ${index + 2}`}
|
||
className="block w-full overflow-hidden rounded-[12px] border border-[#e5edf6] bg-[#f6f8fb] text-left"
|
||
>
|
||
<Image
|
||
src={url}
|
||
alt={`案例图片 ${index + 2}`}
|
||
width={220}
|
||
height={220}
|
||
unoptimized
|
||
className="aspect-square h-auto w-full object-cover"
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ImageFullscreenViewer
|
||
images={displayImages}
|
||
activeIndex={activeIndex}
|
||
onChange={setActiveIndex}
|
||
onClose={() => setActiveIndex(null)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
const gridClass = variant === 'gallery'
|
||
? 'mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4'
|
||
: 'mt-4 grid grid-cols-2 gap-2'
|
||
const imageClass = variant === 'gallery'
|
||
? 'aspect-square h-auto w-full object-cover transition-transform duration-300 group-hover:scale-105'
|
||
: 'aspect-[4/3] h-auto w-full object-cover'
|
||
|
||
return (
|
||
<>
|
||
<div className={gridClass}>
|
||
{displayImages.map((url, index) => (
|
||
<button
|
||
key={`${url}-${index}`}
|
||
type="button"
|
||
onClick={() => setActiveIndex(index)}
|
||
aria-label={`查看案例图片 ${index + 1}`}
|
||
className="group block w-full overflow-hidden rounded-[14px] border border-[#e5edf6] bg-[#f6f8fb] text-left"
|
||
>
|
||
<Image
|
||
src={url}
|
||
alt={`案例图片 ${index + 1}`}
|
||
width={320}
|
||
height={240}
|
||
unoptimized
|
||
className={imageClass}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<ImageFullscreenViewer
|
||
images={displayImages}
|
||
activeIndex={activeIndex}
|
||
onChange={setActiveIndex}
|
||
onClose={() => setActiveIndex(null)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function ImageFullscreenViewer({
|
||
images,
|
||
activeIndex,
|
||
onChange,
|
||
onClose,
|
||
}: {
|
||
images: string[]
|
||
activeIndex: number | null
|
||
onChange: (index: number) => void
|
||
onClose: () => void
|
||
}) {
|
||
const [portalReady, setPortalReady] = useState(false)
|
||
const dialogRef = useRef<HTMLDivElement | null>(null)
|
||
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
setPortalReady(true)
|
||
}, [])
|
||
|
||
const currentIndex = activeIndex == null ? null : Math.max(0, Math.min(images.length - 1, activeIndex))
|
||
const isOpen = currentIndex != null
|
||
const canNavigate = images.length > 1
|
||
|
||
const goPrevious = useCallback(() => {
|
||
if (currentIndex == null || !canNavigate) return
|
||
onChange((currentIndex - 1 + images.length) % images.length)
|
||
}, [canNavigate, currentIndex, images.length, onChange])
|
||
|
||
const goNext = useCallback(() => {
|
||
if (currentIndex == null || !canNavigate) return
|
||
onChange((currentIndex + 1) % images.length)
|
||
}, [canNavigate, currentIndex, images.length, onChange])
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
|
||
const previousOverflow = document.body.style.overflow
|
||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||
document.body.style.overflow = 'hidden'
|
||
const focusFrame = window.requestAnimationFrame(() => closeButtonRef.current?.focus())
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(focusFrame)
|
||
document.body.style.overflow = previousOverflow
|
||
previousFocus?.focus()
|
||
}
|
||
}, [isOpen])
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
onClose()
|
||
return
|
||
}
|
||
if (event.key === 'ArrowLeft') {
|
||
event.preventDefault()
|
||
goPrevious()
|
||
return
|
||
}
|
||
if (event.key === 'ArrowRight') {
|
||
event.preventDefault()
|
||
goNext()
|
||
return
|
||
}
|
||
keepFocusInsideModal(event, dialogRef.current)
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||
}, [goNext, goPrevious, isOpen, onClose])
|
||
|
||
if (currentIndex == null || !portalReady) return null
|
||
|
||
const currentImage = images[currentIndex]
|
||
|
||
return createPortal((
|
||
<div
|
||
ref={dialogRef}
|
||
className="fixed inset-0 z-[1200] flex flex-col bg-black/90 pt-[env(safe-area-inset-top)] text-white"
|
||
onClick={onClose}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`图片预览,第 ${currentIndex + 1} 张,共 ${images.length} 张`}
|
||
tabIndex={-1}
|
||
>
|
||
<div className="flex h-14 shrink-0 items-center justify-between gap-3 px-4" onClick={(event) => event.stopPropagation()}>
|
||
<div className="text-sm font-semibold text-white/80">
|
||
{currentIndex + 1} / {images.length}
|
||
</div>
|
||
<button
|
||
ref={closeButtonRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-[28px] font-light leading-none text-white transition-colors hover:bg-white/20"
|
||
aria-label="关闭图片预览"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative flex min-h-0 flex-1 items-center justify-center px-3 pb-[calc(16px+env(safe-area-inset-bottom))]">
|
||
{canNavigate && (
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
goPrevious()
|
||
}}
|
||
className="absolute left-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-2xl font-light text-white transition-colors hover:bg-white/20"
|
||
aria-label="上一张图片"
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={(event) => event.stopPropagation()}
|
||
className="flex h-full w-full items-center justify-center"
|
||
aria-label="当前图片"
|
||
>
|
||
<Image
|
||
src={currentImage}
|
||
alt={`全屏案例图片 ${currentIndex + 1}`}
|
||
width={1400}
|
||
height={1000}
|
||
unoptimized
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
</button>
|
||
{canNavigate && (
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
goNext()
|
||
}}
|
||
className="absolute right-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-2xl font-light text-white transition-colors hover:bg-white/20"
|
||
aria-label="下一张图片"
|
||
>
|
||
›
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
), document.body)
|
||
}
|
||
|
||
function getSurveyInitials(name: string) {
|
||
const text = String(name || '').trim()
|
||
if (!text) return '用'
|
||
const chars = Array.from(text)
|
||
if (/^[A-Za-z\s]+$/.test(text)) {
|
||
return text
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.map((item) => item[0]?.toUpperCase())
|
||
.join('')
|
||
}
|
||
return chars.slice(0, 2).join('')
|
||
}
|
||
|
||
function getQuestionAccentColor(index: number) {
|
||
const colors = ['#1677ff', '#16a34a', '#1677ff', '#7c3aed', '#f59e0b']
|
||
return colors[index % colors.length]
|
||
}
|
||
|
||
function getSurveyStatusLabel(status: string) {
|
||
const map: Record<string, string> = {
|
||
draft: '草稿',
|
||
open: '进行中',
|
||
closed: '已关闭',
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
function getPerformanceSurveyBackHref(value: string | null) {
|
||
const fallback = '/performance-survey?tab=personal'
|
||
if (!value || value.startsWith('//')) return fallback
|
||
|
||
try {
|
||
const parsed = new URL(value, 'https://performance-survey.local')
|
||
if (parsed.origin !== 'https://performance-survey.local') return fallback
|
||
if (parsed.pathname !== '/performance-survey' && !parsed.pathname.startsWith('/performance-survey/')) {
|
||
return fallback
|
||
}
|
||
|
||
const legacySegment = parsed.pathname.match(/^\/performance-survey\/(personal|public|manage|pending|submitted|results|created|teacher|admin)\/?$/)?.[1]
|
||
if (legacySegment) {
|
||
parsed.pathname = '/performance-survey'
|
||
parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(legacySegment))
|
||
} else if (parsed.pathname === '/performance-survey' && parsed.searchParams.has('tab')) {
|
||
parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(parsed.searchParams.get('tab')))
|
||
}
|
||
|
||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
function formatResultScore(value: unknown) {
|
||
const score = Number(value ?? 0)
|
||
if (!Number.isFinite(score)) return '0'
|
||
return Number.isInteger(score) ? String(score) : score.toFixed(1)
|
||
}
|
||
|
||
function getQuestionMaxScore(template: Template | undefined, question: Template['questions'][number]) {
|
||
const maxScore = Number(question.maxScore ?? template?.questionMaxScore ?? 20)
|
||
return Number.isFinite(maxScore) && maxScore > 0 ? maxScore : 20
|
||
}
|
||
|
||
function isVisualTemplate(template?: Template | null) {
|
||
return template?.surveyType === 'visual_case' || template?.code === 'visual_brand_tone'
|
||
}
|
||
|
||
function getScorePercent(value: unknown, maxScore = 20) {
|
||
const score = Number(value ?? 0)
|
||
if (!Number.isFinite(score)) return 0
|
||
return Math.max(0, Math.min(100, (score / Math.max(1, maxScore)) * 100))
|
||
}
|
||
|
||
function SurveyResults({ surveyId }: { surveyId: string }) {
|
||
const searchParams = useSearchParams()
|
||
const targetId = searchParams.get('targetId')
|
||
const query = targetId ? `?targetId=${encodeURIComponent(targetId)}` : ''
|
||
const backHref = getPerformanceSurveyBackHref(searchParams.get('back'))
|
||
const result = useApi<any>(`/results/${surveyId}${query}`, [surveyId, query])
|
||
|
||
if (result.loading) return <MainPanel>加载中...</MainPanel>
|
||
if (result.error) return <MainPanel><ErrorPanel message={result.error} /></MainPanel>
|
||
if (!result.data) return null
|
||
|
||
const survey = result.data.survey
|
||
const targets = result.data.targets || []
|
||
const template = result.data.survey.template as Template | undefined
|
||
const questions = template?.questions || []
|
||
const totalResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.responseCount || 0), 0)
|
||
const effectiveResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.effectiveCount || 0), 0)
|
||
|
||
return (
|
||
<main className="mx-auto max-w-[430px] bg-white px-5 py-6 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-6xl sm:bg-transparent sm:py-6 md:pb-6">
|
||
<section className="mb-5 rounded-[18px] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<Link
|
||
href={backHref}
|
||
className="inline-flex min-h-[34px] items-center rounded-full bg-[#f3f6fb] px-3 text-[13px] font-semibold text-[#344054] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100"
|
||
>
|
||
返回
|
||
</Link>
|
||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-[13px] font-semibold text-[#0052d9]">调研结果</div>
|
||
<h1 className="mt-2 break-words text-[26px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-3xl">
|
||
{survey.title}
|
||
</h1>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
|
||
<StatusBadge status={survey.status} />
|
||
{survey.closedAt && <span>关闭 {formatDate(survey.closedAt)}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 sm:min-w-[280px]">
|
||
<ResultMetric label="对象" value={targets.length} />
|
||
<ResultMetric label="评价" value={totalResponses} />
|
||
<ResultMetric label="有效" value={effectiveResponses} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="grid gap-5">
|
||
{targets.map((target: any) => {
|
||
const aggregate = target.aggregate || {}
|
||
const dimensionEntries = questions
|
||
.map((question: Template['questions'][number]) => ({
|
||
question,
|
||
value: aggregate.dimensionAverages?.[question.id],
|
||
}))
|
||
.filter((item: { value: unknown }) => item.value !== null && item.value !== undefined)
|
||
|
||
return (
|
||
<article key={target.id} className="rounded-[18px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-[13px] font-semibold text-[#667085] dark:text-gray-400">评价对象</div>
|
||
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828] dark:text-white">{target.targetName}</h2>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
|
||
<span>有效 {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0}</span>
|
||
{aggregate.rawAverage != null && <span>原始均分 {formatResultScore(aggregate.rawAverage)}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 sm:min-w-[220px]">
|
||
<ResultMetric label={aggregate.achievementLabel || '达成分'} value={aggregate.achievementScore} />
|
||
<ResultMetric label="KPI分" value={aggregate.kpiScore} />
|
||
</div>
|
||
</div>
|
||
|
||
{aggregate.band && aggregate.responseCount > 0 && (
|
||
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054] dark:bg-gray-800 dark:text-gray-200">
|
||
{aggregate.band}
|
||
</div>
|
||
)}
|
||
|
||
{dimensionEntries.length > 0 && (
|
||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||
{dimensionEntries.map(({ question, value }: { question: Template['questions'][number]; value: number }) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
return (
|
||
<div key={question.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="truncate text-sm font-semibold text-[#101828] dark:text-white">{question.title}</div>
|
||
<div className="shrink-0 text-sm font-semibold text-[#0052d9]">{formatResultScore(value)}/{maxScore}</div>
|
||
</div>
|
||
<div className="mt-3 h-2 rounded-full bg-[#e8eef7] dark:bg-gray-700">
|
||
<div className="h-full rounded-full bg-[#0052d9]" style={{ width: `${getScorePercent(value, maxScore)}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{aggregate.responseCount > 0 && aggregate.trimNote && (
|
||
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">
|
||
{aggregate.trimNote}
|
||
{aggregate.removedHigh && aggregate.removedLow && (
|
||
<span>;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-5">
|
||
<h3 className="text-sm font-semibold text-[#101828] dark:text-white">评价明细</h3>
|
||
</div>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||
{target.responses.map((response: any) => (
|
||
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-semibold text-[#101828] dark:text-white">{response.respondentName}</span>
|
||
<span className="text-sm font-semibold text-[#667085] dark:text-gray-300">{formatResultScore(response.totalScore)} 分</span>
|
||
</div>
|
||
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054] dark:text-gray-300">{response.comment}</p>}
|
||
</div>
|
||
))}
|
||
{target.responses.length === 0 && (
|
||
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">暂无评价</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function TextInput({
|
||
label,
|
||
value,
|
||
onChange,
|
||
type = 'text',
|
||
placeholder,
|
||
}: {
|
||
label: string
|
||
value: string
|
||
onChange: (value: string) => void
|
||
type?: string
|
||
placeholder?: string
|
||
}) {
|
||
return (
|
||
<label className="grid gap-2 text-sm">
|
||
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">{label}</span>
|
||
<input
|
||
type={type}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
className="h-10 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
|
||
function SelectedChips({ items, onRemove }: { items: Array<{ id: string; label: string }>; onRemove: (id: string) => void }) {
|
||
if (items.length === 0) return null
|
||
return (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{items.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onRemove(item.id)}
|
||
className="border border-[#e2e2e2] bg-white px-2.5 py-1 text-xs font-semibold text-[#1a1c1c] hover:bg-[#f9f9f9]"
|
||
>
|
||
{item.label} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const map: Record<string, string> = {
|
||
draft: '草稿',
|
||
open: '进行中',
|
||
closed: '已关闭',
|
||
pending: '待提交',
|
||
submitted: '已提交',
|
||
sent: '已发送',
|
||
succeeded: '已发送',
|
||
retrying: '重试中',
|
||
exhausted: '失败',
|
||
skipped: '未发送',
|
||
processing: '处理中',
|
||
}
|
||
return (
|
||
<span className="inline-flex rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||
{map[status] || status}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function ResultMetric({ label, value, emphasis = false }: { label: string; value: unknown; emphasis?: boolean }) {
|
||
return (
|
||
<div className={`min-w-0 rounded-[12px] px-3 py-3 text-center ${
|
||
emphasis
|
||
? 'bg-[#0052d9] text-white'
|
||
: 'bg-[#f8fafc] text-[#101828] dark:bg-gray-800 dark:text-white'
|
||
}`}>
|
||
<div className="truncate text-[22px] font-semibold leading-none">{formatResultScore(value)}</div>
|
||
<div className={`mt-1 truncate text-[11px] font-semibold ${emphasis ? 'text-white/75' : 'text-[#667085] dark:text-gray-400'}`}>{label}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Panel({ children }: { children: ReactNode }) {
|
||
return <div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">{children}</div>
|
||
}
|
||
|
||
function MainPanel({ children }: { children: ReactNode }) {
|
||
return <main className="mx-auto max-w-4xl px-4 py-6"><Panel>{children}</Panel></main>
|
||
}
|
||
|
||
function ErrorPanel({ message }: { message: string }) {
|
||
return <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{message}</div>
|
||
}
|
||
|
||
function EmptyState({ title }: { title: string }) {
|
||
return (
|
||
<div className="rounded-[18px] border border-dashed border-[#d9ddeb] bg-white px-5 py-10 text-center shadow-[0_12px_30px_rgba(45,43,80,0.06)] dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MobileSurveyRangeStyles() {
|
||
return (
|
||
<style>{`
|
||
.survey-score-range {
|
||
height: 24px;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.survey-score-range:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.survey-score-range::-webkit-slider-runnable-track {
|
||
height: 4px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(
|
||
to right,
|
||
var(--score-color, #1677ff) 0%,
|
||
var(--score-color, #1677ff) var(--score-percent),
|
||
#e5eaf2 var(--score-percent),
|
||
#e5eaf2 100%
|
||
);
|
||
}
|
||
|
||
.survey-score-range::-webkit-slider-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-top: -7px;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
border: 3px solid var(--score-color, #1677ff);
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
|
||
}
|
||
|
||
.survey-score-range[data-rated="false"]::-webkit-slider-thumb {
|
||
border-color: #b8c2cf;
|
||
background: #f6f8fb;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.survey-score-range::-moz-range-track {
|
||
height: 4px;
|
||
border-radius: 999px;
|
||
background: #e5eaf2;
|
||
}
|
||
|
||
.survey-score-range::-moz-range-progress {
|
||
height: 4px;
|
||
border-radius: 999px;
|
||
background: var(--score-color, #1677ff);
|
||
}
|
||
|
||
.survey-score-range::-moz-range-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 3px solid var(--score-color, #1677ff);
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
|
||
}
|
||
|
||
|
||
.survey-score-range[data-rated="false"]::-moz-range-thumb {
|
||
border-color: #b8c2cf;
|
||
background: #f6f8fb;
|
||
box-shadow: none;
|
||
}
|
||
`}</style>
|
||
)
|
||
}
|
||
|
||
function SurveyOpeningScreen() {
|
||
return (
|
||
<main className="min-h-[calc(100dvh-76px)] bg-white text-[#111827]">
|
||
<section className="mx-auto max-w-[430px] px-5 py-8">
|
||
<div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
|
||
<div className="text-[14px] font-semibold text-[#1677ff]">考核调研</div>
|
||
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1>
|
||
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">正在准备问卷内容,请稍候。</p>
|
||
|
||
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className="text-[15px] font-semibold text-[#101828]">准备问卷内容</span>
|
||
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
|
||
</div>
|
||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
|
||
<div className="h-full w-2/3 animate-pulse rounded-full bg-[#1677ff]" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5">
|
||
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
|
||
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
|
||
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
|
||
<div className="mt-8 space-y-5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
|
||
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
|
||
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
|
||
<span className="h-3 w-2/3 rounded-full bg-[#f1f5f9]" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function LoadingPage() {
|
||
return <SurveyOpeningScreen />
|
||
}
|
||
|
||
function formatDate(value: string | Date) {
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '-'
|
||
return date.toLocaleString('zh-CN', { hour12: false })
|
||
}
|