'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 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 members?: Array<{ id: string displayName: string departmentName?: string | null departmentPath?: string | null positionTitle?: string | null }> 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[] issueCounts?: { unmatched: number conflicts: number excluded?: number } 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, scope: PerformanceSurveyAdminMode) { return request( `/admin/config?month=${encodeURIComponent(month)}&scope=${encodeURIComponent(scope)}`, ) }, auditLogs(request: RequestFn, month: string) { return request( `/admin/config-audit-logs?month=${encodeURIComponent(month)}&pageSize=20`, ) }, createPositionGroup(request: RequestFn, input: { name: 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(`/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(value: T): T { return JSON.parse(JSON.stringify(value)) as T } function getAdminIssueCount( data: AdminConfig, issue: 'unmatched' | 'conflicts', ) { return data.issueCounts?.[issue] ?? data.issues[issue].length } 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 getEffectiveQuestionMaxScore( definition: TemplateDefinition, question: TemplateDefinition['questions'][number], ) { const value = Number(question.maxScore ?? definition.questionMaxScore ?? 20) return Number.isFinite(value) ? value : 0 } function StatusDot({ label, tone, }: { label: string tone: StatusTone }) { const toneClass = { success: 'bg-[#16794b]', warning: 'bg-[#b26a00]', danger: 'bg-[#b42318]', neutral: 'bg-[#8a8a8a]', }[tone] return ( ) } 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 .sort((left, right) => right.version - left.version)[0] || null const draftVersion = availableVersion ? null : template.versions .filter((version) => version.status === 'draft') .sort((left, right) => right.version - left.version)[0] || null return { isAvailable: template.isActive && Boolean(availableVersion), availableVersion, draftVersion, } } function getTemplateDisplayName(template: TemplateRecord) { const current = getTemplateAvailability(template, '').availableVersion || getTemplateAvailability(template, '').draftVersion return current?.definition.name?.trim() || template.name } 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: '待发布', tone: 'warning', meta: '草稿', } } if (availability.availableVersion) { return { label: '可用', tone: 'success', meta: '已发布', } } 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 + getEffectiveQuestionMaxScore(definition, question), 0, ) const allocatedTotal = questionTotal * workItemCount const configuredTotal = Number(definition.maxTotal || 0) const hasQuestionError = definition.questions.some((question) => ( !question.title.trim() || getEffectiveQuestionMaxScore(definition, question) <= 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(null) const [loadedMode, setLoadedMode] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [notice, setNotice] = useState(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, mode) if (requestSequenceRef.current !== sequence) return null setData(next) setLoadedMode(mode) 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) } }, [mode, request]) useEffect(() => { setData(null) 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]) const currentData = loadedMode === mode ? data : null return (

{{ overview: '维护概览', positions: '岗位管理', people: '人员分配', templates: '模板管理', }[mode]}

{dirty && }
{mode === 'overview' && ( )}
{notice && (
{notice}
)} {error && (
{error} {!currentData && }
)} {loading && !currentData &&
配置加载中...
} {currentData && (
{mode === 'overview' && ( )} {mode === 'positions' && ( )} {mode === 'people' && ( { setNotice(message) setError(null) }} /> )} {mode === 'templates' && ( )}
)} {confirmActionDialog}
) } function AdminOverviewWorkspace({ data, month, request, onNavigate, }: { data: AdminConfig month: string request: RequestFn onNavigate?: (mode: PerformanceSurveyAdminMode | 'launch') => void }) { const [audits, setAudits] = useState([]) const [auditError, setAuditError] = useState(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 draftPlanCount = activeGroups.filter((group) => group.draftPlan).length const unmatchedCount = getAdminIssueCount(data, 'unmatched') const conflictCount = getAdminIssueCount(data, 'conflicts') const hasPeopleIssues = unmatchedCount > 0 || conflictCount > 0 const positionsReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length const templatesReady = availablePublishedTemplates > 0 const launchReady = positionsReady && templatesReady && conflictCount === 0 const steps = [ { id: 'people', label: '确认人员资料', status: conflictCount > 0 ? 'blocked' : hasPeopleIssues ? 'attention' : 'done', summary: conflictCount > 0 ? `${conflictCount} 人同时匹配多个岗位,必须先处理` : unmatchedCount > 0 ? `${unmatchedCount} 人待确认:分配岗位,或明确标记为不参与` : '人员岗位归属没有待处理异常', action: '处理人员', target: 'people' as const, }, { id: 'positions', label: '配置并发布岗位', status: positionsReady ? (draftPlanCount > 0 ? 'attention' : 'done') : 'blocked', summary: activeGroups.length === 0 ? '还没有岗位,请先创建岗位并选择考核项' : `${effectivePlanCount}/${activeGroups.length} 个岗位设置已生效${draftPlanCount > 0 ? `;${draftPlanCount} 个岗位有待发布修改` : ''}${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`, action: '配置岗位', target: 'positions' as const, }, { id: 'templates', label: '准备考核模板', status: templatesReady ? (draftTemplates > 0 ? 'attention' : 'done') : 'blocked', summary: templatesReady ? `${availablePublishedTemplates} 个已发布模板可用${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}` : `还没有可用的已发布模板${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 (

{month} 配置概览

先处理人员归属,再准备模板并配置岗位考核项。岗位设置发布后会持续沿用,无需每月重复配置。

本月上线准备

按顺序完成;每一步都可以直接进入对应管理页面。

    {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 (
  1. {step.label}

    {statusLabel}

    {step.summary}

  2. ) })}
{hasPeopleIssues && (
“未匹配”不等于必须参与考核。请逐一确认:需要参与的人分配岗位,不参与的人明确标记为“不参与”;重复匹配人员必须先消除冲突。
)}

最近配置记录

{auditLoading &&
记录加载中...
} {auditError && (
配置记录暂不可用:{auditError}
)} {!auditLoading && !auditError && (
{audits.map((audit) => ( ))} {audits.length === 0 && ( )}
时间 操作人 对象 变更
{audit.createdAt ? new Date(audit.createdAt).toLocaleString('zh-CN') : '-'} {audit.actorName || '-'} {audit.resourceName || audit.resourceType || audit.targetType || '-'} {audit.summary || audit.action || '-'}
暂无配置记录
)}
) } 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 onDirtyChange: (dirty: boolean) => void confirmDiscard: (message?: string) => Promise 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( selected?.draftPlan?.templateVersionIds || selected?.effectivePlan?.templateVersionIds || [], ) const [templateSearch, setTemplateSearch] = useState('') const [saving, setSaving] = useState(false) const [editing, setEditing] = useState(Boolean(selected?.draftPlan) || !selected?.effectivePlan) const [creating, setCreating] = useState(false) const [newName, setNewName] = useState('') const [expandedMemberGroupIds, setExpandedMemberGroupIds] = useState>(() => new Set()) const editorRef = useRef(null) useEffect(() => { if (!selected) return setName(selected.name) setSortOrder(selected.sortOrder) setTemplateVersionIds(selected.draftPlan?.templateVersionIds || selected.effectivePlan?.templateVersionIds || []) setTemplateSearch('') setEditing(Boolean(selected.draftPlan) || !selected.effectivePlan) onDirtyChange(false) }, [onDirtyChange, selected]) const markDirty = () => onDirtyChange(true) const toggleMemberList = (groupId: string) => { setExpandedMemberGroupIds((current) => { const next = new Set(current) if (next.has(groupId)) next.delete(groupId) else next.add(groupId) return next }) } 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) && `${getTemplateDisplayName(template)} ${version.definition.name} ${template.name}` .toLocaleLowerCase('zh-CN') .includes(keyword) )).slice(0, 8) }, [availableTemplateVersions, templateSearch, templateVersionIds, versionById]) if (!selected) return
暂无岗位组
const save = async () => { setSaving(true) onError(null) try { await request(`/admin/position-groups/${selected.id}`, { method: 'PATCH', body: JSON.stringify({ name, sortOrder, templateVersionIds }), }) 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 ?? getAdminIssueCount(data, 'conflicts'), ) 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: (

包含 {templateVersionIds.length} 个考核项,预计影响 {memberCount} 人。

发布后立即生效,后续无需重复设置。

), confirmLabel: '确认发布', loadingLabel: '发布中...', action: async () => { await request(`/admin/position-groups/${selected.id}/publish`, { method: 'POST', body: JSON.stringify({ draftPlanId, draftVersion, draftUpdatedAt, }), }) await onChanged(`${selected.name} 的岗位设置已发布并立即生效`) }, }) } 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(), }) 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 (

{selected.name}

{ setName(value); markDirty() }} /> { setSortOrder(Number(value) || 0); markDirty() }} type="number" />

岗位考核项

{editing && templateSearch.trim() && (
{searchResults.map(({ template, version }) => ( ))} {searchResults.length === 0 && (
没有可添加的匹配模板
)}
)}
{templateVersionIds.map((id, index) => { const item = versionById.get(id) return (
{item ? getTemplateDisplayName(item.template) : '历史模板'}
第 {index + 1} 项
{editing && (
)}
) })} {templateVersionIds.length === 0 && (
尚未选择考核项,请通过上方搜索框添加
)}
{dirty ? '未保存' : selected.draftPlan ? '待发布' : selected.effectivePlan ? '已生效' : '待配置'} {!editing && selected.effectivePlan && !selected.draftPlan && ( )} {editing && ( <> )}
) } 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 onDirtyChange: (dirty: boolean) => void confirmDiscard: (message?: string) => Promise 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 ? getTemplateAvailability(selectedTemplate, month).availableVersion || getTemplateAvailability(selectedTemplate, month).draftVersion || null : null const [versionId, setVersionId] = useState(selectedVersion?.id || null) const [definition, setDefinition] = useState(() => clone(selectedVersion?.definition || newTemplateDefinition())) const [editorStep, setEditorStep] = useState('basics') const [saving, setSaving] = useState(false) const [editingPublishedVersion, setEditingPublishedVersion] = 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(null) useEffect(() => { const availability = selectedTemplate ? getTemplateAvailability(selectedTemplate, month) : null const nextVersion = availability?.availableVersion || availability?.draftVersion || null const nextDefinition = clone(nextVersion?.definition || newTemplateDefinition()) setVersionId(nextVersion?.id || null) setDefinition(nextDefinition) setEditorStep('basics') setEditingPublishedVersion(false) setReadinessOpen(false) onDirtyChange(false) }, [month, onDirtyChange, selectedTemplate]) const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null const editable = !version || version.status === 'draft' || (version.status === 'published' && editingPublishedVersion) 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 () => { const updatingPublishedVersion = version?.status === 'published' setSaving(true) onError(null) setOperationFeedback({ state: 'saving', message: updatingPublishedVersion ? '正在保存模板修改…' : '正在保存模板草稿…', }) 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(updatingPublishedVersion ? '模板修改已保存' : '模板草稿已保存') const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === saved.id)) if (nextTemplate) setSelectedTemplateId(nextTemplate.id) setVersionId(saved.id) setEditingPublishedVersion(false) onDirtyChange(false) const savedTime = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false, }).format(new Date()) setOperationFeedback({ state: 'saved', message: `${updatingPublishedVersion ? '修改' : '草稿'}已保存 · ${savedTime}`, }) } catch (actionError) { const message = actionError instanceof Error ? actionError.message : updatingPublishedVersion ? '模板修改保存失败' : '模板草稿保存失败' onError(message) setOperationFeedback({ state: 'error', message: `保存失败:${message}` }) } finally { setSaving(false) } } const copyTemplate = async () => { if (!versionId || !selectedTemplate || !await confirmDiscard()) return setSaving(true) onError(null) setOperationFeedback({ state: 'saving', message: '正在复制模板…' }) try { const copied = await request<{ id: string; templateId: string; name: string }>( `/admin/templates/${versionId}/copy`, { method: 'POST', body: '{}' }, ) const next = await onChanged(`已复制为“${copied.name}”草稿`) const nextTemplate = next?.templates.find((template) => ( template.id === copied.templateId || template.versions.some((item) => item.id === copied.id) )) if (nextTemplate) setSelectedTemplateId(nextTemplate.id) setVersionId(copied.id) setOperationFeedback({ state: 'saved', message: `已复制为“${copied.name}”草稿` }) } 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: (

模板发布后立即可用,可直接添加到岗位考核项。

{surveyTypeLabel(definition.surveyType)} · {definition.questions.length} 个评分项 · {' '}{definition.workflow.stages.length} 个评分阶段 · 总分 {Number(definition.maxTotal || 0)}

发布后仍可通过“修改模板”更新当前模板;已经发起的调研会保留原始模板快照。

), confirmLabel: '确认发布', loadingLabel: '发布中...', action: async () => { setSaving(true) onError(null) setOperationFeedback({ state: 'publishing', message: '正在发布…' }) try { await request(`/admin/templates/${versionId}/publish`, { method: 'POST', body: '{}', }) await onChanged(`${definition.name} 已发布并立即可用`) setOperationFeedback({ state: 'published', message: `${definition.name} 已发布` }) } 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 cancelPublishedEdit = async () => { if (dirty && !await confirmDiscard('取消修改会放弃当前未保存内容,确定继续吗?')) return if (version) setDefinition(clone(version.definition)) setEditingPublishedVersion(false) onDirtyChange(false) setOperationFeedback({ state: 'idle', message: '' }) } const selectedTemplateName = selectedTemplate ? getTemplateDisplayName(selectedTemplate) : '新建模板' const selectedTemplateState = selectedTemplate ? getTemplateListState(selectedTemplate, month) : null const feedbackMessage = saving ? operationFeedback.message || '处理中' : dirty ? '未保存' : operationFeedback.message || (editingPublishedVersion ? '可修改' : editable ? '已保存' : '只读') const feedbackTone: StatusTone = operationFeedback.state === 'error' ? 'danger' : dirty || saving ? 'warning' : operationFeedback.state === 'saved' || operationFeedback.state === 'published' ? 'success' : 'neutral' return (
{selectedTemplate && (
)}
setReadinessOpen(event.currentTarget.open)} className="mb-5 border border-[#e2e2e2] bg-white" > 发布检查
    {readinessItems.map((item) => { const statusLabel = item.status === 'done' ? '已完成' : item.status === 'error' ? '有错误' : '待完善' return (
  • {item.label}

    {item.summary}

    {item.status !== 'done' && ( )}
  • ) })}
{editorStep === 'basics' && (

基本信息

updateDefinition((current) => ({ ...current, name: value, shortName: value }))} disabled={!editable} /> {definition.surveyType === 'visual_case' && ( updateDefinition((current) => ({ ...current, requiredWorkItemCount: Math.max(1, Number(value) || 1), workItemImageRequired: true }))} /> )}