2384 lines
100 KiB
TypeScript
2384 lines
100 KiB
TypeScript
'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>
|
||
)
|
||
}
|