5693 lines
234 KiB
TypeScript
5693 lines
234 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'
|
||
import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order'
|
||
import { calculatePerformanceSurveyResponseProgress } from '@/lib/performance-survey-response-progress'
|
||
import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping'
|
||
|
||
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 }>
|
||
}
|
||
|
||
type VisualCaseDraft = {
|
||
id: string
|
||
title: string
|
||
description: string
|
||
files: File[]
|
||
}
|
||
|
||
type PerformanceSurveyImageSliceData = {
|
||
url: string
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
type PerformanceSurveyImageGroupData = {
|
||
key: string
|
||
sourceName: string
|
||
previewUrl: string
|
||
previewWidth: number
|
||
previewHeight: number
|
||
width: number
|
||
height: number
|
||
slices: PerformanceSurveyImageSliceData[]
|
||
}
|
||
|
||
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 = 60
|
||
const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 200 * 1024 * 1024
|
||
const DEFAULT_VISUAL_CASE_COUNT = 3
|
||
const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([
|
||
's3.jiansun.vip',
|
||
's3.yydd.ai',
|
||
's3.1080p.hk',
|
||
])
|
||
|
||
type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage'
|
||
type PerformanceSurveyWorkspaceTab = 'overview' | 'pending' | 'submitted' | 'created' | 'results'
|
||
|
||
const PERFORMANCE_SURVEY_WORKSPACE_TABS: PerformanceSurveyWorkspaceTab[] = [
|
||
'overview',
|
||
'pending',
|
||
'submitted',
|
||
'created',
|
||
'results',
|
||
]
|
||
|
||
function normalizePerformanceSurveyDashboardTab(value: unknown): PerformanceSurveyDashboardTab {
|
||
const tab = String(value || '').trim().toLowerCase()
|
||
if (tab === 'public') return 'public'
|
||
if (tab === 'manage' || tab === 'admin') return 'manage'
|
||
return 'personal'
|
||
}
|
||
|
||
function normalizePerformanceSurveyWorkspaceTab(value: unknown): PerformanceSurveyWorkspaceTab {
|
||
const tab = String(value || '').trim().toLowerCase()
|
||
return PERFORMANCE_SURVEY_WORKSPACE_TABS.includes(tab as PerformanceSurveyWorkspaceTab)
|
||
? tab as PerformanceSurveyWorkspaceTab
|
||
: 'overview'
|
||
}
|
||
|
||
function getLegacyPerformanceSurveyWorkspaceTab(value: unknown): PerformanceSurveyWorkspaceTab | null {
|
||
const tab = String(value || '').trim().toLowerCase()
|
||
return ['pending', 'submitted', 'created', 'results'].includes(tab)
|
||
? normalizePerformanceSurveyWorkspaceTab(tab)
|
||
: null
|
||
}
|
||
|
||
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}」超过 200MB`
|
||
return null
|
||
}
|
||
|
||
async function uploadPerformanceSurveyImageFiles(
|
||
files: File[],
|
||
onProgress?: (label: string) => void,
|
||
) {
|
||
const { chunkUpload } = await import('@/lib/chunk-upload')
|
||
const uploaded: Array<{ uploadId: string; mimeType: string }> = []
|
||
const orderedFiles = sortPerformanceSurveyImagesByName(files, (file) => file.name)
|
||
const dingtalkToken = getStoredDingtalkPerformanceToken()
|
||
const requestHeaders = dingtalkToken
|
||
? {
|
||
Authorization: `Bearer ${dingtalkToken}`,
|
||
[DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken,
|
||
}
|
||
: undefined
|
||
|
||
for (let index = 0; index < orderedFiles.length; index += 1) {
|
||
const file = orderedFiles[index]
|
||
onProgress?.(`正在上传图片 ${index + 1}/${orderedFiles.length}`)
|
||
const result = await chunkUpload(
|
||
file,
|
||
{ materialType: '考核调研-视觉评分', fileName: file.name },
|
||
{ skipComplete: true, requestHeaders },
|
||
)
|
||
if (!result?.uploadId) throw new Error(`图片「${file.name}」上传失败,请重试`)
|
||
uploaded.push({ uploadId: result.uploadId, mimeType: file.type })
|
||
}
|
||
|
||
return uploaded
|
||
}
|
||
|
||
function formatPerformanceSurveyImageFileSize(bytes: number) {
|
||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
}
|
||
|
||
function createVisualCaseDrafts(count = DEFAULT_VISUAL_CASE_COUNT): VisualCaseDraft[] {
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
id: `visual-case-${index + 1}`,
|
||
title: '',
|
||
description: '',
|
||
files: [],
|
||
}))
|
||
}
|
||
|
||
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 normalizePerformanceSurveyImageGroups(value: unknown): PerformanceSurveyImageGroupData[] {
|
||
if (!Array.isArray(value) || value.length === 0) return []
|
||
const stringUrls = value
|
||
.filter((item): item is string => typeof item === 'string')
|
||
.map(normalizePerformanceSurveyImageUrl)
|
||
.filter(Boolean)
|
||
if (stringUrls.length === value.length) {
|
||
return stringUrls.length > 0 ? [{
|
||
key: 'legacy-images',
|
||
sourceName: '案例图片',
|
||
previewUrl: stringUrls[0],
|
||
previewWidth: 1200,
|
||
previewHeight: 560,
|
||
width: 1200,
|
||
height: stringUrls.length * 1500,
|
||
slices: stringUrls.map((url) => ({ url, width: 1200, height: 1500 })),
|
||
}] : []
|
||
}
|
||
|
||
const groups = value.flatMap((item, index): PerformanceSurveyImageGroupData[] => {
|
||
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
||
const record = item as Record<string, unknown>
|
||
const width = Math.max(1, Number(record.width) || 1200)
|
||
const rawSlices = Array.isArray(record.slices)
|
||
? record.slices
|
||
: Array.isArray(record.sliceUrls)
|
||
? record.sliceUrls
|
||
: []
|
||
const slices = rawSlices.flatMap((slice): PerformanceSurveyImageSliceData[] => {
|
||
if (typeof slice === 'string') {
|
||
const url = normalizePerformanceSurveyImageUrl(slice)
|
||
return url ? [{ url, width, height: 1500 }] : []
|
||
}
|
||
if (!slice || typeof slice !== 'object' || Array.isArray(slice)) return []
|
||
const sliceRecord = slice as Record<string, unknown>
|
||
const url = normalizePerformanceSurveyImageUrl(String(sliceRecord.url || ''))
|
||
return url ? [{
|
||
url,
|
||
width: Math.max(1, Number(sliceRecord.width) || width),
|
||
height: Math.max(1, Number(sliceRecord.height) || 1500),
|
||
}] : []
|
||
})
|
||
if (slices.length === 0) return []
|
||
const previewUrl = normalizePerformanceSurveyImageUrl(String(record.previewUrl || slices[0].url))
|
||
return [{
|
||
key: `${String(record.sourceName || 'case-image')}-${index}`,
|
||
sourceName: String(record.sourceName || `案例图片 ${index + 1}`),
|
||
previewUrl: previewUrl || slices[0].url,
|
||
previewWidth: Math.max(1, Number(record.previewWidth) || Math.min(width, 720)),
|
||
previewHeight: Math.max(1, Number(record.previewHeight) || 560),
|
||
width,
|
||
height: Math.max(1, Number(record.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
|
||
slices,
|
||
}]
|
||
})
|
||
|
||
if (groups.length <= 1) return groups
|
||
|
||
const orderedGroups = sortPerformanceSurveyImagesByName(groups, (group) => group.sourceName)
|
||
const slices = orderedGroups.flatMap((group) => group.slices)
|
||
const firstGroup = orderedGroups[0]
|
||
return [{
|
||
key: `ordered-slices-${orderedGroups.map((group) => group.key).join('-')}`,
|
||
sourceName: `详情切片(${orderedGroups.length} 张)`,
|
||
previewUrl: firstGroup.previewUrl,
|
||
previewWidth: firstGroup.previewWidth,
|
||
previewHeight: firstGroup.previewHeight,
|
||
width: Math.max(...orderedGroups.map((group) => group.width)),
|
||
height: slices.reduce((sum, slice) => sum + slice.height, 0),
|
||
slices,
|
||
}]
|
||
}
|
||
|
||
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)
|
||
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(path[0])
|
||
if (legacyWorkspaceTab) query.set('workspace', legacyWorkspaceTab)
|
||
router.replace(`/performance-survey?${query.toString()}`)
|
||
}, [legacyPathTab, path, 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 me={currentUser.data} />
|
||
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({ me }: { me: any | null }) {
|
||
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 displayName = me?.employee?.flowerName || me?.employee?.displayName || me?.user?.name || '当前用户'
|
||
const avatar = me?.user?.avatar || me?.user?.image || ''
|
||
const canCreateSurvey = Boolean(me?.permissions?.canCreate)
|
||
const availableTabIds = useMemo(
|
||
() => canCreateSurvey ? ['personal', 'public', 'manage'] : ['personal', 'public'],
|
||
[canCreateSurvey],
|
||
)
|
||
|
||
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)
|
||
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(requestedTabRaw)
|
||
if (legacyWorkspaceTab) query.set('workspace', legacyWorkspaceTab)
|
||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||
}, [requestedTab, requestedTabRaw, router, searchParamsKey])
|
||
|
||
const selectTab = (nextTab: PerformanceSurveyDashboardTab) => {
|
||
setTab(nextTab)
|
||
const query = new URLSearchParams()
|
||
query.set('tab', nextTab)
|
||
const workspaceTab = getLegacyPerformanceSurveyWorkspaceTab(searchParams.get('workspace'))
|
||
if (workspaceTab) query.set('workspace', workspaceTab)
|
||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||
}
|
||
const tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> = [
|
||
{ id: 'personal', label: '个人看板' },
|
||
{ id: 'public', label: '公共看板' },
|
||
...(canCreateSurvey ? [{ id: 'manage' as const, 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 === 'personal' && <PerformanceSurveyWorkspace canCreateSurvey={canCreateSurvey} />}
|
||
{tab === 'public' && <PerformanceDashboardBoard view="public" />}
|
||
{tab === 'manage' && (
|
||
<ManagePanel currentEmployee={me?.employee || null} />
|
||
)}
|
||
</main>
|
||
<nav className={`fixed inset-x-0 bottom-0 z-30 grid h-[68px] ${tabItems.length === 3 ? 'grid-cols-3' : 'grid-cols-2'} border-t border-[#e2e2e2] bg-white sm:hidden`}>
|
||
{tabItems.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => selectTab(item.id)}
|
||
aria-current={tab === item.id ? 'page' : undefined}
|
||
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="min-w-0">
|
||
<div className="truncate text-[16px] font-bold">考核调研</div>
|
||
<div className="truncate text-[11px] font-medium text-white/70" title={displayName}>{displayName}</div>
|
||
</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)}
|
||
aria-current={active ? 'page' : undefined}
|
||
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: PerformanceSurveyWorkspaceTab
|
||
onSelectTab: (tab: PerformanceSurveyWorkspaceTab) => 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 items: Array<{ id: PerformanceSurveyWorkspaceTab; label: string; value?: number; scopeValue?: number }> = [
|
||
{ id: 'overview', label: '月度看板' },
|
||
{ 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="grid grid-cols-6 gap-2 sm:grid-cols-5" role="tablist" aria-label="任务与记录">
|
||
{items.map((item, index) => {
|
||
const active = activeTab === item.id
|
||
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onSelectTab(item.id)}
|
||
role="tab"
|
||
id={`performance-survey-workspace-tab-${item.id}`}
|
||
aria-controls="performance-survey-workspace-panel"
|
||
aria-selected={active}
|
||
tabIndex={active ? 0 : -1}
|
||
onKeyDown={(event) => {
|
||
const currentIndex = items.findIndex((candidate) => candidate.id === item.id)
|
||
const lastIndex = items.length - 1
|
||
let nextIndex = currentIndex
|
||
if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
|
||
else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
|
||
else if (event.key === 'Home') nextIndex = 0
|
||
else if (event.key === 'End') nextIndex = lastIndex
|
||
else return
|
||
event.preventDefault()
|
||
const nextTab = items[nextIndex].id
|
||
onSelectTab(nextTab)
|
||
window.requestAnimationFrame(() => document.getElementById(`performance-survey-workspace-tab-${nextTab}`)?.focus())
|
||
}}
|
||
className={`flex min-h-11 ${index < 3 ? 'col-span-2' : 'col-span-3'} items-center justify-between gap-2 rounded border px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 sm:col-span-1 ${
|
||
active
|
||
? 'border-[#0052d9] bg-[#eef5ff] text-[#0052d9]'
|
||
: 'border-[#e2e2e2] bg-white text-[#5f5e5e] hover:border-[#b8d0ff] hover:text-[#1a1c1c]'
|
||
}`}
|
||
>
|
||
<span className="truncate text-[12px] font-bold sm:text-[13px]">{item.label}</span>
|
||
{typeof item.value === 'number' && (
|
||
<span className={`flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[12px] font-bold ${active ? 'bg-[#0052d9] text-white' : 'bg-[#f4f3f3] text-[#5f5e5e]'}`}>{item.value}</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveyWorkspace({ canCreateSurvey }: { canCreateSurvey: boolean }) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const searchParamsKey = searchParams.toString()
|
||
const activeTab = normalizePerformanceSurveyWorkspaceTab(searchParams.get('workspace'))
|
||
const state = useApi<any>('/surveys', [])
|
||
|
||
const selectTab = (nextTab: PerformanceSurveyWorkspaceTab) => {
|
||
const query = new URLSearchParams(searchParamsKey)
|
||
query.set('tab', 'personal')
|
||
query.set('workspace', nextTab)
|
||
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
|
||
}
|
||
|
||
return (
|
||
<section className="mb-8 border-b border-[#e2e2e2] pb-8" aria-labelledby="performance-survey-workspace-title">
|
||
<div className="mb-3 flex items-end justify-between gap-4">
|
||
<div>
|
||
<h2 id="performance-survey-workspace-title" className="text-[16px] font-bold text-[#1a1c1c]">任务与记录</h2>
|
||
<p className="mt-1 text-[12px] font-medium text-[#727687]">找回待评分任务、已处理记录和调研结果</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => state.refresh()}
|
||
className="shrink-0 text-[12px] font-bold text-[#0050cb] hover:text-[#003fa4]"
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
{state.data && <DashboardStats data={state.data} activeTab={activeTab} onSelectTab={selectTab} />}
|
||
<div
|
||
id="performance-survey-workspace-panel"
|
||
className="mt-4"
|
||
role="tabpanel"
|
||
aria-labelledby={`performance-survey-workspace-tab-${activeTab}`}
|
||
>
|
||
{state.loading && activeTab !== 'overview' && <DashboardPanel>任务加载中...</DashboardPanel>}
|
||
{state.error && <DashboardErrorPanel message={state.error} />}
|
||
{activeTab === 'overview' && <PerformanceDashboardBoard view="personal" canCreateSurvey={canCreateSurvey} />}
|
||
{state.data && activeTab === 'pending' && <PendingList items={state.data.pending || []} />}
|
||
{state.data && activeTab === 'submitted' && <SubmittedList items={state.data.submitted || []} />}
|
||
{state.data && activeTab === 'created' && (
|
||
<CreatedList
|
||
items={state.data.created || []}
|
||
onRefresh={state.refresh}
|
||
resultBackHref="/performance-survey?tab=personal&workspace=created"
|
||
/>
|
||
)}
|
||
{state.data && activeTab === 'results' && <MyResults items={state.data.results || []} />}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function PerformanceDashboardBoard({
|
||
view,
|
||
canCreateSurvey = false,
|
||
}: {
|
||
view: 'personal' | 'public'
|
||
canCreateSurvey?: boolean
|
||
}) {
|
||
const router = useRouter()
|
||
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 publicDepartmentsPath = view === 'public'
|
||
? `/departments?purpose=dashboard&month=${encodeURIComponent(month)}`
|
||
: null
|
||
const departments = useApi<DepartmentOption[]>(publicDepartmentsPath, [publicDepartmentsPath])
|
||
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 || []
|
||
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 && <DashboardErrorPanel message={dashboard.error} />}
|
||
{dashboard.data && (
|
||
<>
|
||
{view === 'personal' ? (
|
||
<MonthlyPersonalLedger
|
||
rows={personalRows}
|
||
templates={templates}
|
||
onOpenDetail={setDetailRequest}
|
||
onQuickCreate={canCreateSurvey ? ({ month: targetMonth, templateCode }) => {
|
||
const params = new URLSearchParams()
|
||
params.set('tab', 'manage')
|
||
params.set('prefillMonth', targetMonth)
|
||
params.set('prefillTemplate', templateCode)
|
||
router.push(`/performance-survey?${params.toString()}`)
|
||
} : undefined}
|
||
/>
|
||
) : (
|
||
<MonthlyPublicLedger
|
||
rows={publicRows}
|
||
templates={templates}
|
||
canOpenPublicDetail={Boolean(permissions.canOpenPublicDetail)}
|
||
onOpenDetail={setDetailRequest}
|
||
/>
|
||
)}
|
||
<MonthlyScoreDetailModal
|
||
request={detailRequest}
|
||
onClose={() => setDetailRequest(null)}
|
||
/>
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function MonthlyPersonalLedger({
|
||
rows,
|
||
templates,
|
||
onOpenDetail,
|
||
onQuickCreate,
|
||
}: {
|
||
rows: any[]
|
||
templates: any[]
|
||
onOpenDetail: (value: { month: string; employeeId: string; templateCode: string }) => void
|
||
onQuickCreate?: (value: { month: 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={`rounded-lg border bg-white p-4 ${
|
||
row.month === getCurrentMonthValue() ? 'border-[#0050cb]' : 'border-[#e2e2e2]'
|
||
}`}
|
||
>
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="text-[20px] font-bold text-[#1a1c1c]">{formatMonthLabel(row.month)}</div>
|
||
{row.month === getCurrentMonthValue() && (
|
||
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">本月</div>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{templates.map((template) => (
|
||
<MonthlyScoreCell
|
||
key={template.code}
|
||
embedded
|
||
template={template}
|
||
cell={row.cells?.[template.code]}
|
||
onQuickCreate={onQuickCreate ? () => onQuickCreate({ month: row.month, templateCode: template.code }) : undefined}
|
||
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 text-center">{getTemplateDisplayName(template)}</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">{formatMonthLabel(row.month)}</td>
|
||
{templates.map((template) => (
|
||
<td key={template.code} className="px-4 py-4">
|
||
<MonthlyTableScoreCell
|
||
cell={row.cells?.[template.code]}
|
||
onQuickCreate={onQuickCreate ? () => onQuickCreate({ month: row.month, templateCode: template.code }) : undefined}
|
||
onClick={() => onOpenDetail({
|
||
month: row.month,
|
||
employeeId: row.employee.id,
|
||
templateCode: template.code,
|
||
})}
|
||
/>
|
||
</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 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>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{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-[200px] px-4 py-3">人员</th>
|
||
<th className="w-[200px] px-4 py-3">组织 / 职级</th>
|
||
{templates.map((template) => (
|
||
<th key={template.code} className="px-3 py-3 text-center">{getTemplateDisplayName(template)}</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-4 py-3">
|
||
<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-3 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-3 py-3 text-center">
|
||
<MonthlyTableScoreCell
|
||
cell={row.cells?.[template.code]}
|
||
showTitle={false}
|
||
canOpenOverride={canOpenPublicDetail}
|
||
onClick={() => onOpenDetail({
|
||
month: row.month,
|
||
employeeId: row.employee.id,
|
||
templateCode: template.code,
|
||
})}
|
||
/>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MonthlyScoreCell({
|
||
template,
|
||
cell,
|
||
compact = false,
|
||
embedded = false,
|
||
canOpenOverride,
|
||
onClick,
|
||
onQuickCreate,
|
||
}: {
|
||
template: any
|
||
cell: any
|
||
compact?: boolean
|
||
embedded?: boolean
|
||
canOpenOverride?: boolean
|
||
onClick: () => void
|
||
onQuickCreate?: () => void
|
||
}) {
|
||
const hasScore = cell && cell.score !== null && cell.score !== undefined
|
||
const hasSurvey = Number(cell?.surveyCount || 0) > 0
|
||
const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed))
|
||
const className = embedded
|
||
? `${compact ? 'min-h-[82px]' : 'min-h-[100px]'} px-1 py-2 text-left transition-colors ${canOpen ? 'hover:bg-[#f8faff]' : ''}`
|
||
: `${compact ? 'min-h-[82px] px-2 py-2' : 'min-h-[108px] 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="flex items-center justify-between gap-2">
|
||
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
|
||
{hasSurvey && <InlineSurveyState status={cell?.status} />}
|
||
</div>
|
||
<div className={`${compact ? 'line-clamp-2 text-[10px] leading-4' : 'truncate text-[12px]'} mt-1.5 font-semibold text-[#1a1c1c]`}>
|
||
{cell?.title || '未发起'}
|
||
</div>
|
||
<div className="mt-2 flex items-baseline gap-1">
|
||
<span className={`${compact ? 'text-[16px]' : 'text-[22px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
|
||
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
|
||
</span>
|
||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||
</div>
|
||
</>
|
||
)
|
||
|
||
if (!hasSurvey && onQuickCreate) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onQuickCreate}
|
||
aria-label={`发起${getTemplateDisplayName(template)}`}
|
||
className={`${className} group cursor-pointer hover:border-[#0050cb] hover:bg-[#f8faff]`}
|
||
>
|
||
{content}
|
||
<div className="mt-2 text-[11px] font-bold text-[#0050cb] group-hover:text-[#003fa4]">+ 快捷发起</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
if (!canOpen) {
|
||
return <div className={className}>{content}</div>
|
||
}
|
||
|
||
return (
|
||
<button type="button" onClick={onClick} className={className}>
|
||
{content}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function MonthlyTableScoreCell({
|
||
cell,
|
||
showTitle = true,
|
||
canOpenOverride,
|
||
onClick,
|
||
onQuickCreate,
|
||
}: {
|
||
cell: any
|
||
showTitle?: boolean
|
||
canOpenOverride?: boolean
|
||
onClick: () => void
|
||
onQuickCreate?: () => void
|
||
}) {
|
||
const hasScore = cell && cell.score !== null && cell.score !== undefined
|
||
const hasSurvey = Number(cell?.surveyCount || 0) > 0
|
||
const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed))
|
||
const accessibleLabel = `${cell?.title || '考核调研'},${
|
||
hasScore ? `得分 ${formatNullableScore(cell.score)},满分 120` : '待评分'
|
||
},查看详情`
|
||
const content = (
|
||
<div className="min-w-0">
|
||
<div className="flex items-baseline justify-center gap-1">
|
||
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
|
||
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
|
||
</span>
|
||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||
</div>
|
||
{hasSurvey && showTitle && <div className="mt-1 truncate text-[10px] font-medium text-[#5f5e5e]" title={cell?.title || ''}>{cell?.title}</div>}
|
||
</div>
|
||
)
|
||
|
||
if (!hasSurvey && onQuickCreate) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onQuickCreate}
|
||
aria-label="快捷发起考核调研"
|
||
className="group inline-flex min-h-12 w-full min-w-[74px] flex-col items-center justify-center text-center transition-colors hover:bg-[#f8faff]"
|
||
>
|
||
{content}
|
||
<span className="mt-1 text-[10px] font-bold text-[#0050cb] group-hover:text-[#003fa4]">+ 发起</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
if (!canOpen) {
|
||
return <div className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center">{content}</div>
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={accessibleLabel}
|
||
className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center 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 getSurveyStatusMeta(status: unknown) {
|
||
const value = String(status || '').toLowerCase()
|
||
if (value === 'open') return { label: '进行中', dot: 'bg-[#0050cb]', text: 'text-[#0050cb]' }
|
||
if (value === 'closed') return { label: '已结束', dot: 'bg-[#5f5e5e]', text: 'text-[#5f5e5e]' }
|
||
if (value === 'cancelled') return { label: '已撤销', dot: 'bg-[#b42318]', text: 'text-[#b42318]' }
|
||
if (value === 'draft') return { label: '草稿', dot: 'bg-[#b26a00]', text: 'text-[#8a5200]' }
|
||
return { label: '待发布', dot: 'bg-[#8a8a8a]', text: 'text-[#5f5e5e]' }
|
||
}
|
||
|
||
function InlineSurveyState({ status }: { status: unknown }) {
|
||
const meta = getSurveyStatusMeta(status)
|
||
return (
|
||
<span className={`inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold ${meta.text}`}>
|
||
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
|
||
{meta.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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 primarySurvey = detail.data?.surveys?.[0] || null
|
||
const workflow = detail.data?.workflow || {}
|
||
|
||
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="flex flex-wrap items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
|
||
<span>{formatMonthLabel(detail.data?.month || request.month)}</span>
|
||
<span>·</span>
|
||
<span>{detail.data?.template?.name || '考核明细'}</span>
|
||
{primarySurvey && <InlineSurveyState status={primarySurvey.status} />}
|
||
</div>
|
||
<h2 id="performance-survey-detail-title" className="mt-2 break-words text-[22px] font-bold leading-tight text-[#1a1c1c] sm:text-[24px]">
|
||
{primarySurvey?.title || detail.data?.template?.fullName || '考核明细'}
|
||
</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] outline-none transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c] focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
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 && <DashboardErrorPanel message={detail.error} />}
|
||
{detail.data && (
|
||
<div className="space-y-7">
|
||
<div className="grid grid-cols-2 gap-5 border-b border-[#e2e2e2] pb-5 sm:grid-cols-4">
|
||
<DetailMetaItem label="被评估人" value={detail.data.employee?.displayName || '-'} />
|
||
<DetailMetaItem label="发起人" value={primarySurvey?.creatorName || '-'} />
|
||
<DetailMetaItem label="截止时间" value={primarySurvey?.dueAt ? formatDate(primarySurvey.dueAt) : '未设置'} />
|
||
<DetailMetaItem label="当前得分" value={detail.data.score === null || detail.data.score === undefined ? '待评分' : `${formatNullableScore(detail.data.score)} / 120`} />
|
||
</div>
|
||
|
||
<section className="space-y-4">
|
||
<DetailSectionTitle>调研进度</DetailSectionTitle>
|
||
<div className="grid grid-cols-3 border-y border-[#e2e2e2] py-4 text-center">
|
||
<WorkflowCount label="通知" value={workflow.notifiedCount || 0} total={workflow.assignmentCount || 0} />
|
||
<WorkflowCount label="已查看" value={workflow.readCount || 0} total={workflow.assignmentCount || 0} />
|
||
<WorkflowCount label="已评分" value={workflow.submittedCount || responseCount || 0} total={workflow.assignmentCount || 0} />
|
||
</div>
|
||
{Number(workflow.submittedCount || responseCount || 0) > 0 && (
|
||
Number(workflow.notifiedCount || 0) < Number(workflow.submittedCount || responseCount || 0) ||
|
||
Number(workflow.readCount || 0) < Number(workflow.submittedCount || responseCount || 0)
|
||
) && (
|
||
<p className="border-l-2 border-[#8aaeff] bg-[#f8faff] px-3 py-2 text-[12px] font-medium leading-5 text-[#5f5e5e]">
|
||
评分已完成,但部分通知或查看状态没有记录,不影响已评分结果。
|
||
</p>
|
||
)}
|
||
<EvaluatorWorkflowList items={detail.data.evaluators || []} />
|
||
</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.imageGroups || item.imageUrls || []} variant="long" />
|
||
<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>
|
||
</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 WorkflowCount({ label, value, total }: { label: string; value: number; total: number }) {
|
||
return (
|
||
<div className="border-r border-[#e2e2e2] last:border-r-0">
|
||
<div className="text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
|
||
<div className="mt-1 text-[20px] font-bold text-[#1a1c1c]">
|
||
{value}<span className="ml-1 text-[12px] font-medium text-[#727687]">/ {total}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function WorkflowState({ label, tone = 'muted' }: { label: string; tone?: 'blue' | 'green' | 'red' | 'muted' }) {
|
||
const toneClass = tone === 'green'
|
||
? 'bg-[#149447]'
|
||
: tone === 'blue'
|
||
? 'bg-[#0050cb]'
|
||
: tone === 'red'
|
||
? 'bg-[#ba1a1a]'
|
||
: 'bg-[#8a8a8a]'
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11px] font-semibold text-[#5f5e5e]">
|
||
<span className={`h-1.5 w-1.5 rounded-full ${toneClass}`} />
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function EvaluatorWorkflowList({ items }: { items: any[] }) {
|
||
if (!items.length) {
|
||
return <div className="border border-dashed border-[#c2c6d8] px-4 py-6 text-center text-sm font-semibold text-[#5f5e5e]">暂无评价人</div>
|
||
}
|
||
|
||
return (
|
||
<div className="divide-y divide-[#eeeeee] border-y border-[#eeeeee]">
|
||
{items.map((item) => {
|
||
const noticeStatus = String(item.notification?.status || '')
|
||
const readStatus = String(item.notification?.readStatus || 'unknown')
|
||
const isSubmitted = item.assignmentStatus === 'submitted'
|
||
const hasNoNotificationRecord = isSubmitted && !item.notification
|
||
const notice = hasNoNotificationRecord
|
||
? { label: '无通知记录', tone: 'muted' as const }
|
||
: noticeStatus === 'succeeded'
|
||
? { label: '已发送', tone: 'blue' as const }
|
||
: noticeStatus === 'processing' || noticeStatus === 'pending' || noticeStatus === 'retrying'
|
||
? { label: '发送中', tone: 'muted' as const }
|
||
: noticeStatus === 'skipped' || noticeStatus === 'exhausted'
|
||
? { label: '发送失败', tone: 'red' as const }
|
||
: { label: '未发起', tone: 'muted' as const }
|
||
const read = hasNoNotificationRecord
|
||
? { label: '查看状态未知', tone: 'muted' as const }
|
||
: readStatus === 'read'
|
||
? { label: '已查看', tone: 'green' as const }
|
||
: readStatus === 'unread'
|
||
? { label: '未查看', tone: 'muted' as const }
|
||
: { label: '查看状态未知', tone: 'muted' as const }
|
||
const submitted = isSubmitted
|
||
? { label: '已评分', tone: 'green' as const }
|
||
: { label: '待评分', tone: 'muted' as const }
|
||
|
||
return (
|
||
<div key={item.id} className="grid gap-3 py-3 sm:grid-cols-[minmax(120px,1fr)_repeat(3,auto)] sm:items-center sm:gap-6">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">{item.name}</div>
|
||
{item.submittedAt && <div className="mt-1 text-[11px] text-[#727687]">{formatDate(item.submittedAt)}</div>}
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3 sm:contents">
|
||
<WorkflowState {...notice} />
|
||
<WorkflowState {...read} />
|
||
<WorkflowState {...submitted} />
|
||
</div>
|
||
</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}
|
||
{item.countsTowardFinal && (
|
||
<span className="ml-2 inline-flex rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[10px] font-semibold text-[#0050cb]">计入最终分</span>
|
||
)}
|
||
</div>
|
||
<div className="shrink-0 text-sm font-semibold text-[#1677ff]">{formatNullableScore(item.score)}</div>
|
||
</div>
|
||
{Array.isArray(item.dimensions) && item.dimensions.length > 0 && (
|
||
<div className="mt-3 grid gap-x-5 gap-y-2 border-t border-[#eeeeee] pt-3 sm:grid-cols-2">
|
||
{item.dimensions.map((dimension: any) => (
|
||
<div key={dimension.id} className="flex min-w-0 items-center justify-between gap-3 text-[13px]">
|
||
<span className="truncate text-[#5f5e5e]">{dimension.title}</span>
|
||
<span className="shrink-0 font-semibold text-[#1a1c1c]">
|
||
{formatNullableScore(dimension.score)}
|
||
<span className="ml-0.5 text-[11px] font-medium text-[#727687]">/{dimension.maxScore}</span>
|
||
</span>
|
||
</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 VisualEvaluatorResultList({
|
||
responses,
|
||
workItems,
|
||
questions,
|
||
template,
|
||
}: {
|
||
responses: any[]
|
||
workItems: any[]
|
||
questions: Template['questions']
|
||
template?: Template
|
||
}) {
|
||
const evaluators = groupVisualScoresByEvaluator(responses, workItems)
|
||
|
||
if (evaluators.length === 0) {
|
||
return <div className="rounded-[12px] bg-[#f8fafc] px-4 py-4 text-sm text-[#667085]">暂无评分记录</div>
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-3">
|
||
{evaluators.map((evaluator) => (
|
||
<section key={evaluator.id} className="overflow-hidden rounded-[14px] border border-[#e5edf6] bg-white">
|
||
<div className="flex items-start justify-between gap-3 border-b border-[#e5edf6] bg-[#f8fafc] px-4 py-3.5">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm font-semibold text-[#101828]">{evaluator.respondentName}</span>
|
||
{['visual_final', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && (
|
||
<span className="rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[11px] font-semibold text-[#0050cb]">计入最终分</span>
|
||
)}
|
||
{['self', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && (
|
||
<span className="rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">自评</span>
|
||
)}
|
||
{evaluator.workflowRole === 'manager' && (
|
||
<span className="rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">主管评分</span>
|
||
)}
|
||
</div>
|
||
{evaluator.createdAt && <div className="mt-1 text-[11px] text-[#98a2b3]">提交于 {formatDate(evaluator.createdAt)}</div>}
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-[18px] font-semibold text-[#0052d9]">{formatResultScore(evaluator.totalScore)}</div>
|
||
<div className="text-[11px] font-medium text-[#667085]">总分</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="divide-y divide-[#edf1f6] px-4">
|
||
{evaluator.workItemScores.map((item, itemIndex) => {
|
||
const itemScore = item.score
|
||
const rawScores = itemScore?.scores && typeof itemScore.scores === 'object'
|
||
? itemScore.scores as Record<string, unknown>
|
||
: {}
|
||
const dimensions = questions
|
||
.map((question) => ({
|
||
question,
|
||
value: rawScores[question.id],
|
||
}))
|
||
.filter(({ value }) => value !== null && value !== undefined && value !== '')
|
||
|
||
return (
|
||
<div key={item.workItemId} className="py-3.5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] font-semibold text-[#667085]">作品 {itemIndex + 1}</div>
|
||
<div className="mt-0.5 break-words text-sm font-semibold text-[#101828]">{item.workItemTitle}</div>
|
||
</div>
|
||
<div className={`shrink-0 text-sm font-semibold ${itemScore ? 'text-[#0052d9]' : 'text-[#98a2b3]'}`}>
|
||
{itemScore ? `${formatResultScore(itemScore.totalScore)} 分` : '未评分'}
|
||
</div>
|
||
</div>
|
||
{dimensions.length > 0 && (
|
||
<div className="mt-3 grid gap-x-4 gap-y-2 rounded-[10px] bg-[#f8fafc] px-3 py-2.5 sm:grid-cols-2">
|
||
{dimensions.map(({ question, value }) => (
|
||
<div key={question.id} className="flex items-center justify-between gap-3 text-[12px]">
|
||
<span className="truncate text-[#667085]">{question.title}</span>
|
||
<span className="shrink-0 font-semibold text-[#344054]">
|
||
{formatResultScore(value)} / {getQuestionMaxScore(template, question)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{itemScore?.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#475467]">{itemScore.comment}</p>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{evaluator.comment && (
|
||
<div className="border-t border-[#e5edf6] bg-[#fbfcfe] px-4 py-3">
|
||
<div className="text-[11px] font-semibold text-[#667085]">总体评语</div>
|
||
<p className="mt-1 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{evaluator.comment}</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
))}
|
||
</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 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 <EmptyState title="暂无待处理" description="当前没有需要你评分的调研" />
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-3 md:hidden">
|
||
{items.map((item) => (
|
||
<article key={item.assignmentId} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">{item.title}</h2>
|
||
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
|
||
<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-2.5 py-1 text-[12px] font-bold text-[#0052d9]">待填写</span>
|
||
</div>
|
||
<div className="mt-4 flex justify-end">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
aria-label={`去评价:${item.title}`}
|
||
className="inline-flex min-h-11 items-center justify-center rounded bg-[#0050cb] px-5 text-sm font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
去评价
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white md:block">
|
||
<table className="w-full min-w-[720px] border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
|
||
<th scope="col" className="px-4 py-3">调研</th>
|
||
<th scope="col" className="w-[180px] px-4 py-3">评价对象</th>
|
||
<th scope="col" className="w-[180px] px-4 py-3">截止</th>
|
||
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
|
||
<th scope="col" className="w-[110px] px-4 py-3 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#c2c6d8]">
|
||
{items.map((item) => (
|
||
<tr key={item.assignmentId} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
|
||
<td className="px-4 py-4 font-bold">{item.title}</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.targetName}</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.dueAt ? formatDate(item.dueAt) : '无截止'}</td>
|
||
<td className="px-4 py-4">
|
||
<span className="inline-flex rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-2.5 py-1 text-[12px] font-bold text-[#0052d9]">待填写</span>
|
||
</td>
|
||
<td className="px-4 py-4 text-right">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
aria-label={`去评价:${item.title}`}
|
||
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
去评价
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function SubmittedList({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return <EmptyState title="暂无已处理" description="完成评分后,记录会显示在这里" />
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-3 md:hidden">
|
||
{items.map((item) => (
|
||
<article key={item.assignmentId} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">{item.title}</h2>
|
||
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
|
||
<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-2.5 py-1 text-[12px] font-bold text-[#039855]">已提交</span>
|
||
</div>
|
||
<div className="mt-4 flex justify-end">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
aria-label={`查看详情:${item.title}`}
|
||
className="inline-flex min-h-11 items-center justify-center rounded border border-[#c2c6d8] bg-white px-5 text-sm font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
查看详情
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white md:block">
|
||
<table className="w-full min-w-[720px] border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
|
||
<th scope="col" className="px-4 py-3">调研</th>
|
||
<th scope="col" className="w-[180px] px-4 py-3">评价对象</th>
|
||
<th scope="col" className="w-[180px] px-4 py-3">提交时间</th>
|
||
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
|
||
<th scope="col" className="w-[120px] px-4 py-3 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#c2c6d8]">
|
||
{items.map((item) => (
|
||
<tr key={item.assignmentId} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
|
||
<td className="px-4 py-4 font-bold">{item.title}</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.targetName}</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.submittedAt ? formatDate(item.submittedAt) : '已提交'}</td>
|
||
<td className="px-4 py-4">
|
||
<span className="inline-flex rounded-full border border-[#b9ead0] bg-[#ecfdf3] px-2.5 py-1 text-[12px] font-bold text-[#039855]">已提交</span>
|
||
</td>
|
||
<td className="px-4 py-4 text-right">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
aria-label={`查看详情:${item.title}`}
|
||
className="inline-flex min-h-9 items-center justify-center rounded border border-[#c2c6d8] bg-white px-4 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
查看详情
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</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)
|
||
}
|
||
}
|
||
|
||
const cancelSurvey = async (item: any) => {
|
||
const reason = window.prompt('请输入撤销原因(必填)')?.trim()
|
||
if (!reason) return
|
||
if (!window.confirm(`确认撤销“${item.title}”吗?\n\n撤销原因:${reason}`)) return
|
||
|
||
setBusyId(item.id)
|
||
try {
|
||
await apiRequest(`/surveys/${item.id}/cancel`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ reason }),
|
||
})
|
||
await onRefresh()
|
||
} catch (error) {
|
||
window.alert(error instanceof Error ? error.message : '撤销失败,请稍后重试')
|
||
} finally {
|
||
setBusyId(null)
|
||
}
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
return <EmptyState title={emptyTitle} description="发起调研后,可在这里查看进度和结果" />
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-3 lg:hidden">
|
||
{items.map((item) => {
|
||
const targetCount = Number(item.counts?.targets || 0)
|
||
const assignmentCount = Number(item.counts?.assignments || 0)
|
||
const responseCount = Number(item.counts?.responses || 0)
|
||
const progressText = assignmentCount > 0
|
||
? `已提交 ${responseCount}/${assignmentCount}`
|
||
: '尚未生成评价任务'
|
||
|
||
return (
|
||
<article
|
||
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">
|
||
<h2 className="break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">
|
||
{item.title}
|
||
</h2>
|
||
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
|
||
<p className="break-words">{item.departmentName || '未指定部门'}</p>
|
||
<p>对象 {targetCount} 人 · 评价任务 {assignmentCount} 份</p>
|
||
<p>{progressText} · {item.dueAt ? `截止 ${formatDate(item.dueAt)}` : '无截止'}</p>
|
||
</div>
|
||
</div>
|
||
<DashboardStatusBadge status={item.status} />
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||
{item.status === 'draft' && (
|
||
<button
|
||
type="button"
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'open')}
|
||
aria-label={`发布调研:${item.title}`}
|
||
className="min-h-11 rounded border border-[#b8d0ff] bg-[#edf4ff] px-4 text-sm font-bold text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
|
||
>
|
||
发布
|
||
</button>
|
||
)}
|
||
{item.status !== 'closed' && item.status !== 'cancelled' && (
|
||
<button
|
||
type="button"
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'closed')}
|
||
aria-label={`关闭调研:${item.title}`}
|
||
className="min-h-11 rounded border border-[#c2c6d8] bg-white px-4 text-sm font-bold text-[#1a1c1c] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
|
||
>
|
||
关闭
|
||
</button>
|
||
)}
|
||
{item.canCancel && (
|
||
<button
|
||
type="button"
|
||
disabled={busyId === item.id}
|
||
onClick={() => cancelSurvey(item)}
|
||
aria-label={`撤销调研:${item.title}`}
|
||
className="min-h-11 rounded border border-[#f0b8b8] bg-white px-4 text-sm font-bold text-[#b42318] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2 disabled:opacity-50"
|
||
>
|
||
撤销
|
||
</button>
|
||
)}
|
||
<Link
|
||
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
|
||
aria-label={`查看结果:${item.title}`}
|
||
className="inline-flex min-h-11 items-center justify-center rounded bg-[#0050cb] px-5 text-sm font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
查看结果
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white lg:block">
|
||
<table className="w-full min-w-[920px] border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
|
||
<th scope="col" className="px-4 py-3">调研</th>
|
||
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
|
||
<th scope="col" className="w-[210px] px-4 py-3">完成进度</th>
|
||
<th scope="col" className="w-[180px] px-4 py-3">截止</th>
|
||
<th scope="col" className="w-[270px] px-4 py-3 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#c2c6d8]">
|
||
{items.map((item) => {
|
||
const targetCount = Number(item.counts?.targets || 0)
|
||
const assignmentCount = Number(item.counts?.assignments || 0)
|
||
const responseCount = Number(item.counts?.responses || 0)
|
||
|
||
return (
|
||
<tr key={item.id} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
|
||
<td className="px-4 py-4">
|
||
<div className="font-bold">{item.title}</div>
|
||
<div className="mt-1 text-[12px] font-medium text-[#5f5e5e]">{item.departmentName || '未指定部门'}</div>
|
||
</td>
|
||
<td className="px-4 py-4"><DashboardStatusBadge status={item.status} /></td>
|
||
<td className="px-4 py-4">
|
||
<div className="text-[13px] font-bold text-[#1a1c1c]">
|
||
{assignmentCount > 0 ? `已提交 ${responseCount}/${assignmentCount}` : '尚未生成评价任务'}
|
||
</div>
|
||
<div className="mt-1 text-[12px] font-medium text-[#5f5e5e]">对象 {targetCount} 人 · 评价任务 {assignmentCount} 份</div>
|
||
</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.dueAt ? formatDate(item.dueAt) : '无截止'}</td>
|
||
<td className="px-4 py-4">
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
{item.status === 'draft' && (
|
||
<button
|
||
type="button"
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'open')}
|
||
aria-label={`发布调研:${item.title}`}
|
||
className="min-h-9 rounded border border-[#b8d0ff] bg-[#edf4ff] px-3 text-[13px] font-bold text-[#0050cb] hover:bg-[#dae8ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
|
||
>
|
||
发布
|
||
</button>
|
||
)}
|
||
{item.status !== 'closed' && item.status !== 'cancelled' && (
|
||
<button
|
||
type="button"
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'closed')}
|
||
aria-label={`关闭调研:${item.title}`}
|
||
className="min-h-9 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
|
||
>
|
||
关闭
|
||
</button>
|
||
)}
|
||
{item.canCancel && (
|
||
<button
|
||
type="button"
|
||
disabled={busyId === item.id}
|
||
onClick={() => cancelSurvey(item)}
|
||
aria-label={`撤销调研:${item.title}`}
|
||
className="min-h-9 rounded border border-[#f0b8b8] bg-white px-3 text-[13px] font-bold text-[#b42318] hover:bg-[#fff5f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2 disabled:opacity-50"
|
||
>
|
||
撤销
|
||
</button>
|
||
)}
|
||
<Link
|
||
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
|
||
aria-label={`查看结果:${item.title}`}
|
||
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-3 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
查看结果
|
||
</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 && <DashboardPanel>加载中...</DashboardPanel>}
|
||
{state.error && <DashboardErrorPanel 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-11 items-center gap-1 rounded px-1 py-1 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-9 w-9 shrink-0 items-center justify-center rounded border text-sm ${
|
||
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-h-9 min-w-0 flex-1 truncate rounded px-2 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)}
|
||
aria-expanded={open}
|
||
aria-haspopup="dialog"
|
||
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)]" role="dialog" aria-label="选择组织">
|
||
<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 <EmptyState title="暂无结果" description="已归档的考核结果会显示在这里" />
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-3 lg:hidden">
|
||
{items.map((item) => (
|
||
<article key={`${item.surveyId}-${item.targetId}`} className="rounded-lg border border-[#e2e2e2] bg-white p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="text-[18px] font-bold text-[#1a1c1c]">{formatMonthLabel(item.assessmentMonth)}</div>
|
||
<span className="rounded bg-[#e8f0ff] px-2 py-1 text-[12px] font-bold text-[#0050cb]">
|
||
{getTemplateDisplayName({ code: item.templateCode, name: item.templateName })}
|
||
</span>
|
||
</div>
|
||
<h2 className="mt-3 break-words text-[16px] font-bold leading-snug text-[#1a1c1c]">{item.title}</h2>
|
||
<div className="mt-2 space-y-1 text-[12px] font-medium leading-5 text-[#5f5e5e]">
|
||
<p>评价对象:{item.targetName}</p>
|
||
<p>{item.closedAt ? `${formatDate(item.closedAt)} 归档` : '已归档'}</p>
|
||
</div>
|
||
<div className="mt-4 flex justify-end">
|
||
<Link
|
||
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=personal&workspace=results')}`}
|
||
aria-label={`查看最终结果:${item.title}`}
|
||
className="inline-flex min-h-11 items-center justify-center rounded bg-[#0050cb] px-5 text-sm font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
查看最终结果
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto rounded border border-[#c2c6d8] bg-white lg:block">
|
||
<table className="w-full min-w-[840px] border-collapse text-left">
|
||
<thead>
|
||
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
|
||
<th scope="col" className="w-[130px] px-4 py-3">考核月份</th>
|
||
<th scope="col" className="px-4 py-3">考核项目 / 调研</th>
|
||
<th scope="col" className="w-[160px] px-4 py-3">评价对象</th>
|
||
<th scope="col" className="w-[190px] px-4 py-3">归档时间</th>
|
||
<th scope="col" className="w-[150px] px-4 py-3 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[#c2c6d8]">
|
||
{items.map((item) => (
|
||
<tr key={`${item.surveyId}-${item.targetId}`} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
|
||
<td className="px-4 py-4 text-[16px] font-bold">{formatMonthLabel(item.assessmentMonth)}</td>
|
||
<td className="px-4 py-4">
|
||
<div className="mb-1 inline-flex rounded bg-[#e8f0ff] px-2 py-1 text-[12px] font-bold text-[#0050cb]">
|
||
{getTemplateDisplayName({ code: item.templateCode, name: item.templateName })}
|
||
</div>
|
||
<div className="font-bold">{item.title}</div>
|
||
</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.targetName}</td>
|
||
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.closedAt ? formatDate(item.closedAt) : '已归档'}</td>
|
||
<td className="px-4 py-4 text-right">
|
||
<Link
|
||
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=personal&workspace=results')}`}
|
||
aria-label={`查看最终结果:${item.title}`}
|
||
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
查看结果
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function ManagePanel({ currentEmployee }: { currentEmployee: Employee | null }) {
|
||
const templates = useApi<Template[]>('/templates', [])
|
||
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
|
||
const surveys = useApi<any>('/surveys', [])
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
|
||
return (
|
||
<div className="grid gap-4">
|
||
<CreateSurveyForm
|
||
templates={templates.data || []}
|
||
departments={departments.data || []}
|
||
currentEmployee={currentEmployee}
|
||
onCreated={async () => {
|
||
setMessage('已发布调研')
|
||
await surveys.refresh()
|
||
}}
|
||
/>
|
||
{message && (
|
||
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
|
||
{message}
|
||
</div>
|
||
)}
|
||
<section className="mt-4 border-t border-[#e2e2e2] pt-6" aria-labelledby="created-survey-list-title">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 id="created-survey-list-title" className="text-[18px] font-bold text-[#1a1c1c]">我发起的</h2>
|
||
<p className="mt-1 text-[12px] font-medium text-[#727687]">回看进度、关闭调研或进入结果页</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => surveys.refresh()}
|
||
className="inline-flex min-h-9 items-center rounded px-2 text-[12px] font-bold text-[#0050cb] hover:bg-[#f4f7ff] hover:text-[#003fa4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
{surveys.loading && <DashboardPanel>调研加载中...</DashboardPanel>}
|
||
{surveys.error && <DashboardErrorPanel message={surveys.error} />}
|
||
{surveys.data && (
|
||
<CreatedList
|
||
items={surveys.data.created || []}
|
||
onRefresh={surveys.refresh}
|
||
resultBackHref="/performance-survey?tab=manage"
|
||
/>
|
||
)}
|
||
</section>
|
||
</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,
|
||
currentEmployee,
|
||
onCreated,
|
||
}: {
|
||
templates: Template[]
|
||
departments: DepartmentOption[]
|
||
currentEmployee: Employee | null
|
||
onCreated: () => Promise<void>
|
||
}) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const prefillMonth = searchParams.get('prefillMonth') || ''
|
||
const prefillTemplate = searchParams.get('prefillTemplate') || ''
|
||
const [title, setTitle] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [templateCode, setTemplateCode] = useState(prefillTemplate)
|
||
const [assessmentMonth, setAssessmentMonth] = useState(prefillMonth || getCurrentMonthValue())
|
||
const [deadlineAt, setDeadlineAt] = useState('')
|
||
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 [visualCases, setVisualCases] = useState<VisualCaseDraft[]>(() => createVisualCaseDrafts())
|
||
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
const [publishingLabel, setPublishingLabel] = useState('')
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (prefillMonth && /^\d{4}-\d{2}$/.test(prefillMonth)) setAssessmentMonth(prefillMonth)
|
||
if (prefillTemplate && templates.some((template) => template.code === prefillTemplate)) {
|
||
setTemplateCode(prefillTemplate)
|
||
}
|
||
}, [prefillMonth, prefillTemplate, templates])
|
||
|
||
const selectedTemplate = templateCode || templates[0]?.code || ''
|
||
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
|
||
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
|
||
const visualTemplateSelected = isVisualTemplate(selectedTemplateInfo)
|
||
const visualRequiredCount = Math.max(
|
||
1,
|
||
Number(selectedTemplateInfo?.requiredWorkItemCount || DEFAULT_VISUAL_CASE_COUNT),
|
||
)
|
||
const effectiveTargetEmployees = useMemo(
|
||
() => surveyKind === 'item_course'
|
||
? []
|
||
: mergeEmployeesById(currentEmployee ? [currentEmployee] : [], targetEmployees),
|
||
[currentEmployee, surveyKind, targetEmployees],
|
||
)
|
||
const currentTargetSelected = Boolean(
|
||
currentEmployee?.id && effectiveTargetEmployees.some((employee) => employee.id === currentEmployee.id),
|
||
)
|
||
const createdUploadTarget = visualTemplateSelected
|
||
? (createdSurvey?.targets || []).find((target: any) => target.canUploadWorkItems)
|
||
: null
|
||
const delegatedTargetCount = surveyKind === 'item_course'
|
||
? 0
|
||
: effectiveTargetEmployees.filter((employee) => employee.id !== currentEmployee?.id).length
|
||
const evaluatorEmployees = useMemo(
|
||
() => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)),
|
||
[manualEvaluatorEmployees, evaluatorDepartmentEmployees],
|
||
)
|
||
const publishValidationIssues: string[] = []
|
||
if (!selectedTemplate) publishValidationIssues.push('选择考评模板')
|
||
if (!assessmentMonth) publishValidationIssues.push('选择考核月份')
|
||
if (surveyKind === 'item_course' && targetItems.length === 0 && !itemName.trim()) {
|
||
publishValidationIssues.push(`填写${selectedTemplateInfo?.itemName || '课程、事项或活动'}名称`)
|
||
}
|
||
if (surveyKind !== 'item_course' && !currentEmployee?.id) {
|
||
publishValidationIssues.push('当前账户需要先匹配考核人员档案')
|
||
}
|
||
if (surveyKind !== 'item_course' && effectiveTargetEmployees.length === 0) {
|
||
publishValidationIssues.push('确认考核对象')
|
||
}
|
||
if (!visualTemplateSelected && evaluatorEmployees.length === 0) publishValidationIssues.push('至少选择 1 名评分人')
|
||
if (visualTemplateSelected && currentTargetSelected) {
|
||
const visibleVisualCases = visualCases.slice(0, visualRequiredCount)
|
||
if (visibleVisualCases.length < visualRequiredCount) {
|
||
publishValidationIssues.push(`准备 ${visualRequiredCount} 个案例作品`)
|
||
}
|
||
visibleVisualCases.forEach((draft, index) => {
|
||
const validationError = validatePerformanceSurveyImageFiles(draft.files)
|
||
if (validationError) publishValidationIssues.push(`案例 ${index + 1}:${validationError}`)
|
||
})
|
||
}
|
||
const publishDisabled = saving || Boolean(createdSurvey) || publishValidationIssues.length > 0
|
||
|
||
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 () => {
|
||
if (publishValidationIssues.length > 0) {
|
||
setError(`还需完成:${publishValidationIssues.join(';')}`)
|
||
return
|
||
}
|
||
setSaving(true)
|
||
setPublishingLabel('正在创建调研')
|
||
setError(null)
|
||
setCreatedSurvey(null)
|
||
let createdForAttempt: any | null = 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' && !currentEmployee?.id) {
|
||
throw new Error('当前账户尚未匹配考核人员档案,暂时无法发起调研')
|
||
}
|
||
if (surveyKind !== 'item_course' && effectiveTargetEmployees.length === 0) {
|
||
throw new Error('请至少选择 1 个考核对象')
|
||
}
|
||
if (!visualTemplateSelected && evaluatorEmployees.length === 0) {
|
||
throw new Error('请至少选择 1 名评分人')
|
||
}
|
||
const visualCasesToSubmit = visualCases.slice(0, visualRequiredCount)
|
||
if (visualTemplateSelected && currentTargetSelected) {
|
||
if (visualCasesToSubmit.length < visualRequiredCount) {
|
||
throw new Error(`请提交 ${visualRequiredCount} 个案例作品`)
|
||
}
|
||
for (let index = 0; index < visualCasesToSubmit.length; index += 1) {
|
||
const validationError = validatePerformanceSurveyImageFiles(visualCasesToSubmit[index].files)
|
||
if (validationError) {
|
||
throw new Error(`案例 ${index + 1}:${validationError}`)
|
||
}
|
||
}
|
||
}
|
||
const selectedAssessmentMonth = assessmentMonth || getCurrentMonthValue()
|
||
const submitTitle = title.trim()
|
||
|| targetItemsToSubmit[0]?.name
|
||
|| `${formatMonthLabel(selectedAssessmentMonth)} · ${getTemplateDisplayName(selectedTemplateInfo)}`
|
||
const created = await apiRequest<any>('/surveys', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
title: submitTitle,
|
||
description,
|
||
surveyKind,
|
||
templateCode: selectedTemplate,
|
||
assessmentMonth: selectedAssessmentMonth,
|
||
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
|
||
openNow,
|
||
targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id),
|
||
targetItems: targetItemsToSubmit,
|
||
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
|
||
}),
|
||
})
|
||
createdForAttempt = created
|
||
setCreatedSurvey(created)
|
||
|
||
let uploadTarget: any | null = null
|
||
if (visualTemplateSelected && currentTargetSelected) {
|
||
uploadTarget = (created.targets || []).find((target: any) => target.canUploadWorkItems)
|
||
if (!uploadTarget?.id) throw new Error('未找到当前账户对应的作品上传入口')
|
||
|
||
for (let index = 0; index < visualCasesToSubmit.length; index += 1) {
|
||
const draft = visualCasesToSubmit[index]
|
||
setPublishingLabel(`正在上传作品 ${index + 1}/${visualCasesToSubmit.length}`)
|
||
const images = await uploadPerformanceSurveyImageFiles(draft.files, (label) => {
|
||
setPublishingLabel(`作品 ${index + 1}/${visualCasesToSubmit.length} · ${label}`)
|
||
})
|
||
await apiRequest(`/surveys/${created.id}/work-items`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
targetId: uploadTarget.id,
|
||
title: draft.title.trim() || `案例 ${index + 1}`,
|
||
description: draft.description.trim(),
|
||
images,
|
||
}),
|
||
})
|
||
}
|
||
}
|
||
|
||
setTitle('')
|
||
setDescription('')
|
||
setTemplateCode('')
|
||
setAssessmentMonth(getCurrentMonthValue())
|
||
setDeadlineAt('')
|
||
setTargetEmployees([])
|
||
setManualEvaluatorEmployees([])
|
||
setEvaluatorDepartmentValues([])
|
||
setEvaluatorDepartmentEmployees({})
|
||
setTargetItems([])
|
||
setItemName('')
|
||
setVisualCases(createVisualCaseDrafts())
|
||
await onCreated()
|
||
setCreatedSurvey(null)
|
||
|
||
if (uploadTarget?.id) {
|
||
router.push(`/performance-survey/survey/${created.id}?targetId=${encodeURIComponent(uploadTarget.id)}`)
|
||
}
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '创建失败'
|
||
setError(
|
||
createdForAttempt && visualTemplateSelected
|
||
? `调研已创建,但作品没有全部上传完成:${message}`
|
||
: message,
|
||
)
|
||
} finally {
|
||
setPublishingLabel('')
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="grid gap-6 lg:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.25fr)]">
|
||
<div>
|
||
<div className="border border-[#eeeeee] bg-white p-4 sm:p-6">
|
||
<div className="space-y-6">
|
||
<TextInput label="考核月份" value={assessmentMonth} onChange={setAssessmentMonth} type="month" />
|
||
<TextInput label="截止时间(可选)" value={deadlineAt} onChange={setDeadlineAt} type="datetime-local" />
|
||
<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>
|
||
</div>
|
||
|
||
<div className="border border-[#eeeeee] bg-white p-4 sm:p-6">
|
||
<div className="grid gap-8 lg:grid-cols-2">
|
||
<div>
|
||
{surveyKind !== 'item_course' ? (
|
||
<div className="grid gap-4">
|
||
<div>
|
||
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">
|
||
{selectedTemplateInfo?.ownerRoleName || '被考核人'}
|
||
</div>
|
||
{currentEmployee ? (
|
||
<div className="mt-2 flex items-center gap-3 border border-[#dce5f3] bg-[#f8faff] px-3 py-3">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#0050cb] text-[13px] font-bold text-white">
|
||
{getSurveyInitials(currentEmployee.displayName)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">{currentEmployee.displayName}</div>
|
||
<div className="mt-0.5 truncate text-[12px] text-[#5f5e5e]">
|
||
{[currentEmployee.departmentName, currentEmployee.positionLevel || currentEmployee.levelType]
|
||
.filter(Boolean)
|
||
.join(' · ') || '当前登录账户'}
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full bg-[#e8f0ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">
|
||
当前账户
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 border border-[#ffdad6] bg-[#fff6f4] px-3 py-3 text-[12px] font-semibold text-[#93000a]">
|
||
当前账户尚未匹配考核人员档案
|
||
</div>
|
||
)}
|
||
</div>
|
||
<EmployeePicker
|
||
title="添加其他被考核人(可选)"
|
||
purpose="target"
|
||
selected={targetEmployees}
|
||
excludedIds={currentEmployee?.id ? [currentEmployee.id] : []}
|
||
onChange={setTargetEmployees}
|
||
/>
|
||
{delegatedTargetCount > 0 && (
|
||
<div className="text-[12px] font-semibold text-[#0050cb]">
|
||
将向 {delegatedTargetCount} 位被考核人发送钉钉确认
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<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>
|
||
{visualTemplateSelected ? (
|
||
<div className="border border-[#dce5f3] bg-[#f8faff] px-4 py-4">
|
||
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">评分人员</div>
|
||
<div className="mt-2 text-[14px] font-bold text-[#1a1c1c]">系统自动选择元泽和被考核人的直属主管</div>
|
||
<p className="mt-2 text-[12px] leading-5 text-[#5f5e5e]">被考核人先完成自评,之后才会通知元泽和主管;最终评分以元泽提交的结果为准。</p>
|
||
</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>
|
||
|
||
<div className={`min-w-0 lg:col-span-2 ${visualTemplateSelected ? '' : 'hidden'}`}>
|
||
<VisualCaseDraftUploader
|
||
expanded={visualTemplateSelected}
|
||
enabled={currentTargetSelected}
|
||
requiredCount={visualRequiredCount}
|
||
cases={visualCases}
|
||
onChange={setVisualCases}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3 border-t border-[#e2e2e2] pt-5 lg:col-span-2">
|
||
{error && <div className="border border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-sm font-semibold text-[#93000a]" role="alert">{error}</div>}
|
||
<div
|
||
id="performance-survey-publish-readiness"
|
||
className={`px-4 py-3 text-[13px] font-semibold ${
|
||
publishValidationIssues.length > 0
|
||
? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]'
|
||
: 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]'
|
||
}`}
|
||
aria-live="polite"
|
||
>
|
||
{publishValidationIssues.length > 0
|
||
? `还需完成:${publishValidationIssues.join(';')}`
|
||
: visualTemplateSelected
|
||
? '配置已完整,发布后请先完成自评'
|
||
: '配置已完整,可以发布调研'}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={publishDisabled}
|
||
aria-describedby="performance-survey-publish-readiness"
|
||
onClick={submit}
|
||
className="h-12 w-full rounded bg-[#0050cb] px-5 text-[15px] font-bold text-white hover:bg-[#003fa4] disabled:cursor-not-allowed disabled:bg-[#aeb9c9]"
|
||
>
|
||
{saving ? publishingLabel || '发布中...' : createdSurvey ? '调研已发布' : visualTemplateSelected ? '发布并开始自评' : '发布调研'}
|
||
</button>
|
||
{createdSurvey && (
|
||
<div className="flex items-center justify-between gap-3 border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
|
||
<span>
|
||
{isVisualTemplate(selectedTemplateInfo) && !currentTargetSelected
|
||
? '已发布,已通知被考核人提交作品'
|
||
: '已发布'}
|
||
</span>
|
||
{createdUploadTarget?.id && (
|
||
<Link
|
||
href={`/performance-survey/survey/${createdSurvey.id}?targetId=${encodeURIComponent(createdUploadTarget.id)}`}
|
||
className="shrink-0 rounded bg-[#0050cb] px-3 py-1.5 text-white"
|
||
>
|
||
上传作品
|
||
</Link>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveyImageDropzone({
|
||
files,
|
||
disabled,
|
||
ariaLabel,
|
||
onFiles,
|
||
}: {
|
||
files: File[]
|
||
disabled?: boolean
|
||
ariaLabel: string
|
||
onFiles: (files: File[]) => void
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||
const dragDepthRef = useRef(0)
|
||
const [dragging, setDragging] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (files.length === 0 && inputRef.current) inputRef.current.value = ''
|
||
}, [files.length])
|
||
|
||
const addFiles = (selected: FileList | File[]) => {
|
||
if (disabled) return
|
||
const next = Array.from(selected)
|
||
if (next.length > 0) onFiles(next)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
disabled={disabled}
|
||
tabIndex={-1}
|
||
aria-hidden="true"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
addFiles(event.currentTarget.files || [])
|
||
event.currentTarget.value = ''
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
aria-label={ariaLabel}
|
||
onClick={() => inputRef.current?.click()}
|
||
onDragEnter={(event) => {
|
||
event.preventDefault()
|
||
if (disabled) return
|
||
dragDepthRef.current += 1
|
||
setDragging(true)
|
||
}}
|
||
onDragOver={(event) => {
|
||
event.preventDefault()
|
||
if (!disabled) event.dataTransfer.dropEffect = 'copy'
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
if (disabled) return
|
||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||
if (dragDepthRef.current === 0) setDragging(false)
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault()
|
||
dragDepthRef.current = 0
|
||
setDragging(false)
|
||
addFiles(event.dataTransfer.files)
|
||
}}
|
||
className={`flex min-h-24 w-full flex-col items-center justify-center border border-dashed px-4 py-4 text-center transition-[border-color,background-color,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||
dragging
|
||
? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]'
|
||
: 'border-[#aebbd0] bg-white hover:border-[#0050cb] hover:bg-[#f7faff]'
|
||
}`}
|
||
>
|
||
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-[20px] font-light ${dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eef5ff] text-[#0050cb]'}`}>↑</span>
|
||
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">{dragging ? '松开即可添加' : '拖拽图片到这里'}</span>
|
||
<span className="mt-0.5 text-[11px] text-[#98a2b3]">或点击选择 · 单张长图,或最多 60 张有序切片;单张 200MB</span>
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveySelectedImageFiles({
|
||
files,
|
||
onRemove,
|
||
}: {
|
||
files: File[]
|
||
onRemove: (index: number) => void
|
||
}) {
|
||
if (files.length === 0) return null
|
||
return (
|
||
<div className="divide-y divide-[#e5eaf2] border border-[#e5eaf2] bg-white">
|
||
{files.map((file, index) => (
|
||
<div key={`${file.name}-${file.size}-${file.lastModified}`} className="flex min-h-11 items-center gap-3 px-3 py-2">
|
||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-[#eef5ff] text-[11px] font-bold text-[#0050cb]">图</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-[12px] font-semibold text-[#111827]">{file.name}</span>
|
||
<span className="block text-[10px] text-[#98a2b3]">{formatPerformanceSurveyImageFileSize(file.size)}</span>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(index)}
|
||
aria-label={`移除图片 ${index + 1}`}
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[18px] leading-none text-[#667085] transition-colors hover:bg-[#f2f4f7] hover:text-[#111827]"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VisualCaseDraftUploader({
|
||
expanded,
|
||
enabled,
|
||
requiredCount,
|
||
cases,
|
||
onChange,
|
||
}: {
|
||
expanded: boolean
|
||
enabled: boolean
|
||
requiredCount: number
|
||
cases: VisualCaseDraft[]
|
||
onChange: (cases: VisualCaseDraft[]) => void
|
||
}) {
|
||
const visibleCases = cases.slice(0, requiredCount)
|
||
const completedCount = visibleCases.filter((item) => item.files.length > 0).length
|
||
|
||
const updateCase = (index: number, next: VisualCaseDraft) => {
|
||
onChange(cases.map((item, itemIndex) => itemIndex === index ? next : item))
|
||
}
|
||
|
||
return (
|
||
<div
|
||
aria-hidden={!expanded}
|
||
className={`grid transition-[grid-template-rows,opacity,transform,margin] duration-300 ease-out motion-reduce:transition-none ${
|
||
expanded
|
||
? 'mt-0 grid-rows-[1fr] translate-y-0 opacity-100'
|
||
: '-mt-6 grid-rows-[0fr] -translate-y-2 opacity-0 pointer-events-none'
|
||
}`}
|
||
>
|
||
<div className="min-h-0 overflow-hidden">
|
||
<section className="border border-[#dfe6f0] bg-white p-4 shadow-[0_12px_30px_rgba(15,23,42,0.05)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-[15px] font-bold text-[#111827]">作品上传</h2>
|
||
<span className="text-[12px] font-semibold text-[#667085]">{completedCount}/{requiredCount}</span>
|
||
</div>
|
||
|
||
{!enabled ? (
|
||
<div className="mt-3 border border-dashed border-[#cfd8e6] bg-[#f8faff] px-3 py-4 text-center text-[13px] font-semibold text-[#667085]">
|
||
由被考核人通过钉钉提交作品
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{visibleCases.map((draft, index) => (
|
||
<VisualCaseDraftEditor
|
||
key={draft.id}
|
||
index={index}
|
||
draft={draft}
|
||
disabled={!expanded}
|
||
onChange={(next) => updateCase(index, next)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VisualCaseDraftEditor({
|
||
index,
|
||
draft,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
index: number
|
||
draft: VisualCaseDraft
|
||
disabled: boolean
|
||
onChange: (draft: VisualCaseDraft) => void
|
||
}) {
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
|
||
const addFiles = (selectedFiles: File[]) => {
|
||
const deduplicated = [...draft.files, ...selectedFiles].filter((file, fileIndex, items) => {
|
||
const key = `${file.name}-${file.size}-${file.lastModified}`
|
||
return items.findIndex((candidate) => `${candidate.name}-${candidate.size}-${candidate.lastModified}` === key) === fileIndex
|
||
})
|
||
const orderedFiles = sortPerformanceSurveyImagesByName(deduplicated, (file) => file.name)
|
||
const validationError = validatePerformanceSurveyImageFiles(orderedFiles)
|
||
if (validationError) {
|
||
setMessage(validationError)
|
||
return
|
||
}
|
||
setMessage(null)
|
||
onChange({ ...draft, files: orderedFiles })
|
||
}
|
||
|
||
return (
|
||
<div className="border border-[#e5eaf2] bg-[#fbfcfe] p-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[13px] font-bold text-[#111827]">案例 {index + 1}</span>
|
||
<span className={`text-[11px] font-semibold ${draft.files.length > 0 ? 'text-[#0050cb]' : 'text-[#98a2b3]'}`}>
|
||
{draft.files.length > 1 ? `${draft.files.length} 张切片` : draft.files.length === 1 ? '1 张图片' : '待上传'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-2.5 grid gap-2">
|
||
<input
|
||
value={draft.title}
|
||
onChange={(event) => onChange({ ...draft, title: event.target.value })}
|
||
placeholder="案例名称"
|
||
aria-label={`案例 ${index + 1} 名称`}
|
||
disabled={disabled}
|
||
className="h-9 border border-[#d9e0ea] bg-white px-2.5 text-[13px] outline-none transition-colors focus:border-[#0050cb] disabled:bg-[#f3f5f8]"
|
||
/>
|
||
<textarea
|
||
value={draft.description}
|
||
onChange={(event) => onChange({ ...draft, description: event.target.value })}
|
||
placeholder="案例说明(可选)"
|
||
aria-label={`案例 ${index + 1} 说明`}
|
||
disabled={disabled}
|
||
rows={2}
|
||
className="resize-none border border-[#d9e0ea] bg-white px-2.5 py-2 text-[13px] outline-none transition-colors focus:border-[#0050cb] disabled:bg-[#f3f5f8]"
|
||
/>
|
||
|
||
<PerformanceSurveyImageDropzone
|
||
files={draft.files}
|
||
disabled={disabled}
|
||
ariaLabel={`为案例 ${index + 1} 添加图片`}
|
||
onFiles={addFiles}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-2.5">
|
||
<PerformanceSurveySelectedImageFiles
|
||
files={draft.files}
|
||
onRemove={(fileIndex) => {
|
||
setMessage(null)
|
||
onChange({ ...draft, files: draft.files.filter((_, indexToKeep) => indexToKeep !== fileIndex) })
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{message && <div className="mt-2 text-[12px] font-semibold text-[#d92d20]">{message}</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EmployeePicker({
|
||
title,
|
||
purpose,
|
||
selected,
|
||
excludedIds = [],
|
||
onChange,
|
||
onAddEmployee,
|
||
onRemoveEmployee,
|
||
}: {
|
||
title: string
|
||
purpose?: 'target' | 'evaluator'
|
||
selected: Employee[]
|
||
excludedIds?: string[]
|
||
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), ...excludedIds])
|
||
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-11 items-center gap-1 px-1 py-1 text-sm transition-colors hover:bg-[#f9f9f9]"
|
||
style={{ paddingLeft: `${8 + depth * 18}px` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => hasChildren && toggle(department.id)}
|
||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded border text-sm ${
|
||
hasChildren
|
||
? 'border-[#e2e2e2] text-[#5f5e5e] hover:bg-[#f4f3f3]'
|
||
: 'border-transparent text-transparent'
|
||
}`}
|
||
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
|
||
disabled={!hasChildren}
|
||
>
|
||
{hasChildren ? (isExpanded ? '-' : '+') : ''}
|
||
</button>
|
||
{department.selectable !== false && department.value ? (
|
||
<label className="flex min-h-9 min-w-0 flex-1 cursor-pointer items-center justify-between gap-3 rounded px-2">
|
||
<span className="truncate font-semibold text-[#1a1c1c]">{department.name}</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.includes(department.value)}
|
||
disabled={loadingValue === department.value}
|
||
onChange={(event) => {
|
||
void toggleValue(department.value!, event.target.checked)
|
||
}}
|
||
className="h-5 w-5 shrink-0"
|
||
aria-label={`选择${department.name}`}
|
||
/>
|
||
</label>
|
||
) : (
|
||
<div className="min-h-9 min-w-0 flex-1 px-2 py-2">
|
||
<div className="truncate font-semibold text-[#1a1c1c]">{department.name}</div>
|
||
</div>
|
||
)}
|
||
{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>
|
||
<SurveyUnavailablePanel title="问卷加载失败" message={detail.error} onRetry={detail.refresh} />
|
||
</MainPanel>
|
||
)
|
||
}
|
||
if (!detail.data) return null
|
||
if (targets.length === 0) {
|
||
return <MainPanel><SurveyUnavailablePanel message="当前账号没有可填写或可提交作品的任务" /></MainPanel>
|
||
}
|
||
|
||
const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId)
|
||
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
|
||
const submittedScores = activeTarget?.currentAssignment?.response?.scores
|
||
const progress = calculatePerformanceSurveyResponseProgress({
|
||
questions,
|
||
draftScores: scores,
|
||
submittedScores: submittedScores && typeof submittedScores === 'object' ? submittedScores : {},
|
||
submitted,
|
||
})
|
||
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-xl 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-xl 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]">{progress.ratedCount}/{progress.totalCount} · {progress.totalScore} 分</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: `${progress.percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{targets.length > 1 && (
|
||
<label className="mb-4 grid gap-2 rounded-xl 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' ? (
|
||
<SurveyUnavailablePanel message="该调研未开放或已关闭" />
|
||
) : (
|
||
<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-xl 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-xl 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-lg 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 || progress.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-xl 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&workspace=submitted"
|
||
className="shrink-0 rounded-full border border-[#dbe5f2] bg-[#f8fbff] px-3 py-1.5 text-[12px] font-semibold text-[#101828]"
|
||
>
|
||
返回个人看板
|
||
</Link>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
|
||
<div className="text-[11px] font-semibold text-[#667085]">总分</div>
|
||
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.totalScore)}</div>
|
||
</div>
|
||
<div className="rounded-2xl bg-[#f6f8fb] px-3 py-3">
|
||
<div className="text-[11px] font-semibold text-[#667085]">达成分</div>
|
||
<div className="mt-1 text-[22px] font-semibold text-[#101828]">{formatScore(response?.achievementScore)}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{questions.map((question, index) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
const value = scoreOf(question.id)
|
||
const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100))
|
||
const accentColor = getQuestionAccentColor(index)
|
||
return (
|
||
<section key={question.id} className="rounded-xl 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-xl 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-xl 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-xl 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-xl 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 && (
|
||
detail.data?.status === 'open' ? (
|
||
<div className="rounded-xl border border-dashed border-[#d9e0ea] bg-white px-4 py-10 text-center text-[15px] font-semibold text-[#667085]">
|
||
{canUpload ? '请先上传案例图片' : '等待被考核人提交案例'}
|
||
</div>
|
||
) : (
|
||
<SurveyUnavailablePanel message="该调研未开放或已关闭" />
|
||
)
|
||
)}
|
||
|
||
<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>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<Link
|
||
href="/"
|
||
className="rounded-[12px] border border-[#dbe5f2] bg-white px-3 py-2.5 text-[13px] font-semibold text-[#101828]"
|
||
>
|
||
素材库
|
||
</Link>
|
||
<Link
|
||
href="/performance-survey?tab=personal&workspace=pending"
|
||
className="rounded-[14px] bg-[#1677ff] px-4 py-3 text-[14px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)]"
|
||
>
|
||
个人看板
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function WorkItemUploadForm({
|
||
surveyId,
|
||
targetId,
|
||
disabled,
|
||
onUploaded,
|
||
}: {
|
||
surveyId: string
|
||
targetId: string
|
||
disabled: boolean
|
||
onUploaded: () => Promise<void>
|
||
}) {
|
||
const [title, setTitle] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [files, setFiles] = useState<File[]>([])
|
||
const [busy, setBusy] = useState(false)
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
|
||
const addFiles = (selectedFiles: File[]) => {
|
||
const merged = [...files, ...selectedFiles].filter((file, index, items) => {
|
||
const key = `${file.name}-${file.size}-${file.lastModified}`
|
||
return items.findIndex((candidate) => `${candidate.name}-${candidate.size}-${candidate.lastModified}` === key) === index
|
||
})
|
||
const orderedFiles = sortPerformanceSurveyImagesByName(merged, (file) => file.name)
|
||
const validationError = validatePerformanceSurveyImageFiles(orderedFiles)
|
||
if (validationError) {
|
||
setMessage(validationError)
|
||
return
|
||
}
|
||
setFiles(orderedFiles)
|
||
setMessage(null)
|
||
}
|
||
|
||
const submit = async () => {
|
||
if (disabled) return
|
||
const validationError = validatePerformanceSurveyImageFiles(files)
|
||
if (validationError) {
|
||
setMessage(validationError)
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setMessage(null)
|
||
try {
|
||
const images = await uploadPerformanceSurveyImageFiles(files, setMessage)
|
||
setMessage('图片上传完成,正在生成评分预览')
|
||
await apiRequest(`/surveys/${surveyId}/work-items`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
targetId,
|
||
title: title.trim() || '案例作品',
|
||
description: description.trim(),
|
||
images,
|
||
}),
|
||
})
|
||
setTitle('')
|
||
setDescription('')
|
||
setFiles([])
|
||
await onUploaded()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '上传失败')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="mb-3 rounded-xl 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]"
|
||
/>
|
||
<PerformanceSurveyImageDropzone
|
||
files={files}
|
||
disabled={disabled}
|
||
ariaLabel="添加案例图片"
|
||
onFiles={addFiles}
|
||
/>
|
||
<PerformanceSurveySelectedImageFiles
|
||
files={files}
|
||
onRemove={(fileIndex) => {
|
||
setFiles((current) => current.filter((_, index) => index !== fileIndex))
|
||
setMessage(null)
|
||
}}
|
||
/>
|
||
{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-xl 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.imageGroups || item.imageUrls || []} variant="long" />
|
||
|
||
{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 ProgressiveLongImageGallery({ images }: { images: unknown[] }) {
|
||
const groups = useMemo(() => normalizePerformanceSurveyImageGroups(images), [images])
|
||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => new Set())
|
||
const [activeGroupIndex, setActiveGroupIndex] = useState<number | null>(null)
|
||
|
||
if (groups.length === 0) return null
|
||
|
||
return (
|
||
<>
|
||
<div className="mt-4 space-y-4">
|
||
{groups.map((group, groupIndex) => {
|
||
const expanded = expandedKeys.has(group.key)
|
||
return (
|
||
<section key={group.key} className="overflow-hidden rounded-[14px] border border-[#e5edf6] bg-white">
|
||
{groups.length > 1 && (
|
||
<div className="flex items-center justify-between gap-3 border-b border-[#edf1f6] px-3 py-2.5">
|
||
<span className="min-w-0 truncate text-[12px] font-semibold text-[#667085]">{group.sourceName}</span>
|
||
<span className="shrink-0 text-[11px] text-[#98a2b3]">{group.slices.length} 段</span>
|
||
</div>
|
||
)}
|
||
|
||
{!expanded ? (
|
||
<div className="relative h-[300px] bg-[#f6f8fb] sm:h-[360px]">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveGroupIndex(groupIndex)}
|
||
aria-label={`全屏查看${group.sourceName}`}
|
||
className="block h-full w-full text-left"
|
||
>
|
||
<Image
|
||
src={group.previewUrl}
|
||
alt={`${group.sourceName}预览`}
|
||
width={group.previewWidth}
|
||
height={group.previewHeight}
|
||
unoptimized
|
||
{...(groupIndex === 0 ? { priority: true } : { loading: 'lazy' as const })}
|
||
className="h-full w-full object-cover object-top"
|
||
/>
|
||
</button>
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white via-white/90 to-transparent" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpandedKeys((current) => new Set(current).add(group.key))}
|
||
className="absolute bottom-4 left-1/2 h-10 -translate-x-1/2 rounded-full bg-[#0050cb] px-5 text-[13px] font-bold text-white shadow-[0_10px_24px_rgba(0,80,203,0.24)] transition-transform hover:scale-[1.02]"
|
||
>
|
||
加载全部
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="bg-[#f6f8fb]">
|
||
{group.slices.map((slice, sliceIndex) => (
|
||
<button
|
||
key={`${slice.url}-${sliceIndex}`}
|
||
type="button"
|
||
onClick={() => setActiveGroupIndex(groupIndex)}
|
||
aria-label={`全屏查看${group.sourceName}第 ${sliceIndex + 1} 段`}
|
||
className="block w-full border-0 bg-transparent p-0 text-left"
|
||
>
|
||
<Image
|
||
src={slice.url}
|
||
alt={`${group.sourceName}第 ${sliceIndex + 1} 段`}
|
||
width={slice.width}
|
||
height={slice.height}
|
||
unoptimized
|
||
loading="lazy"
|
||
className="block h-auto w-full"
|
||
/>
|
||
</button>
|
||
))}
|
||
<div className="sticky bottom-3 flex justify-center py-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpandedKeys((current) => {
|
||
const next = new Set(current)
|
||
next.delete(group.key)
|
||
return next
|
||
})}
|
||
className="h-9 rounded-full border border-[#d9e0ea] bg-white/95 px-5 text-[13px] font-bold text-[#344054] shadow-sm backdrop-blur"
|
||
>
|
||
收起
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
})}
|
||
</div>
|
||
<PerformanceSurveyLongImageViewer
|
||
group={activeGroupIndex == null ? null : groups[activeGroupIndex]}
|
||
onClose={() => setActiveGroupIndex(null)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveyLongImageViewer({
|
||
group,
|
||
onClose,
|
||
}: {
|
||
group: PerformanceSurveyImageGroupData | null
|
||
onClose: () => void
|
||
}) {
|
||
const [portalReady, setPortalReady] = useState(false)
|
||
const dialogRef = useRef<HTMLDivElement | null>(null)
|
||
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||
|
||
useEffect(() => setPortalReady(true), [])
|
||
|
||
useEffect(() => {
|
||
if (!group) 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)
|
||
window.removeEventListener('keydown', handleKeyDown)
|
||
document.body.style.overflow = previousOverflow
|
||
previousFocus?.focus()
|
||
}
|
||
}, [group, onClose])
|
||
|
||
if (!group || !portalReady) return null
|
||
|
||
return createPortal((
|
||
<div
|
||
ref={dialogRef}
|
||
className="fixed inset-0 z-[1200] flex flex-col bg-black/[0.92] pt-[env(safe-area-inset-top)] text-white"
|
||
onClick={onClose}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`查看${group.sourceName}`}
|
||
tabIndex={-1}
|
||
>
|
||
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-white/10 px-4" onClick={(event) => event.stopPropagation()}>
|
||
<div className="min-w-0 truncate text-sm font-semibold text-white/80">{group.sourceName}</div>
|
||
<button
|
||
ref={closeButtonRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex h-10 w-10 shrink-0 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="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-[calc(20px+env(safe-area-inset-bottom))] sm:px-6" onClick={(event) => event.stopPropagation()}>
|
||
<div className="mx-auto max-w-[1200px] bg-white">
|
||
{group.slices.map((slice, index) => (
|
||
<Image
|
||
key={`${slice.url}-${index}`}
|
||
src={slice.url}
|
||
alt={`${group.sourceName}第 ${index + 1} 段`}
|
||
width={slice.width}
|
||
height={slice.height}
|
||
unoptimized
|
||
loading="lazy"
|
||
className="block h-auto w-full"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
), document.body)
|
||
}
|
||
|
||
function ImagePreviewGrid({
|
||
images,
|
||
variant = 'default',
|
||
}: {
|
||
images: unknown[]
|
||
variant?: 'default' | 'gallery' | 'review' | 'long'
|
||
}) {
|
||
if (variant === 'long') return <ProgressiveLongImageGallery images={images} />
|
||
const stringImages = images.filter((item): item is string => typeof item === 'string')
|
||
return <StandardImagePreviewGrid images={stringImages} variant={variant} />
|
||
}
|
||
|
||
function StandardImagePreviewGrid({
|
||
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: '已关闭',
|
||
cancelled: '已撤销',
|
||
}
|
||
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))
|
||
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(legacySegment)
|
||
if (legacyWorkspaceTab) parsed.searchParams.set('workspace', legacyWorkspaceTab)
|
||
} else if (parsed.pathname === '/performance-survey' && parsed.searchParams.has('tab')) {
|
||
const requestedTab = parsed.searchParams.get('tab')
|
||
parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(requestedTab))
|
||
const legacyWorkspaceTab = getLegacyPerformanceSurveyWorkspaceTab(requestedTab)
|
||
if (legacyWorkspaceTab) parsed.searchParams.set('workspace', legacyWorkspaceTab)
|
||
}
|
||
|
||
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 visualTemplate = Boolean(template?.workItemImageRequired || template?.code === 'visual_brand_tone')
|
||
const questions = template?.questions || []
|
||
const totalResponses = targets.reduce((sum: number, target: any) => sum + Number(target.responses?.length || 0), 0)
|
||
const effectiveResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.effectiveCount || 0), 0)
|
||
const isCancelled = survey.status === 'cancelled'
|
||
|
||
return (
|
||
<main className="mx-auto min-h-screen max-w-[430px] bg-[#f6f8fc] px-5 py-6 pb-24 text-[#101828] sm:max-w-6xl sm:py-6 md:pb-6">
|
||
<section className="mb-5 rounded-xl border border-[#e3e9f2] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)]">
|
||
<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]"
|
||
>
|
||
返回
|
||
</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] sm:text-3xl">
|
||
{survey.title}
|
||
</h1>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085]">
|
||
<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={visualTemplate ? '评分记录' : '评价'} value={totalResponses} />
|
||
<ResultMetric label={visualTemplate ? '计分记录' : '有效'} value={effectiveResponses} />
|
||
</div>
|
||
</div>
|
||
{isCancelled && (
|
||
<div className="mt-4 rounded-[10px] border border-[#fecdca] bg-[#fff6f5] px-4 py-3 text-sm font-medium text-[#b42318]">
|
||
此调研已撤销,页面数据仅供留档,不再作为当前考核结果。
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<div className="grid gap-5">
|
||
{targets.map((target: any) => {
|
||
const aggregate = target.aggregate || {}
|
||
const workItems = Array.isArray(target.workItems) ? target.workItems : []
|
||
const responses = Array.isArray(target.responses) ? target.responses : []
|
||
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-xl border border-[#e3e9f2] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)]">
|
||
<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]">评价对象</div>
|
||
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828]">{target.targetName}</h2>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085]">
|
||
<span>{visualTemplate ? '权威评分' : '有效'} {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0}</span>
|
||
{aggregate.rawAverage != null && <span>{visualTemplate ? '权威原始分' : '原始均分'} {formatResultScore(aggregate.rawAverage)}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 sm:min-w-[220px]">
|
||
<ResultMetric label={visualTemplate ? '权威最终分' : aggregate.achievementLabel || '达成分'} value={aggregate.achievementScore} emphasis={visualTemplate} />
|
||
<ResultMetric label={visualTemplate ? '折算KPI分' : 'KPI分'} value={aggregate.kpiScore} />
|
||
</div>
|
||
</div>
|
||
|
||
{visualTemplate && (
|
||
<div className="mt-4 rounded-[10px] border border-[#c7d7fe] bg-[#f5f8ff] px-4 py-3 text-[13px] leading-5 text-[#344054]">
|
||
最终分只采用指定最终评分人的评分;自评和主管评分保留在下方明细中,仅供参考。
|
||
</div>
|
||
)}
|
||
|
||
{aggregate.band && aggregate.responseCount > 0 && (
|
||
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054]">
|
||
{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">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="truncate text-sm font-semibold text-[#101828]">{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]">
|
||
<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]">
|
||
{aggregate.trimNote}
|
||
{aggregate.removedHigh && aggregate.removedLow && (
|
||
<span>;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{visualTemplate && workItems.length > 0 && (
|
||
<section className="mt-5 space-y-4">
|
||
<h3 className="text-sm font-semibold text-[#101828]">视觉评分作品</h3>
|
||
{workItems.map((item: any, itemIndex: number) => (
|
||
<div key={item.id} className="rounded-[14px] border border-[#e5edf6] 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-[#0052d9]">作品 {itemIndex + 1}</div>
|
||
<div className="mt-1 break-words text-[16px] font-semibold text-[#101828]">{item.title}</div>
|
||
{item.description && <p className="mt-1 whitespace-pre-wrap text-sm text-[#667085]">{item.description}</p>}
|
||
</div>
|
||
<span className="shrink-0 rounded bg-[#f3f6fb] px-3 py-1.5 text-sm font-semibold text-[#0052d9]">
|
||
{formatNullableScore(item.averageScore)} 分{visualTemplate ? '(最终评分)' : ''}
|
||
</span>
|
||
</div>
|
||
<ImagePreviewGrid images={item.imageGroups || item.imageUrls || []} variant="long" />
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
<section className="mt-5">
|
||
<h3 className="text-sm font-semibold text-[#101828]">{visualTemplate ? `评分人明细(${responses.length}人)` : '评价明细'}</h3>
|
||
<div className="mt-3">
|
||
{visualTemplate ? (
|
||
<VisualEvaluatorResultList
|
||
responses={responses}
|
||
workItems={workItems}
|
||
questions={questions}
|
||
template={template}
|
||
/>
|
||
) : (
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{responses.map((response: any) => (
|
||
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-sm font-semibold text-[#101828]">{response.respondentName}</span>
|
||
<span className="text-sm font-semibold text-[#667085]">{formatResultScore(response.totalScore)} 分</span>
|
||
</div>
|
||
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{response.comment}</p>}
|
||
</div>
|
||
))}
|
||
{responses.length === 0 && (
|
||
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">暂无评价</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</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: '已关闭',
|
||
cancelled: '已撤销',
|
||
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">
|
||
{map[status] || status}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function DashboardStatusBadge({ status }: { status: string }) {
|
||
const tones: Record<string, string> = {
|
||
draft: 'border-[#f3d29b] bg-[#fff8e8] text-[#9a5b00]',
|
||
open: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||
closed: 'border-[#d8dced] bg-[#f5f7fb] text-[#5f5e5e]',
|
||
cancelled: 'border-[#f0b8b8] bg-[#fff5f5] text-[#b42318]',
|
||
pending: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||
submitted: 'border-[#b9ead0] bg-[#ecfdf3] text-[#039855]',
|
||
}
|
||
return (
|
||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[12px] font-bold ${tones[status] || 'border-[#d8dced] bg-[#f5f7fb] text-[#5f5e5e]'}`}>
|
||
{getSurveyStatusLabel(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]'
|
||
}`}>
|
||
<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]'}`}>{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 DashboardPanel({ children }: { children: ReactNode }) {
|
||
return <div className="rounded border border-[#e2e2e2] bg-white p-4 text-[#1a1c1c]">{children}</div>
|
||
}
|
||
|
||
function MainPanel({ children }: { children: ReactNode }) {
|
||
return <main className="mx-auto max-w-4xl px-4 py-6"><Panel>{children}</Panel></main>
|
||
}
|
||
|
||
function SurveyUnavailablePanel({
|
||
message,
|
||
title = '当前无法填写调研',
|
||
onRetry,
|
||
}: {
|
||
message: string
|
||
title?: string
|
||
onRetry?: () => Promise<void>
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-[#dce6f2] bg-white px-5 py-9 text-center shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<h1 className="text-[18px] font-bold text-[#101828]">{title}</h1>
|
||
<p className="mt-2 text-[14px] font-medium leading-6 text-[#667085]">{message}</p>
|
||
<div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
|
||
{onRetry && (
|
||
<button
|
||
type="button"
|
||
onClick={() => { void onRetry() }}
|
||
className="inline-flex min-h-11 items-center justify-center rounded-md bg-[#1677ff] px-5 text-sm font-semibold text-white"
|
||
>
|
||
重新加载
|
||
</button>
|
||
)}
|
||
<Link
|
||
href="/performance-survey?tab=personal&workspace=pending"
|
||
className={`inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-semibold ${onRetry ? 'border border-[#dbe5f2] bg-white text-[#101828]' : 'bg-[#1677ff] text-white'}`}
|
||
>
|
||
返回个人看板
|
||
</Link>
|
||
<Link
|
||
href="/"
|
||
className="inline-flex min-h-11 items-center justify-center rounded-md border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828]"
|
||
>
|
||
返回素材库
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 DashboardErrorPanel({ message }: { message: string }) {
|
||
return <div className="rounded border border-[#f0b8b8] bg-[#fff5f5] px-4 py-3 text-sm font-medium text-[#b42318]">{message}</div>
|
||
}
|
||
|
||
function EmptyState({ title, description }: { title: string; description?: string }) {
|
||
return (
|
||
<div className="rounded border border-dashed border-[#c2c6d8] bg-[#fafafa] px-5 py-7 text-center">
|
||
<h2 className="text-[15px] font-bold text-[#1a1c1c]">{title}</h2>
|
||
{description && <p className="mt-1.5 text-[12px] font-medium text-[#727687]">{description}</p>}
|
||
</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-xl 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-xl 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 new Intl.DateTimeFormat('zh-CN', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
}).format(date)
|
||
}
|