'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 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(`/admin/config?month=${encodeURIComponent(month)}`) }, auditLogs(request: RequestFn, month: string) { return request( `/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(`/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 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 ( ) } 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(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) 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 (

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

{dirty && }
{notice && (
{notice}
)} {error && (
{error} {!data && }
)} {loading && !data &&
配置加载中...
} {data && (
{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 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 (

{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. ) })}
{(data.issues.unmatched.length > 0 || data.issues.conflicts.length > 0) && (
“未匹配”不等于必须参与考核。请逐一确认:需要参与的人分配岗位,不参与的人明确标记为“不参与”;重复匹配人员必须先消除冲突。
)}

最近配置记录

{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 [creating, setCreating] = useState(false) const [newName, setNewName] = useState('') const editorRef = useRef(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
暂无岗位组
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: (

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

发布后从 {month} 起持续生效,后续月份无需重复设置。

), 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 (

{selected.name}

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

岗位考核项

{templateSearch.trim() && (
{searchResults.map(({ template, version }) => ( ))} {searchResults.length === 0 && (
没有可添加的匹配模板
)}
)}
{templateVersionIds.map((id, index) => { const item = versionById.get(id) return (
{item?.version.definition.name || item?.template.name || '历史模板'}
第 {index + 1} 项{item ? ` · v${item.version.version}` : ''}
) })} {templateVersionIds.length === 0 && (
尚未选择考核项,请通过上方搜索框添加
)}
{dirty ? '未保存' : selected.draftPlan ? '待发布' : selected.effectivePlan ? '已生效' : '待配置'}
) } 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?.versions.find((version) => version.status === 'draft') || selectedTemplate?.versions[0] || 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 [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 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: (

模板将从 {month} 起成为可用模板,可直接添加到岗位考核项。

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

发布后的版本不可直接修改;后续调整需要创建新的草稿版本。

), 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 (
{selectedTemplate && (
{selectedTemplate.versions.map((item) => ( ))}
)}
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 }))} /> )}