gongxiangp2/app/performance-survey/PerformanceSurveyAdminConfig.tsx

2384 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import {
useConfirmActionDialog,
type ConfirmActionOptions,
type ConfirmActionResult,
} from '@/components/ConfirmActionDialog'
import {
PerformanceSurveyAdminPeople,
type PerformanceSurveyAdminRequest,
} from './PerformanceSurveyAdminPeople'
type RequestFn = PerformanceSurveyAdminRequest
type ConfirmActionFn = (options: ConfirmActionOptions) => Promise<ConfirmActionResult>
export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates'
type ReviewerSource = 'self' | 'direct_manager' | 'fixed_employee' | 'manual_selection'
type ReviewerDraft = {
source: ReviewerSource
label: string
employeeId?: string
employeeName?: string
}
type StageDraft = {
key: string
label: string
order: number
reviewers: ReviewerDraft[]
}
type TemplateDefinition = {
code: string
name: string
shortName?: string
description: string
surveyType: 'person_period' | 'visual_case' | 'item_course'
weight: number
maxTotal?: number
questionMaxScore?: number
requiredWorkItemCount?: number
workItemImageRequired?: boolean
questions: Array<{ id: string; title: string; description?: string; maxScore?: number }>
scoreBands: unknown[]
workflow: {
stages: StageDraft[]
scoring: {
mode: 'final_reviewer' | 'average_reviewers'
stageKey?: string
reviewerSource?: ReviewerSource
stageKeys?: string[]
}
}
}
type TemplateVersion = {
id: string
version: number
status: 'draft' | 'published' | 'archived'
effectiveMonth?: string | null
publishedAt?: string | null
definition: TemplateDefinition
}
type TemplateRecord = {
id: string
code: string
name: string
description: string
surveyType: string
isActive: boolean
versions: TemplateVersion[]
}
type TemplateEditorStep = 'basics' | 'questions' | 'workflow'
type TemplateReadinessStatus = 'done' | 'pending' | 'error'
type TemplateReadinessItem = {
step: TemplateEditorStep
label: string
status: TemplateReadinessStatus
summary: string
}
type StatusTone = 'success' | 'warning' | 'danger' | 'neutral'
type PositionGroupRecord = {
id: string
code: string
name: string
sortOrder: number
isActive: boolean
status: 'ready' | 'pending' | 'disabled'
memberCount: number
rules: Array<{
id: string
departmentPathPrefix: string
positionTitle: string
isActive: boolean
sortOrder: number
}>
effectivePlan: null | {
id: string
version: number
effectiveMonth: string
templateVersionIds: string[]
}
draftPlan: null | {
id: string
version: number
templateVersionIds: string[]
}
}
type AdminConfig = {
month: string
groups: PositionGroupRecord[]
templates: TemplateRecord[]
issues: {
unmatched: Array<{ id: string; displayName: string; departmentName?: string; departmentPath?: string; positionTitle?: string }>
conflicts: Array<{ id: string; displayName: string; groupNames: string[]; departmentName?: string; departmentPath?: string; positionTitle?: string }>
}
}
type ConfigAudit = {
id: string
action?: string
resourceType?: string
targetType?: string
resourceName?: string
actorName?: string
createdAt?: string
summary?: string
}
const adminConfigApi = {
configuration(request: RequestFn, month: string) {
return request<AdminConfig>(`/admin/config?month=${encodeURIComponent(month)}`)
},
auditLogs(request: RequestFn, month: string) {
return request<ConfigAudit[] | { items?: ConfigAudit[] }>(
`/admin/config-audit-logs?month=${encodeURIComponent(month)}&pageSize=20`,
)
},
createPositionGroup(request: RequestFn, input: { name: string; effectiveMonth: string }) {
return request<{ id?: string; group?: { id?: string } }>('/admin/position-groups', {
method: 'POST',
body: JSON.stringify(input),
})
},
previewPositionGroup(request: RequestFn, groupId: string, month: string) {
return request<any>(`/admin/position-groups/${groupId}/preview?month=${encodeURIComponent(month)}`)
},
removePositionGroup(request: RequestFn, groupId: string, reason: string) {
return request<{ id: string; action: 'deleted' | 'archived' }>(`/admin/position-groups/${groupId}`, {
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason }),
})
},
removeTemplate(request: RequestFn, templateId: string, reason: string) {
return request<{ id: string; action: 'deleted' | 'archived' }>(`/admin/templates/${templateId}`, {
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason }),
})
},
}
function currentMonth() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function newTemplateDefinition(): TemplateDefinition {
return {
code: `template_${Date.now().toString(36)}`,
name: '',
shortName: '',
description: '',
surveyType: 'person_period',
weight: 1,
maxTotal: 100,
questions: [{ id: `score_${Date.now().toString(36)}`, title: '评分项 1', description: '', maxScore: 100 }],
scoreBands: [],
workflow: {
stages: [{
key: 'review',
label: '评价',
order: 0,
reviewers: [{ source: 'direct_manager', label: '直属主管' }],
}],
scoring: { mode: 'average_reviewers', stageKeys: ['review'] },
},
}
}
function reviewerSourceLabel(source: ReviewerSource) {
return {
self: '本人自评',
direct_manager: '直属主管',
fixed_employee: '元泽',
manual_selection: '发起时选择任何人',
}[source]
}
function surveyTypeLabel(type: TemplateDefinition['surveyType']) {
return {
person_period: '员工工作表现',
visual_case: '作品或案例',
item_course: '课程或事项',
}[type]
}
function StatusDot({
label,
tone,
}: {
label: string
tone: StatusTone
}) {
const toneClass = {
success: 'bg-[#16794b]',
warning: 'bg-[#b26a00]',
danger: 'bg-[#b42318]',
neutral: 'bg-[#8a8a8a]',
}[tone]
return (
<span
role="img"
aria-label={label}
title={label}
data-status-dot={tone}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${toneClass}`}
/>
)
}
function getPositionGroupState(group: PositionGroupRecord): {
label: string
tone: StatusTone
} {
if (group.status === 'disabled') return { label: '已删除', tone: 'neutral' }
if (group.draftPlan) {
return {
label: group.effectivePlan ? '有待发布修改' : '待发布',
tone: 'warning',
}
}
if (group.effectivePlan) return { label: '已生效', tone: 'success' }
return { label: '待配置', tone: 'warning' }
}
function getTemplateAvailability(template: TemplateRecord, month: string) {
const publishedVersions = template.versions.filter((version) => version.status === 'published')
const availableVersion = publishedVersions
.filter((version) => Boolean(version.effectiveMonth) && String(version.effectiveMonth) <= month)
.sort((left, right) => (
String(right.effectiveMonth).localeCompare(String(left.effectiveMonth))
|| right.version - left.version
))[0] || null
const futureVersion = publishedVersions
.filter((version) => Boolean(version.effectiveMonth) && String(version.effectiveMonth) > month)
.sort((left, right) => (
String(left.effectiveMonth).localeCompare(String(right.effectiveMonth))
|| right.version - left.version
))[0] || null
const draftVersion = template.versions.find((version) => version.status === 'draft') || null
const hasPublishedVersionWithoutMonth = publishedVersions.some((version) => !version.effectiveMonth)
return {
isAvailable: template.isActive && Boolean(availableVersion),
availableVersion,
futureVersion,
draftVersion,
hasPublishedVersionWithoutMonth,
}
}
function getTemplateListState(template: TemplateRecord, month: string): {
label: string
tone: StatusTone
meta: string
} {
const availability = getTemplateAvailability(template, month)
if (!template.isActive) {
return { label: '已停用', tone: 'neutral', meta: '' }
}
if (availability.draftVersion) {
return {
label: availability.availableVersion ? '有待发布修改' : '待发布',
tone: 'warning',
meta: `v${availability.draftVersion.version} · 草稿`,
}
}
if (availability.availableVersion) {
return {
label: '可用',
tone: 'success',
meta: `v${availability.availableVersion.version} · ${availability.availableVersion.effectiveMonth}`,
}
}
if (availability.futureVersion) {
return {
label: '待生效',
tone: 'warning',
meta: `v${availability.futureVersion.version} · ${availability.futureVersion.effectiveMonth}`,
}
}
if (availability.hasPublishedVersionWithoutMonth) {
const version = template.versions.find((item) => item.status === 'published')
return {
label: '待配置',
tone: 'warning',
meta: version ? `v${version.version}` : '',
}
}
return { label: '不可用', tone: 'neutral', meta: '' }
}
function getTemplateReadiness(definition: TemplateDefinition): TemplateReadinessItem[] {
const workItemCount = definition.surveyType === 'visual_case'
? Math.max(1, Number(definition.requiredWorkItemCount || 1))
: 1
const questionTotal = definition.questions.reduce(
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
0,
)
const allocatedTotal = questionTotal * workItemCount
const configuredTotal = Number(definition.maxTotal || 0)
const hasQuestionError = definition.questions.some((question) => (
!question.title.trim() || Number(question.maxScore || definition.questionMaxScore || 0) <= 0
))
const scoreMatches = Math.round(allocatedTotal * 100) === Math.round(configuredTotal * 100)
const stages = definition.workflow.stages
const hasWorkflowError = stages.some((stage) => (
!stage.label.trim()
|| stage.reviewers.length === 0
))
const unresolvedFixedReviewer = stages.some((stage) => (
stage.reviewers.some((reviewer) => reviewer.source === 'fixed_employee' && !reviewer.employeeId)
))
const finalScoringInvalid = definition.workflow.scoring.mode === 'final_reviewer' && (
!stages.some((stage) => stage.key === definition.workflow.scoring.stageKey)
|| !stages
.find((stage) => stage.key === definition.workflow.scoring.stageKey)
?.reviewers.some((reviewer) => reviewer.source === definition.workflow.scoring.reviewerSource)
)
const basicsReady = Boolean(definition.name.trim())
&& (definition.surveyType !== 'visual_case' || workItemCount > 0)
const questionsReady = definition.questions.length > 0
&& !hasQuestionError
&& configuredTotal > 0
&& scoreMatches
const workflowReady = stages.length > 0
&& !hasWorkflowError
&& !unresolvedFixedReviewer
&& !finalScoringInvalid
return [
{
step: 'basics',
label: '基本信息',
status: basicsReady ? 'done' : 'pending',
summary: basicsReady
? `${surveyTypeLabel(definition.surveyType)} · 名称已填写`
: '请填写模板名称',
},
{
step: 'questions',
label: '评分项',
status: questionsReady
? 'done'
: definition.questions.length === 0 ? 'pending' : 'error',
summary: questionsReady
? `${definition.questions.length} 项 · 合计 ${allocatedTotal} 分`
: definition.questions.length === 0
? '请至少添加 1 个评分项'
: hasQuestionError
? '评分项名称和满分需要完整填写'
: `评分项合计 ${allocatedTotal} 分,与模板总分 ${configuredTotal || 0} 分不一致`,
},
{
step: 'workflow',
label: '评分人设置',
status: workflowReady
? 'done'
: stages.length === 0 || unresolvedFixedReviewer ? 'pending' : 'error',
summary: workflowReady
? `${stages.length} 个阶段 · 最终成绩规则已确认`
: stages.length === 0
? '请至少添加 1 个评分阶段'
: unresolvedFixedReviewer
? '请确认元泽对应的人员档案'
: finalScoringInvalid
? '最终成绩规则指向了无效的阶段或评分人'
: '阶段名称和评分人需要完整填写',
},
]
}
export function PerformanceSurveyAdminConfig({
mode,
request,
onDirtyChange,
month: controlledMonth,
onMonthChange,
onNavigate,
}: {
mode: PerformanceSurveyAdminMode
request: RequestFn
onDirtyChange?: (dirty: boolean) => void
month?: string
onMonthChange?: (month: string) => void
onNavigate?: (mode: PerformanceSurveyAdminMode | 'launch') => void
}) {
const [internalMonth, setInternalMonth] = useState(controlledMonth || currentMonth())
const month = controlledMonth || internalMonth
const [data, setData] = useState<AdminConfig | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const requestSequenceRef = useRef(0)
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const setDirtyState = useCallback((next: boolean) => {
setDirty(next)
onDirtyChange?.(next)
}, [onDirtyChange])
const load = useCallback(async (targetMonth: string) => {
const sequence = requestSequenceRef.current + 1
requestSequenceRef.current = sequence
setLoading(true)
setError(null)
try {
const next = await adminConfigApi.configuration(request, targetMonth)
if (requestSequenceRef.current !== sequence) return null
setData(next)
return next
} catch (loadError) {
if (requestSequenceRef.current !== sequence) return null
setError(loadError instanceof Error ? loadError.message : '配置加载失败')
return null
} finally {
if (requestSequenceRef.current === sequence) setLoading(false)
}
}, [request])
useEffect(() => {
void load(month)
return () => {
requestSequenceRef.current += 1
}
}, [load, month])
useEffect(() => {
if (!dirty) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [dirty])
useEffect(() => {
if (!notice) return
const timer = window.setTimeout(() => setNotice(null), 4500)
return () => window.clearTimeout(timer)
}, [notice])
const confirmDiscard = useCallback(async (message = '当前有未保存修改,确定放弃吗?') => {
if (!dirty) return true
const result = await confirmAction({
title: '放弃未保存的修改?',
description: message,
confirmLabel: '放弃修改',
tone: 'danger',
})
return result.confirmed
}, [confirmAction, dirty])
const changeMonth = async (nextMonth: string) => {
if (!nextMonth || nextMonth === month) return
if (!await confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return
setDirtyState(false)
setNotice(null)
if (onMonthChange) onMonthChange(nextMonth)
else setInternalMonth(nextMonth)
}
const changed = useCallback(async (message: string) => {
setDirtyState(false)
setNotice(message)
setError(null)
return load(month)
}, [load, month, setDirtyState])
return (
<section className="performance-admin-config min-w-0 border border-[#c2c6d8] bg-white" aria-label="考核调研管理控制台">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] px-4 py-3">
<div className="flex items-center gap-2">
<h2 className="text-[16px] font-bold text-[#1a1c1c]">
{{
overview: '维护概览',
positions: '岗位管理',
people: '人员分配',
templates: '模板管理',
}[mode]}
</h2>
{dirty && <StatusDot label="未保存" tone="warning" />}
</div>
<label className="flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
查看月份
<input
type="month"
value={month}
onChange={(event) => void changeMonth(event.target.value)}
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
</div>
{notice && (
<div role="status" aria-live="polite" className="border-b border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-[13px] font-semibold text-[#0050cb]">
{notice}
</div>
)}
{error && (
<div role="alert" className="flex flex-wrap items-center justify-between gap-3 border-b border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-[13px] font-semibold text-[#93000a]">
<span>{error}</span>
{!data && <button type="button" onClick={() => void load(month)} className="min-h-9 px-2 underline">重新加载</button>}
</div>
)}
{loading && !data && <div role="status" aria-live="polite" className="px-5 py-12 text-center text-[13px] font-semibold text-[#5f5e5e]">配置加载中...</div>}
{data && (
<div className={`p-4 sm:p-5 ${loading ? 'pointer-events-none opacity-60' : ''}`} aria-busy={loading}>
{mode === 'overview' && (
<AdminOverviewWorkspace
data={data}
month={month}
request={request}
onNavigate={onNavigate}
/>
)}
{mode === 'positions' && (
<PositionConfigWorkspace
data={data}
month={month}
request={request}
onError={setError}
onChanged={changed}
onDirtyChange={setDirtyState}
confirmDiscard={confirmDiscard}
confirmAction={confirmAction}
dirty={dirty}
/>
)}
{mode === 'people' && (
<PerformanceSurveyAdminPeople
month={month}
groups={data.groups}
request={request}
onError={setError}
onSuccess={(message) => {
setNotice(message)
setError(null)
}}
/>
)}
{mode === 'templates' && (
<TemplateConfigWorkspace
data={data}
month={month}
request={request}
onError={setError}
onChanged={changed}
onDirtyChange={setDirtyState}
confirmDiscard={confirmDiscard}
confirmAction={confirmAction}
dirty={dirty}
/>
)}
</div>
)}
{confirmActionDialog}
</section>
)
}
function AdminOverviewWorkspace({
data,
month,
request,
onNavigate,
}: {
data: AdminConfig
month: string
request: RequestFn
onNavigate?: (mode: PerformanceSurveyAdminMode | 'launch') => void
}) {
const [audits, setAudits] = useState<ConfigAudit[]>([])
const [auditError, setAuditError] = useState<string | null>(null)
const [auditLoading, setAuditLoading] = useState(true)
const activeGroups = data.groups.filter((group) => group.status !== 'disabled')
const disabledGroups = data.groups.filter((group) => group.status === 'disabled').length
const draftTemplates = data.templates.flatMap((template) => template.versions).filter((version) => version.status === 'draft').length
const publishedTemplates = data.templates.flatMap((template) => template.versions).filter((version) => version.status === 'published').length
const availablePublishedTemplates = data.templates.filter((template) => (
getTemplateAvailability(template, month).isAvailable
)).length
const effectivePlanCount = activeGroups.filter((group) => group.effectivePlan).length
const inheritedPlanCount = activeGroups.filter((group) => (
group.effectivePlan && group.effectivePlan.effectiveMonth < month
)).length
const draftPlanCount = activeGroups.filter((group) => group.draftPlan).length
const hasPeopleIssues = data.issues.unmatched.length > 0 || data.issues.conflicts.length > 0
const positionsReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length
const templatesReady = availablePublishedTemplates > 0
const launchReady = positionsReady && templatesReady && data.issues.conflicts.length === 0
const steps = [
{
id: 'people',
label: '确认人员资料',
status: data.issues.conflicts.length > 0 ? 'blocked' : hasPeopleIssues ? 'attention' : 'done',
summary: data.issues.conflicts.length > 0
? `${data.issues.conflicts.length} 人同时匹配多个岗位,必须先处理`
: data.issues.unmatched.length > 0
? `${data.issues.unmatched.length} 人待确认:分配岗位,或明确标记为不参与`
: '人员岗位归属没有待处理异常',
action: '处理人员',
target: 'people' as const,
},
{
id: 'positions',
label: '配置并发布岗位',
status: positionsReady ? (draftPlanCount > 0 ? 'attention' : 'done') : 'blocked',
summary: activeGroups.length === 0
? '还没有岗位,请先创建岗位并选择考核项'
: `${effectivePlanCount}/${activeGroups.length} 个岗位设置已生效${inheritedPlanCount > 0 ? `,其中 ${inheritedPlanCount} 个沿用较早设置` : ''}${draftPlanCount > 0 ? `;${draftPlanCount} 个岗位有待发布修改` : ''}${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`,
action: '配置岗位',
target: 'positions' as const,
},
{
id: 'templates',
label: '准备考核模板',
status: templatesReady ? (draftTemplates > 0 ? 'attention' : 'done') : 'blocked',
summary: templatesReady
? `${availablePublishedTemplates} 个模板可用于 ${month}${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}`
: `还没有可用于 ${month} 的已发布模板${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个其它月份版本)` : ''}`,
action: '管理模板',
target: 'templates' as const,
},
{
id: 'launch',
label: '发起调研',
status: launchReady ? 'ready' : 'blocked',
summary: launchReady ? `${month} 的岗位、模板和方案已可用于发起调研` : '完成上面的必要配置后再发起,避免人员或模板选错',
action: '去发起调研',
target: 'launch' as const,
},
]
const loadAudits = useCallback(async () => {
setAuditLoading(true)
setAuditError(null)
try {
const payload = await adminConfigApi.auditLogs(request, month)
setAudits(Array.isArray(payload) ? payload : Array.isArray(payload?.items) ? payload.items : [])
} catch (error) {
setAudits([])
setAuditError(error instanceof Error ? error.message : '配置记录加载失败')
} finally {
setAuditLoading(false)
}
}, [month, request])
useEffect(() => {
void loadAudits()
}, [loadAudits])
return (
<section aria-labelledby="performance-survey-admin-overview-title">
<div className="border-b border-[#e2e2e2] pb-4">
<h2 id="performance-survey-admin-overview-title" className="text-[18px] font-bold text-[#1a1c1c]">
{month} 配置概览
</h2>
<p className="mt-1 max-w-3xl text-[13px] leading-5 text-[#5f5e5e]">
先处理人员归属,再准备模板并配置岗位考核项。岗位设置发布后会持续沿用,无需每月重复配置。
</p>
</div>
<div className="mt-4 border border-[#c2c6d8]">
<div className="border-b border-[#e2e2e2] bg-[#f7f7f7] px-4 py-3">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">本月上线准备</h3>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">按顺序完成;每一步都可以直接进入对应管理页面。</p>
</div>
<ol className="divide-y divide-[#e2e2e2]">
{steps.map((step, index) => {
const statusLabel = {
done: '已完成',
attention: '待确认',
blocked: '未完成',
ready: '可发起',
}[step.status]
const statusClass = {
done: 'bg-[#eaf7ef] text-[#16794b]',
attention: 'bg-[#fff4da] text-[#7a4b00]',
blocked: 'bg-[#f3f3f3] text-[#5f5e5e]',
ready: 'bg-[#e9efff] text-[#0050cb]',
}[step.status]
return (
<li key={step.id} className="grid gap-3 px-4 py-4 sm:grid-cols-[40px_minmax(0,1fr)_auto] sm:items-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-[#c2c6d8] text-[13px] font-bold text-[#1a1c1c]" aria-hidden="true">
{index + 1}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-[14px] font-bold text-[#1a1c1c]">{step.label}</h4>
<span className={`rounded-full px-2 py-1 text-[11px] font-bold ${statusClass}`}>{statusLabel}</span>
</div>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">{step.summary}</p>
</div>
<button
type="button"
onClick={() => onNavigate?.(step.target)}
className="min-h-11 w-full rounded border border-[#1a1c1c] px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#1a1c1c] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 sm:w-auto"
>
{step.action}
</button>
</li>
)
})}
</ol>
</div>
{(data.issues.unmatched.length > 0 || data.issues.conflicts.length > 0) && (
<div className="mt-4 border border-[#e4b86a] bg-[#fffaf0] px-4 py-3 text-[13px] leading-5 text-[#6f4700]">
“未匹配”不等于必须参与考核。请逐一确认:需要参与的人分配岗位,不参与的人明确标记为“不参与”;重复匹配人员必须先消除冲突。
</div>
)}
<section className="mt-6" aria-labelledby="performance-survey-admin-audit-title">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] pb-2">
<h3 id="performance-survey-admin-audit-title" className="text-[14px] font-bold text-[#1a1c1c]">最近配置记录</h3>
<button type="button" onClick={() => void loadAudits()} className="min-h-9 px-2 text-[12px] font-bold text-[#0050cb] hover:underline">
刷新
</button>
</div>
{auditLoading && <div role="status" aria-live="polite" className="py-6 text-[13px] font-semibold text-[#5f5e5e]">记录加载中...</div>}
{auditError && (
<div role="note" className="py-4 text-[13px] text-[#8a5700]">
配置记录暂不可用:{auditError}
</div>
)}
{!auditLoading && !auditError && (
<div className="overflow-x-auto">
<table className="w-full min-w-[620px] border-collapse text-left">
<thead>
<tr className="border-b border-[#e2e2e2] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="px-2 py-3">时间</th>
<th scope="col" className="px-2 py-3">操作人</th>
<th scope="col" className="px-2 py-3">对象</th>
<th scope="col" className="px-2 py-3">变更</th>
</tr>
</thead>
<tbody className="divide-y divide-[#eeeeee]">
{audits.map((audit) => (
<tr key={audit.id} className="text-[13px] text-[#5f5e5e]">
<td className="px-2 py-3">{audit.createdAt ? new Date(audit.createdAt).toLocaleString('zh-CN') : '-'}</td>
<td className="px-2 py-3">{audit.actorName || '-'}</td>
<td className="px-2 py-3">{audit.resourceName || audit.resourceType || audit.targetType || '-'}</td>
<td className="px-2 py-3">{audit.summary || audit.action || '-'}</td>
</tr>
))}
{audits.length === 0 && (
<tr>
<td colSpan={4} className="px-2 py-8 text-center text-[13px] text-[#5f5e5e]">暂无配置记录</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</section>
</section>
)
}
function PositionConfigWorkspace({
data,
month,
request,
onError,
onChanged,
onDirtyChange,
confirmDiscard,
confirmAction,
dirty,
}: {
data: AdminConfig
month: string
request: RequestFn
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
onDirtyChange: (dirty: boolean) => void
confirmDiscard: (message?: string) => Promise<boolean>
confirmAction: ConfirmActionFn
dirty: boolean
}) {
const [selectedId, setSelectedId] = useState(data.groups[0]?.id || '')
const selected = data.groups.find((group) => group.id === selectedId) || data.groups[0]
const [name, setName] = useState(selected?.name || '')
const [sortOrder, setSortOrder] = useState(selected?.sortOrder || 0)
const [templateVersionIds, setTemplateVersionIds] = useState<string[]>(
selected?.draftPlan?.templateVersionIds || selected?.effectivePlan?.templateVersionIds || [],
)
const [templateSearch, setTemplateSearch] = useState('')
const [saving, setSaving] = useState(false)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!selected) return
setName(selected.name)
setSortOrder(selected.sortOrder)
setTemplateVersionIds(selected.draftPlan?.templateVersionIds || selected.effectivePlan?.templateVersionIds || [])
setTemplateSearch('')
onDirtyChange(false)
}, [onDirtyChange, selected])
const markDirty = () => onDirtyChange(true)
const availableTemplateVersions = useMemo(() => data.templates.flatMap((template) => {
const availability = getTemplateAvailability(template, month)
return availability.availableVersion
? [{ template, version: availability.availableVersion }]
: []
}), [data.templates, month])
const versionById = useMemo(
() => new Map(data.templates.flatMap((template) => (
template.versions.map((version) => [version.id, { template, version }] as const)
))),
[data.templates],
)
const searchResults = useMemo(() => {
const keyword = templateSearch.trim().toLocaleLowerCase('zh-CN')
if (!keyword) return []
const selectedTemplateIds = new Set(
templateVersionIds
.map((id) => versionById.get(id)?.template.id)
.filter(Boolean),
)
return availableTemplateVersions.filter(({ template, version }) => (
!templateVersionIds.includes(version.id)
&& !selectedTemplateIds.has(template.id)
&& `${version.definition.name} ${template.name}`.toLocaleLowerCase('zh-CN').includes(keyword)
)).slice(0, 8)
}, [availableTemplateVersions, templateSearch, templateVersionIds, versionById])
if (!selected) return <div className="px-5 py-12 text-center text-sm text-[#727687]">暂无岗位组</div>
const save = async () => {
setSaving(true)
onError(null)
try {
await request(`/admin/position-groups/${selected.id}`, {
method: 'PATCH',
body: JSON.stringify({ name, sortOrder, templateVersionIds, effectiveMonth: month }),
})
await onChanged('岗位设置草稿已保存,可直接在当前页面发布')
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位方案保存失败')
} finally {
setSaving(false)
}
}
const publishPlan = async () => {
if (dirty) {
onError('请先保存当前修改,再发布岗位设置')
return
}
if (!selected.draftPlan) {
onError('当前没有待发布修改')
return
}
if (templateVersionIds.length === 0) {
onError('岗位至少需要选择 1 个可用考核模板')
return
}
setSaving(true)
onError(null)
try {
const preview = await adminConfigApi.previewPositionGroup(request, selected.id, month)
const conflictCount = Number(preview?.conflictCount ?? preview?.conflicts?.length ?? data.issues.conflicts.length)
const blockingIssues = Array.isArray(preview?.blockingIssues)
? preview.blockingIssues.map((item: unknown) => String(item || '').trim()).filter(Boolean)
: []
if (conflictCount > 0 || blockingIssues.length > 0) {
const details = [
conflictCount > 0 ? `${conflictCount} 人同时匹配多个岗位` : '',
...blockingIssues,
].filter(Boolean)
onError(`发布前检查未通过:${details.join(';')}`)
return
}
const draftPlanId = String(preview?.draftPlanId || '').trim()
const draftVersion = Number(preview?.draftVersion)
const draftUpdatedAt = String(preview?.draftUpdatedAt || '').trim()
if (!draftPlanId || !Number.isInteger(draftVersion) || !draftUpdatedAt) {
onError('发布前检查信息不完整,请刷新页面后重试')
return
}
const memberCount = Number(preview?.memberCount ?? preview?.affectedEmployees ?? selected.memberCount)
await confirmAction({
title: `发布“${selected.name}”的岗位设置?`,
description: (
<div className="grid gap-1">
<p>包含 {templateVersionIds.length} 个考核项,预计影响 {memberCount} 人。</p>
<p>发布后从 {month} 起持续生效,后续月份无需重复设置。</p>
</div>
),
confirmLabel: '确认发布',
loadingLabel: '发布中...',
action: async () => {
await request(`/admin/position-groups/${selected.id}/publish`, {
method: 'POST',
body: JSON.stringify({
effectiveMonth: month,
draftPlanId,
draftVersion,
draftUpdatedAt,
}),
})
await onChanged(`${selected.name} 的岗位设置已发布,从 ${month} 起持续生效`)
},
})
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位设置发布失败')
} finally {
setSaving(false)
}
}
const createGroup = async () => {
if (!newName.trim()) {
onError('请填写岗位名称')
return
}
setSaving(true)
onError(null)
try {
const created = await adminConfigApi.createPositionGroup(request, {
name: newName.trim(),
effectiveMonth: month,
})
const next = await onChanged(`岗位“${newName.trim()}”已创建`)
const nextSelectedId = created.id || created.group?.id || next?.groups.find((group) => group.name === newName.trim())?.id
if (nextSelectedId) setSelectedId(nextSelectedId)
setCreating(false)
setNewName('')
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位创建失败')
} finally {
setSaving(false)
}
}
const removeGroup = async () => {
if (!selected) return
const impactMessages = [
selected.memberCount > 0 ? `${selected.memberCount} 人将变为待分配。` : '',
dirty ? '未保存的修改会一并丢失。' : '',
].filter(Boolean)
await confirmAction({
title: `删除岗位“${selected.name}”?`,
description: impactMessages.length > 0 ? impactMessages.join(' ') : undefined,
confirmLabel: '删除岗位',
loadingLabel: '删除中...',
tone: 'danger',
reason: {
label: '删除原因',
placeholder: '简要说明原因',
required: true,
},
action: async (reason) => {
setSaving(true)
onError(null)
try {
const removeResult = await adminConfigApi.removePositionGroup(request, selected.id, reason)
const next = await onChanged(
removeResult.action === 'deleted'
? `岗位“${selected.name}”已删除`
: `岗位“${selected.name}”已从管理列表删除,历史记录已保留`,
)
setSelectedId(next?.groups[0]?.id || '')
} finally {
setSaving(false)
}
},
})
}
const addTemplateVersion = (versionId: string) => {
setTemplateVersionIds((current) => current.includes(versionId) ? current : [...current, versionId])
setTemplateSearch('')
markDirty()
}
const removeTemplateVersion = (versionId: string) => {
setTemplateVersionIds((current) => current.filter((id) => id !== versionId))
markDirty()
}
const moveTemplateVersion = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= templateVersionIds.length) return
setTemplateVersionIds((current) => {
const next = [...current]
const [moved] = next.splice(index, 1)
next.splice(targetIndex, 0, moved)
return next
})
markDirty()
}
const selectGroup = async (groupId: string) => {
if (groupId === selected?.id) return
if (!await confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setSelectedId(groupId)
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) {
window.requestAnimationFrame(() => editorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
}
}
const selectedGroupState = getPositionGroupState(selected)
return (
<div className="grid min-h-[620px] md:grid-cols-[240px_minmax(0,1fr)]">
<aside className="border-b border-[#e2e2e2] md:border-b-0 md:border-r">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
onClick={() => setCreating((current) => !current)}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-3 text-[13px] font-bold text-white hover:bg-black"
>
{creating ? '取消新增' : '新增岗位'}
</button>
{creating && (
<div className="mt-3 grid gap-2">
<input
aria-label="新岗位名称"
value={newName}
onChange={(event) => setNewName(event.target.value)}
placeholder="岗位名称"
autoFocus
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]"
/>
<button type="button" disabled={saving} onClick={() => void createGroup()} className="min-h-11 rounded bg-[#0050cb] px-3 text-[13px] font-bold text-white disabled:opacity-50">
确认创建
</button>
</div>
)}
</div>
{data.groups.map((group) => {
const groupState = getPositionGroupState(group)
return (
<button
key={group.id}
type="button"
onClick={() => void selectGroup(group.id)}
aria-label={`${group.name},${group.memberCount} 人,${groupState.label}`}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#eeeeee] px-4 py-3 text-left ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{group.name}</span>
<span className="mt-0.5 block text-[11px] text-[#5f5e5e]">{group.memberCount} 人</span>
</span>
<StatusDot label={groupState.label} tone={groupState.tone} />
</button>
)
})}
</aside>
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] pb-3">
<div className="flex items-center gap-2">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">{selected.name}</h3>
<StatusDot label={selectedGroupState.label} tone={selectedGroupState.tone} />
</div>
<button type="button" disabled={saving} onClick={() => void removeGroup()} className="min-h-10 rounded border border-[#f0b8b8] px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">
删除岗位
</button>
</div>
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_140px]">
<AdminField label="岗位名称" value={name} onChange={(value) => { setName(value); markDirty() }} />
<AdminField label="排序" value={String(sortOrder)} onChange={(value) => { setSortOrder(Number(value) || 0); markDirty() }} type="number" />
</div>
<div className="mt-6 border-t border-[#eeeeee] pt-5">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">岗位考核项</h3>
<div className="relative mt-3 max-w-xl">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
添加考核项
<input
value={templateSearch}
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="输入模板名称搜索"
className="h-11 w-full rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]"
/>
</label>
{templateSearch.trim() && (
<div className="absolute z-30 mt-1 max-h-64 w-full overflow-y-auto border border-[#c2c6d8] bg-white shadow-[0_4px_8px_rgba(0,0,0,0.12)]">
{searchResults.map(({ template, version }) => (
<button
key={version.id}
type="button"
onClick={() => addTemplateVersion(version.id)}
className="flex min-h-11 w-full items-center justify-between gap-3 border-b border-[#eeeeee] px-3 py-2 text-left last:border-b-0 hover:bg-[#f4f6ff]"
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{version.definition.name}</span>
</span>
<span className="shrink-0 text-[11px] text-[#5f5e5e]">v{version.version}</span>
</button>
))}
{searchResults.length === 0 && (
<div className="px-3 py-4 text-center text-[12px] text-[#727687]">没有可添加的匹配模板</div>
)}
</div>
)}
</div>
<div className="mt-4 grid gap-2">
{templateVersionIds.map((id, index) => {
const item = versionById.get(id)
return (
<div key={id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-[#c2c6d8] px-3 py-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-bold text-[#1a1c1c]">{item?.version.definition.name || item?.template.name || '历史模板'}</div>
<div className="mt-0.5 text-[11px] text-[#727687]">
第 {index + 1} 项{item ? ` · v${item.version.version}` : ''}
</div>
</div>
<div className="flex flex-wrap justify-end gap-1">
<button type="button" disabled={index === 0} onClick={() => moveTemplateVersion(index, -1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">上移</button>
<button type="button" disabled={index === templateVersionIds.length - 1} onClick={() => moveTemplateVersion(index, 1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">下移</button>
<button type="button" onClick={() => removeTemplateVersion(id)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">移除</button>
</div>
</div>
)
})}
{templateVersionIds.length === 0 && (
<div className="border border-dashed border-[#c2c6d8] px-4 py-7 text-center text-[12px] text-[#727687]">
尚未选择考核项,请通过上方搜索框添加
</div>
)}
</div>
</div>
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-wrap items-center justify-between gap-2 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:justify-end sm:shadow-none">
<span className="flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e] sm:mr-auto">
<StatusDot
label={dirty ? '未保存' : selected.draftPlan ? '待发布' : selected.effectivePlan ? '已生效' : '待配置'}
tone={dirty || selected.draftPlan || !selected.effectivePlan ? 'warning' : 'success'}
/>
{dirty ? '未保存' : selected.draftPlan ? '待发布' : selected.effectivePlan ? '已生效' : '待配置'}
</span>
<button type="button" disabled={saving || selected.status === 'disabled' || !dirty} onClick={() => void save()} className="min-h-11 rounded border border-[#1a1c1c] px-5 text-[13px] font-bold text-[#1a1c1c] disabled:opacity-50">
{saving ? '处理中...' : '保存草稿'}
</button>
<button
type="button"
disabled={saving || selected.status === 'disabled' || dirty || !selected.draftPlan || templateVersionIds.length === 0}
onClick={() => void publishPlan()}
title={dirty ? '请先保存当前修改' : selected.draftPlan ? undefined : '当前没有待发布修改'}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white disabled:opacity-50"
>
发布岗位设置
</button>
</div>
</div>
</div>
)
}
function TemplateConfigWorkspace({
data,
month,
request,
onError,
onChanged,
onDirtyChange,
confirmDiscard,
confirmAction,
dirty,
}: {
data: AdminConfig
month: string
request: RequestFn
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
onDirtyChange: (dirty: boolean) => void
confirmDiscard: (message?: string) => Promise<boolean>
confirmAction: ConfirmActionFn
dirty: boolean
}) {
const firstTemplate = data.templates[0]
const [selectedTemplateId, setSelectedTemplateId] = useState(firstTemplate?.id || 'new')
const selectedTemplate = data.templates.find((template) => template.id === selectedTemplateId) || null
const selectedVersion = selectedTemplate?.versions.find((version) => version.status === 'draft')
|| selectedTemplate?.versions[0]
|| null
const [versionId, setVersionId] = useState<string | null>(selectedVersion?.id || null)
const [definition, setDefinition] = useState<TemplateDefinition>(() => clone(selectedVersion?.definition || newTemplateDefinition()))
const [editorStep, setEditorStep] = useState<TemplateEditorStep>('basics')
const [saving, setSaving] = useState(false)
const [readinessOpen, setReadinessOpen] = useState(false)
const [operationFeedback, setOperationFeedback] = useState<{
state: 'idle' | 'saving' | 'saved' | 'publishing' | 'published' | 'error'
message: string
}>({ state: 'idle', message: '' })
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const nextVersion = selectedTemplate?.versions.find((version) => version.status === 'draft')
|| selectedTemplate?.versions[0]
|| null
const nextDefinition = clone(nextVersion?.definition || newTemplateDefinition())
setVersionId(nextVersion?.id || null)
setDefinition(nextDefinition)
setEditorStep('basics')
setReadinessOpen(false)
onDirtyChange(false)
}, [onDirtyChange, selectedTemplate])
const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null
const editable = !version || version.status === 'draft'
const readinessItems = getTemplateReadiness(definition)
const readinessCompleted = readinessItems.filter((item) => item.status === 'done').length
const templateDefinitionReady = readinessCompleted === readinessItems.length
const updateDefinition = (next: TemplateDefinition | ((current: TemplateDefinition) => TemplateDefinition)) => {
setDefinition(next)
onDirtyChange(true)
setOperationFeedback({ state: 'idle', message: '未保存' })
}
const save = async () => {
setSaving(true)
onError(null)
setOperationFeedback({ state: 'saving', message: '正在保存模板草稿…' })
try {
const saved = versionId
? await request<{ id: string }>(`/admin/templates/${versionId}`, {
method: 'PATCH',
body: JSON.stringify({ definition }),
})
: await request<{ id: string }>('/admin/templates', {
method: 'POST',
body: JSON.stringify({ definition }),
})
const next = await onChanged('模板草稿已保存')
const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === saved.id))
if (nextTemplate) setSelectedTemplateId(nextTemplate.id)
setVersionId(saved.id)
onDirtyChange(false)
const savedTime = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date())
setOperationFeedback({ state: 'saved', message: `草稿已保存 · ${savedTime}` })
} catch (actionError) {
const message = actionError instanceof Error ? actionError.message : '模板草稿保存失败'
onError(message)
setOperationFeedback({ state: 'error', message: `保存失败:${message}` })
} finally {
setSaving(false)
}
}
const cloneVersion = async () => {
if (!versionId || !await confirmDiscard()) return
setSaving(true)
onError(null)
setOperationFeedback({ state: 'saving', message: '正在创建可编辑草稿…' })
try {
const draft = await request<{ id: string }>(`/admin/templates/${versionId}/clone`, { method: 'POST', body: '{}' })
const next = await onChanged('已创建新的模板草稿')
const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === draft.id))
if (nextTemplate) setSelectedTemplateId(nextTemplate.id)
setVersionId(draft.id)
setOperationFeedback({ state: 'saved', message: '新的可编辑草稿已创建' })
} catch (actionError) {
const message = actionError instanceof Error ? actionError.message : '模板草稿创建失败'
onError(message)
setOperationFeedback({ state: 'error', message: `创建草稿失败:${message}` })
} finally {
setSaving(false)
}
}
const publish = async () => {
if (!versionId) return
if (dirty) {
onError('请先保存模板草稿,再执行发布')
setOperationFeedback({ state: 'error', message: '发布前请先保存当前修改' })
return
}
if (!templateDefinitionReady) {
const firstIssue = readinessItems.find((item) => item.status !== 'done')
onError(firstIssue?.summary || '请先完成发布前检查')
setOperationFeedback({ state: 'error', message: firstIssue?.summary || '发布前检查未完成' })
return
}
await confirmAction({
title: `发布模板“${definition.name}”?`,
description: (
<div className="grid gap-1">
<p>模板将从 {month} 起成为可用模板,可直接添加到岗位考核项。</p>
<p>
{surveyTypeLabel(definition.surveyType)} · {definition.questions.length} 个评分项 ·
{' '}{definition.workflow.stages.length} 个评分阶段 · 总分 {Number(definition.maxTotal || 0)}
</p>
<p>发布后的版本不可直接修改;后续调整需要创建新的草稿版本。</p>
</div>
),
confirmLabel: `发布至 ${month}`,
loadingLabel: '发布中...',
action: async () => {
setSaving(true)
onError(null)
setOperationFeedback({ state: 'publishing', message: `正在发布至 ${month}…` })
try {
await request(`/admin/templates/${versionId}/publish`, {
method: 'POST',
body: JSON.stringify({ effectiveMonth: month }),
})
await onChanged(`${definition.name} 已从 ${month} 起发布`)
setOperationFeedback({ state: 'published', message: `${definition.name} 已从 ${month} 起生效` })
} catch (actionError) {
const message = actionError instanceof Error ? actionError.message : '模板发布失败'
onError(message)
setOperationFeedback({ state: 'error', message: `发布失败:${message}` })
throw actionError
} finally {
setSaving(false)
}
},
})
}
const removeTemplate = async () => {
if (!selectedTemplate) return
const displayName = version?.definition.name || selectedTemplate.versions[0]?.definition.name || selectedTemplate.name
await confirmAction({
title: `删除模板“${displayName}”?`,
description: dirty ? '未保存的修改会一并丢失。' : undefined,
confirmLabel: '删除模板',
loadingLabel: '删除中...',
tone: 'danger',
reason: {
label: '删除原因',
placeholder: '简要说明原因',
required: true,
},
action: async (reason) => {
setSaving(true)
onError(null)
try {
const removeResult = await adminConfigApi.removeTemplate(request, selectedTemplate.id, reason)
const next = await onChanged(
removeResult.action === 'deleted'
? `模板“${displayName}”已删除`
: `模板“${displayName}”已从模板列表删除,历史记录已保留`,
)
setSelectedTemplateId(next?.templates[0]?.id || 'new')
} finally {
setSaving(false)
}
},
})
}
const selectTemplate = async (templateId: string) => {
if (templateId === selectedTemplateId) return
if (!await confirmDiscard('切换模板会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setOperationFeedback({ state: 'idle', message: '' })
setSelectedTemplateId(templateId)
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) {
window.requestAnimationFrame(() => editorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
}
}
const selectVersion = async (nextVersion: TemplateVersion) => {
if (nextVersion.id === versionId) return
if (!await confirmDiscard('切换版本会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setOperationFeedback({ state: 'idle', message: '' })
setVersionId(nextVersion.id)
setDefinition(clone(nextVersion.definition))
setEditorStep('basics')
}
const selectedTemplateName = selectedTemplate
? selectedTemplate.versions[0]?.definition.name || selectedTemplate.name
: '新建模板'
const selectedTemplateState = selectedTemplate
? getTemplateListState(selectedTemplate, month)
: null
const feedbackMessage = saving
? operationFeedback.message || '处理中'
: dirty
? '未保存'
: operationFeedback.message || (editable ? '已保存' : '只读')
const feedbackTone: StatusTone = operationFeedback.state === 'error'
? 'danger'
: dirty || saving
? 'warning'
: operationFeedback.state === 'saved' || operationFeedback.state === 'published'
? 'success'
: 'neutral'
return (
<div className="grid min-h-[680px] md:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-b border-[#e2e2e2] md:border-b-0 md:border-r">
<div className="p-3 md:hidden">
<label className="grid gap-2 text-[13px] font-bold text-[#1a1c1c]">
<span className="sr-only">选择模板</span>
<select
aria-label="选择模板"
value={selectedTemplateId}
onChange={(event) => void selectTemplate(event.target.value)}
className="h-12 w-full rounded border border-[#c2c6d8] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c]"
>
<option value="new">+ 新建模板</option>
{data.templates.map((template) => (
<option key={template.id} value={template.id}>
{template.versions[0]?.definition.name || template.name}
</option>
))}
</select>
</label>
<div className="mt-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
<span className="min-w-0 truncate">{selectedTemplateName}</span>
{selectedTemplateState && (
<StatusDot label={selectedTemplateState.label} tone={selectedTemplateState.tone} />
)}
</div>
</div>
<div className="hidden md:block">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
onClick={() => void selectTemplate('new')}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-4 text-center text-[13px] font-bold text-white hover:bg-black"
>
新建模板
</button>
</div>
{data.templates.map((template) => {
const state = getTemplateListState(template, month)
const templateName = template.versions[0]?.definition.name || template.name
return (
<button
key={template.id}
type="button"
onClick={() => void selectTemplate(template.id)}
aria-label={`${templateName},${state.label}`}
className={`min-h-14 w-full border-b border-[#eeeeee] px-4 py-3 text-left ${selectedTemplateId === template.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{templateName}</span>
{state.meta && <span className="mt-0.5 block text-[11px] text-[#5f5e5e]">{state.meta}</span>}
</span>
<StatusDot label={state.label} tone={state.tone} />
</span>
</button>
)
})}
</div>
</aside>
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
{selectedTemplate && (
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-[#eeeeee] pb-3">
{selectedTemplate.versions.map((item) => (
<button
key={item.id}
type="button"
onClick={() => void selectVersion(item)}
className={`min-h-10 rounded border px-3 text-left text-[12px] font-bold ${versionId === item.id ? 'border-[#0050cb] bg-[#f1f4ff] text-[#0050cb]' : 'border-[#e2e2e2] text-[#5f5e5e]'}`}
>
<span className="block">v{item.version} · {item.status === 'draft' ? '草稿' : item.status === 'published' ? '已发布' : '已归档'}</span>
{item.effectiveMonth && <span className="mt-0.5 block text-[10px] font-semibold opacity-80">{item.effectiveMonth} 起</span>}
</button>
))}
<button
type="button"
disabled={saving}
onClick={() => void removeTemplate()}
className="ml-auto min-h-10 rounded border border-[#f0b8b8] px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:opacity-50"
>
删除模板
</button>
</div>
)}
<nav aria-label="模板编辑步骤" className="mb-5 grid grid-cols-3 border border-[#c2c6d8]">
{([
['basics', '1. 基本信息'],
['questions', '2. 评分项'],
['workflow', '3. 评分人设置'],
] as const).map(([step, label]) => (
(() => {
const readiness = readinessItems.find((item) => item.step === step)
const statusLabel = readiness?.status === 'done'
? '完成'
: readiness?.status === 'error' ? '有错误' : '待完善'
return (
<button
key={step}
type="button"
aria-label={label}
aria-current={editorStep === step ? 'step' : undefined}
title={statusLabel}
onClick={() => setEditorStep(step)}
className={`min-h-12 border-r border-[#c2c6d8] px-2 py-2 text-[12px] font-bold last:border-r-0 ${
editorStep === step ? 'bg-[#1a1c1c] text-white' : 'bg-white text-[#5f5e5e] hover:bg-[#f4f4f4]'
}`}
>
<span className="flex items-center justify-center gap-2">
<span>{label}</span>
<StatusDot
label={statusLabel}
tone={readiness?.status === 'done' ? 'success' : readiness?.status === 'error' ? 'danger' : 'warning'}
/>
</span>
</button>
)
})()
))}
</nav>
<details
key={`${selectedTemplateId}-${versionId || 'new'}`}
open={readinessOpen}
onToggle={(event) => setReadinessOpen(event.currentTarget.open)}
className="mb-5 border border-[#e2e2e2] bg-white"
>
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 text-[13px] font-bold text-[#1a1c1c]">
<span>发布检查</span>
<StatusDot
label={templateDefinitionReady ? '已完成' : `${readinessCompleted}/${readinessItems.length} 项完成`}
tone={templateDefinitionReady ? 'success' : 'warning'}
/>
</summary>
<div className="border-t border-[#eeeeee] px-4 py-3">
<ul className="grid gap-2">
{readinessItems.map((item) => {
const statusLabel = item.status === 'done' ? '已完成' : item.status === 'error' ? '有错误' : '待完善'
return (
<li key={item.step} className="flex flex-col gap-2 border-b border-black/5 py-2 last:border-b-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[13px] font-bold text-[#1a1c1c]">{item.label}</span>
<StatusDot
label={statusLabel}
tone={item.status === 'done' ? 'success' : item.status === 'error' ? 'danger' : 'warning'}
/>
</div>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">{item.summary}</p>
</div>
{item.status !== 'done' && (
<button
type="button"
onClick={() => setEditorStep(item.step)}
className="min-h-11 shrink-0 rounded border border-[#0050cb] px-3 text-[12px] font-bold text-[#0050cb] hover:bg-[#edf3ff]"
>
去完善
</button>
)}
</li>
)
})}
</ul>
</div>
</details>
{editorStep === 'basics' && (
<section aria-labelledby="template-basics-heading">
<div className="mb-3">
<h3 id="template-basics-heading" className="text-[15px] font-bold text-[#1a1c1c]">基本信息</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<AdminField
label="模板名称"
value={definition.name}
onChange={(value) => updateDefinition((current) => ({ ...current, name: value, shortName: value }))}
disabled={!editable}
/>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
考核内容类型
<select
aria-label="考核内容类型"
value={definition.surveyType}
disabled={!editable}
onChange={(event) => updateDefinition((current) => ({ ...current, surveyType: event.target.value as TemplateDefinition['surveyType'] }))}
className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
>
<option value="person_period">员工工作表现</option>
<option value="visual_case">作品或案例</option>
<option value="item_course">课程或事项</option>
</select>
</label>
{definition.surveyType === 'visual_case' && (
<AdminField
label="每人需要提交的作品数量"
value={String(definition.requiredWorkItemCount || 3)}
type="number"
disabled={!editable}
onChange={(value) => updateDefinition((current) => ({ ...current, requiredWorkItemCount: Math.max(1, Number(value) || 1), workItemImageRequired: true }))}
/>
)}
<label className="grid gap-1 sm:col-span-2 text-[12px] font-semibold text-[#5f5e5e]">
给发起人和评分人的说明
<textarea
value={definition.description}
disabled={!editable}
onChange={(event) => updateDefinition((current) => ({ ...current, description: event.target.value }))}
className="min-h-24 rounded border border-[#e2e2e2] px-3 py-2 text-[13px] font-normal text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
</div>
</section>
)}
{editorStep === 'questions' && (
<QuestionEditor definition={definition} editable={editable} onChange={updateDefinition} />
)}
{editorStep === 'workflow' && (
<WorkflowEditor definition={definition} editable={editable} request={request} onChange={updateDefinition} />
)}
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-col gap-3 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:flex-row sm:items-center sm:justify-between sm:shadow-none">
<div
role="status"
aria-live="polite"
className={`flex min-w-0 items-center gap-2 text-[12px] font-semibold ${
operationFeedback.state === 'error'
? 'text-[#b42318]'
: operationFeedback.state === 'saved' || operationFeedback.state === 'published'
? 'text-[#16794b]'
: 'text-[#5f5e5e]'
}`}
>
<StatusDot label={feedbackMessage} tone={feedbackTone} />
<span>{feedbackMessage}</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
{!editable && (
<button
type="button"
disabled={saving}
onClick={() => void cloneVersion()}
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#0042a8] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? '处理中…' : '修改模板'}
</button>
)}
{editable && (
<button
type="button"
disabled={saving}
onClick={() => void save()}
className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && operationFeedback.state === 'saving' ? '保存中…' : '保存草稿'}
</button>
)}
{editable && versionId && (
<button
type="button"
disabled={saving || !templateDefinitionReady}
onClick={() => void publish()}
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#0042a8] disabled:cursor-not-allowed disabled:opacity-50"
title={templateDefinitionReady
? undefined
: readinessItems.find((item) => item.status !== 'done')?.summary}
>
{saving && operationFeedback.state === 'publishing' ? '发布中…' : `发布至 ${month}`}
</button>
)}
</div>
</div>
</div>
</div>
)
}
function QuestionEditor({
definition,
editable,
onChange,
}: {
definition: TemplateDefinition
editable: boolean
onChange: (definition: TemplateDefinition) => void
}) {
const perItemTotal = definition.questions.reduce(
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
0,
)
const workItemCount = definition.surveyType === 'visual_case'
? Math.max(1, Number(definition.requiredWorkItemCount || 1))
: 1
const allocatedTotal = perItemTotal * workItemCount
const configuredTotal = Number(definition.maxTotal || 0)
const totalMatches = Math.round(allocatedTotal * 100) === Math.round(configuredTotal * 100)
const [expandedQuestionId, setExpandedQuestionId] = useState<string | null>(
definition.questions[0]?.id || null,
)
const [deletedQuestion, setDeletedQuestion] = useState<{
question: TemplateDefinition['questions'][number]
index: number
} | null>(null)
useEffect(() => {
if (
expandedQuestionId
&& definition.questions.some((question) => question.id === expandedQuestionId)
) return
setExpandedQuestionId(definition.questions[0]?.id || null)
}, [definition.questions, expandedQuestionId])
useEffect(() => {
if (!deletedQuestion) return
const timer = window.setTimeout(() => setDeletedQuestion(null), 6000)
return () => window.clearTimeout(timer)
}, [deletedQuestion])
const addQuestion = () => {
const nextQuestion = {
id: `score_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
title: `评分项 ${definition.questions.length + 1}`,
description: '',
maxScore: definition.questionMaxScore || 20,
}
onChange({ ...definition, questions: [...definition.questions, nextQuestion] })
setExpandedQuestionId(nextQuestion.id)
}
const copyQuestion = (index: number) => {
const source = definition.questions[index]
if (!source) return
const copy = {
...source,
id: `score_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
title: `${source.title || `评分项 ${index + 1}`}(副本)`,
}
const questions = [...definition.questions]
questions.splice(index + 1, 0, copy)
onChange({ ...definition, questions })
setExpandedQuestionId(copy.id)
}
const moveQuestion = (index: number, offset: -1 | 1) => {
const targetIndex = index + offset
if (targetIndex < 0 || targetIndex >= definition.questions.length) return
const questions = [...definition.questions]
const [question] = questions.splice(index, 1)
questions.splice(targetIndex, 0, question)
onChange({ ...definition, questions })
setExpandedQuestionId(question.id)
}
const removeQuestion = (index: number) => {
const question = definition.questions[index]
if (!question) return
setDeletedQuestion({ question, index })
onChange({
...definition,
questions: definition.questions.filter((_, itemIndex) => itemIndex !== index),
})
}
const undoRemoveQuestion = () => {
if (!deletedQuestion) return
const questions = [...definition.questions]
questions.splice(
Math.min(deletedQuestion.index, questions.length),
0,
deletedQuestion.question,
)
onChange({ ...definition, questions })
setExpandedQuestionId(deletedQuestion.question.id)
setDeletedQuestion(null)
}
return (
<section aria-labelledby="template-questions-heading">
<h3 id="template-questions-heading" className="text-[15px] font-bold text-[#1a1c1c]">评分项</h3>
<div className={`sticky top-2 z-10 mt-3 grid gap-3 border px-4 py-4 shadow-sm sm:grid-cols-[minmax(0,240px)_1fr] sm:items-end ${totalMatches ? 'border-[#c2c6d8] bg-white' : 'border-[#e7b64a] bg-[#fff9ec]'}`}>
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
总分
<input
type="number"
min="1"
value={configuredTotal || ''}
disabled={!editable}
onChange={(event) => onChange({ ...definition, maxTotal: Math.max(1, Number(event.target.value) || 1) })}
className="h-11 min-w-0 w-full rounded border border-[#c2c6d8] px-3 text-[13px] font-bold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
<div className={`text-[12px] leading-5 ${totalMatches ? 'text-[#5f5e5e]' : 'font-bold text-[#8a5700]'}`}>
{definition.surveyType === 'visual_case'
? `已分配 ${perItemTotal} × ${workItemCount} 个作品 = ${allocatedTotal} / ${configuredTotal || 0} 分`
: `已分配 ${allocatedTotal} / ${configuredTotal || 0} 分`}
{!totalMatches && (
<span className="block">
{allocatedTotal < configuredTotal
? `还需分配 ${configuredTotal - allocatedTotal} 分。`
: `已超出 ${allocatedTotal - configuredTotal} 分。`}
请调整评分项满分或总分。
</span>
)}
</div>
</div>
{deletedQuestion && (
<div
role="status"
aria-live="polite"
className="mt-3 flex min-h-11 flex-wrap items-center justify-between gap-2 border border-[#b3c5ff] bg-[#f8faff] px-3 py-2 text-[12px] font-semibold text-[#1a1c1c]"
>
<span>已删除“{deletedQuestion.question.title || '未命名评分项'}”</span>
<button
type="button"
onClick={undoRemoveQuestion}
className="min-h-11 rounded px-3 font-bold text-[#0050cb] hover:bg-[#edf3ff]"
>
撤销删除
</button>
</div>
)}
<div className="mt-4 grid gap-3">
{definition.questions.length === 0 && (
<div className="border border-dashed border-[#c2c6d8] px-4 py-8 text-center text-[12px] text-[#727687]">
还没有评分项,请点击“添加评分项”
</div>
)}
{definition.questions.map((question, index) => {
const expanded = expandedQuestionId === question.id
const complete = Boolean(question.title.trim()) && Number(question.maxScore || 0) > 0
const contentId = `question-editor-${question.id}`
return (
<div key={question.id} className={`border ${expanded ? 'border-[#9fb8ff]' : 'border-[#e2e2e2]'}`}>
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
<button
type="button"
aria-expanded={expanded}
aria-controls={contentId}
onClick={() => setExpandedQuestionId(expanded ? null : question.id)}
className="flex min-h-11 min-w-0 basis-full items-center gap-3 rounded px-1 text-left hover:bg-[#f7f7f7] sm:basis-auto sm:flex-1"
>
<span className="min-w-0 flex-1">
<span className="block text-[12px] font-semibold text-[#727687]">评分项 {index + 1}</span>
<span className="mt-0.5 block truncate text-[13px] font-bold text-[#1a1c1c]">
{question.title || '名称待填写'}
</span>
</span>
<span className="shrink-0 text-[12px] font-bold text-[#1a1c1c]">
{Number(question.maxScore || 0)} 分
</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
complete ? 'bg-[#eaf7ef] text-[#16794b]' : 'bg-[#fff0c7] text-[#7a4b00]'
}`}>
{complete ? '完整' : '待填写'}
</span>
<span aria-hidden="true" className="w-4 shrink-0 text-center text-[#5f5e5e]">
{expanded ? '−' : '+'}
</span>
</button>
{editable && (
<div className="flex w-full flex-wrap items-center justify-end gap-1 border-t border-[#eeeeee] pt-1 sm:w-auto sm:border-t-0 sm:pt-0">
<button type="button" aria-label={`复制评分项 ${index + 1}`} onClick={() => copyQuestion(index)} className="min-h-11 rounded px-3 text-[11px] font-bold text-[#0050cb] hover:bg-[#edf3ff]">复制</button>
<button type="button" aria-label={`上移评分项 ${index + 1}`} disabled={index === 0} onClick={() => moveQuestion(index, -1)} className="min-h-11 rounded px-3 text-[11px] font-bold text-[#5f5e5e] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-35">上移</button>
<button type="button" aria-label={`下移评分项 ${index + 1}`} disabled={index === definition.questions.length - 1} onClick={() => moveQuestion(index, 1)} className="min-h-11 rounded px-3 text-[11px] font-bold text-[#5f5e5e] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-35">下移</button>
<button type="button" aria-label={`删除评分项 ${index + 1}`} onClick={() => removeQuestion(index)} className="min-h-11 rounded px-3 text-[11px] font-bold text-[#b42318] hover:bg-[#fff5f5]">删除</button>
</div>
)}
</div>
{expanded && (
<div id={contentId} className="grid gap-3 border-t border-[#eeeeee] p-4 sm:grid-cols-[minmax(0,1fr)_120px]">
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
评分项名称
<input
value={question.title}
disabled={!editable}
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item) })}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
满分
<input
type="number"
min="1"
value={question.maxScore || 20}
disabled={!editable}
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, maxScore: Number(event.target.value) || 1 } : item) })}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e] sm:col-span-2">
评分说明(可选)
<textarea
value={question.description || ''}
disabled={!editable}
placeholder="告诉评分人这一项应该看什么"
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, description: event.target.value } : item) })}
className="min-h-20 rounded border border-[#e2e2e2] px-3 py-2 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
</div>
)}
</div>
)
})}
</div>
{editable && (
<button
type="button"
onClick={addQuestion}
className="mt-4 min-h-12 w-full rounded border border-dashed border-[#0050cb] px-4 text-[13px] font-bold text-[#0050cb] hover:bg-[#edf3ff]"
>
添加评分项
</button>
)}
</section>
)
}
function WorkflowEditor({
definition,
editable,
request,
onChange,
}: {
definition: TemplateDefinition
editable: boolean
request: RequestFn
onChange: (
definition: TemplateDefinition | ((current: TemplateDefinition) => TemplateDefinition)
) => void
}) {
const reviewerResolutionSequenceRef = useRef<Record<string, number>>({})
const updateStages = (
nextStages: StageDraft[] | ((currentStages: StageDraft[]) => StageDraft[]),
) => {
onChange((currentDefinition) => {
const resolvedStages = typeof nextStages === 'function'
? nextStages(currentDefinition.workflow.stages)
: nextStages
const stages = resolvedStages.map((stage, index) => ({ ...stage, order: index }))
const currentScoring = currentDefinition.workflow.scoring
const scoring = currentScoring.mode === 'average_reviewers'
? { mode: 'average_reviewers' as const, stageKeys: stages.map((stage) => stage.key) }
: (() => {
const selectedStage = stages.find((stage) => stage.key === currentScoring.stageKey) || stages[stages.length - 1]
const selectedReviewer = selectedStage?.reviewers.find((reviewer) => reviewer.source === currentScoring.reviewerSource)
|| selectedStage?.reviewers[0]
return {
mode: 'final_reviewer' as const,
stageKey: selectedStage?.key || '',
reviewerSource: selectedReviewer?.source || 'manual_selection' as ReviewerSource,
}
})()
return {
...currentDefinition,
workflow: { ...currentDefinition.workflow, stages, scoring },
}
})
}
const stages = definition.workflow.stages
const changeReviewerSource = async (
stageIndex: number,
reviewerIndex: number,
source: ReviewerSource,
) => {
const stageKey = stages[stageIndex]?.key
if (!stageKey) return
const resolutionKey = `${stageIndex}:${stageKey}:${reviewerIndex}`
const sequence = (reviewerResolutionSequenceRef.current[resolutionKey] || 0) + 1
reviewerResolutionSequenceRef.current[resolutionKey] = sequence
const nextReviewer: ReviewerDraft = {
source,
label: reviewerSourceLabel(source),
}
updateStages((currentStages) => currentStages.map((stage, index) => (
index === stageIndex && stage.key === stageKey
? {
...stage,
reviewers: stage.reviewers.map((reviewer, indexInStage) => (
indexInStage === reviewerIndex ? nextReviewer : reviewer
)),
}
: stage
)))
if (source !== 'fixed_employee') return
try {
const employees = await request<Array<{ id: string; displayName: string }>>(
'/employees?purpose=evaluator&limit=20&search=%E5%85%83%E6%B3%BD',
)
const exactMatches = employees.filter((employee) => employee.displayName.trim() === '元泽')
if (
reviewerResolutionSequenceRef.current[resolutionKey] !== sequence
|| exactMatches.length !== 1
) return
updateStages((currentStages) => currentStages.map((stage, index) => (
index === stageIndex && stage.key === stageKey
? {
...stage,
reviewers: stage.reviewers.map((reviewer, indexInStage) => (
indexInStage === reviewerIndex && reviewer.source === 'fixed_employee'
? {
...reviewer,
label: '元泽',
employeeId: exactMatches[0].id,
employeeName: exactMatches[0].displayName,
}
: reviewer
)),
}
: stage
)))
} catch {
// 保留未解析状态,让管理员通过下方人员搜索完成选择。
}
}
return (
<section aria-labelledby="template-workflow-heading">
<div className="flex flex-wrap items-start justify-between gap-3">
<h3 id="template-workflow-heading" className="text-[15px] font-bold text-[#1a1c1c]">评分人设置</h3>
{editable && <button type="button" onClick={() => updateStages([...stages, { key: `stage_${stages.length + 1}`, label: `阶段 ${stages.length + 1}`, order: stages.length, reviewers: [{ source: 'manual_selection', label: '评价人' }] }])} className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#1a1c1c] hover:text-white">添加阶段</button>}
</div>
<div className="mt-3 grid gap-3">
{stages.map((stage, stageIndex) => (
<div key={`${stage.key}-${stageIndex}`} className="border border-[#e2e2e2] p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3">
<label className="grid min-w-0 flex-1 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
阶段 {stageIndex + 1} 名称
<input
value={stage.label}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, label: event.target.value } : item))}
className="h-11 rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
{editable && (
<button
type="button"
disabled={stages.length <= 1}
onClick={() => updateStages(stages.filter((_, index) => index !== stageIndex))}
className="min-h-11 self-end rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#aaaaaa]"
>
删除阶段
</button>
)}
</div>
<p className="mt-3 text-[11px] font-bold text-[#727687]">本阶段评分人</p>
<div className="mt-3 grid gap-2">
{stage.reviewers.map((reviewer, reviewerIndex) => (
<div key={`${stage.key}-${reviewerIndex}`} className="grid gap-2 sm:grid-cols-[180px_minmax(180px,1fr)_72px]">
<select
aria-label={`阶段 ${stageIndex + 1} 评分人类型 ${reviewerIndex + 1}`}
value={reviewer.source}
disabled={!editable}
onChange={(event) => void changeReviewerSource(
stageIndex,
reviewerIndex,
event.target.value as ReviewerSource,
)}
className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
>
<option value="direct_manager">直属主管</option>
<option value="fixed_employee">元泽</option>
<option value="manual_selection">任何人(发起时选择)</option>
<option value="self">本人自评</option>
</select>
{reviewer.source === 'fixed_employee' ? (
<FixedReviewerPicker
value={reviewer}
disabled={!editable}
request={request}
onChange={(value) => updateStages(stages.map((item, index) => index === stageIndex ? {
...item,
reviewers: item.reviewers.map((entry, entryIndex) => entryIndex === reviewerIndex ? value : entry),
} : item))}
/>
) : (
<input
aria-label={`阶段 ${stageIndex + 1} 评分人显示名称 ${reviewerIndex + 1}`}
value={reviewer.label}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: item.reviewers.map((entry, entryIndex) => entryIndex === reviewerIndex ? { ...entry, label: event.target.value } : entry) } : item))}
className="h-11 rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
)}
{editable && (
<button
type="button"
disabled={stage.reviewers.length <= 1}
aria-label={`删除阶段 ${stageIndex + 1} 的第 ${reviewerIndex + 1} 类评分人`}
onClick={() => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: item.reviewers.filter((_, entryIndex) => entryIndex !== reviewerIndex) } : item))}
className="min-h-11 rounded text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#aaaaaa]"
>
删除
</button>
)}
</div>
))}
{editable && <button type="button" onClick={() => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: [...item.reviewers, { source: 'manual_selection', label: '发起时选择任何人' }] } : item))} className="min-h-11 w-fit rounded border border-[#e2e2e2] px-3 text-[11px] font-bold text-[#1a1c1c] hover:border-[#1a1c1c]">添加评分人</button>}
</div>
<details className="mt-3">
<summary className="min-h-11 cursor-pointer py-3 text-[11px] font-bold text-[#727687]">高级设置</summary>
<label className="grid max-w-sm gap-1 text-[11px] font-semibold text-[#727687]">
系统内部阶段代码
<input
value={stage.key}
disabled={!editable}
onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, key: event.target.value } : item))}
className="h-11 rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
</label>
</details>
</div>
))}
</div>
<div className="mt-5 border-t border-[#eeeeee] pt-5">
<h4 className="text-[14px] font-bold text-[#1a1c1c]">最终成绩采用哪一组评分</h4>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
最终成绩规则
<select
value={definition.workflow.scoring.mode}
disabled={!editable}
onChange={(event) => onChange({
...definition,
workflow: {
...definition.workflow,
scoring: event.target.value === 'final_reviewer'
? { mode: 'final_reviewer', stageKey: stages[stages.length - 1]?.key || '', reviewerSource: stages[stages.length - 1]?.reviewers[0]?.source || 'manual_selection' }
: { mode: 'average_reviewers', stageKeys: stages.map((stage) => stage.key) },
},
})}
className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]"
>
<option value="average_reviewers">所有指定阶段评分取平均</option>
<option value="final_reviewer">以指定终审人评分为准</option>
</select>
</label>
{definition.workflow.scoring.mode === 'final_reviewer' && (
<>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
终审阶段
<select value={definition.workflow.scoring.stageKey || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', stageKey: event.target.value } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
{stages.map((stage) => <option key={stage.key} value={stage.key}>{stage.label}</option>)}
</select>
</label>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
终审人来源
<select value={definition.workflow.scoring.reviewerSource || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', reviewerSource: event.target.value as ReviewerSource } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
{(stages.find((stage) => stage.key === definition.workflow.scoring.stageKey)?.reviewers || []).map((reviewer) => <option key={reviewer.source} value={reviewer.source}>{reviewer.employeeName || reviewer.label}</option>)}
</select>
</label>
</>
)}
</div>
</section>
)
}
function FixedReviewerPicker({
value,
disabled,
request,
onChange,
}: {
value: ReviewerDraft
disabled: boolean
request: RequestFn
onChange: (value: ReviewerDraft) => void
}) {
const listboxId = useId()
const statusId = useId()
const [search, setSearch] = useState('')
const [items, setItems] = useState<Array<{ id: string; displayName: string; departmentName?: string }>>([])
const [loading, setLoading] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const normalizedSearch = search.trim()
useEffect(() => {
if (disabled || normalizedSearch.length < 1) {
setItems([])
setLoading(false)
setSearchError(null)
return
}
let cancelled = false
setLoading(true)
setSearchError(null)
const timer = window.setTimeout(async () => {
try {
const result = await request<Array<{ id: string; displayName: string; departmentName?: string }>>(
`/employees?purpose=evaluator&limit=20&search=${encodeURIComponent(normalizedSearch)}`,
)
if (cancelled) return
setItems(result)
setActiveIndex(0)
} catch (error) {
if (cancelled) return
setItems([])
setSearchError(error instanceof Error ? error.message : '人员搜索失败,请重试')
} finally {
if (!cancelled) setLoading(false)
}
}, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [disabled, normalizedSearch, request])
const selectItem = (item: { id: string; displayName: string }) => {
onChange({
source: 'fixed_employee',
label: '元泽',
employeeId: item.id,
employeeName: item.displayName,
})
setSearch('')
setItems([])
setSearchError(null)
}
const resultsOpen = !disabled && normalizedSearch.length > 0
const searchStatus = disabled
? ''
: value.employeeId && normalizedSearch.length === 0
? `已选择:${value.employeeName}`
: normalizedSearch.length === 0
? '输入姓名,确认元泽对应的人员档案'
: loading
? '正在搜索人员…'
: searchError
? searchError
: items.length > 0 ? `找到 ${items.length} 人` : '没有找到匹配人员'
return (
<div className="relative min-w-0">
<input
role="combobox"
aria-autocomplete="list"
aria-expanded={resultsOpen}
aria-controls={listboxId}
aria-activedescendant={resultsOpen && items[activeIndex] ? `${listboxId}-${items[activeIndex].id}` : undefined}
aria-describedby={statusId}
aria-label="选择元泽人员档案"
value={search || value.employeeName || ''}
disabled={disabled}
onChange={(event) => {
setSearch(event.target.value)
onChange({ source: 'fixed_employee', label: '元泽' })
}}
onKeyDown={(event) => {
if (!resultsOpen || items.length === 0) {
if (event.key === 'Escape') setSearch('')
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(items.length - 1, index + 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(0, index - 1))
} else if (event.key === 'Enter') {
event.preventDefault()
selectItem(items[activeIndex])
} else if (event.key === 'Escape') {
event.preventDefault()
setSearch('')
setItems([])
}
}}
placeholder="搜索并选择元泽"
className="h-11 w-full rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
/>
<div id={statusId} role="status" aria-live="polite" className={`mt-1 min-h-5 text-[11px] leading-5 ${searchError ? 'font-semibold text-[#b42318]' : 'text-[#727687]'}`}>
{searchStatus}
</div>
{resultsOpen && (
<ul
id={listboxId}
role="listbox"
aria-label="人员搜索结果"
className="absolute inset-x-0 top-[68px] z-30 max-h-48 overflow-y-auto border border-[#e2e2e2] bg-white shadow-[0_10px_24px_rgba(0,0,0,0.1)]"
>
{!loading && !searchError && items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-${item.id}`}
role="option"
aria-selected={index === activeIndex}
tabIndex={-1}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
selectItem(item)
}}
className={`flex min-h-11 cursor-pointer items-center border-b border-[#eeeeee] px-3 py-2 text-left text-[12px] ${
index === activeIndex ? 'bg-[#edf3ff]' : 'hover:bg-[#f7f7f7]'
}`}
>
<strong>{item.displayName}</strong>{item.departmentName ? ` · ${item.departmentName}` : ''}
</li>
))}
{(loading || searchError || items.length === 0) && (
<li className="px-3 py-3 text-[12px] text-[#5f5e5e]" aria-hidden="true">
{loading ? '正在搜索…' : searchError || '没有找到匹配人员'}
</li>
)}
</ul>
)}
</div>
)
}
function AdminField({
label,
value,
onChange,
type = 'text',
disabled = false,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
disabled?: boolean
}) {
return (
<label className="grid min-w-0 gap-1 text-[12px] font-semibold text-[#5f5e5e]">
{label}
<input
type={type}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4] disabled:text-[#8a8a8a]"
/>
</label>
)
}