3285 lines
136 KiB
TypeScript
3285 lines
136 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 Header from '@/components/Header'
|
||
|
||
type ApiState<T> = {
|
||
loading: boolean
|
||
data: T | null
|
||
error: string | null
|
||
}
|
||
|
||
type Employee = {
|
||
id: string
|
||
displayName: string
|
||
flowerName?: string | null
|
||
realName?: string | null
|
||
dingEmail?: string | null
|
||
jobNumber?: string | null
|
||
departmentName?: string | null
|
||
positionTitle?: string | null
|
||
positionLevel?: string | null
|
||
levelType?: string | null
|
||
isLeadership?: boolean | null
|
||
managerName?: string | null
|
||
dingtalkUserId?: string | null
|
||
}
|
||
|
||
type DepartmentOption = {
|
||
id: string
|
||
name: string
|
||
parentId?: string | null
|
||
parentName?: string | null
|
||
employeeCount?: number
|
||
selectable?: boolean
|
||
value?: string | null
|
||
departmentName?: string | null
|
||
departmentPath?: string | null
|
||
fullPath?: string | null
|
||
}
|
||
|
||
type Template = {
|
||
code: string
|
||
name: string
|
||
shortName?: string
|
||
description: string
|
||
surveyType: string
|
||
ownerRoleName?: string
|
||
itemName?: string
|
||
itemNamePlaceholder?: string
|
||
itemNoteName?: string
|
||
itemNotePlaceholder?: string
|
||
defaultCategory?: string
|
||
respondentNameLabel?: string
|
||
respondentDepartmentLabel?: string
|
||
respondentNameFallback?: string
|
||
dataSource?: string
|
||
weight: number
|
||
weightLabel?: string
|
||
maxTotal?: number
|
||
questionMaxScore?: number
|
||
requiredWorkItemCount?: number
|
||
workItemName?: string
|
||
workItemImageRequired?: boolean
|
||
achievementLabel?: string
|
||
achievementSuffix?: string
|
||
trimNote?: string
|
||
trimmedNote?: string
|
||
belowBand?: string
|
||
questions: Array<{ id: string; title: string; description?: string; maxScore?: number }>
|
||
}
|
||
|
||
const API_BASE = '/api/performance-survey'
|
||
const DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY = 'performanceSurveyDingtalkToken'
|
||
const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session'
|
||
|
||
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()
|
||
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 depsKey = JSON.stringify(deps)
|
||
|
||
const load = useCallback(async () => {
|
||
if (!path) return
|
||
setState((current) => ({ ...current, loading: true, error: null }))
|
||
try {
|
||
const data = await apiRequest<T>(path)
|
||
setState({ loading: false, data, error: null })
|
||
} catch (error) {
|
||
setState({ loading: false, data: null, error: error instanceof Error ? error.message : '加载失败' })
|
||
}
|
||
}, [path])
|
||
|
||
useEffect(() => {
|
||
void load()
|
||
}, [load, depsKey])
|
||
|
||
return { ...state, refresh: load }
|
||
}
|
||
|
||
export default function PerformanceSurveyPage() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const params = useParams()
|
||
const rawPath = (params as any).path
|
||
const path = useMemo(() => (Array.isArray(rawPath) ? (rawPath as string[]) : []), [rawPath])
|
||
const [dingtalkSessionChecked, setDingtalkSessionChecked] = useState(false)
|
||
const [hasDingtalkPerformanceSession, setHasDingtalkPerformanceSession] = useState(false)
|
||
const [dingtalkUserProfile, setDingtalkUserProfile] = useState<any>(null)
|
||
const canLoadCurrentUser = Boolean(session || hasDingtalkPerformanceSession)
|
||
const currentUser = useApi<any>(canLoadCurrentUser ? '/me' : null, [canLoadCurrentUser])
|
||
|
||
useEffect(() => {
|
||
const hasToken = Boolean(getStoredDingtalkPerformanceToken())
|
||
setHasDingtalkPerformanceSession(hasToken)
|
||
setDingtalkUserProfile(hasToken ? decodeStoredDingtalkPerformanceSession() : null)
|
||
setDingtalkSessionChecked(true)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated' && dingtalkSessionChecked && !hasDingtalkPerformanceSession) {
|
||
const currentPath =
|
||
typeof window === 'undefined'
|
||
? '/performance-survey'
|
||
: `${window.location.pathname}${window.location.search}`
|
||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
|
||
}
|
||
}, [status, dingtalkSessionChecked, hasDingtalkPerformanceSession, router])
|
||
|
||
useEffect(() => {
|
||
if (path[0] === 'teacher' || path[0] === 'admin') {
|
||
router.replace('/performance-survey')
|
||
}
|
||
}, [path, router])
|
||
|
||
if (status === 'loading' || (status === 'unauthenticated' && !dingtalkSessionChecked)) {
|
||
return (
|
||
<div className="min-h-screen bg-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])
|
||
let content = <Dashboard />
|
||
if (isSurveySubmitPage && path[1]) {
|
||
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
|
||
}
|
||
if (path[0] === 'results' && path[1]) {
|
||
content = <SurveyResults surveyId={path[1]} />
|
||
}
|
||
|
||
if (isSurveySubmitPage) {
|
||
return (
|
||
<div className="min-h-screen overflow-x-hidden bg-white dark:bg-gray-900">
|
||
{content}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen overflow-x-hidden bg-white dark:bg-gray-900 sm:bg-gray-50">
|
||
<div className="hidden sm:block">
|
||
<Header session={effectiveSession} />
|
||
</div>
|
||
<PerformanceSurveyMobileHeader session={effectiveSession} />
|
||
{content}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PerformanceSurveyMobileHeader({ session }: { session: any }) {
|
||
const user = session?.user || {}
|
||
const name = String(user.name || '当前用户')
|
||
const avatar = typeof user.avatar === 'string' && user.avatar
|
||
? user.avatar
|
||
: typeof user.image === 'string'
|
||
? user.image
|
||
: ''
|
||
|
||
return (
|
||
<header className="sticky top-0 z-30 bg-[#30313a] text-white sm:hidden">
|
||
<div className="mx-auto flex h-[72px] max-w-[430px] items-center justify-between gap-4 px-5">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-[#eef2f7] text-[14px] font-semibold text-[#1677ff] shadow-[0_8px_18px_rgba(0,0,0,0.18)]">
|
||
{avatar ? (
|
||
<Image
|
||
src={avatar}
|
||
alt={name}
|
||
width={44}
|
||
height={44}
|
||
unoptimized
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
getSurveyInitials(name)
|
||
)}
|
||
</div>
|
||
<div className="truncate text-[20px] font-semibold tracking-tight">考核调研</div>
|
||
</div>
|
||
<div className="shrink-0 text-[14px] font-semibold text-[#dce4ff]">由壹点「素材库」</div>
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
function Dashboard() {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const requestedTab = searchParams.get('tab') || 'pending'
|
||
const [tab, setTab] = useState(() => (
|
||
['pending', 'submitted', 'created', 'results', 'manage'].includes(requestedTab) ? requestedTab : 'pending'
|
||
))
|
||
const surveys = useApi<any>('/surveys', [])
|
||
const me = useApi<any>('/me', [])
|
||
const canManage = Boolean(me.data?.permissions?.isDepartmentAdmin)
|
||
const pendingItems = surveys.data?.pending || []
|
||
const submittedItems = surveys.data?.submitted || []
|
||
const createdItems = surveys.data?.created || []
|
||
const resultItems = surveys.data?.results || []
|
||
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
|
||
const departmentLabel = me.data?.employee?.departmentName || me.data?.user?.department || '未同步部门'
|
||
const positionLabel = [me.data?.employee?.positionTitle, me.data?.employee?.positionLevel].filter(Boolean).join(' · ')
|
||
const availableTabIds = useMemo(
|
||
() => ['pending', 'submitted', 'created', 'results', 'manage'],
|
||
[],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (availableTabIds.includes(requestedTab) && tab !== requestedTab) {
|
||
setTab(requestedTab)
|
||
return
|
||
}
|
||
if (!availableTabIds.includes(tab)) {
|
||
setTab('pending')
|
||
}
|
||
}, [availableTabIds, requestedTab, tab])
|
||
|
||
const selectTab = (nextTab: string) => {
|
||
setTab(nextTab)
|
||
router.replace(`/performance-survey?tab=${encodeURIComponent(nextTab)}`, { scroll: false })
|
||
}
|
||
|
||
return (
|
||
<main className="mx-auto max-w-[430px] bg-white px-5 py-6 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-7xl sm:bg-transparent sm:py-6 md:pb-6">
|
||
<section className="mb-6 rounded-[14px] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-[20px] sm:p-5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<h1 className="text-[25px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-2xl">我的调研</h1>
|
||
<button
|
||
type="button"
|
||
onClick={() => selectTab('manage')}
|
||
className={`shrink-0 rounded-full px-3 py-1.5 text-[13px] font-semibold transition-colors ${
|
||
tab === 'manage'
|
||
? 'bg-[#0052d9] text-white'
|
||
: 'bg-[#f3f6fb] text-[#101828] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100'
|
||
}`}
|
||
>
|
||
{canManage ? '管理' : '发起'}
|
||
</button>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-[14px] font-semibold leading-5 text-[#111827] dark:text-gray-200">
|
||
<span className="max-w-full truncate">{displayName}</span>
|
||
<span className="text-[#98a2b3]">·</span>
|
||
<span className="max-w-full truncate">{departmentLabel}</span>
|
||
{positionLabel && (
|
||
<>
|
||
<span className="text-[#98a2b3]">·</span>
|
||
<span className="max-w-full break-words">{positionLabel}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{surveys.loading && <Panel>加载中...</Panel>}
|
||
{surveys.error && <ErrorPanel message={surveys.error} />}
|
||
{surveys.data && (
|
||
<PerformanceDashboardBoard
|
||
canManage={canManage}
|
||
onSelectTab={selectTab}
|
||
/>
|
||
)}
|
||
{surveys.data && tab === 'pending' && <PendingList items={pendingItems} />}
|
||
{surveys.data && tab === 'submitted' && <SubmittedList items={submittedItems} />}
|
||
{surveys.data && tab === 'created' && (
|
||
<CreatedList items={createdItems} onRefresh={surveys.refresh} resultBackHref="/performance-survey?tab=created" />
|
||
)}
|
||
{surveys.data && tab === 'results' && <MyResults items={resultItems} />}
|
||
{surveys.data && tab === 'manage' && (
|
||
<ManagePanel
|
||
canManage={canManage}
|
||
onChanged={async () => {
|
||
await surveys.refresh()
|
||
}}
|
||
templateStats={surveys.data?.templateStats || []}
|
||
/>
|
||
)}
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function DashboardStats({
|
||
data,
|
||
activeTab,
|
||
onSelectTab,
|
||
}: {
|
||
data: any
|
||
activeTab: string
|
||
onSelectTab: (tab: string) => void
|
||
}) {
|
||
const summary = data.summary || {}
|
||
const personal = summary.personal || {
|
||
pendingCount: summary.pendingCount || 0,
|
||
submittedCount: summary.submittedCount || 0,
|
||
createdCount: summary.createdCount || 0,
|
||
resultCount: summary.resultCount || 0,
|
||
}
|
||
const scope = summary.scope || null
|
||
const scopeLabel = summary.scopeMode === 'system' ? '全局' : '架构'
|
||
const items = [
|
||
{ id: 'pending', label: '待处理', value: personal.pendingCount || 0, scopeValue: scope?.pendingCount },
|
||
{ id: 'submitted', label: '已处理', value: personal.submittedCount || 0, scopeValue: scope?.submittedCount },
|
||
{ id: 'created', label: '我发起的', value: personal.createdCount || 0, scopeValue: scope?.createdCount },
|
||
{ id: 'results', label: '我的结果', value: personal.resultCount || 0, scopeValue: scope?.resultCount },
|
||
]
|
||
|
||
return (
|
||
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
|
||
{items.map((item) => {
|
||
const active = activeTab === item.id
|
||
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onSelectTab(item.id)}
|
||
className={`rounded-[14px] border px-4 py-4 text-left transition-colors sm:rounded-lg sm:p-5 ${
|
||
active
|
||
? 'border-[#0052d9] bg-[#0052d9] text-white shadow-[0_12px_26px_rgba(0,82,217,0.22)]'
|
||
: 'border-[#e5eaf2] bg-white text-[#101828] shadow-[0_8px_18px_rgba(45,43,80,0.05)] hover:border-[#b8d0ff] dark:border-gray-700 dark:bg-gray-900 dark:text-white'
|
||
}`}
|
||
>
|
||
<div className={`text-[13px] font-semibold ${active ? 'text-white/80' : 'text-[#667085] dark:text-gray-400'}`}>
|
||
{item.label}
|
||
</div>
|
||
<div className="mt-2 text-[28px] font-semibold leading-none">{item.value}</div>
|
||
{typeof item.scopeValue === 'number' && (
|
||
<div className={`mt-2 text-[11px] font-semibold ${active ? 'text-white/70' : 'text-[#98a2b3] dark:text-gray-500'}`}>
|
||
{scopeLabel} {item.scopeValue}
|
||
</div>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PerformanceDashboardBoard({
|
||
canManage,
|
||
onSelectTab,
|
||
}: {
|
||
canManage: boolean
|
||
onSelectTab: (tab: string) => void
|
||
}) {
|
||
const [month, setMonth] = useState(getCurrentMonthValue())
|
||
const [department, setDepartment] = useState('')
|
||
const [templateCode, setTemplateCode] = useState('')
|
||
const [employee, setEmployee] = useState<Employee | null>(null)
|
||
const [expandedPersonId, setExpandedPersonId] = useState<string | null>(null)
|
||
const departments = useApi<DepartmentOption[]>(canManage ? '/departments?purpose=evaluator' : null, [canManage])
|
||
const templates = useApi<Template[]>('/templates', [])
|
||
const dashboardPath = useMemo(() => {
|
||
const params = new URLSearchParams()
|
||
if (month) params.set('month', month)
|
||
if (department) params.set('department', department)
|
||
if (templateCode) params.set('templateCode', templateCode)
|
||
if (employee?.id) params.set('employeeId', employee.id)
|
||
const query = params.toString()
|
||
return `/dashboard${query ? `?${query}` : ''}`
|
||
}, [month, department, templateCode, employee?.id])
|
||
const dashboard = useApi<any>(dashboardPath, [dashboardPath])
|
||
const personal = dashboard.data?.personal
|
||
const people = dashboard.data?.people || []
|
||
const templateSections = dashboard.data?.templates || []
|
||
const scopeMode = dashboard.data?.permissions?.scopeMode || 'self'
|
||
const scopeLabel = scopeMode === 'system' ? '全局' : scopeMode === 'organization' ? '架构' : '个人'
|
||
|
||
return (
|
||
<section className="mb-5 space-y-4">
|
||
<div 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-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-[18px] font-semibold text-[#101828] dark:text-white">当月看板</h2>
|
||
<div className="mt-1 text-xs font-medium text-[#667085] dark:text-gray-400">{scopeLabel} · {month}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectTab(canManage ? 'manage' : 'created')}
|
||
className="rounded-full bg-[#f3f6fb] px-3 py-1.5 text-[13px] font-semibold text-[#101828] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100"
|
||
>
|
||
发起调研
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-2 sm:grid-cols-[140px_minmax(180px,1fr)_minmax(160px,0.7fr)]">
|
||
<input
|
||
type="month"
|
||
value={month}
|
||
onChange={(event) => setMonth(event.target.value)}
|
||
className="rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
{canManage ? (
|
||
<DepartmentFilterTree
|
||
departments={departments.data || []}
|
||
value={department}
|
||
onChange={(value) => setDepartment(value)}
|
||
/>
|
||
) : (
|
||
<div className="rounded-lg border border-[#eef1f6] px-3 py-2 text-sm text-[#667085] dark:border-gray-700">仅本人</div>
|
||
)}
|
||
<select
|
||
value={templateCode}
|
||
onChange={(event) => setTemplateCode(event.target.value)}
|
||
className="rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||
>
|
||
<option value="">全部模板</option>
|
||
{(templates.data || []).map((template) => (
|
||
<option key={template.code} value={template.code}>{template.shortName || template.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{canManage && (
|
||
<div className="mt-3">
|
||
<SingleEmployeeFilter value={employee} onChange={setEmployee} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{dashboard.loading && <Panel>看板加载中...</Panel>}
|
||
{dashboard.error && <ErrorPanel message={dashboard.error} />}
|
||
{dashboard.data && (
|
||
<>
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<MetricCard label="调研数量" value={personal?.surveyCount || 0} />
|
||
<MetricCard label="个人均分" value={formatNullableScore(personal?.totalAverage)} />
|
||
<MetricCard label="模板数量" value={(personal?.templates || []).filter((item: any) => item.count > 0).length} />
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
{templateSections.map((item: any) => (
|
||
<TemplateSectionCard key={item.code} item={item} />
|
||
))}
|
||
</div>
|
||
|
||
<div 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="mb-3 flex items-center justify-between">
|
||
<h2 className="text-[18px] font-semibold text-[#101828] dark:text-white">人员总看板</h2>
|
||
<span className="text-xs font-semibold text-[#667085] dark:text-gray-400">{people.length} 人</span>
|
||
</div>
|
||
<div className="divide-y divide-[#eef1f6] dark:divide-gray-800">
|
||
{people.length === 0 && <div className="py-8 text-center text-sm font-medium text-[#667085]">暂无数据</div>}
|
||
{people.map((row: any) => {
|
||
const expanded = expandedPersonId === row.employee.id
|
||
return (
|
||
<div key={row.employee.id} className="py-3">
|
||
<button
|
||
type="button"
|
||
disabled={!row.canExpand}
|
||
onClick={() => setExpandedPersonId(expanded ? null : row.employee.id)}
|
||
className="grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-left disabled:cursor-default"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[15px] font-semibold text-[#101828] dark:text-white">{row.employee.displayName}</div>
|
||
<div className="mt-1 truncate text-xs font-medium text-[#667085] dark:text-gray-400">
|
||
{[row.employee.departmentName, row.employee.positionTitle, row.employee.positionLevel || row.employee.levelType].filter(Boolean).join(' · ')}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-[18px] font-semibold text-[#101828] dark:text-white">{formatNullableScore(row.totalAverage)}</div>
|
||
<div className="text-xs text-[#667085]">{row.surveyCount} 条</div>
|
||
</div>
|
||
</button>
|
||
{expanded && (
|
||
<div className="mt-3 space-y-3 rounded-[12px] bg-[#f8fbff] p-3 dark:bg-gray-950">
|
||
{row.templates.map((template: any) => (
|
||
<div key={template.code}>
|
||
<div className="flex items-center justify-between text-sm font-semibold text-[#101828] dark:text-white">
|
||
<span>{template.name}</span>
|
||
<span>{formatNullableScore(template.averageScore)}</span>
|
||
</div>
|
||
<div className="mt-2 grid gap-2">
|
||
{template.surveys.map((survey: any) => (
|
||
<Link
|
||
key={`${survey.surveyId}-${survey.targetId}`}
|
||
href={`/performance-survey/results/${survey.surveyId}?targetId=${encodeURIComponent(survey.targetId)}&back=${encodeURIComponent(`/performance-survey?tab=created&month=${month}`)}`}
|
||
className="rounded-[10px] border border-[#dbe5f2] bg-white px-3 py-2 text-sm hover:bg-[#f6f9ff] dark:border-gray-700 dark:bg-gray-900"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<span className="min-w-0 break-words font-semibold text-[#101828] dark:text-white">{survey.title}</span>
|
||
<span className="shrink-0 text-[#1677ff]">{formatNullableScore(survey.score)}</span>
|
||
</div>
|
||
<div className="mt-1 text-xs text-[#667085]">{survey.templateName} · {survey.responseCount}/{survey.assignmentCount}</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
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 formatNullableScore(value: unknown) {
|
||
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: 'target' })
|
||
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="w-full rounded-lg border border-[#dbe5f2] bg-white px-3 py-2 text-sm font-medium text-[#101828] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
{value && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(null)
|
||
setKeyword('')
|
||
}}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full px-2 py-1 text-xs font-semibold text-[#667085] hover:bg-[#f3f6fb]"
|
||
>
|
||
清除
|
||
</button>
|
||
)}
|
||
{open && (
|
||
<div className="absolute z-20 mt-2 max-h-56 w-full overflow-y-auto rounded-[10px] border border-[#dbe5f2] bg-white shadow-[0_14px_34px_rgba(15,23,42,0.12)] dark:border-gray-700 dark:bg-gray-900">
|
||
{loading ? (
|
||
<div className="px-3 py-2 text-sm text-[#667085]">搜索中...</div>
|
||
) : items.length === 0 ? (
|
||
<div className="px-3 py-2 text-sm text-[#667085]">无匹配人员</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-[#f6f9ff] dark:hover:bg-gray-800"
|
||
>
|
||
<div className="font-semibold text-[#101828] dark:text-white">{employee.displayName}</div>
|
||
<div className="mt-0.5 truncate text-xs text-[#667085]">{employee.departmentName || ''}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PendingList({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无待处理</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<article key={item.assignmentId} className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
|
||
<p className="break-words">{item.targetName}</p>
|
||
<p>{item.dueAt ? formatDate(item.dueAt) : '无截止'}</p>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-3 py-1 text-[12px] font-semibold text-[#0052d9] dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-200">待填写</span>
|
||
</div>
|
||
<div className="mt-6 flex justify-end text-sm">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-7 font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)] hover:bg-[#003da6] dark:bg-white dark:text-gray-900"
|
||
>
|
||
去评价 ->
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SubmittedList({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无已处理</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<article key={item.assignmentId} className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white sm:text-base">{item.title}</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
|
||
<p className="break-words">{item.targetName}</p>
|
||
<p>{item.submittedAt ? formatDate(item.submittedAt) : '已提交'}</p>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-[#b9ead0] bg-[#ecfdf3] px-3 py-1 text-[12px] font-semibold text-[#039855] dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200">已提交</span>
|
||
</div>
|
||
<div className="mt-6 flex justify-end text-sm">
|
||
<Link
|
||
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] border border-[#dbe5f2] bg-white px-7 font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] hover:bg-[#f8fbff] dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||
>
|
||
查看详情
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CreatedList({
|
||
items,
|
||
onRefresh,
|
||
emptyTitle = '还没有发起过考核调研',
|
||
resultBackHref = '/performance-survey?tab=created',
|
||
}: {
|
||
items: any[]
|
||
onRefresh: () => Promise<void>
|
||
emptyTitle?: string
|
||
resultBackHref?: string
|
||
}) {
|
||
const [busyId, setBusyId] = useState<string | null>(null)
|
||
|
||
const changeStatus = async (id: string, status: string) => {
|
||
setBusyId(id)
|
||
try {
|
||
await apiRequest(`/surveys/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ status }),
|
||
})
|
||
await onRefresh()
|
||
} finally {
|
||
setBusyId(null)
|
||
}
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
return <EmptyState title={emptyTitle} />
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-5 sm:hidden">
|
||
{items.map((item) => {
|
||
const assignmentCount = Number(item.counts?.assignments || 0)
|
||
const responseCount = Number(item.counts?.responses || 0)
|
||
const done = assignmentCount > 0 && responseCount >= assignmentCount
|
||
const progressText = assignmentCount > 0 ? `${responseCount}/${assignmentCount}` : '0/0'
|
||
|
||
return (
|
||
<article
|
||
key={item.id}
|
||
className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)]"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828]">
|
||
{item.title}
|
||
</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085]">
|
||
<p className="break-words">{item.departmentName || '未指定部门'}</p>
|
||
<p>{item.dueAt ? formatDate(item.dueAt) : '无截止'} · {progressText}</p>
|
||
</div>
|
||
</div>
|
||
<span className={`shrink-0 rounded-full border px-3 py-1 text-[12px] font-semibold ${
|
||
done
|
||
? 'border-[#b9ead0] bg-[#ecfdf3] text-[#039855]'
|
||
: item.status === 'open'
|
||
? 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]'
|
||
: 'border-[#d8dced] bg-[#f5f7fb] text-[#667085]'
|
||
}`}>
|
||
{done ? '已全部提交' : getSurveyStatusLabel(item.status)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||
{item.status === 'draft' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'open')}
|
||
className="min-h-[40px] rounded-[10px] border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] disabled:opacity-50"
|
||
>
|
||
发布
|
||
</button>
|
||
)}
|
||
{item.status !== 'closed' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'closed')}
|
||
className="min-h-[40px] rounded-[10px] border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828] shadow-[0_10px_22px_rgba(29,33,41,0.06)] disabled:opacity-50"
|
||
>
|
||
关闭
|
||
</button>
|
||
)}
|
||
<Link
|
||
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-6 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)]"
|
||
>
|
||
结果 ->
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="hidden overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800 sm:block">
|
||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
<thead className="bg-gray-50 dark:bg-gray-900/40">
|
||
<tr>
|
||
{['调研', '状态', '对象/评价/提交', '截止', '操作'].map((item) => (
|
||
<th key={item} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{item}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||
{items.map((item) => (
|
||
<tr key={item.id}>
|
||
<td className="px-4 py-3">
|
||
<div className="font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400">{item.departmentName || '未指定部门'}</div>
|
||
</td>
|
||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||
{item.counts?.targets || 0} / {item.counts?.assignments || 0} / {item.counts?.responses || 0}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{item.dueAt ? formatDate(item.dueAt) : '-'}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{item.status === 'draft' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'open')}
|
||
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
发布
|
||
</button>
|
||
)}
|
||
{item.status !== 'closed' && (
|
||
<button
|
||
disabled={busyId === item.id}
|
||
onClick={() => changeStatus(item.id, 'closed')}
|
||
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
关闭
|
||
</button>
|
||
)}
|
||
<Link
|
||
href={`/performance-survey/results/${item.id}?back=${encodeURIComponent(resultBackHref)}`}
|
||
className="rounded-md bg-gray-900 px-2.5 py-1 text-sm text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900"
|
||
>
|
||
结果
|
||
</Link>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function getCurrentMonthValue() {
|
||
const date = new Date()
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||
}
|
||
|
||
function getDepartmentFilterValue(department: DepartmentOption) {
|
||
return department.value || department.departmentPath || department.departmentName || department.name
|
||
}
|
||
|
||
function getDepartmentFilterLabel(department: DepartmentOption) {
|
||
return department.fullPath || department.departmentPath || department.departmentName || department.name
|
||
}
|
||
|
||
function ManagedSurveyList({
|
||
canManage,
|
||
departments,
|
||
refreshKey,
|
||
}: {
|
||
canManage: boolean
|
||
departments: DepartmentOption[]
|
||
refreshKey: number
|
||
}) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const [month, setMonth] = useState(searchParams.get('month') || getCurrentMonthValue())
|
||
const [department, setDepartment] = useState(searchParams.get('department') || '')
|
||
const managedPath = useMemo(() => {
|
||
const params = new URLSearchParams()
|
||
if (month) params.set('month', month)
|
||
if (department) params.set('department', department)
|
||
const query = params.toString()
|
||
return `/surveys${query ? `?${query}` : ''}`
|
||
}, [month, department])
|
||
const state = useApi<any>(canManage ? managedPath : null, [canManage, managedPath, refreshKey])
|
||
const resultBackHref = useMemo(() => {
|
||
const params = new URLSearchParams()
|
||
params.set('tab', 'manage')
|
||
if (month) params.set('month', month)
|
||
if (department) params.set('department', department)
|
||
return `/performance-survey?${params.toString()}`
|
||
}, [month, department])
|
||
|
||
const updateFilterUrl = (nextMonth: string, nextDepartment: string) => {
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
params.set('tab', 'manage')
|
||
if (nextMonth) params.set('month', nextMonth)
|
||
else params.delete('month')
|
||
if (nextDepartment) params.set('department', nextDepartment)
|
||
else params.delete('department')
|
||
router.replace(`/performance-survey?${params.toString()}`, { scroll: false })
|
||
}
|
||
|
||
const surveys = state.data?.managed || []
|
||
|
||
return (
|
||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">架构调研</h2>
|
||
<div className="grid gap-2 sm:grid-cols-[minmax(220px,320px)_150px_auto]">
|
||
<DepartmentFilterTree
|
||
departments={departments}
|
||
value={department}
|
||
onChange={(nextDepartment) => {
|
||
setDepartment(nextDepartment)
|
||
updateFilterUrl(month, nextDepartment)
|
||
}}
|
||
/>
|
||
<input
|
||
type="month"
|
||
value={month}
|
||
onChange={(event) => {
|
||
const nextMonth = event.target.value
|
||
setMonth(nextMonth)
|
||
updateFilterUrl(nextMonth, department)
|
||
}}
|
||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const nextMonth = getCurrentMonthValue()
|
||
setMonth(nextMonth)
|
||
updateFilterUrl(nextMonth, department)
|
||
}}
|
||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
>
|
||
当月
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4">
|
||
{state.loading && <Panel>加载中...</Panel>}
|
||
{state.error && <ErrorPanel message={state.error} />}
|
||
{state.data && <CreatedList items={surveys} onRefresh={state.refresh} emptyTitle="暂无架构调研" resultBackHref={resultBackHref} />}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function DepartmentFilterTree({
|
||
departments,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
departments: DepartmentOption[]
|
||
value: string
|
||
onChange: (value: string) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [keyword, setKeyword] = useState('')
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||
const selectedLabel = useMemo(() => {
|
||
if (!value) return '全部组织'
|
||
const selected = departments.find((department) => String(getDepartmentFilterValue(department) || '') === value)
|
||
return selected ? getDepartmentFilterLabel(selected) : value
|
||
}, [departments, value])
|
||
|
||
const { roots, childrenByParent, visibleIds } = useMemo(() => {
|
||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||
const byId = new Map<string, DepartmentOption>()
|
||
const children = new Map<string, DepartmentOption[]>()
|
||
|
||
departments.forEach((department) => {
|
||
byId.set(department.id, department)
|
||
const parentKey = department.parentId || ''
|
||
const list = children.get(parentKey) || []
|
||
list.push(department)
|
||
children.set(parentKey, list)
|
||
})
|
||
|
||
children.forEach((list) => {
|
||
list.sort((a, b) => {
|
||
const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0)
|
||
if (countDiff !== 0) return countDiff
|
||
return a.name.localeCompare(b.name, 'zh-CN')
|
||
})
|
||
})
|
||
|
||
const matchedIds = new Set<string>()
|
||
if (normalizedKeyword) {
|
||
departments.forEach((department) => {
|
||
const haystack = [
|
||
department.name,
|
||
department.parentName,
|
||
department.departmentName,
|
||
department.departmentPath,
|
||
department.fullPath,
|
||
].filter(Boolean)
|
||
if (!matchesPickerSearch(normalizedKeyword, haystack)) return
|
||
|
||
let current: DepartmentOption | undefined = department
|
||
while (current) {
|
||
matchedIds.add(current.id)
|
||
current = current.parentId ? byId.get(current.parentId) : undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
return {
|
||
roots: children.get('') || [],
|
||
childrenByParent: children,
|
||
visibleIds: matchedIds,
|
||
}
|
||
}, [departments, keyword])
|
||
|
||
const toggle = (id: string) => {
|
||
setExpanded((current) => {
|
||
const next = new Set(current)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const choose = (nextValue: string) => {
|
||
onChange(nextValue)
|
||
setOpen(false)
|
||
setKeyword('')
|
||
}
|
||
|
||
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
|
||
const children = childrenByParent.get(department.id) || []
|
||
const hasChildren = children.length > 0
|
||
const isExpanded = expanded.has(department.id) || Boolean(keyword.trim())
|
||
const isVisible = !keyword.trim() || visibleIds.has(department.id)
|
||
const optionValue = String(getDepartmentFilterValue(department) || '')
|
||
const selectable = Boolean(optionValue)
|
||
const selected = selectable && optionValue === value
|
||
if (!isVisible) return null
|
||
|
||
return (
|
||
<div key={department.id}>
|
||
<div
|
||
className={`flex min-h-9 items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
|
||
selected ? 'bg-[#edf4ff] text-[#0052d9]' : 'text-gray-800 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700'
|
||
}`}
|
||
style={{ paddingLeft: `${8 + depth * 18}px` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => hasChildren && toggle(department.id)}
|
||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs ${
|
||
hasChildren
|
||
? 'border-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600'
|
||
: 'border-transparent text-transparent'
|
||
}`}
|
||
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
|
||
disabled={!hasChildren}
|
||
>
|
||
{hasChildren ? (isExpanded ? '-' : '+') : ''}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!selectable}
|
||
onClick={() => selectable && choose(optionValue)}
|
||
className="min-w-0 flex-1 truncate text-left font-medium disabled:cursor-default"
|
||
>
|
||
{department.name}
|
||
</button>
|
||
{selected && <span className="text-xs font-semibold">已选</span>}
|
||
</div>
|
||
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((current) => !current)}
|
||
className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-gray-300 bg-white px-3 text-left text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
>
|
||
<span className="truncate">{selectedLabel}</span>
|
||
<span className="text-gray-400">{open ? '收起' : '选择'}</span>
|
||
</button>
|
||
{open && (
|
||
<div className="absolute left-0 right-0 z-30 mt-2 rounded-[12px] border border-gray-200 bg-white p-2 shadow-[0_18px_42px_rgba(15,23,42,0.16)] dark:border-gray-700 dark:bg-gray-900">
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索组织"
|
||
className="mb-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-950 dark:text-white"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => choose('')}
|
||
className={`mb-1 flex min-h-9 w-full items-center rounded-md px-3 text-left text-sm font-medium ${
|
||
!value ? 'bg-[#edf4ff] text-[#0052d9]' : 'text-gray-800 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
全部组织
|
||
</button>
|
||
<div className="max-h-72 overflow-y-auto">
|
||
{roots.map((department) => renderNode(department))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MyResults({ items }: { items: any[] }) {
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="rounded-[16px] border border-dashed border-[#d9ddeb] bg-white px-5 py-9 text-center shadow-[0_10px_24px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg">
|
||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-[18px] font-semibold text-gray-900 dark:text-white">暂无结果</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<article
|
||
key={`${item.surveyId}-${item.targetId}`}
|
||
className="rounded-[14px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900 sm:rounded-lg sm:shadow-none"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="break-words text-[19px] font-semibold leading-snug text-[#101828] dark:text-white">{item.title}</h2>
|
||
<div className="mt-4 space-y-1.5 text-[13px] font-medium leading-5 text-[#667085] dark:text-gray-300">
|
||
<p className="break-words">{item.targetName}</p>
|
||
<p>{item.closedAt ? formatDate(item.closedAt) : '已关闭'}</p>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-[#b8d0ff] bg-[#edf4ff] px-3 py-1 text-[12px] font-semibold text-[#0052d9] dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-200">可查看</span>
|
||
</div>
|
||
<div className="mt-6 flex justify-end text-sm">
|
||
<Link
|
||
href={`/performance-survey/results/${item.surveyId}?targetId=${encodeURIComponent(item.targetId)}&back=${encodeURIComponent('/performance-survey?tab=results')}`}
|
||
className="inline-flex min-h-[40px] items-center justify-center rounded-[10px] bg-[#0052d9] px-7 font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.22)] hover:bg-[#003da6] dark:bg-white dark:text-gray-900"
|
||
>
|
||
看结果 ->
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ManagePanel({
|
||
canManage,
|
||
onChanged,
|
||
templateStats,
|
||
}: {
|
||
canManage: boolean
|
||
onChanged: () => Promise<void>
|
||
templateStats: any[]
|
||
}) {
|
||
const templates = useApi<Template[]>('/templates', [])
|
||
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
|
||
const notifications = useApi<any[]>(canManage ? '/admin/notifications?limit=30' : null, [canManage])
|
||
const notificationStatus = useApi<any>(canManage ? '/admin/notifications/status' : null, [canManage])
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
const [managedRefreshKey, setManagedRefreshKey] = useState(0)
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||
<CreateSurveyForm
|
||
templates={templates.data || []}
|
||
departments={departments.data || []}
|
||
onCreated={async () => {
|
||
setMessage('已创建考核调研')
|
||
setManagedRefreshKey((value) => value + 1)
|
||
await onChanged()
|
||
if (canManage) await Promise.all([notifications.refresh(), notificationStatus.refresh()])
|
||
}}
|
||
/>
|
||
<TemplateStatsPanel items={templateStats} />
|
||
</div>
|
||
{canManage && (
|
||
<ManagedSurveyList
|
||
canManage={canManage}
|
||
departments={departments.data || []}
|
||
refreshKey={managedRefreshKey}
|
||
/>
|
||
)}
|
||
<div className={canManage ? 'grid gap-4 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]' : 'grid gap-4'}>
|
||
<div className="space-y-4">
|
||
{canManage && (
|
||
<AdminActions
|
||
onMessage={setMessage}
|
||
/>
|
||
)}
|
||
{message && (
|
||
<div className="rounded-[14px] bg-white px-4 py-3 text-sm font-medium text-[#0052d9] shadow-[0_8px_18px_rgba(45,43,80,0.06)] dark:bg-gray-900 dark:text-blue-200">
|
||
{message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{canManage && <NotificationList state={notifications} statusState={notificationStatus} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TemplateStatsPanel({ items }: { items: any[] }) {
|
||
return (
|
||
<section className="rounded-[14px] bg-white px-4 py-4 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<h2 className="text-[18px] font-semibold text-gray-900 dark:text-white">模板</h2>
|
||
<div className="mt-3 divide-y divide-[#eef1f6] dark:divide-gray-800">
|
||
{items.map((item) => {
|
||
const achievement = item.responseCount > 0 ? `${item.aggregate?.achievementScore}${item.achievementSuffix || ''}` : '-'
|
||
return (
|
||
<div key={item.code} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[15px] font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{item.responseCount || 0} 份
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-[18px] font-semibold text-gray-900 dark:text-white">{achievement}</div>
|
||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">均分</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{items.length === 0 && <div className="py-3 text-sm text-gray-500 dark:text-gray-400">暂无模板</div>}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function CreateSurveyForm({
|
||
templates,
|
||
departments,
|
||
onCreated,
|
||
}: {
|
||
templates: Template[]
|
||
departments: DepartmentOption[]
|
||
onCreated: () => Promise<void>
|
||
}) {
|
||
const [title, setTitle] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [templateCode, setTemplateCode] = useState('')
|
||
const [dueAt, setDueAt] = useState('')
|
||
const [openNow, setOpenNow] = useState(true)
|
||
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
|
||
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
|
||
const [evaluatorDepartmentValues, setEvaluatorDepartmentValues] = useState<string[]>([])
|
||
const [evaluatorDepartmentEmployees, setEvaluatorDepartmentEmployees] = useState<Record<string, Employee[]>>({})
|
||
const [itemName, setItemName] = useState('')
|
||
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
|
||
const [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const selectedTemplate = templateCode || templates[0]?.code || ''
|
||
const selectedTemplateInfo = templates.find((template) => template.code === selectedTemplate)
|
||
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
|
||
const evaluatorEmployees = useMemo(
|
||
() => mergeEmployeesById(manualEvaluatorEmployees, ...Object.values(evaluatorDepartmentEmployees)),
|
||
[manualEvaluatorEmployees, evaluatorDepartmentEmployees],
|
||
)
|
||
|
||
const removeEvaluatorEmployee = (id: string) => {
|
||
setManualEvaluatorEmployees((current) => current.filter((employee) => employee.id !== id))
|
||
setEvaluatorDepartmentEmployees((current) => {
|
||
const next: Record<string, Employee[]> = {}
|
||
for (const [department, employees] of Object.entries(current)) {
|
||
const filtered = employees.filter((employee) => employee.id !== id)
|
||
if (filtered.length > 0) next[department] = filtered
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
const submit = async () => {
|
||
setSaving(true)
|
||
setError(null)
|
||
setCreatedSurvey(null)
|
||
try {
|
||
const pendingItemName = itemName.trim()
|
||
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
|
||
? [...targetItems, { name: pendingItemName }]
|
||
: targetItems
|
||
if (surveyKind === 'item_course' && targetItemsToSubmit.length === 0) {
|
||
throw new Error(`请填写${selectedTemplateInfo?.itemName || '课程、事项或活动'}名称`)
|
||
}
|
||
if (surveyKind !== 'item_course' && targetEmployees.length === 0) {
|
||
throw new Error(`请至少选择 1 个${selectedTemplateInfo?.ownerRoleName || '被考核人'}`)
|
||
}
|
||
const submitTitle = title.trim()
|
||
|| targetItemsToSubmit[0]?.name
|
||
|| (surveyKind === 'visual_case' ? `${selectedTemplateInfo?.shortName || '视觉评分'}-${getCurrentMonthValue()}` : '')
|
||
const created = await apiRequest<any>('/surveys', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
title: submitTitle,
|
||
description,
|
||
surveyKind,
|
||
templateCode: selectedTemplate,
|
||
dueAt: dueAt || null,
|
||
openNow,
|
||
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
|
||
targetItems: targetItemsToSubmit,
|
||
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
|
||
}),
|
||
})
|
||
setTitle('')
|
||
setDescription('')
|
||
setTargetEmployees([])
|
||
setManualEvaluatorEmployees([])
|
||
setEvaluatorDepartmentValues([])
|
||
setEvaluatorDepartmentEmployees({})
|
||
setTargetItems([])
|
||
setItemName('')
|
||
setCreatedSurvey(created)
|
||
await onCreated()
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '创建失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
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-4 grid gap-3.5">
|
||
<TextInput
|
||
label="调研标题"
|
||
value={title}
|
||
onChange={setTitle}
|
||
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写这次调研的名称'}
|
||
/>
|
||
<label className="grid gap-1 text-sm">
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">模板</span>
|
||
<select
|
||
value={selectedTemplate}
|
||
onChange={(event) => setTemplateCode(event.target.value)}
|
||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
>
|
||
{templates.map((template) => (
|
||
<option key={template.code} value={template.code}>
|
||
{template.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
|
||
|
||
{surveyKind !== 'item_course' ? (
|
||
<>
|
||
<EmployeePicker title={selectedTemplateInfo?.ownerRoleName || '被考核人'} purpose="target" selected={targetEmployees} onChange={setTargetEmployees} />
|
||
<TextInput
|
||
label={selectedTemplateInfo?.itemNoteName || '备注'}
|
||
value={description}
|
||
onChange={setDescription}
|
||
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
|
||
/>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<div className="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">{selectedTemplateInfo?.itemName || '调研事项'}</div>
|
||
<input
|
||
value={itemName}
|
||
onChange={(event) => setItemName(event.target.value)}
|
||
placeholder={selectedTemplateInfo?.itemNamePlaceholder || '填写要评价的课程、事项或活动名称'}
|
||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
<div className="mt-3">
|
||
<TextInput
|
||
label={selectedTemplateInfo?.itemNoteName || '备注'}
|
||
value={description}
|
||
onChange={setDescription}
|
||
placeholder={selectedTemplateInfo?.itemNotePlaceholder || '填写必要背景'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<EmployeePicker
|
||
title="评价人"
|
||
purpose="evaluator"
|
||
selected={evaluatorEmployees}
|
||
onChange={setManualEvaluatorEmployees}
|
||
onAddEmployee={(employee) => setManualEvaluatorEmployees((current) => mergeEmployeesById(current, [employee]))}
|
||
onRemoveEmployee={removeEvaluatorEmployee}
|
||
/>
|
||
<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
|
||
})
|
||
}}
|
||
/>
|
||
|
||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input type="checkbox" checked={openNow} onChange={(event) => setOpenNow(event.target.checked)} />
|
||
创建后立即发布
|
||
</label>
|
||
{error && <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{error}</div>}
|
||
<button
|
||
disabled={saving}
|
||
onClick={submit}
|
||
className="rounded-[10px] bg-[#0052d9] px-4 py-2.5 text-sm font-semibold text-white shadow-[0_10px_22px_rgba(0,82,217,0.18)] hover:bg-[#003da6] disabled:opacity-50 dark:bg-white dark:text-gray-900"
|
||
>
|
||
{saving ? '创建中...' : '创建'}
|
||
</button>
|
||
{createdSurvey && (
|
||
<div className="rounded-[12px] border border-blue-100 bg-blue-50 px-3 py-2.5 text-sm font-medium text-blue-800 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-100">
|
||
已创建,系统会按评价人发送钉钉通知。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function EmployeePicker({
|
||
title,
|
||
purpose,
|
||
selected,
|
||
onChange,
|
||
onAddEmployee,
|
||
onRemoveEmployee,
|
||
}: {
|
||
title: string
|
||
purpose?: 'target' | 'evaluator'
|
||
selected: Employee[]
|
||
onChange: (employees: Employee[]) => void
|
||
onAddEmployee?: (employee: Employee) => void
|
||
onRemoveEmployee?: (id: string) => void
|
||
}) {
|
||
const [keyword, setKeyword] = useState('')
|
||
const [items, setItems] = useState<Employee[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [open, setOpen] = useState(false)
|
||
const pickerRef = useRef<HTMLDivElement | null>(null)
|
||
const cacheRef = useRef<Map<string, Employee[]>>(new Map())
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(async () => {
|
||
const trimmedKeyword = keyword.trim()
|
||
if (!trimmedKeyword) {
|
||
setItems([])
|
||
setError(null)
|
||
setLoading(false)
|
||
setOpen(false)
|
||
return
|
||
}
|
||
const cacheKey = `${purpose || 'default'}:${trimmedKeyword}`
|
||
const cached = cacheRef.current.get(cacheKey)
|
||
if (cached) {
|
||
setItems(cached)
|
||
setError(null)
|
||
setLoading(false)
|
||
setOpen(true)
|
||
return
|
||
}
|
||
setLoading(true)
|
||
setError(null)
|
||
setOpen(true)
|
||
try {
|
||
const params = new URLSearchParams({
|
||
search: trimmedKeyword,
|
||
limit: '50',
|
||
...(purpose ? { purpose } : {}),
|
||
})
|
||
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||
cacheRef.current.set(cacheKey, data)
|
||
setItems(data)
|
||
} catch (err) {
|
||
setItems([])
|
||
setError(err instanceof Error ? err.message : '搜索失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, 250)
|
||
|
||
return () => window.clearTimeout(timer)
|
||
}, [keyword, purpose])
|
||
|
||
useEffect(() => {
|
||
const closeWhenOutside = (event: MouseEvent) => {
|
||
if (!pickerRef.current?.contains(event.target as Node)) {
|
||
setOpen(false)
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', closeWhenOutside)
|
||
return () => document.removeEventListener('mousedown', closeWhenOutside)
|
||
}, [])
|
||
|
||
const selectedIds = new Set(selected.map((employee) => employee.id))
|
||
const selectEmployee = (employee: Employee) => {
|
||
if (selectedIds.has(employee.id)) return
|
||
if (onAddEmployee) {
|
||
onAddEmployee(employee)
|
||
} else {
|
||
onChange([...selected, employee])
|
||
}
|
||
setError(null)
|
||
setKeyword('')
|
||
setItems([])
|
||
setOpen(false)
|
||
}
|
||
|
||
return (
|
||
<div ref={pickerRef}>
|
||
<label className="grid gap-1 text-sm">
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">{title}</span>
|
||
<input
|
||
value={keyword}
|
||
onFocus={() => {
|
||
if (keyword.trim()) setOpen(true)
|
||
}}
|
||
onChange={(event) => {
|
||
setKeyword(event.target.value)
|
||
if (event.target.value.trim()) setOpen(true)
|
||
}}
|
||
placeholder="搜索人员"
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') setOpen(false)
|
||
}}
|
||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
</label>
|
||
{open && keyword && (
|
||
<div className="mt-2 max-h-56 overflow-y-auto rounded-[10px] border border-gray-200 bg-white shadow-[0_10px_20px_rgba(15,23,42,0.06)] dark:border-gray-700 dark:bg-gray-900">
|
||
{loading ? (
|
||
<div className="px-3 py-2 text-sm text-gray-500">搜索中...</div>
|
||
) : error ? (
|
||
<div className="px-3 py-2 text-sm text-red-600">{error}</div>
|
||
) : items.length === 0 ? (
|
||
<div className="px-3 py-2 text-sm text-gray-500">无匹配人员</div>
|
||
) : (
|
||
items.map((employee) => (
|
||
<button
|
||
key={employee.id}
|
||
type="button"
|
||
disabled={selectedIds.has(employee.id)}
|
||
onClick={() => selectEmployee(employee)}
|
||
className="w-full px-3 py-2.5 text-left text-sm hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-gray-700"
|
||
>
|
||
<div className="font-medium text-gray-900 dark:text-white">{employee.displayName}</div>
|
||
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||
{[
|
||
employee.departmentName,
|
||
employee.positionTitle,
|
||
employee.positionLevel || employee.levelType,
|
||
].filter(Boolean).join(' · ')}
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
<SelectedChips
|
||
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
|
||
onRemove={(id) => {
|
||
if (onRemoveEmployee) {
|
||
onRemoveEmployee(id)
|
||
} else {
|
||
onChange(selected.filter((employee) => employee.id !== id))
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DepartmentPicker({
|
||
departments,
|
||
selected,
|
||
onChange,
|
||
onDepartmentEmployeesChange,
|
||
}: {
|
||
departments: DepartmentOption[]
|
||
selected: string[]
|
||
onChange: (items: string[]) => void
|
||
onDepartmentEmployeesChange?: (value: string, checked: boolean, employees: Employee[]) => void
|
||
}) {
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||
const [keyword, setKeyword] = useState('')
|
||
const [loadingValue, setLoadingValue] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const employeeCacheRef = useRef<Map<string, Employee[]>>(new Map())
|
||
|
||
const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => {
|
||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||
const byId = new Map<string, DepartmentOption>()
|
||
const children = new Map<string, DepartmentOption[]>()
|
||
|
||
departments.forEach((department) => {
|
||
byId.set(department.id, department)
|
||
const parentKey = department.parentId || ''
|
||
const list = children.get(parentKey) || []
|
||
list.push(department)
|
||
children.set(parentKey, list)
|
||
})
|
||
|
||
children.forEach((list) => {
|
||
list.sort((a, b) => {
|
||
const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0)
|
||
if (countDiff !== 0) return countDiff
|
||
return a.name.localeCompare(b.name, 'zh-CN')
|
||
})
|
||
})
|
||
|
||
const values = new Map<string, DepartmentOption>()
|
||
departments.forEach((department) => {
|
||
if (department.value) values.set(department.value, department)
|
||
})
|
||
|
||
const matchedIds = new Set<string>()
|
||
if (normalizedKeyword) {
|
||
departments.forEach((department) => {
|
||
const haystack = [
|
||
department.name,
|
||
department.parentName,
|
||
department.departmentName,
|
||
department.departmentPath,
|
||
department.fullPath,
|
||
].filter(Boolean)
|
||
if (!matchesPickerSearch(normalizedKeyword, haystack)) return
|
||
|
||
let current: DepartmentOption | undefined = department
|
||
while (current) {
|
||
matchedIds.add(current.id)
|
||
current = current.parentId ? byId.get(current.parentId) : undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
return {
|
||
roots: children.get('') || [],
|
||
childrenByParent: children,
|
||
optionByValue: values,
|
||
visibleIds: matchedIds,
|
||
}
|
||
}, [departments, keyword])
|
||
|
||
const selectedLabels = selected
|
||
.map((value) => optionByValue.get(value))
|
||
.filter((item): item is DepartmentOption => Boolean(item))
|
||
|
||
const toggle = (id: string) => {
|
||
setExpanded((current) => {
|
||
const next = new Set(current)
|
||
if (next.has(id)) {
|
||
next.delete(id)
|
||
} else {
|
||
next.add(id)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
const getDepartmentEmployees = async (value: string) => {
|
||
const cached = employeeCacheRef.current.get(value)
|
||
if (cached) return cached
|
||
|
||
const params = new URLSearchParams({
|
||
department: value,
|
||
limit: '1000',
|
||
purpose: 'evaluator',
|
||
})
|
||
const employees = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||
employeeCacheRef.current.set(value, employees)
|
||
return employees
|
||
}
|
||
|
||
const toggleValue = async (value: string, checked: boolean) => {
|
||
if (!checked) {
|
||
onChange(selected.filter((item) => item !== value))
|
||
onDepartmentEmployeesChange?.(value, false, [])
|
||
setError(null)
|
||
return
|
||
}
|
||
|
||
setLoadingValue(value)
|
||
setError(null)
|
||
try {
|
||
const employees = await getDepartmentEmployees(value)
|
||
if (employees.length === 0) {
|
||
setError('该部门暂无可选人员')
|
||
return
|
||
}
|
||
onChange(Array.from(new Set([...selected, value])))
|
||
onDepartmentEmployeesChange?.(value, true, employees)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '部门人员加载失败')
|
||
} finally {
|
||
setLoadingValue(null)
|
||
}
|
||
}
|
||
|
||
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
|
||
const children = childrenByParent.get(department.id) || []
|
||
const hasChildren = children.length > 0
|
||
const isExpanded = expanded.has(department.id) || Boolean(keyword.trim())
|
||
const isVisible = !keyword.trim() || visibleIds.has(department.id)
|
||
if (!isVisible) return null
|
||
|
||
return (
|
||
<div key={department.id}>
|
||
<div
|
||
className="flex min-h-9 items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
style={{ paddingLeft: `${8 + depth * 18}px` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => hasChildren && toggle(department.id)}
|
||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs ${
|
||
hasChildren
|
||
? 'border-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600'
|
||
: 'border-transparent text-transparent'
|
||
}`}
|
||
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
|
||
disabled={!hasChildren}
|
||
>
|
||
{hasChildren ? (isExpanded ? '-' : '+') : ''}
|
||
</button>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium text-gray-800 dark:text-gray-100">{department.name}</div>
|
||
</div>
|
||
{department.selectable !== false && department.value && (
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.includes(department.value)}
|
||
disabled={loadingValue === department.value}
|
||
onChange={(event) => {
|
||
void toggleValue(department.value!, event.target.checked)
|
||
}}
|
||
aria-label={`选择${department.name}`}
|
||
/>
|
||
)}
|
||
{department.value && loadingValue === department.value && (
|
||
<span className="text-xs text-gray-400 dark:text-gray-500">加载中</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-sm font-medium text-gray-700 dark:text-gray-300">部门</div>
|
||
{selected.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
selected.forEach((value) => onDepartmentEmployeesChange?.(value, false, []))
|
||
onChange([])
|
||
setError(null)
|
||
}}
|
||
className="text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||
>
|
||
清空
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索部门"
|
||
className="mb-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
<div className="max-h-64 overflow-y-auto rounded-[10px] border border-gray-200 p-1.5 dark:border-gray-700">
|
||
{roots.map((department) => renderNode(department))}
|
||
</div>
|
||
{error && <div className="mt-2 text-xs text-red-600 dark:text-red-300">{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="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
|
||
>
|
||
{department.fullPath || department.departmentPath || (
|
||
department.parentName && department.parentName !== '全部部门'
|
||
? `${department.parentName} / ${department.name}`
|
||
: department.name
|
||
)} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AdminActions({
|
||
onMessage,
|
||
}: {
|
||
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(() => {
|
||
if (questions.length > 0) {
|
||
setScores(
|
||
Object.fromEntries(
|
||
questions.map((question) => [question.id, 16]),
|
||
),
|
||
)
|
||
}
|
||
}, [questions])
|
||
|
||
const submit = async () => {
|
||
setSubmitting(true)
|
||
setMessage(null)
|
||
try {
|
||
await apiRequest(`/surveys/${surveyId}/responses`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ targetId: activeTargetId, scores, comment }),
|
||
})
|
||
setMessage('提交成功')
|
||
await detail.refresh()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '提交失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (detail.loading) return <SurveyOpeningScreen />
|
||
if (detail.error) return <MainPanel><ErrorPanel message={detail.error} /></MainPanel>
|
||
if (!detail.data) return null
|
||
|
||
const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId)
|
||
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
|
||
const scoreValues = questions.map((question) => Number(scores[question.id] ?? 16))
|
||
const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0)
|
||
const ratedCount = questions.filter((question) => typeof scores[question.id] === 'number').length
|
||
const 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: string) => {
|
||
setAccountMenuOpen(false)
|
||
router.push(`/performance-survey?tab=${encodeURIComponent(nextTab)}`)
|
||
}
|
||
|
||
if (isVisualTemplate(template)) {
|
||
return (
|
||
<VisualCaseSurvey
|
||
surveyId={surveyId}
|
||
detail={detail}
|
||
template={template}
|
||
targets={targets}
|
||
activeTarget={activeTarget}
|
||
activeTargetId={activeTargetId}
|
||
setTargetId={setTargetId}
|
||
viewerName={viewerName}
|
||
viewerAvatar={viewerAvatar}
|
||
dueLabel={dueLabel}
|
||
surveyStatusLabel={surveyStatusLabel}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<main className="min-h-[100dvh] bg-white text-[#111827]">
|
||
<MobileSurveyRangeStyles />
|
||
<div className="mx-auto flex max-w-[430px] flex-col px-4 pb-[calc(92px+env(safe-area-inset-bottom))] pt-3 sm:my-6 sm:rounded-[28px] sm:border sm:border-[#e5eaf2] sm:bg-white sm:p-5 sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
|
||
<section className="mb-3 overflow-hidden rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-3.5 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[13px] font-semibold text-[#1677ff]">考核调研问卷</span>
|
||
<span className="shrink-0 rounded-full bg-[#eef6ff] px-3 py-1 text-[12px] font-semibold text-[#1677ff]">
|
||
{surveyStatusLabel}
|
||
</span>
|
||
</div>
|
||
<h1 className="mt-2.5 break-words text-[1.22rem] font-semibold leading-[1.22] tracking-[-0.01em] text-[#101828]">
|
||
{detail.data.title}
|
||
</h1>
|
||
<div className="mt-2.5 space-y-1 text-[12px] font-medium leading-5 text-[#667085]">
|
||
<p className="break-words text-[#4b5565]">评价对象:{activeTarget?.targetName || '待选择'}</p>
|
||
<p>{dueLabel}</p>
|
||
</div>
|
||
<div className="mt-3 rounded-2xl bg-[#f6f8fb] px-3.5 py-2.5">
|
||
<div className="flex items-center justify-between text-[12px] font-semibold text-[#667085]">
|
||
<span>填写进度</span>
|
||
<span className="text-[#101828]">{ratedCount}/{questions.length || 0} · {scoreTotal} 分</span>
|
||
</div>
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
|
||
<div
|
||
className="h-full rounded-full bg-[#1677ff] transition-all"
|
||
style={{ width: `${questions.length > 0 ? Math.min(100, (ratedCount / questions.length) * 100) : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{targets.length > 1 && (
|
||
<label className="mb-4 grid gap-2 rounded-[20px] border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<span className="font-semibold text-[#4b5565]">切换评价对象</span>
|
||
<select
|
||
value={activeTargetId}
|
||
onChange={(event) => setTargetId(event.target.value)}
|
||
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
>
|
||
{targets.map((target: any) => (
|
||
<option key={target.id} value={target.id}>{target.targetName}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
{submitted ? (
|
||
<SubmittedResponseDetail
|
||
questions={questions}
|
||
template={template}
|
||
response={activeTarget?.currentAssignment?.response}
|
||
submittedAt={activeTarget?.currentAssignment?.submittedAt}
|
||
/>
|
||
) : detail.data.status !== 'open' ? (
|
||
<div className="rounded-[24px] border border-[#dce6f2] bg-white px-4 py-10 text-center text-[16px] font-semibold text-[#4b5565] shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
该调研未开放或已关闭
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{questions.map((question, index) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
const value = scores[question.id] ?? Math.min(16, maxScore)
|
||
const scorePercent = Math.max(0, Math.min(100, (value / maxScore) * 100))
|
||
const accentColor = getQuestionAccentColor(index)
|
||
return (
|
||
<section key={question.id} className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
|
||
<span
|
||
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
|
||
style={{ backgroundColor: accentColor }}
|
||
>
|
||
{index + 1}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] font-semibold text-[#98a2b3]">问题 {index + 1}</div>
|
||
<h2 className="mt-1 break-words text-[17px] font-semibold leading-6 text-[#111827]">{question.title}</h2>
|
||
{question.description && (
|
||
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
|
||
)}
|
||
</div>
|
||
<div className="rounded-xl bg-[#f6f8fb] px-2.5 py-1.5 text-right">
|
||
<span className="text-[18px] font-semibold leading-none" style={{ color: accentColor }}>{value}</span>
|
||
<span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={maxScore}
|
||
value={value}
|
||
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
|
||
className="survey-score-range mt-4 w-full"
|
||
style={{ '--score-percent': `${scorePercent}%`, '--score-color': accentColor } as CSSProperties}
|
||
/>
|
||
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
|
||
<span>0</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.25)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.5)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.75)}</span>
|
||
<span className="text-right">{maxScore}</span>
|
||
</div>
|
||
</section>
|
||
)
|
||
})}
|
||
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<label className="grid gap-3">
|
||
<span className="text-[15px] font-semibold text-[#111827]">补充评价</span>
|
||
<textarea
|
||
value={comment}
|
||
onChange={(event) => setComment(event.target.value.slice(0, 300))}
|
||
rows={4}
|
||
placeholder="可以从优点、待改进或建议等方面补充说明..."
|
||
className="min-h-[92px] resize-none rounded-[9px] border border-[#d9e0ea] px-3 py-2.5 text-[14px] leading-6 text-[#111827] outline-none transition placeholder:text-[#c0c7d2] focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
/>
|
||
<span className="text-right text-[11px] font-medium text-[#9aa3b2]">{comment.length} / 300</span>
|
||
</label>
|
||
</section>
|
||
|
||
{message && <div className="rounded-[10px] bg-[#eef6ff] px-3 py-2 text-sm font-medium text-[#1677ff]">{message}</div>}
|
||
|
||
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-1">
|
||
<div className="mx-auto grid max-w-[390px] grid-cols-[minmax(0,0.42fr)_minmax(0,1fr)] gap-3">
|
||
<div className="relative min-w-0">
|
||
{accountMenuOpen && (
|
||
<div className="absolute bottom-[calc(100%+10px)] left-0 z-30 w-[196px] overflow-hidden rounded-[18px] border border-[#dbe5f2] bg-white shadow-[0_18px_46px_rgba(15,23,42,0.16)]">
|
||
<button
|
||
type="button"
|
||
onClick={() => goToSurveyTab('pending')}
|
||
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('submitted')}
|
||
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('results')}
|
||
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 || ratedCount !== questions.length}
|
||
onClick={submit}
|
||
className="min-h-[52px] w-full rounded-[16px] bg-[#1677ff] px-4 py-2.5 text-[17px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.28)] transition active:scale-[0.99] disabled:bg-[#9ec9ff] disabled:shadow-none"
|
||
>
|
||
{submitting ? '提交中...' : '提交评价'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function SubmittedResponseDetail({
|
||
questions,
|
||
template,
|
||
response,
|
||
submittedAt,
|
||
}: {
|
||
questions: Template['questions']
|
||
template?: Template
|
||
response?: any
|
||
submittedAt?: string | Date | null
|
||
}) {
|
||
const responseScores = response?.scores && typeof response.scores === 'object'
|
||
? response.scores as Record<string, unknown>
|
||
: {}
|
||
const scoreOf = (questionId: string) => {
|
||
const value = Number(responseScores[questionId])
|
||
return Number.isFinite(value) ? value : 0
|
||
}
|
||
const formatScore = (value: unknown) => {
|
||
const numberValue = Number(value)
|
||
if (!Number.isFinite(numberValue)) return '-'
|
||
return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1)
|
||
}
|
||
const submittedLabel = submittedAt || response?.createdAt
|
||
? formatDate(submittedAt || response?.createdAt)
|
||
: '已提交'
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[13px] font-semibold text-[#16a34a]">已提交</div>
|
||
<div className="mt-1 text-[12px] font-medium text-[#667085]">{submittedLabel}</div>
|
||
</div>
|
||
<Link
|
||
href="/performance-survey?tab=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-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="grid grid-cols-[32px_minmax(0,1fr)_auto] items-start gap-3">
|
||
<span
|
||
className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-[14px] font-semibold leading-none text-white"
|
||
style={{ backgroundColor: accentColor }}
|
||
>
|
||
{index + 1}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] font-semibold text-[#98a2b3]">问题 {index + 1}</div>
|
||
<h2 className="mt-1 break-words text-[17px] font-semibold leading-6 text-[#111827]">{question.title}</h2>
|
||
{question.description && (
|
||
<p className="mt-1 text-[13px] font-medium leading-5 text-[#7b8494]">{question.description}</p>
|
||
)}
|
||
</div>
|
||
<div className="rounded-xl bg-[#f6f8fb] px-2.5 py-1.5 text-right">
|
||
<span className="text-[18px] font-semibold leading-none" style={{ color: accentColor }}>{formatScore(value)}</span>
|
||
<span className="ml-0.5 text-[11px] font-medium text-[#98a2b3]">/{maxScore}</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
|
||
<div
|
||
className="h-full rounded-full"
|
||
style={{ width: `${scorePercent}%`, backgroundColor: accentColor }}
|
||
/>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-5 text-[11px] font-medium text-[#9aa3b2]">
|
||
<span>0</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.25)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.5)}</span>
|
||
<span className="text-center">{Math.round(maxScore * 0.75)}</span>
|
||
<span className="text-right">{maxScore}</span>
|
||
</div>
|
||
</section>
|
||
)
|
||
})}
|
||
|
||
{response?.comment && (
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="text-[15px] font-semibold text-[#111827]">补充评价</div>
|
||
<p className="mt-3 whitespace-pre-wrap text-[14px] leading-6 text-[#4b5565]">{response.comment}</p>
|
||
</section>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VisualCaseSurvey({
|
||
surveyId,
|
||
detail,
|
||
template,
|
||
targets,
|
||
activeTarget,
|
||
activeTargetId,
|
||
setTargetId,
|
||
viewerName,
|
||
viewerAvatar,
|
||
dueLabel,
|
||
surveyStatusLabel,
|
||
}: {
|
||
surveyId: string
|
||
detail: ApiState<any> & { refresh: () => Promise<void> }
|
||
template: Template | undefined
|
||
targets: any[]
|
||
activeTarget: any
|
||
activeTargetId: string
|
||
setTargetId: (id: string) => void
|
||
viewerName: string
|
||
viewerAvatar: string
|
||
dueLabel: string
|
||
surveyStatusLabel: string
|
||
}) {
|
||
const requiredCount = Number(template?.requiredWorkItemCount || 3)
|
||
const workItems = activeTarget?.workItems || []
|
||
const canUpload = Boolean(activeTarget?.canUploadWorkItems && detail.data?.status === 'open')
|
||
const canScore = Boolean(activeTarget?.currentAssignment && detail.data?.status === 'open')
|
||
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
|
||
const completedScores = workItems.filter((item: any) => item.scores?.length > 0).length
|
||
|
||
return (
|
||
<main className="min-h-[100dvh] bg-white text-[#111827]">
|
||
<MobileSurveyRangeStyles />
|
||
<div className="mx-auto flex max-w-[430px] flex-col px-4 pb-[calc(92px+env(safe-area-inset-bottom))] pt-3 sm:my-6 sm:rounded-[28px] sm:border sm:border-[#e5eaf2] sm:bg-white sm:p-5 sm:shadow-[0_24px_80px_rgba(15,23,42,0.14)]">
|
||
<section className="mb-3 rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[13px] font-semibold text-[#1677ff]">{template?.shortName || '视觉评分'}</span>
|
||
<span className="shrink-0 rounded-full bg-[#eef6ff] px-3 py-1 text-[12px] font-semibold text-[#1677ff]">
|
||
{surveyStatusLabel}
|
||
</span>
|
||
</div>
|
||
<h1 className="mt-2.5 break-words text-[1.22rem] font-semibold leading-[1.22] tracking-[-0.01em] text-[#101828]">
|
||
{detail.data?.title}
|
||
</h1>
|
||
<div className="mt-2.5 space-y-1 text-[12px] font-medium leading-5 text-[#667085]">
|
||
<p className="break-words text-[#4b5565]">被考核人:{activeTarget?.targetName || '待选择'}</p>
|
||
<p>{dueLabel}</p>
|
||
</div>
|
||
<div className="mt-3 rounded-2xl bg-[#f6f8fb] px-3.5 py-2.5">
|
||
<div className="flex items-center justify-between text-[12px] font-semibold text-[#667085]">
|
||
<span>{canUpload ? '案例提交' : '案例评分'}</span>
|
||
<span className="text-[#101828]">{canUpload ? `${workItems.length}/${requiredCount}` : `${completedScores}/${workItems.length || requiredCount}`}</span>
|
||
</div>
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[#e5edf6]">
|
||
<div
|
||
className="h-full rounded-full bg-[#1677ff] transition-all"
|
||
style={{ width: `${Math.min(100, ((canUpload ? workItems.length : completedScores) / Math.max(1, canUpload ? requiredCount : workItems.length || requiredCount)) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{targets.length > 1 && (
|
||
<label className="mb-4 grid gap-2 rounded-[20px] border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<span className="font-semibold text-[#4b5565]">切换对象</span>
|
||
<select
|
||
value={activeTargetId}
|
||
onChange={(event) => setTargetId(event.target.value)}
|
||
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
|
||
>
|
||
{targets.map((target: any) => (
|
||
<option key={target.id} value={target.id}>{target.targetName}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
{canUpload && (
|
||
<WorkItemUploadForm
|
||
surveyId={surveyId}
|
||
targetId={activeTargetId}
|
||
disabled={workItems.length >= requiredCount}
|
||
onUploaded={detail.refresh}
|
||
/>
|
||
)}
|
||
|
||
{workItems.length === 0 && (
|
||
<div className="rounded-[20px] border border-dashed border-[#d9e0ea] bg-white px-4 py-10 text-center text-[15px] font-semibold text-[#667085]">
|
||
{canUpload ? '请先上传案例图片' : '等待被考核人提交案例'}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
{workItems.map((item: any, index: number) => (
|
||
<VisualWorkItemCard
|
||
key={item.id}
|
||
surveyId={surveyId}
|
||
item={item}
|
||
index={index}
|
||
template={template}
|
||
canScore={canScore && !submitted}
|
||
onScored={detail.refresh}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-3">
|
||
<div className="mx-auto flex max-w-[390px] items-center justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#eaf3ff] text-[13px] font-semibold text-[#1677ff]">
|
||
{viewerAvatar ? (
|
||
<Image src={viewerAvatar} alt={viewerName} width={40} height={40} unoptimized className="h-full w-full object-cover" />
|
||
) : (
|
||
getSurveyInitials(viewerName)
|
||
)}
|
||
</span>
|
||
<span className="min-w-0 truncate text-[14px] font-semibold text-[#101828]">{viewerName}</span>
|
||
</div>
|
||
<Link
|
||
href={submitted ? '/performance-survey?tab=submitted' : '/performance-survey?tab=pending'}
|
||
className="rounded-[14px] bg-[#1677ff] px-5 py-3 text-[15px] font-semibold text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)]"
|
||
>
|
||
返回看板
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function WorkItemUploadForm({
|
||
surveyId,
|
||
targetId,
|
||
disabled,
|
||
onUploaded,
|
||
}: {
|
||
surveyId: string
|
||
targetId: string
|
||
disabled: boolean
|
||
onUploaded: () => Promise<void>
|
||
}) {
|
||
const [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 submit = async () => {
|
||
if (disabled) return
|
||
if (files.length === 0) {
|
||
setMessage('请至少选择 1 张图片')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setMessage(null)
|
||
try {
|
||
const formData = new FormData()
|
||
formData.set('targetId', targetId)
|
||
formData.set('title', title.trim() || '案例作品')
|
||
formData.set('description', description.trim())
|
||
files.forEach((file) => formData.append('images', file))
|
||
await apiRequest(`/surveys/${surveyId}/work-items`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
setTitle('')
|
||
setDescription('')
|
||
setFiles([])
|
||
await onUploaded()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '上传失败')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="mb-3 rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="text-[15px] font-semibold text-[#111827]">提交案例</div>
|
||
<div className="mt-3 grid gap-3">
|
||
<input
|
||
value={title}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
placeholder="案例名称"
|
||
disabled={disabled}
|
||
className="rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
|
||
/>
|
||
<textarea
|
||
value={description}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
placeholder="案例说明(可选)"
|
||
disabled={disabled}
|
||
rows={3}
|
||
className="resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
disabled={disabled}
|
||
onChange={(event) => setFiles(Array.from(event.target.files || []))}
|
||
className="text-sm text-[#667085] file:mr-3 file:rounded-full file:border-0 file:bg-[#eef6ff] file:px-3 file:py-2 file:text-sm file:font-semibold file:text-[#1677ff]"
|
||
/>
|
||
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
|
||
<button
|
||
type="button"
|
||
disabled={busy || disabled}
|
||
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 [scores, setScores] = useState<Record<string, number>>(() => Object.fromEntries((template?.questions || []).map((question) => [question.id, Math.min(6, getQuestionMaxScore(template, question))])))
|
||
const [comment, setComment] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
const total = (template?.questions || []).reduce((sum, question) => sum + Number(scores[question.id] || 0), 0)
|
||
|
||
const submit = async () => {
|
||
setBusy(true)
|
||
setMessage(null)
|
||
try {
|
||
await apiRequest(`/surveys/${surveyId}/work-items/${item.id}/scores`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ scores, comment }),
|
||
})
|
||
await onScored()
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : '评分失败')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-[20px] border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-[12px] font-semibold text-[#1677ff]">案例 {index + 1}</div>
|
||
<h2 className="mt-1 break-words text-[18px] font-semibold text-[#111827]">{item.title}</h2>
|
||
{item.description && <p className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-[#667085]">{item.description}</p>}
|
||
</div>
|
||
<span className="shrink-0 rounded-xl bg-[#f6f8fb] px-3 py-1.5 text-[15px] font-semibold text-[#101828]">
|
||
{existingScore ? `${formatNullableScore(existingScore.totalScore)} 分` : `${total} 分`}
|
||
</span>
|
||
</div>
|
||
|
||
<ImagePreviewGrid images={item.imageUrls || []} />
|
||
|
||
{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">
|
||
{(template?.questions || []).map((question, questionIndex) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
const value = scores[question.id] ?? Math.min(6, maxScore)
|
||
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-[16px] font-semibold" style={{ color: accentColor }}>{value}/{maxScore}</div>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={maxScore}
|
||
value={value}
|
||
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}
|
||
onClick={submit}
|
||
className="w-full rounded-[14px] bg-[#1677ff] px-4 py-3 text-[15px] font-semibold text-white disabled:bg-[#9ec9ff]"
|
||
>
|
||
{busy ? '提交中...' : '提交本案例评分'}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ImagePreviewGrid({ images }: { images: string[] }) {
|
||
if (images.length === 0) return null
|
||
return (
|
||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
{images.map((url, index) => (
|
||
<a
|
||
key={`${url}-${index}`}
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="block overflow-hidden rounded-[14px] border border-[#e5edf6] bg-[#f6f8fb]"
|
||
>
|
||
<Image
|
||
src={url}
|
||
alt={`案例图片 ${index + 1}`}
|
||
width={320}
|
||
height={240}
|
||
unoptimized
|
||
className="aspect-[4/3] h-auto w-full object-cover"
|
||
/>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getSurveyInitials(name: string) {
|
||
const text = String(name || '').trim()
|
||
if (!text) return '用'
|
||
const chars = Array.from(text)
|
||
if (/^[A-Za-z\s]+$/.test(text)) {
|
||
return text
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.map((item) => item[0]?.toUpperCase())
|
||
.join('')
|
||
}
|
||
return chars.slice(0, 2).join('')
|
||
}
|
||
|
||
function getQuestionAccentColor(index: number) {
|
||
const colors = ['#1677ff', '#16a34a', '#1677ff', '#7c3aed', '#f59e0b']
|
||
return colors[index % colors.length]
|
||
}
|
||
|
||
function getSurveyStatusLabel(status: string) {
|
||
const map: Record<string, string> = {
|
||
draft: '草稿',
|
||
open: '进行中',
|
||
closed: '已关闭',
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
function getPerformanceSurveyBackHref(value: string | null) {
|
||
if (!value) return '/performance-survey?tab=results'
|
||
if (!value.startsWith('/performance-survey')) return '/performance-survey?tab=results'
|
||
if (value.startsWith('//')) return '/performance-survey?tab=results'
|
||
return value
|
||
}
|
||
|
||
function formatResultScore(value: unknown) {
|
||
const score = Number(value ?? 0)
|
||
if (!Number.isFinite(score)) return '0'
|
||
return Number.isInteger(score) ? String(score) : score.toFixed(1)
|
||
}
|
||
|
||
function getQuestionMaxScore(template: Template | undefined, question: Template['questions'][number]) {
|
||
const maxScore = Number(question.maxScore ?? template?.questionMaxScore ?? 20)
|
||
return Number.isFinite(maxScore) && maxScore > 0 ? maxScore : 20
|
||
}
|
||
|
||
function isVisualTemplate(template?: Template | null) {
|
||
return template?.surveyType === 'visual_case' || template?.code === 'visual_brand_tone'
|
||
}
|
||
|
||
function getScorePercent(value: unknown, maxScore = 20) {
|
||
const score = Number(value ?? 0)
|
||
if (!Number.isFinite(score)) return 0
|
||
return Math.max(0, Math.min(100, (score / Math.max(1, maxScore)) * 100))
|
||
}
|
||
|
||
function SurveyResults({ surveyId }: { surveyId: string }) {
|
||
const searchParams = useSearchParams()
|
||
const targetId = searchParams.get('targetId')
|
||
const query = targetId ? `?targetId=${encodeURIComponent(targetId)}` : ''
|
||
const backHref = getPerformanceSurveyBackHref(searchParams.get('back'))
|
||
const result = useApi<any>(`/results/${surveyId}${query}`, [surveyId, query])
|
||
|
||
if (result.loading) return <MainPanel>加载中...</MainPanel>
|
||
if (result.error) return <MainPanel><ErrorPanel message={result.error} /></MainPanel>
|
||
if (!result.data) return null
|
||
|
||
const survey = result.data.survey
|
||
const targets = result.data.targets || []
|
||
const template = result.data.survey.template as Template | undefined
|
||
const questions = template?.questions || []
|
||
const totalResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.responseCount || 0), 0)
|
||
const effectiveResponses = targets.reduce((sum: number, target: any) => sum + Number(target.aggregate?.effectiveCount || 0), 0)
|
||
|
||
return (
|
||
<main className="mx-auto max-w-[430px] bg-white px-5 py-6 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-6xl sm:bg-transparent sm:py-6 md:pb-6">
|
||
<section className="mb-5 rounded-[18px] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<Link
|
||
href={backHref}
|
||
className="inline-flex min-h-[34px] items-center rounded-full bg-[#f3f6fb] px-3 text-[13px] font-semibold text-[#344054] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100"
|
||
>
|
||
返回
|
||
</Link>
|
||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-[13px] font-semibold text-[#0052d9]">调研结果</div>
|
||
<h1 className="mt-2 break-words text-[26px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-3xl">
|
||
{survey.title}
|
||
</h1>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
|
||
<StatusBadge status={survey.status} />
|
||
{survey.closedAt && <span>关闭 {formatDate(survey.closedAt)}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 sm:min-w-[280px]">
|
||
<ResultMetric label="对象" value={targets.length} />
|
||
<ResultMetric label="评价" value={totalResponses} />
|
||
<ResultMetric label="有效" value={effectiveResponses} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="grid gap-5">
|
||
{targets.map((target: any) => {
|
||
const aggregate = target.aggregate || {}
|
||
const dimensionEntries = questions
|
||
.map((question: Template['questions'][number]) => ({
|
||
question,
|
||
value: aggregate.dimensionAverages?.[question.id],
|
||
}))
|
||
.filter((item: { value: unknown }) => item.value !== null && item.value !== undefined)
|
||
|
||
return (
|
||
<article key={target.id} className="rounded-[18px] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-[13px] font-semibold text-[#667085] dark:text-gray-400">评价对象</div>
|
||
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828] dark:text-white">{target.targetName}</h2>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
|
||
<span>有效 {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0}</span>
|
||
{aggregate.rawAverage != null && <span>原始均分 {formatResultScore(aggregate.rawAverage)}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 sm:min-w-[220px]">
|
||
<ResultMetric label={aggregate.achievementLabel || '达成分'} value={aggregate.achievementScore} />
|
||
<ResultMetric label="KPI分" value={aggregate.kpiScore} />
|
||
</div>
|
||
</div>
|
||
|
||
{aggregate.band && aggregate.responseCount > 0 && (
|
||
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054] dark:bg-gray-800 dark:text-gray-200">
|
||
{aggregate.band}
|
||
</div>
|
||
)}
|
||
|
||
{dimensionEntries.length > 0 && (
|
||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||
{dimensionEntries.map(({ question, value }: { question: Template['questions'][number]; value: number }) => {
|
||
const maxScore = getQuestionMaxScore(template, question)
|
||
return (
|
||
<div key={question.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="truncate text-sm font-semibold text-[#101828] dark:text-white">{question.title}</div>
|
||
<div className="shrink-0 text-sm font-semibold text-[#0052d9]">{formatResultScore(value)}/{maxScore}</div>
|
||
</div>
|
||
<div className="mt-3 h-2 rounded-full bg-[#e8eef7] dark:bg-gray-700">
|
||
<div className="h-full rounded-full bg-[#0052d9]" style={{ width: `${getScorePercent(value, maxScore)}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{aggregate.responseCount > 0 && aggregate.trimNote && (
|
||
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">
|
||
{aggregate.trimNote}
|
||
{aggregate.removedHigh && aggregate.removedLow && (
|
||
<span>;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-5">
|
||
<h3 className="text-sm font-semibold text-[#101828] dark:text-white">评价明细</h3>
|
||
</div>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||
{target.responses.map((response: any) => (
|
||
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-semibold text-[#101828] dark:text-white">{response.respondentName}</span>
|
||
<span className="text-sm font-semibold text-[#667085] dark:text-gray-300">{formatResultScore(response.totalScore)} 分</span>
|
||
</div>
|
||
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054] dark:text-gray-300">{response.comment}</p>}
|
||
</div>
|
||
))}
|
||
{target.responses.length === 0 && (
|
||
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">暂无评价</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function TextInput({
|
||
label,
|
||
value,
|
||
onChange,
|
||
type = 'text',
|
||
placeholder,
|
||
}: {
|
||
label: string
|
||
value: string
|
||
onChange: (value: string) => void
|
||
type?: string
|
||
placeholder?: string
|
||
}) {
|
||
return (
|
||
<label className="grid gap-1 text-sm">
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>
|
||
<input
|
||
type={type}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
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"
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
|
||
function SelectedChips({ items, onRemove }: { items: Array<{ id: string; label: string }>; onRemove: (id: string) => void }) {
|
||
if (items.length === 0) return null
|
||
return (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{items.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onRemove(item.id)}
|
||
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
|
||
>
|
||
{item.label} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const map: Record<string, string> = {
|
||
draft: '草稿',
|
||
open: '进行中',
|
||
closed: '已关闭',
|
||
pending: '待提交',
|
||
submitted: '已提交',
|
||
sent: '已发送',
|
||
succeeded: '已发送',
|
||
retrying: '重试中',
|
||
exhausted: '失败',
|
||
skipped: '未发送',
|
||
processing: '处理中',
|
||
}
|
||
return (
|
||
<span className="inline-flex rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||
{map[status] || status}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function ResultMetric({ label, value, emphasis = false }: { label: string; value: unknown; emphasis?: boolean }) {
|
||
return (
|
||
<div className={`min-w-0 rounded-[12px] px-3 py-3 text-center ${
|
||
emphasis
|
||
? 'bg-[#0052d9] text-white'
|
||
: 'bg-[#f8fafc] text-[#101828] dark:bg-gray-800 dark:text-white'
|
||
}`}>
|
||
<div className="truncate text-[22px] font-semibold leading-none">{formatResultScore(value)}</div>
|
||
<div className={`mt-1 truncate text-[11px] font-semibold ${emphasis ? 'text-white/75' : 'text-[#667085] dark:text-gray-400'}`}>{label}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Panel({ children }: { children: ReactNode }) {
|
||
return <div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">{children}</div>
|
||
}
|
||
|
||
function MainPanel({ children }: { children: ReactNode }) {
|
||
return <main className="mx-auto max-w-4xl px-4 py-6"><Panel>{children}</Panel></main>
|
||
}
|
||
|
||
function ErrorPanel({ message }: { message: string }) {
|
||
return <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{message}</div>
|
||
}
|
||
|
||
function EmptyState({ title }: { title: string }) {
|
||
return (
|
||
<div className="rounded-[18px] border border-dashed border-[#d9ddeb] bg-white px-5 py-10 text-center shadow-[0_12px_30px_rgba(45,43,80,0.06)] dark:border-gray-700 dark:bg-gray-900">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-[#eef2f7] text-lg font-semibold text-[#667085] dark:bg-gray-800 dark:text-gray-300">0</div>
|
||
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MobileSurveyRangeStyles() {
|
||
return (
|
||
<style>{`
|
||
.survey-score-range {
|
||
height: 24px;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.survey-score-range:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.survey-score-range::-webkit-slider-runnable-track {
|
||
height: 4px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(
|
||
to right,
|
||
var(--score-color, #1677ff) 0%,
|
||
var(--score-color, #1677ff) var(--score-percent),
|
||
#e5eaf2 var(--score-percent),
|
||
#e5eaf2 100%
|
||
);
|
||
}
|
||
|
||
.survey-score-range::-webkit-slider-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-top: -7px;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
border: 3px solid var(--score-color, #1677ff);
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
|
||
}
|
||
|
||
.survey-score-range::-moz-range-track {
|
||
height: 4px;
|
||
border-radius: 999px;
|
||
background: #e5eaf2;
|
||
}
|
||
|
||
.survey-score-range::-moz-range-progress {
|
||
height: 4px;
|
||
border-radius: 999px;
|
||
background: var(--score-color, #1677ff);
|
||
}
|
||
|
||
.survey-score-range::-moz-range-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 3px solid var(--score-color, #1677ff);
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22);
|
||
}
|
||
`}</style>
|
||
)
|
||
}
|
||
|
||
function SurveyOpeningScreen() {
|
||
return (
|
||
<main className="min-h-[calc(100dvh-76px)] bg-white text-[#111827]">
|
||
<section className="mx-auto max-w-[430px] px-5 py-8">
|
||
<div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
|
||
<div className="text-[14px] font-semibold text-[#1677ff]">考核调研</div>
|
||
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1>
|
||
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">正在准备问卷内容,请稍候。</p>
|
||
|
||
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className="text-[15px] font-semibold text-[#101828]">准备问卷内容</span>
|
||
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
|
||
</div>
|
||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
|
||
<div className="h-full w-2/3 animate-pulse rounded-full bg-[#1677ff]" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5">
|
||
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
|
||
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
|
||
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
|
||
<div className="mt-8 space-y-5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
|
||
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
|
||
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
|
||
<span className="h-3 w-2/3 rounded-full bg-[#f1f5f9]" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function LoadingPage() {
|
||
return <SurveyOpeningScreen />
|
||
}
|
||
|
||
function formatDate(value: string | Date) {
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '-'
|
||
return date.toLocaleString('zh-CN', { hour12: false })
|
||
}
|