From 91ad3941e718b68503f55cb75fbbbf592d73de02 Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 27 Jul 2026 19:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=80=83=E6=A0=B8?= =?UTF-8?q?=E8=B0=83=E7=A0=94=E7=AE=A1=E7=90=86=E9=85=8D=E7=BD=AE=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerformanceSurveyAdminConfig.test.tsx | 147 +++- .../PerformanceSurveyAdminConfig.tsx | 675 +++++++++--------- ...formanceSurveyDashboardNavigation.test.tsx | 170 +++++ app/performance-survey/[[...path]]/page.tsx | 178 +++-- 4 files changed, 770 insertions(+), 400 deletions(-) create mode 100644 app/performance-survey/PerformanceSurveyDashboardNavigation.test.tsx diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx index c5762259..c85db1f3 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, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig' const templateDefinition = { @@ -123,6 +123,9 @@ function createRequest(configuration = config) { pageSize: 200, } } + if (path.startsWith('/employees?purpose=evaluator')) { + return [{ id: 'employee-yuanze', displayName: '元泽', departmentName: '品牌中心' }] + } if (path.endsWith('/preview?month=2026-07')) { return { memberCount: 6, @@ -156,7 +159,7 @@ describe('PerformanceSurveyAdminConfig', () => { jest.restoreAllMocks() }) - test('概览展示岗位、人员、模板和岗位考核方案状态', async () => { + test('概览将岗位设置与发布状态合并展示', async () => { const request = createRequest() const onNavigate = jest.fn() render() @@ -183,7 +186,7 @@ describe('PerformanceSurveyAdminConfig', () => { ) await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument()) - expect(screen.getByText(/1\/2 个在用岗位已有生效方案,.*1 个沿用较早版本/)).toBeInTheDocument() + expect(screen.getByText(/1\/2 个岗位设置已生效,.*1 个沿用较早设置/)).toBeInTheDocument() fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } }) await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09')) }) @@ -256,7 +259,7 @@ describe('PerformanceSurveyAdminConfig', () => { fireEvent.change(screen.getByLabelText('添加考核项'), { target: { value: '视觉案例' } }) fireEvent.click(screen.getByRole('button', { name: /视觉案例评分/ })) expect(screen.getByText('视觉案例评分')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '保存修改' })) + fireEvent.click(screen.getByRole('button', { name: '保存草稿' })) await waitFor(() => { const call = request.mock.calls.find(([path, options]) => ( @@ -330,6 +333,52 @@ describe('PerformanceSurveyAdminConfig', () => { expect(screen.queryByText('使用场景')).not.toBeInTheDocument() }) + test('模板列表明确区分当前可用、未来可用和仅草稿状态', async () => { + const configuration = { + ...config, + templates: [{ + ...config.templates[0], + versions: [{ + ...config.templates[0].versions[0], + id: 'version-draft', + version: 2, + status: 'draft', + effectiveMonth: null, + }, config.templates[0].versions[0]], + }, { + ...config.templates[1], + versions: [{ + ...config.templates[1].versions[0], + id: 'version-future', + effectiveMonth: '2026-09', + }], + }, { + ...config.templates[1], + id: 'template-draft-only', + code: 'draft_only', + name: '草稿模板', + versions: [{ + ...config.templates[1].versions[0], + id: 'version-draft-only', + status: 'draft', + effectiveMonth: null, + definition: { + ...config.templates[1].versions[0].definition, + code: 'draft_only', + name: '草稿模板', + }, + }], + }], + } + const request = createRequest(configuration) + render() + + await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /视觉案例评分.*有未发布修改/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /课程分享.*不可用.*2026-09 起可用/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /草稿模板.*不可用.*仅有草稿/ })).toBeInTheDocument() + }) + test('删除模板使用统一弹窗并保留接口错误以便重试', async () => { const baseRequest = createRequest() let deleteAttempts = 0 @@ -384,7 +433,7 @@ describe('PerformanceSurveyAdminConfig', () => { fireEvent.click(screen.getByRole('button', { name: /发布至 2026-07/ })) const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' }) - expect(dialog).toHaveTextContent('模板将从 2026-07 起供岗位考核方案使用') + expect(dialog).toHaveTextContent('模板将从 2026-07 起成为可用模板') fireEvent.click(within(dialog).getByRole('button', { name: '发布至 2026-07' })) await waitFor(() => expect(request).toHaveBeenCalledWith( @@ -396,22 +445,87 @@ describe('PerformanceSurveyAdminConfig', () => { )) }) - test('模板编辑按基本信息、评分项、评审与计分分步展示', async () => { + test('模板编辑按基本信息、评分项、评分人设置分步展示', async () => { const request = createRequest() render() await waitFor(() => expect(screen.getByRole('navigation', { name: '模板编辑步骤' })).toBeInTheDocument()) expect(screen.getByRole('heading', { name: '基本信息' })).toBeInTheDocument() + expect(screen.getByLabelText('考核内容类型')).toHaveValue('visual_case') + expect(screen.getByText(/决定调研按员工表现、作品案例或一次具体事项来评分/)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '2. 评分项' })) expect(screen.getByRole('heading', { name: '评分项' })).toBeInTheDocument() expect(screen.getByLabelText('评分项名称')).toHaveValue('作品质量') expect(screen.getByLabelText('总分')).toHaveValue(60) - fireEvent.click(screen.getByRole('button', { name: '3. 评审与计分' })) - expect(screen.getByRole('heading', { name: '评审与计分' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' })) + expect(screen.getByRole('heading', { name: '评分人设置' })).toBeInTheDocument() expect(screen.getByLabelText('最终成绩规则')).toBeInTheDocument() }) + 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.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' })) + const reviewerSelect = screen.getByLabelText('阶段 1 评分人类型 1') + expect(reviewerSelect).toHaveValue('direct_manager') + expect(within(reviewerSelect).getByRole('option', { name: '直属主管' })).toBeInTheDocument() + expect(within(reviewerSelect).getByRole('option', { name: '元泽' })).toBeInTheDocument() + expect(within(reviewerSelect).getByRole('option', { name: '任何人(发起时选择)' })).toBeInTheDocument() + + fireEvent.change(reviewerSelect, { target: { value: 'fixed_employee' } }) + await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽')) + + fireEvent.change(reviewerSelect, { target: { value: 'manual_selection' } }) + await waitFor(() => expect(screen.getByText(/终评:发起时选择任何人/)).toBeInTheDocument()) + }) + + 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), + } + let resolveEmployees: ((employees: Array<{ id: string; displayName: string }>) => void) | null = null + const employeeRequest = new Promise>((resolve) => { + resolveEmployees = resolve + }) + const baseRequest = createRequest(configuration) + const request = jest.fn(async (path: string, options?: RequestInit) => { + if (path.startsWith('/employees?purpose=evaluator')) return employeeRequest + return baseRequest(path, options) + }) + render() + await waitFor(() => expect(screen.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' })) + fireEvent.change(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } }) + fireEvent.change(screen.getByLabelText('阶段 1 名称'), { target: { value: '终审修改' } }) + + await act(async () => { + resolveEmployees?.([{ id: 'employee-yuanze', displayName: '元泽' }]) + await employeeRequest + }) + + await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽')) + expect(screen.getByLabelText('阶段 1 名称')).toHaveValue('终审修改') + }) + test('模板草稿可自由增删评分项并显示总分分配状态', async () => { const configuration = { ...config, @@ -434,7 +548,7 @@ describe('PerformanceSurveyAdminConfig', () => { expect(screen.getByText('已分配 20 × 3 个作品 = 60 / 60 分')).toBeInTheDocument() }) - test('岗位考核方案只复核岗位页草稿,并经过影响确认后发布', async () => { + test('岗位页保存草稿后可直接经过影响确认并发布', async () => { const configuration = { ...config, groups: config.groups.map((group: any) => group.id === 'group-1' @@ -450,15 +564,14 @@ describe('PerformanceSurveyAdminConfig', () => { : group), } const request = createRequest(configuration) - render() - await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核方案' })).toBeInTheDocument()) + render() + await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument()) + expect(screen.getByText('草稿已保存,等待发布')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '发布岗位设置' })) - expect(screen.getByRole('heading', { name: '待发布的考核项' })).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: '可用已发布模板' })).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '发布前检查' })) - - await waitFor(() => expect(screen.getByRole('region', { name: '发布影响确认' })).toBeInTheDocument()) - fireEvent.click(screen.getByRole('button', { name: '确认发布,2026-07 起生效' })) + const dialog = await screen.findByRole('dialog', { name: '发布“品牌设计师”的岗位设置?' }) + expect(dialog).toHaveTextContent('包含 2 个考核项,预计影响 6 人') + fireEvent.click(within(dialog).getByRole('button', { name: '确认发布' })) await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups/group-1/publish', expect.objectContaining({ method: 'POST', diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.tsx index eddd607b..929f968e 100644 --- a/app/performance-survey/PerformanceSurveyAdminConfig.tsx +++ b/app/performance-survey/PerformanceSurveyAdminConfig.tsx @@ -14,7 +14,7 @@ import { type RequestFn = PerformanceSurveyAdminRequest type ConfirmActionFn = (options: ConfirmActionOptions) => Promise -export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates' | 'plans' +export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates' type ReviewerSource = 'self' | 'direct_manager' | 'fixed_employee' | 'manual_selection' @@ -181,7 +181,7 @@ function newTemplateDefinition(): TemplateDefinition { key: 'review', label: '评价', order: 0, - reviewers: [{ source: 'manual_selection', label: '评价人' }], + reviewers: [{ source: 'direct_manager', label: '直属主管' }], }], scoring: { mode: 'average_reviewers', stageKeys: ['review'] }, }, @@ -190,21 +190,46 @@ function newTemplateDefinition(): TemplateDefinition { function reviewerSourceLabel(source: ReviewerSource) { return { - self: '本人', + self: '本人自评', direct_manager: '直属主管', - fixed_employee: '固定员工', - manual_selection: '发起时选择', + fixed_employee: '元泽', + manual_selection: '发起时选择任何人', }[source] } function surveyTypeLabel(type: TemplateDefinition['surveyType']) { return { - person_period: '按人员评分', - visual_case: '按作品评分', - item_course: '按课程或事项评分', + person_period: '员工工作表现', + visual_case: '作品或案例', + item_course: '课程或事项', }[type] } +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, + } +} + export function PerformanceSurveyAdminConfig({ mode, request, @@ -271,6 +296,12 @@ export function PerformanceSurveyAdminConfig({ 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({ @@ -308,7 +339,6 @@ export function PerformanceSurveyAdminConfig({ positions: '岗位管理', people: '人员分配', templates: '模板管理', - plans: '岗位考核方案', }[mode]}

@@ -317,9 +347,7 @@ export function PerformanceSurveyAdminConfig({ : mode === 'templates' ? '已发布模板会长期使用;只有评分规则变化时才创建新版本' : mode === 'positions' - ? '岗位名称、排序和考核项修改会先保存为草稿,发布后生效' - : mode === 'plans' - ? `正在查看 ${month} 的生效方案;已发布方案会持续沿用` + ? '岗位名称、排序和考核项在当前页面保存并发布,发布后持续生效' : `正在维护 ${month};已发布版本保持不变`}

@@ -334,14 +362,9 @@ export function PerformanceSurveyAdminConfig({ {notice && ( - <> -
- {notice} -
-
- {notice} -
- +
+ {notice} +
)} {error && (
@@ -398,15 +421,6 @@ export function PerformanceSurveyAdminConfig({ dirty={dirty} /> )} - {mode === 'plans' && ( - - )}
)} {confirmActionDialog} @@ -432,22 +446,18 @@ function AdminOverviewWorkspace({ 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 - .flatMap((template) => template.versions) - .filter((version) => ( - version.status === 'published' - && (!version.effectiveMonth || version.effectiveMonth <= month) - )).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 + const positionsReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length const templatesReady = availablePublishedTemplates > 0 - const plansReady = activeGroups.length > 0 && effectivePlanCount === activeGroups.length - const launchReady = positionsReady && templatesReady && plansReady && data.issues.conflicts.length === 0 + const launchReady = positionsReady && templatesReady && data.issues.conflicts.length === 0 const steps = [ { id: 'people', @@ -463,12 +473,12 @@ function AdminOverviewWorkspace({ }, { id: 'positions', - label: '确认岗位', - status: positionsReady ? 'done' : 'attention', + label: '配置并发布岗位', + status: positionsReady ? (draftPlanCount > 0 ? 'attention' : 'done') : 'blocked', summary: activeGroups.length === 0 - ? '还没有可用岗位' - : `${activeGroups.length} 个岗位可用;人员归属请在“人员分配”中维护${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`, - action: '管理岗位', + ? '还没有岗位,请先创建岗位并选择考核项' + : `${effectivePlanCount}/${activeGroups.length} 个岗位设置已生效${inheritedPlanCount > 0 ? `,其中 ${inheritedPlanCount} 个沿用较早设置` : ''}${draftPlanCount > 0 ? `;${draftPlanCount} 个岗位有待发布修改` : ''}${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`, + action: '配置岗位', target: 'positions' as const, }, { @@ -476,19 +486,11 @@ function AdminOverviewWorkspace({ label: '准备考核模板', status: templatesReady ? (draftTemplates > 0 ? 'attention' : 'done') : 'blocked', summary: templatesReady - ? `${availablePublishedTemplates} 个版本可用于 ${month}${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}` - : `还没有可用于 ${month} 的已发布模板,岗位考核方案无法完成${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个其它月份版本)` : ''}`, + ? `${availablePublishedTemplates} 个模板可用于 ${month}${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}` + : `还没有可用于 ${month} 的已发布模板${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个其它月份版本)` : ''}`, action: '管理模板', target: 'templates' as const, }, - { - id: 'plans', - label: '确认岗位考核方案', - status: plansReady ? 'done' : draftPlanCount > 0 ? 'attention' : 'blocked', - summary: `${effectivePlanCount}/${activeGroups.length} 个在用岗位已有生效方案${inheritedPlanCount > 0 ? `,其中 ${inheritedPlanCount} 个沿用较早版本` : ''}${draftPlanCount > 0 ? `;另有 ${draftPlanCount} 个草稿待确认` : ''}`, - action: '配置方案', - target: 'plans' as const, - }, { id: 'launch', label: '发起调研', @@ -524,7 +526,7 @@ function AdminOverviewWorkspace({ {month} 配置概览

- 先处理人员匹配问题,再检查模板与岗位考核方案。已发布方案会持续沿用,无需每月重复配置。 + 先处理人员归属,再准备模板并配置岗位考核项。岗位设置发布后会持续沿用,无需每月重复配置。

@@ -670,17 +672,17 @@ function PositionConfigWorkspace({ const markDirty = () => onDirtyChange(true) - const publishedVersions = useMemo(() => data.templates.flatMap((template) => ( - template.versions - .filter((version) => ( - version.status === 'published' - && (!version.effectiveMonth || version.effectiveMonth <= month) - )) - .map((version) => ({ template, version })) - )), [data.templates, month]) + 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(publishedVersions.map((item) => [item.version.id, item])), - [publishedVersions], + () => 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') @@ -690,12 +692,12 @@ function PositionConfigWorkspace({ .map((id) => versionById.get(id)?.template.id) .filter(Boolean), ) - return publishedVersions.filter(({ template, version }) => ( + 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) - }, [publishedVersions, templateSearch, templateVersionIds, versionById]) + }, [availableTemplateVersions, templateSearch, templateVersionIds, versionById]) if (!selected) return
暂无岗位组
@@ -707,7 +709,7 @@ function PositionConfigWorkspace({ method: 'PATCH', body: JSON.stringify({ name, sortOrder, templateVersionIds, effectiveMonth: month }), }) - await onChanged('岗位和考核项修改已保存,发布后生效') + await onChanged('岗位设置草稿已保存,可直接在当前页面发布') } catch (actionError) { onError(actionError instanceof Error ? actionError.message : '岗位方案保存失败') } finally { @@ -715,6 +717,75 @@ function PositionConfigWorkspace({ } } + 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('请填写岗位名称') @@ -839,29 +910,43 @@ function PositionConfigWorkspace({ )} - {data.groups.map((group) => ( - - ))} + + + ) + })}
正在编辑:{selected.name}
-

岗位名称、排序和考核项保存后进入草稿,需要发布岗位考核方案后才会生效。

+

+ {selected.effectivePlan + ? `当前设置从 ${selected.effectivePlan.effectiveMonth} 起生效${selected.draftPlan ? ',另有待发布修改' : ''}` + : selected.draftPlan ? '岗位设置已保存,尚未发布' : '岗位尚未完成设置'} +

+
@@ -991,6 +1091,9 @@ function TemplateConfigWorkspace({ const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null const editable = !version || version.status === 'draft' + const selectedAvailability = selectedTemplate + ? getTemplateAvailability(selectedTemplate, month) + : null const questionTotal = definition.questions.reduce( (sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20), 0, @@ -1057,7 +1160,7 @@ function TemplateConfigWorkspace({ title: `发布模板“${definition.name}”?`, description: (
-

模板将从 {month} 起供岗位考核方案使用。

+

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

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

), @@ -1148,17 +1251,32 @@ function TemplateConfigWorkspace({ 新建模板 - {data.templates.map((template) => ( - - ))} + {data.templates.map((template) => { + const availability = getTemplateAvailability(template, month) + const statusDetail = availability.isAvailable + ? `v${availability.availableVersion?.version} · ${availability.availableVersion?.effectiveMonth} 起${availability.draftVersion ? ' · 有未发布修改' : ''}` + : availability.futureVersion + ? `${availability.futureVersion.effectiveMonth} 起可用` + : availability.hasPublishedVersionWithoutMonth + ? '已发布版本缺少生效月份' + : availability.draftVersion ? '仅有草稿,发布后可用' : '没有可用版本' + return ( + + ) + })}
@@ -1190,7 +1308,14 @@ function TemplateConfigWorkspace({
-
当前模板
+
+ 当前模板 + {selectedAvailability && ( + + {selectedAvailability.isAvailable ? '当前可用' : '当前不可用'} + + )} +
{definition.name || '未命名模板'}
{surveyTypeLabel(definition.surveyType)} · {definition.questions.length} 个评分项 · {definition.workflow.stages.length} 个评审阶段 @@ -1215,7 +1340,7 @@ function TemplateConfigWorkspace({ {([ ['basics', '1. 基本信息'], ['questions', '2. 评分项'], - ['workflow', '3. 评审与计分'], + ['workflow', '3. 评分人设置'], ] as const).map(([step, label]) => ( - -
-
- )} - -
- - {group.draftPlan ? '当前有待发布修改' : '当前没有待发布修改'} - - -
-
- ) -} - function QuestionEditor({ definition, editable, @@ -1633,39 +1568,111 @@ function WorkflowEditor({ definition: TemplateDefinition editable: boolean request: RequestFn - onChange: (definition: TemplateDefinition) => void + onChange: ( + definition: TemplateDefinition | ((current: TemplateDefinition) => TemplateDefinition) + ) => void }) { - const updateStages = (nextStages: StageDraft[]) => { - const stages = nextStages.map((stage, index) => ({ ...stage, order: index })) - const currentScoring = definition.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, - } - })() - onChange({ - ...definition, - workflow: { ...definition.workflow, stages, scoring }, + const reviewerResolutionSequenceRef = useRef>({}) + 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 reviewerFlowSummary = stages.map((stage) => ( + `${stage.label}:${stage.reviewers.map((reviewer) => reviewer.employeeName || reviewerSourceLabel(reviewer.source)).join('、')}` + )).join(' → ') + + 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>( + '/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 (
-

评审与计分

-

同阶段并行,上一阶段全部提交后开放下一阶段

+

评分人设置

+

设置评分任务交给谁;同一阶段的多人需要全部完成,才会进入下一阶段。

{editable && }
+
+ 当前流程:{reviewerFlowSummary || '尚未设置评分人'} +
{stages.map((stage, stageIndex) => (
@@ -1698,19 +1705,17 @@ function WorkflowEditor({ aria-label={`阶段 ${stageIndex + 1} 评分人类型 ${reviewerIndex + 1}`} value={reviewer.source} disabled={!editable} - onChange={(event) => updateStages(stages.map((item, index) => index === stageIndex ? { - ...item, - reviewers: item.reviewers.map((entry, entryIndex) => entryIndex === reviewerIndex ? { - source: event.target.value as ReviewerSource, - label: reviewerSourceLabel(event.target.value as ReviewerSource), - } : entry), - } : item))} + 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]" > - - - + + + {reviewer.source === 'fixed_employee' ? ( ))} - {editable && } + {editable && }
高级设置 @@ -1840,14 +1845,14 @@ function FixedReviewerPicker({ return (
{ setSearch(event.target.value) - onChange({ source: 'fixed_employee', label: '固定员工' }) + onChange({ source: 'fixed_employee', label: '元泽' }) }} - placeholder="搜索固定评分人" + placeholder="搜索并选择元泽" className="h-11 w-full rounded border border-[#e2e2e2] px-3 text-[12px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]" /> {items.length > 0 && ( @@ -1857,7 +1862,7 @@ function FixedReviewerPicker({ key={item.id} type="button" onClick={() => { - onChange({ source: 'fixed_employee', label: '固定员工', employeeId: item.id, employeeName: item.displayName }) + onChange({ source: 'fixed_employee', label: '元泽', employeeId: item.id, employeeName: item.displayName }) setSearch('') setItems([]) }} diff --git a/app/performance-survey/PerformanceSurveyDashboardNavigation.test.tsx b/app/performance-survey/PerformanceSurveyDashboardNavigation.test.tsx new file mode 100644 index 00000000..72cbb77f --- /dev/null +++ b/app/performance-survey/PerformanceSurveyDashboardNavigation.test.tsx @@ -0,0 +1,170 @@ +/** @jest-environment jsdom */ + +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import PerformanceSurveyPage from './[[...path]]/page' + +const mockRouterReplace = jest.fn() +const mockRouterPush = jest.fn() +let mockSearchParams = new URLSearchParams() + +jest.mock('next/navigation', () => ({ + useParams: () => ({ path: [] }), + useRouter: () => ({ + push: mockRouterPush, + replace: mockRouterReplace, + }), + useSearchParams: () => mockSearchParams, +})) + +jest.mock('next-auth/react', () => ({ + useSession: () => ({ + data: { + user: { + id: 'user-admin', + name: '管理员', + email: 'admin@example.com', + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }), +})) + +const templateDefinition = { + code: 'visual_review', + name: '视觉案例评分', + shortName: '视觉评分', + description: '视觉案例评分', + surveyType: 'visual_case', + weight: 1, + maxTotal: 60, + questionMaxScore: 20, + requiredWorkItemCount: 3, + workItemImageRequired: true, + questions: [{ id: 'quality', title: '作品质量', maxScore: 20 }], + scoreBands: [], + workflow: { + stages: [{ + key: 'review', + label: '终评', + order: 0, + reviewers: [{ source: 'direct_manager', label: '直属主管' }], + }], + scoring: { mode: 'final_reviewer', stageKey: 'review', reviewerSource: 'direct_manager' }, + }, +} + +const adminConfig = { + month: '2026-07', + groups: [{ + id: 'group-1', + code: 'brand_designer', + name: '品牌设计师', + sortOrder: 10, + isActive: true, + status: 'ready', + memberCount: 2, + rules: [], + effectivePlan: { + id: 'plan-1', + version: 1, + effectiveMonth: '2026-07', + templateVersionIds: ['version-1'], + }, + draftPlan: null, + }], + templates: [{ + id: 'template-1', + code: 'visual_review', + name: '视觉案例评分', + description: '视觉案例评分', + surveyType: 'visual_case', + isActive: true, + versions: [{ + id: 'version-1', + version: 1, + status: 'published', + effectiveMonth: '2026-07', + definition: templateDefinition, + }], + }], + issues: { + unmatched: [], + conflicts: [], + }, +} + +function jsonResponse(data: unknown) { + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, data }), + text: async () => '', + } as unknown as Response +} + +describe('考核调研顶层导航保护', () => { + const originalFetch = global.fetch + + beforeEach(() => { + mockRouterReplace.mockReset() + mockRouterPush.mockReset() + mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=positions&adminMonth=2026-07') + global.fetch = jest.fn(async (input) => { + const url = String(input) + if (url.includes('/admin/config-audit-logs')) return jsonResponse({ items: [] }) + if (url.includes('/admin/config?')) return jsonResponse(adminConfig) + if (url.endsWith('/me')) { + return jsonResponse({ + user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' }, + employee: { id: 'employee-admin', displayName: '管理员' }, + permissions: { + canCreate: true, + isSystemAdmin: true, + isDepartmentAdmin: false, + isLeadership: false, + }, + }) + } + if (url.includes('/departments?purpose=evaluator')) return jsonResponse([]) + if (url.endsWith('/surveys')) return jsonResponse([]) + return jsonResponse([]) + }) as jest.Mock + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn().mockReturnValue({ matches: false }), + }) + }) + + afterEach(() => { + global.fetch = originalFetch + jest.restoreAllMocks() + }) + + test('岗位存在未保存修改时切换个人看板会使用统一弹窗保护', async () => { + render() + + const nameInput = await screen.findByLabelText('岗位名称') + fireEvent.change(nameInput, { target: { value: '品牌设计师(修改中)' } }) + expect(await screen.findByText('当前修改尚未保存')).toBeInTheDocument() + + fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0]) + const firstDialog = await screen.findByRole('alertdialog', { name: '放弃尚未保存的修改?' }) + fireEvent.click(within(firstDialog).getByRole('button', { name: '取消' })) + await waitFor(() => expect(screen.queryByRole('alertdialog', { name: '放弃尚未保存的修改?' })).not.toBeInTheDocument()) + expect(mockRouterReplace).not.toHaveBeenCalledWith( + expect.stringContaining('tab=personal'), + expect.anything(), + ) + + fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0]) + const secondDialog = await screen.findByRole('alertdialog', { name: '放弃尚未保存的修改?' }) + fireEvent.click(within(secondDialog).getByRole('button', { name: '放弃并离开' })) + + await waitFor(() => expect(mockRouterReplace).toHaveBeenCalledWith( + '/performance-survey?tab=personal&adminWorkspace=positions', + { scroll: false }, + )) + }) +}) diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index 6de596d8..f1f71dc9 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/performance-survey-response-progress' import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping' import { PerformanceSurveyAdminConfig } from '@/app/performance-survey/PerformanceSurveyAdminConfig' +import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' type ApiState = { loading: boolean @@ -720,6 +721,11 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad const requestedTab = normalizePerformanceSurveyDashboardTab(requestedTabRaw) const searchParamsKey = searchParams.toString() const [tab, setTab] = useState(requestedTab) + const [manageConfigurationDirty, setManageConfigurationDirty] = useState(false) + const { + confirmAction: confirmDashboardNavigation, + confirmActionDialog: dashboardNavigationDialog, + } = useConfirmActionDialog() const displayName = me?.employee?.flowerName || me?.employee?.displayName || me?.user?.name || '当前用户' const avatar = me?.user?.avatar || me?.user?.image || '' const canCreateSurvey = Boolean(me?.permissions?.canCreate) @@ -728,26 +734,63 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad () => canCreateSurvey ? ['personal', 'public', 'manage'] : ['personal', 'public'], [canCreateSurvey], ) + const confirmLeaveManage = useCallback(async () => { + if (tab !== 'manage' || !manageConfigurationDirty) return true + const result = await confirmDashboardNavigation({ + title: '放弃尚未保存的修改?', + description: '离开调研管理后,本次尚未保存的配置不会保留。', + confirmLabel: '放弃并离开', + tone: 'danger', + }) + return result.confirmed + }, [confirmDashboardNavigation, manageConfigurationDirty, tab]) useEffect(() => { if (!permissionsResolved) return - if (requestedTab === 'manage' && !canCreateSurvey) { - setTab('personal') - const query = new URLSearchParams(searchParamsKey) - query.set('tab', 'personal') - query.delete('prefillMonth') - query.delete('prefillTemplate') - router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) - return + let cancelled = false + const syncRequestedTab = async () => { + if (requestedTab === 'manage' && !canCreateSurvey) { + setManageConfigurationDirty(false) + setTab('personal') + const query = new URLSearchParams(searchParamsKey) + query.set('tab', 'personal') + query.delete('prefillMonth') + query.delete('prefillTemplate') + router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) + return + } + if (availableTabIds.includes(requestedTab) && tab !== requestedTab) { + if (!await confirmLeaveManage()) { + if (cancelled) return + const query = new URLSearchParams(searchParamsKey) + query.set('tab', tab) + router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) + return + } + if (cancelled) return + setManageConfigurationDirty(false) + setTab(requestedTab) + return + } + if (!availableTabIds.includes(tab)) { + setManageConfigurationDirty(false) + setTab('personal') + } } - if (availableTabIds.includes(requestedTab) && tab !== requestedTab) { - setTab(requestedTab) - return + void syncRequestedTab() + return () => { + cancelled = true } - if (!availableTabIds.includes(tab)) { - setTab('personal') - } - }, [availableTabIds, canCreateSurvey, permissionsResolved, requestedTab, router, searchParamsKey, tab]) + }, [ + availableTabIds, + canCreateSurvey, + confirmLeaveManage, + permissionsResolved, + requestedTab, + router, + searchParamsKey, + tab, + ]) useEffect(() => { if (requestedTabRaw === requestedTab) return @@ -758,7 +801,9 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad router.replace(`/performance-survey?${query.toString()}`, { scroll: false }) }, [requestedTab, requestedTabRaw, router, searchParamsKey]) - const selectTab = (nextTab: PerformanceSurveyDashboardTab) => { + const selectTab = async (nextTab: PerformanceSurveyDashboardTab) => { + if (nextTab === tab || !await confirmLeaveManage()) return + setManageConfigurationDirty(false) setTab(nextTab) const query = new URLSearchParams() query.set('tab', nextTab) @@ -795,7 +840,7 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad activeTab={tab} avatar={avatar} displayName={displayName} - onSelectTab={selectTab} + onSelectTab={(nextTab) => void selectTab(nextTab)} tabItems={tabItems} />
@@ -815,15 +860,18 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad || me?.permissions?.isDepartmentAdmin || me?.permissions?.isLeadership, )} + configurationDirty={manageConfigurationDirty} + onConfigurationDirtyChange={setManageConfigurationDirty} /> )}
+ {dashboardNavigationDialog}