diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx index db0b06b..c576225 100644 --- a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx +++ b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx @@ -1,6 +1,6 @@ /** @jest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig' const templateDefinition = { @@ -133,6 +133,12 @@ function createRequest(configuration = config) { } } if (path === '/admin/position-groups' && options?.method === 'POST') return { id: 'group-3' } + if (path === '/admin/position-groups/group-1' && options?.method === 'DELETE') { + return { id: 'group-1', action: 'deleted' } + } + if (path === '/admin/templates/template-1' && options?.method === 'DELETE') { + return { id: 'template-1', action: 'deleted' } + } if (path.endsWith('/clone')) return { id: 'draft-version-2' } return {} }) @@ -140,7 +146,6 @@ function createRequest(configuration = config) { describe('PerformanceSurveyAdminConfig', () => { beforeEach(() => { - jest.spyOn(window, 'confirm').mockReturnValue(true) Object.defineProperty(window, 'matchMedia', { configurable: true, value: jest.fn().mockReturnValue({ matches: false }), @@ -180,7 +185,7 @@ describe('PerformanceSurveyAdminConfig', () => { await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument()) expect(screen.getByText(/1\/2 个在用岗位已有生效方案,.*1 个沿用较早版本/)).toBeInTheDocument() fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } }) - expect(onMonthChange).toHaveBeenCalledWith('2026-09') + await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09')) }) test('岗位编辑产生 dirty 状态并保护岗位切换', async () => { @@ -192,15 +197,40 @@ describe('PerformanceSurveyAdminConfig', () => { fireEvent.change(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } }) expect(onDirtyChange).toHaveBeenLastCalledWith(true) - ;(window.confirm as jest.Mock).mockReturnValueOnce(false) fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ })) + expect(await screen.findByRole('alertdialog', { name: '放弃未保存的修改?' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '取消' })) expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌视觉设计师') - ;(window.confirm as jest.Mock).mockReturnValueOnce(true) fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ })) + fireEvent.click(await screen.findByRole('button', { name: '放弃修改' })) await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('AI 技术设计师')) }) + test('删除岗位使用统一弹窗收集必填原因并提交', async () => { + const request = createRequest() + render() + await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师')) + + fireEvent.click(screen.getByRole('button', { name: '删除岗位' })) + const dialog = await screen.findByRole('alertdialog', { name: '删除岗位“品牌设计师”?' }) + const dialogQueries = within(dialog) + expect(dialog).toHaveTextContent('当前仍有 6 人属于该岗位') + expect(dialog).toHaveTextContent('历史调研记录会保留') + expect(dialogQueries.getByRole('button', { name: '删除岗位' })).toBeDisabled() + + fireEvent.change(dialogQueries.getByLabelText(/删除原因/), { target: { value: '岗位合并' } }) + fireEvent.click(dialogQueries.getByRole('button', { name: '删除岗位' })) + + await waitFor(() => expect(request).toHaveBeenCalledWith( + '/admin/position-groups/group-1', + expect.objectContaining({ + method: 'DELETE', + body: JSON.stringify({ mode: 'auto', reason: '岗位合并' }), + }), + )) + }) + test('岗位页以搜索方式维护草稿考核项,并只展示已选择内容', async () => { const configuration = { ...config, @@ -300,6 +330,72 @@ describe('PerformanceSurveyAdminConfig', () => { expect(screen.queryByText('使用场景')).not.toBeInTheDocument() }) + test('删除模板使用统一弹窗并保留接口错误以便重试', async () => { + const baseRequest = createRequest() + let deleteAttempts = 0 + const request = jest.fn(async (path: string, options?: RequestInit) => { + if (path === '/admin/templates/template-1' && options?.method === 'DELETE') { + deleteAttempts += 1 + if (deleteAttempts === 1) throw new Error('模板仍被岗位使用,请先移除岗位考核项') + } + return baseRequest(path, options) + }) + render() + await waitFor(() => expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: '删除模板' })) + const dialog = await screen.findByRole('alertdialog', { name: '删除模板“视觉案例评分”?' }) + const dialogQueries = within(dialog) + expect(dialog).toHaveTextContent('历史调研中的模板和评分数据会继续保留') + fireEvent.change(dialogQueries.getByLabelText(/删除原因/), { target: { value: '模板重复' } }) + fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' })) + + expect(await dialogQueries.findByRole('alert')).toHaveTextContent('模板仍被岗位使用') + expect(dialogQueries.getByLabelText(/删除原因/)).toHaveValue('模板重复') + fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' })) + + await waitFor(() => expect(request).toHaveBeenCalledWith( + '/admin/templates/template-1', + expect.objectContaining({ + method: 'DELETE', + body: JSON.stringify({ mode: 'auto', reason: '模板重复' }), + }), + )) + expect(deleteAttempts).toBe(2) + }) + + test('发布模板通过统一弹窗确认模板和生效月份', async () => { + const configuration = { + ...config, + templates: config.templates.map((template: any) => template.id === 'template-1' + ? { + ...template, + versions: template.versions.map((version: any) => ({ + ...version, + status: 'draft', + effectiveMonth: null, + })), + } + : template), + } + const request = createRequest(configuration) + render() + await waitFor(() => expect(screen.getByText(/v1 · 草稿/)).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: /发布至 2026-07/ })) + const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' }) + expect(dialog).toHaveTextContent('模板将从 2026-07 起供岗位考核方案使用') + fireEvent.click(within(dialog).getByRole('button', { name: '发布至 2026-07' })) + + await waitFor(() => expect(request).toHaveBeenCalledWith( + '/admin/templates/version-1/publish', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ effectiveMonth: '2026-07' }), + }), + )) + }) + test('模板编辑按基本信息、评分项、评审与计分分步展示', async () => { const request = createRequest() render() diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.tsx index 3f0b3f4..eddd607 100644 --- a/app/performance-survey/PerformanceSurveyAdminConfig.tsx +++ b/app/performance-survey/PerformanceSurveyAdminConfig.tsx @@ -1,12 +1,18 @@ 'use client' import { useCallback, useEffect, 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' | 'plans' @@ -222,6 +228,7 @@ export function PerformanceSurveyAdminConfig({ 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) @@ -264,13 +271,20 @@ export function PerformanceSurveyAdminConfig({ return () => window.removeEventListener('beforeunload', handleBeforeUnload) }, [dirty]) - const confirmDiscard = useCallback((message = '当前有未保存修改,确定放弃吗?') => ( - !dirty || window.confirm(message) - ), [dirty]) + 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 = (nextMonth: string) => { + const changeMonth = async (nextMonth: string) => { if (!nextMonth || nextMonth === month) return - if (!confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return + if (!await confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return setDirtyState(false) setNotice(null) if (onMonthChange) onMonthChange(nextMonth) @@ -314,7 +328,7 @@ export function PerformanceSurveyAdminConfig({ changeMonth(event.target.value)} + 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]" /> @@ -355,6 +369,8 @@ export function PerformanceSurveyAdminConfig({ onChanged={changed} onDirtyChange={setDirtyState} confirmDiscard={confirmDiscard} + confirmAction={confirmAction} + dirty={dirty} /> )} {mode === 'people' && ( @@ -378,6 +394,7 @@ export function PerformanceSurveyAdminConfig({ onChanged={changed} onDirtyChange={setDirtyState} confirmDiscard={confirmDiscard} + confirmAction={confirmAction} dirty={dirty} /> )} @@ -392,6 +409,7 @@ export function PerformanceSurveyAdminConfig({ )} )} + {confirmActionDialog} ) } @@ -615,6 +633,8 @@ function PositionConfigWorkspace({ onChanged, onDirtyChange, confirmDiscard, + confirmAction, + dirty, }: { data: AdminConfig month: string @@ -622,7 +642,9 @@ function PositionConfigWorkspace({ onError: (message: string | null) => void onChanged: (message: string) => Promise onDirtyChange: (dirty: boolean) => void - confirmDiscard: (message?: string) => boolean + 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] @@ -718,28 +740,43 @@ function PositionConfigWorkspace({ } const removeGroup = async () => { - if (!selected || !confirmDiscard()) return + if (!selected) return const memberWarning = selected.memberCount > 0 ? `当前仍有 ${selected.memberCount} 人属于该岗位,删除后这些人员会变为待分配。` : '该岗位当前没有人员。' - if (!window.confirm(`确认删除岗位“${selected.name}”吗?${memberWarning}历史调研记录不会删除。`)) return - const reason = window.prompt('请输入删除原因(用于操作记录)')?.trim() - if (!reason) return - setSaving(true) - onError(null) - try { - const result = await adminConfigApi.removePositionGroup(request, selected.id, reason) - const next = await onChanged( - result.action === 'deleted' - ? `岗位“${selected.name}”已删除` - : `岗位“${selected.name}”已从管理列表删除,历史记录已保留`, - ) - setSelectedId(next?.groups[0]?.id || '') - } catch (actionError) { - onError(actionError instanceof Error ? actionError.message : '岗位删除失败') - } finally { - setSaving(false) - } + await confirmAction({ + title: `删除岗位“${selected.name}”?`, + description: ( +
+

{memberWarning}

+

历史调研记录会保留,但该岗位将不再出现在当前管理列表中。

+ {dirty &&

当前未保存的岗位修改也会同时放弃。

} +
+ ), + 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) => { @@ -765,9 +802,9 @@ function PositionConfigWorkspace({ markDirty() } - const selectGroup = (groupId: string) => { + const selectGroup = async (groupId: string) => { if (groupId === selected?.id) return - if (!confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return + if (!await confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return onDirtyChange(false) setSelectedId(groupId) if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) { @@ -806,7 +843,7 @@ function PositionConfigWorkspace({