feat: 优化考核调研管理配置体验

This commit is contained in:
chenpei 2026-07-27 19:30:16 +08:00
parent b07088affe
commit 91ad3941e7
4 changed files with 770 additions and 400 deletions

View File

@ -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(<PerformanceSurveyAdminConfig mode="overview" request={request} onNavigate={onNavigate} />)
@ -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(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
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(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
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(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
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<Array<{ id: string; displayName: string }>>((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(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
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(<PerformanceSurveyAdminConfig mode="plans" request={request} />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核方案' })).toBeInTheDocument())
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
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',

View File

@ -14,7 +14,7 @@ import {
type RequestFn = PerformanceSurveyAdminRequest
type ConfirmActionFn = (options: ConfirmActionOptions) => Promise<ConfirmActionResult>
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]}
</h2>
<p className="mt-0.5 text-[12px] text-[#5f5e5e]">
@ -317,9 +347,7 @@ export function PerformanceSurveyAdminConfig({
: mode === 'templates'
? '已发布模板会长期使用;只有评分规则变化时才创建新版本'
: mode === 'positions'
? '岗位名称、排序和考核项修改会先保存为草稿,发布后生效'
: mode === 'plans'
? `正在查看 ${month} 的生效方案;已发布方案会持续沿用`
? '岗位名称、排序和考核项在当前页面保存并发布,发布后持续生效'
: `正在维护 ${month};已发布版本保持不变`}
</p>
</div>
@ -334,14 +362,9 @@ export function PerformanceSurveyAdminConfig({
</label>
</div>
{notice && (
<>
<div role="status" aria-live="polite" className="border-b border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-[13px] font-semibold text-[#0050cb]">
{notice}
</div>
<div className="fixed bottom-[76px] right-4 z-50 max-w-[calc(100vw-2rem)] border border-[#b3c5ff] bg-white px-4 py-3 text-[13px] font-semibold text-[#0050cb] shadow-[0_12px_30px_rgba(0,0,0,0.14)] sm:bottom-5">
{notice}
</div>
</>
<div role="status" aria-live="polite" className="border-b border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-[13px] font-semibold text-[#0050cb]">
{notice}
</div>
)}
{error && (
<div role="alert" className="flex flex-wrap items-center justify-between gap-3 border-b border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-[13px] font-semibold text-[#93000a]">
@ -398,15 +421,6 @@ export function PerformanceSurveyAdminConfig({
dirty={dirty}
/>
)}
{mode === 'plans' && (
<MonthlyPlanWorkspace
data={data}
month={month}
request={request}
onError={setError}
onChanged={changed}
/>
)}
</div>
)}
{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} 配置概览
</h2>
<p className="mt-1 max-w-3xl text-[13px] leading-5 text-[#5f5e5e]">
先处理人员匹配问题,再检查模板与岗位考核方案。已发布方案会持续沿用,无需每月重复配置。
先处理人员归属,再准备模板并配置岗位考核项。岗位设置发布后会持续沿用,无需每月重复配置。
</p>
</div>
@ -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 <div className="px-5 py-12 text-center text-sm text-[#727687]">暂无岗位组</div>
@ -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: (
<div className="grid gap-1">
<p>包含 {templateVersionIds.length} 个考核项,预计影响 {memberCount} 人。</p>
<p>发布后从 {month} 起持续生效,后续月份无需重复设置。</p>
</div>
),
confirmLabel: '确认发布',
loadingLabel: '发布中...',
action: async () => {
await request(`/admin/position-groups/${selected.id}/publish`, {
method: 'POST',
body: JSON.stringify({
effectiveMonth: month,
draftPlanId,
draftVersion,
draftUpdatedAt,
}),
})
await onChanged(`${selected.name} 的岗位设置已发布,从 ${month} 起持续生效`)
},
})
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位设置发布失败')
} finally {
setSaving(false)
}
}
const createGroup = async () => {
if (!newName.trim()) {
onError('请填写岗位名称')
@ -839,29 +910,43 @@ function PositionConfigWorkspace({
</div>
)}
</div>
{data.groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => void selectGroup(group.id)}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#eeeeee] px-4 py-3 text-left ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{group.name}</span>
<span className="mt-0.5 block text-[11px] text-[#5f5e5e]">
{group.memberCount} 人 · {group.status === 'ready' ? '已配置' : group.status === 'disabled' ? '已归档' : '待配置'}
{data.groups.map((group) => {
const groupState = group.status === 'disabled'
? '已删除'
: group.draftPlan
? group.effectivePlan ? '有待发布修改' : '待发布'
: group.effectivePlan ? '已生效' : '待配置'
const groupStateClass = group.status === 'disabled'
? 'bg-[#8a8a8a]'
: group.draftPlan || !group.effectivePlan ? 'bg-[#b26a00]' : 'bg-[#16794b]'
return (
<button
key={group.id}
type="button"
onClick={() => void selectGroup(group.id)}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#eeeeee] px-4 py-3 text-left ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{group.name}</span>
<span className="mt-0.5 block text-[11px] text-[#5f5e5e]">
{group.memberCount} 人 · {groupState}
</span>
</span>
</span>
<span className={`h-2 w-2 rounded-full ${group.status === 'ready' ? 'bg-[#16794b]' : group.status === 'disabled' ? 'bg-[#8a8a8a]' : 'bg-[#b26a00]'}`} />
</button>
))}
<span className={`h-2 w-2 rounded-full ${groupStateClass}`} />
</button>
)
})}
</aside>
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e2e2] pb-3">
<div>
<div className="text-[12px] font-bold text-[#0050cb]">正在编辑:{selected.name}</div>
<p className="mt-1 text-[11px] leading-5 text-[#5f5e5e]">岗位名称、排序和考核项保存后进入草稿,需要发布岗位考核方案后才会生效。</p>
<p className="mt-1 text-[11px] leading-5 text-[#5f5e5e]">
{selected.effectivePlan
? `当前设置从 ${selected.effectivePlan.effectiveMonth} 起生效${selected.draftPlan ? ',另有待发布修改' : ''}`
: selected.draftPlan ? '岗位设置已保存,尚未发布' : '岗位尚未完成设置'}
</p>
</div>
<button type="button" disabled={saving} onClick={() => void removeGroup()} className="min-h-10 rounded border border-[#f0b8b8] px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">
删除岗位
@ -875,7 +960,7 @@ function PositionConfigWorkspace({
<div className="mt-6 border-t border-[#eeeeee] pt-5">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">岗位考核项</h3>
<p className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">
在这里选择该岗位需要参加的考核。保存后会形成草稿,再到“岗位考核方案”检查并发布。
选择该岗位需要参加的考核并调整顺序。保存草稿后,可直接在本页检查并发布。
</p>
<div className="relative mt-4 max-w-xl">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
@ -936,9 +1021,24 @@ function PositionConfigWorkspace({
</div>
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-wrap items-center justify-between gap-2 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:justify-end sm:shadow-none">
<span className="text-[12px] font-semibold text-[#5f5e5e] sm:mr-auto">保存后请前往“岗位考核方案”发布生效</span>
<button type="button" disabled={saving || selected.status === 'disabled'} onClick={() => void save()} className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white disabled:opacity-50">
{saving ? '保存中...' : '保存修改'}
<span className="text-[12px] font-semibold text-[#5f5e5e] sm:mr-auto">
{dirty
? '当前修改尚未保存'
: selected.draftPlan
? '草稿已保存,等待发布'
: selected.effectivePlan ? '当前岗位设置已生效' : '请先选择考核项并保存'}
</span>
<button type="button" disabled={saving || selected.status === 'disabled' || !dirty} onClick={() => void save()} className="min-h-11 rounded border border-[#1a1c1c] px-5 text-[13px] font-bold text-[#1a1c1c] disabled:opacity-50">
{saving ? '处理中...' : '保存草稿'}
</button>
<button
type="button"
disabled={saving || selected.status === 'disabled' || dirty || !selected.draftPlan || templateVersionIds.length === 0}
onClick={() => void publishPlan()}
title={dirty ? '请先保存当前修改' : selected.draftPlan ? undefined : '当前没有待发布修改'}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white disabled:opacity-50"
>
发布岗位设置
</button>
</div>
</div>
@ -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: (
<div className="grid gap-1">
<p>模板将从 {month} 起供岗位考核方案使用。</p>
<p>模板将从 {month} 起成为可用模板,可直接添加到岗位考核项。</p>
<p>发布后的版本不可直接修改;后续调整需要创建新的草稿版本。</p>
</div>
),
@ -1148,17 +1251,32 @@ function TemplateConfigWorkspace({
新建模板
</button>
</div>
{data.templates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => void selectTemplate(template.id)}
className={`w-full border-b border-[#eeeeee] px-4 py-3 text-left ${selectedTemplateId === template.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{template.versions[0]?.definition.name || template.name}</span>
<span className="mt-0.5 block text-[11px] text-[#727687]">{template.versions.length} 个版本</span>
</button>
))}
{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 (
<button
key={template.id}
type="button"
onClick={() => void selectTemplate(template.id)}
className={`w-full border-b border-[#eeeeee] px-4 py-3 text-left ${selectedTemplateId === template.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-bold text-[#1a1c1c]">{template.versions[0]?.definition.name || template.name}</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${availability.isAvailable ? 'bg-[#eaf7ef] text-[#16794b]' : 'bg-[#f3f3f3] text-[#5f5e5e]'}`}>
{availability.isAvailable ? '可用' : '不可用'}
</span>
</span>
<span className="mt-1 block text-[11px] leading-4 text-[#727687]">{statusDetail}</span>
</button>
)
})}
</aside>
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
@ -1190,7 +1308,14 @@ function TemplateConfigWorkspace({
<section aria-label="模板摘要" className="mb-5 border border-[#c2c6d8] bg-[#fafafa] px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-bold text-[#727687]">当前模板</div>
<div className="flex items-center gap-2 text-[11px] font-bold text-[#727687]">
<span>当前模板</span>
{selectedAvailability && (
<span className={`rounded-full px-2 py-0.5 ${selectedAvailability.isAvailable ? 'bg-[#eaf7ef] text-[#16794b]' : 'bg-[#f0f0f0] text-[#5f5e5e]'}`}>
{selectedAvailability.isAvailable ? '当前可用' : '当前不可用'}
</span>
)}
</div>
<div className="mt-1 truncate text-[16px] font-bold text-[#1a1c1c]">{definition.name || '未命名模板'}</div>
<div className="mt-1 text-[12px] leading-5 text-[#5f5e5e]">
{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]) => (
<button
key={step}
@ -1245,17 +1370,21 @@ function TemplateConfigWorkspace({
disabled={!editable}
/>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
评分对象
考核内容类型
<select
aria-label="考核内容类型"
value={definition.surveyType}
disabled={!editable}
onChange={(event) => updateDefinition((current) => ({ ...current, surveyType: event.target.value as TemplateDefinition['surveyType'] }))}
className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"
>
<option value="person_period">员工表现(评价一段时间内的工作表现)</option>
<option value="visual_case">作品或案例(需要提交图片或案例)</option>
<option value="item_course">课程或事项(评价一次活动或任务)</option>
<option value="person_period">员工工作表现</option>
<option value="visual_case">作品或案例</option>
<option value="item_course">课程或事项</option>
</select>
<span className="font-normal leading-5 text-[#727687]">
决定调研按员工表现、作品案例或一次具体事项来评分;具体考核人员在发起调研时选择。
</span>
</label>
{definition.surveyType === 'visual_case' && (
<AdminField
@ -1306,200 +1435,6 @@ function TemplateConfigWorkspace({
)
}
function MonthlyPlanWorkspace({
data,
month,
request,
onError,
onChanged,
}: {
data: AdminConfig
month: string
request: RequestFn
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
}) {
const [selectedGroupId, setSelectedGroupId] = useState(data.groups.find((group) => group.status !== 'disabled')?.id || data.groups[0]?.id || '')
const group = data.groups.find((item) => item.id === selectedGroupId) || data.groups[0]
const [templateVersionIds, setTemplateVersionIds] = useState<string[]>(
group?.draftPlan?.templateVersionIds || group?.effectivePlan?.templateVersionIds || [],
)
const [saving, setSaving] = useState(false)
const [preview, setPreview] = useState<any | null>(null)
const publishedVersions = useMemo(() => data.templates.flatMap((template) => (
template.versions
.filter((version) => version.status === 'published')
.map((version) => ({ template, version }))
)), [data.templates])
const versionById = useMemo(() => new Map(publishedVersions.map((item) => [item.version.id, item])), [publishedVersions])
useEffect(() => {
if (!group) return
setTemplateVersionIds(group.draftPlan?.templateVersionIds || group.effectivePlan?.templateVersionIds || [])
setPreview(null)
}, [group])
const changeGroup = (groupId: string) => {
if (groupId === selectedGroupId) return
setSelectedGroupId(groupId)
}
const openPublishPreview = async () => {
if (!group) return
if (!group.draftPlan) {
onError('当前没有待发布的岗位修改,请先到“岗位”页面调整考核项并保存')
return
}
if (templateVersionIds.length === 0) {
onError('岗位考核方案至少需要 1 个已发布模板')
return
}
setSaving(true)
onError(null)
try {
const serverPreview = await adminConfigApi.previewPositionGroup(request, group.id, month)
setPreview(serverPreview || {})
} catch (previewError) {
const warning = previewError instanceof Error ? previewError.message : '服务端影响预览暂不可用'
setPreview({
warning,
blockingIssues: [warning],
memberCount: group.memberCount,
unmatchedCount: data.issues.unmatched.length,
conflictCount: data.issues.conflicts.length,
})
} finally {
setSaving(false)
}
}
const publishPlan = async () => {
if (!group) 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('发布预览信息已失效,请重新执行发布前检查')
setPreview(null)
return
}
setSaving(true)
onError(null)
try {
await request(`/admin/position-groups/${group.id}/publish`, {
method: 'POST',
body: JSON.stringify({
effectiveMonth: month,
draftPlanId,
draftVersion,
draftUpdatedAt,
}),
})
setPreview(null)
await onChanged(`${group.name} 的岗位考核方案已发布,将从 ${month} 起持续生效`)
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位考核方案发布失败')
} finally {
setSaving(false)
}
}
if (!group) {
return <div className="py-12 text-center text-[13px] text-[#5f5e5e]">请先创建岗位</div>
}
const previewConflictCount = Number(preview?.conflictCount ?? preview?.conflicts?.length ?? data.issues.conflicts.length)
const previewMemberCount = Number(preview?.memberCount ?? preview?.affectedEmployees ?? group.memberCount)
const previewBlockingIssues = Array.isArray(preview?.blockingIssues)
? preview.blockingIssues.map((item: unknown) => String(item || '').trim()).filter(Boolean)
: []
return (
<section aria-labelledby="performance-survey-plan-title">
<div className="grid gap-3 border-b border-[#e2e2e2] pb-4 md:grid-cols-[minmax(240px,360px)_1fr] md:items-end">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
岗位
<select value={group.id} onChange={(event) => changeGroup(event.target.value)} className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] outline-none focus:border-[#0050cb]">
{data.groups.map((item) => <option key={item.id} value={item.id}>{item.name}{item.status === 'disabled' ? '(已归档)' : ''}</option>)}
</select>
</label>
<div>
<h2 id="performance-survey-plan-title" className="text-[18px] font-bold text-[#1a1c1c]">岗位考核方案</h2>
<p className="mt-1 text-[12px] text-[#5f5e5e]">
查看月份:{month};当前使用:{group.effectivePlan
? `v${group.effectivePlan.version}(${group.effectivePlan.effectiveMonth} 起生效${group.effectivePlan.effectiveMonth < month ? `,${month} 继续沿用` : ''})`
: '尚未发布'}
{group.draftPlan ? `;草稿 v${group.draftPlan.version}` : ''}
</p>
</div>
</div>
<p className="mt-4 max-w-3xl text-[12px] leading-5 text-[#5f5e5e]">
已发布方案会从生效月份起持续沿用,后续月份无需重复发布。请先在“岗位”页面调整考核项并保存,再到这里检查影响并发布。
</p>
<div className="mt-5">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">
{group.draftPlan ? '待发布的考核项' : '当前生效的考核项'}
</h3>
<div className="mt-2 overflow-x-auto border border-[#c2c6d8]">
<table className="w-full min-w-[620px] border-collapse text-left">
<thead>
<tr className="border-b border-[#c2c6d8] bg-[#f4f3f3] text-[12px] font-bold text-[#5f5e5e]">
<th scope="col" className="w-16 px-3 py-3">顺序</th>
<th scope="col" className="px-3 py-3">模板</th>
<th scope="col" className="w-32 px-3 py-3">版本</th>
</tr>
</thead>
<tbody className="divide-y divide-[#e2e2e2]">
{templateVersionIds.map((id, index) => {
const item = versionById.get(id)
return (
<tr key={id}>
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">{index + 1}</td>
<td className="px-3 py-3 text-[13px] font-bold text-[#1a1c1c]">{item?.version.definition.name || item?.template.name || '历史模板版本'}</td>
<td className="px-3 py-3 text-[12px] text-[#5f5e5e]">{item ? `v${item.version.version}` : id}</td>
</tr>
)
})}
{templateVersionIds.length === 0 && <tr><td colSpan={3} className="px-4 py-8 text-center text-[13px] text-[#8a5700]">尚未选择考核项,请先到“岗位”页面添加</td></tr>}
</tbody>
</table>
</div>
</div>
{preview && (
<div className="mt-5 border border-[#b3c5ff] bg-[#f8faff] px-4 py-4" role="region" aria-label="发布影响确认">
<h3 className="text-[14px] font-bold text-[#1a1c1c]">发布影响确认</h3>
<ul className="mt-2 space-y-1 text-[13px] leading-5 text-[#5f5e5e]">
<li>新方案生效月份:{month}</li>
<li>影响岗位:{group.name},约 {previewMemberCount} 人</li>
<li>模板数量:{templateVersionIds.length} 个</li>
<li className={previewConflictCount > 0 ? 'font-bold text-[#b42318]' : ''}>重复匹配:{previewConflictCount} 人</li>
{preview.warning && <li className="text-[#8a5700]">预览提示:{preview.warning}</li>}
{previewBlockingIssues.map((issue: string) => (
<li key={issue} className="font-bold text-[#b42318]">阻塞项:{issue}</li>
))}
</ul>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<button type="button" onClick={() => setPreview(null)} className="min-h-11 rounded border border-[#c2c6d8] px-4 text-[13px] font-bold text-[#1a1c1c]">返回修改</button>
<button type="button" disabled={saving || previewConflictCount > 0 || previewBlockingIssues.length > 0} onClick={() => void publishPlan()} className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white disabled:opacity-50">
{saving ? '发布中...' : `确认发布,${month} 起生效`}
</button>
</div>
</div>
)}
<div className="sticky bottom-[68px] z-20 -mx-4 mt-6 flex flex-wrap justify-end gap-2 border-t border-[#c2c6d8] bg-white/95 px-4 py-3 shadow-[0_-4px_10px_rgba(0,0,0,0.06)] backdrop-blur sm:bottom-0 sm:mx-0 sm:shadow-none">
<span className="mr-auto text-[12px] font-semibold text-[#5f5e5e]">
{group.draftPlan ? '当前有待发布修改' : '当前没有待发布修改'}
</span>
<button type="button" disabled={saving || group.status === 'disabled' || !group.draftPlan} onClick={() => void openPublishPreview()} className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white disabled:opacity-50">发布前检查</button>
</div>
</section>
)
}
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<Record<string, number>>({})
const updateStages = (
nextStages: StageDraft[] | ((currentStages: StageDraft[]) => StageDraft[]),
) => {
onChange((currentDefinition) => {
const resolvedStages = typeof nextStages === 'function'
? nextStages(currentDefinition.workflow.stages)
: nextStages
const stages = resolvedStages.map((stage, index) => ({ ...stage, order: index }))
const currentScoring = currentDefinition.workflow.scoring
const scoring = currentScoring.mode === 'average_reviewers'
? { mode: 'average_reviewers' as const, stageKeys: stages.map((stage) => stage.key) }
: (() => {
const selectedStage = stages.find((stage) => stage.key === currentScoring.stageKey) || stages[stages.length - 1]
const selectedReviewer = selectedStage?.reviewers.find((reviewer) => reviewer.source === currentScoring.reviewerSource)
|| selectedStage?.reviewers[0]
return {
mode: 'final_reviewer' as const,
stageKey: selectedStage?.key || '',
reviewerSource: selectedReviewer?.source || 'manual_selection' as ReviewerSource,
}
})()
return {
...currentDefinition,
workflow: { ...currentDefinition.workflow, stages, scoring },
}
})
}
const stages = definition.workflow.stages
const 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<Array<{ id: string; displayName: string }>>(
'/employees?purpose=evaluator&limit=20&search=%E5%85%83%E6%B3%BD',
)
const exactMatches = employees.filter((employee) => employee.displayName.trim() === '元泽')
if (
reviewerResolutionSequenceRef.current[resolutionKey] !== sequence
|| exactMatches.length !== 1
) return
updateStages((currentStages) => currentStages.map((stage, index) => (
index === stageIndex && stage.key === stageKey
? {
...stage,
reviewers: stage.reviewers.map((reviewer, indexInStage) => (
indexInStage === reviewerIndex && reviewer.source === 'fixed_employee'
? {
...reviewer,
label: '元泽',
employeeId: exactMatches[0].id,
employeeName: exactMatches[0].displayName,
}
: reviewer
)),
}
: stage
)))
} catch {
// 保留未解析状态,让管理员通过下方人员搜索完成选择。
}
}
return (
<section aria-labelledby="template-workflow-heading">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 id="template-workflow-heading" className="text-[15px] font-bold text-[#1a1c1c]">评审与计分</h3>
<p className="mt-1 text-[12px] text-[#727687]">同阶段并行,上一阶段全部提交后开放下一阶段</p>
<h3 id="template-workflow-heading" className="text-[15px] font-bold text-[#1a1c1c]">评分人设置</h3>
<p className="mt-1 text-[12px] text-[#727687]">设置评分任务交给谁;同一阶段的多人需要全部完成,才会进入下一阶段。</p>
</div>
{editable && <button type="button" onClick={() => updateStages([...stages, { key: `stage_${stages.length + 1}`, label: `阶段 ${stages.length + 1}`, order: stages.length, reviewers: [{ source: 'manual_selection', label: '评价人' }] }])} className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[12px] font-bold text-[#1a1c1c] hover:bg-[#1a1c1c] hover:text-white">添加阶段</button>}
</div>
<div className="mt-3 border border-[#c2c6d8] bg-[#fafafa] px-3 py-3 text-[12px] leading-5 text-[#5f5e5e]">
<span className="font-bold text-[#1a1c1c]">当前流程:</span>{reviewerFlowSummary || '尚未设置评分人'}
</div>
<div className="mt-3 grid gap-3">
{stages.map((stage, stageIndex) => (
<div key={`${stage.key}-${stageIndex}`} className="border border-[#e2e2e2] p-4">
@ -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]"
>
<option value="self">本人</option>
<option value="direct_manager">直属主管</option>
<option value="fixed_employee">固定员工</option>
<option value="manual_selection">发起时选择</option>
<option value="fixed_employee">元泽</option>
<option value="manual_selection">任何人(发起时选择)</option>
<option value="self">本人自评</option>
</select>
{reviewer.source === 'fixed_employee' ? (
<FixedReviewerPicker
@ -1744,7 +1749,7 @@ function WorkflowEditor({
)}
</div>
))}
{editable && <button type="button" onClick={() => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: [...item.reviewers, { source: 'manual_selection', label: '评价人' }] } : item))} className="min-h-11 w-fit rounded border border-[#e2e2e2] px-3 text-[11px] font-bold text-[#1a1c1c] hover:border-[#1a1c1c]">添加一类评分人</button>}
{editable && <button type="button" onClick={() => updateStages(stages.map((item, index) => index === stageIndex ? { ...item, reviewers: [...item.reviewers, { source: 'manual_selection', label: '发起时选择任何人' }] } : item))} className="min-h-11 w-fit rounded border border-[#e2e2e2] px-3 text-[11px] font-bold text-[#1a1c1c] hover:border-[#1a1c1c]">添加评分人</button>}
</div>
<details className="mt-3">
<summary className="min-h-11 cursor-pointer py-3 text-[11px] font-bold text-[#727687]">高级设置</summary>
@ -1840,14 +1845,14 @@ function FixedReviewerPicker({
return (
<div className="relative min-w-0">
<input
aria-label="搜索固定评分人"
aria-label="选择元泽人员档案"
value={search || value.employeeName || ''}
disabled={disabled}
onChange={(event) => {
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([])
}}

View File

@ -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(<PerformanceSurveyPage />)
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 },
))
})
})

View File

@ -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<T> = {
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<PerformanceSurveyDashboardTab>(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}
/>
<main className="mx-auto w-full max-w-[1200px] bg-white px-4 py-8 pb-24 text-[#1a1c1c] sm:px-6 sm:pb-10">
@ -815,15 +860,18 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
|| me?.permissions?.isDepartmentAdmin
|| me?.permissions?.isLeadership,
)}
configurationDirty={manageConfigurationDirty}
onConfigurationDirtyChange={setManageConfigurationDirty}
/>
)}
</main>
{dashboardNavigationDialog}
<nav className={`fixed inset-x-0 bottom-0 z-30 grid h-[68px] ${tabItems.length === 3 ? 'grid-cols-3' : 'grid-cols-2'} border-t border-[#e2e2e2] bg-white sm:hidden`}>
{tabItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectTab(item.id)}
onClick={() => void selectTab(item.id)}
aria-current={tab === item.id ? 'page' : undefined}
className={`flex items-center justify-center text-[12px] font-semibold ${
tab === item.id ? 'text-[#0050cb]' : 'text-[#5f5e5e]'
@ -2766,7 +2814,6 @@ type PerformanceSurveyAdminWorkspace =
| 'positions'
| 'people'
| 'templates'
| 'plans'
| 'operations'
const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
@ -2775,7 +2822,6 @@ const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
'positions',
'people',
'templates',
'plans',
'operations',
]
@ -2783,24 +2829,34 @@ function ManagePanel({
currentEmployee,
isSystemAdmin,
canSelectAnyPosition,
configurationDirty,
onConfigurationDirtyChange,
}: {
currentEmployee: Employee | null
isSystemAdmin: boolean
canSelectAnyPosition: boolean
configurationDirty: boolean
onConfigurationDirtyChange: (dirty: boolean) => void
}) {
const router = useRouter()
const searchParams = useSearchParams()
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
const surveys = useApi<any>('/surveys', [])
const [message, setMessage] = useState<string | null>(null)
const requestedWorkspace = String(searchParams.get('adminWorkspace') || '').trim() as PerformanceSurveyAdminWorkspace
const {
confirmAction: confirmManageNavigation,
confirmActionDialog: manageNavigationDialog,
} = useConfirmActionDialog()
const requestedWorkspaceValue = String(searchParams.get('adminWorkspace') || '').trim()
const requestedWorkspace = (
requestedWorkspaceValue === 'plans' ? 'positions' : requestedWorkspaceValue
) as PerformanceSurveyAdminWorkspace
const requestedAdminMonth = String(searchParams.get('adminMonth') || '').trim()
const adminMonth = /^\d{4}-\d{2}$/.test(requestedAdminMonth) ? requestedAdminMonth : getCurrentMonthValue()
const initialWorkspace = isSystemAdmin && PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace)
? requestedWorkspace
: isSystemAdmin ? 'overview' : 'launch'
const [workspace, setWorkspace] = useState<PerformanceSurveyAdminWorkspace>(initialWorkspace)
const [configurationDirty, setConfigurationDirty] = useState(false)
const notifications = useApi<any[]>(
isSystemAdmin && workspace === 'operations' ? '/admin/notifications?limit=50' : null,
[workspace, isSystemAdmin],
@ -2809,40 +2865,67 @@ function ManagePanel({
isSystemAdmin && workspace === 'operations' ? '/admin/notifications/status' : null,
[workspace, isSystemAdmin],
)
const confirmLeaveConfiguration = useCallback(async () => {
if (!configurationDirty) return true
const result = await confirmManageNavigation({
title: '放弃尚未保存的修改?',
description: '离开当前页面后,本次尚未保存的配置不会保留。',
confirmLabel: '放弃并离开',
tone: 'danger',
})
return result.confirmed
}, [configurationDirty, confirmManageNavigation])
useEffect(() => {
if (!isSystemAdmin || requestedWorkspaceValue !== 'plans') return
const query = new URLSearchParams(searchParams.toString())
query.set('tab', 'manage')
query.set('adminWorkspace', 'positions')
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
}, [isSystemAdmin, requestedWorkspaceValue, router, searchParams])
useEffect(() => {
if (!isSystemAdmin) {
if (configurationDirty) onConfigurationDirtyChange(false)
if (workspace !== 'launch') setWorkspace('launch')
return
}
if (PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) && requestedWorkspace !== workspace) {
if (
configurationDirty
&& typeof window !== 'undefined'
&& !window.confirm('当前配置尚未保存,确定离开吗?')
) {
if (!PERFORMANCE_SURVEY_ADMIN_WORKSPACES.includes(requestedWorkspace) || requestedWorkspace === workspace) {
return
}
let cancelled = false
const syncRequestedWorkspace = async () => {
if (!await confirmLeaveConfiguration()) {
if (cancelled) return
const query = new URLSearchParams(searchParams.toString())
query.set('tab', 'manage')
query.set('adminWorkspace', workspace)
router.replace(`/performance-survey?${query.toString()}`, { scroll: false })
return
}
setConfigurationDirty(false)
if (cancelled) return
onConfigurationDirtyChange(false)
setWorkspace(requestedWorkspace)
}
}, [configurationDirty, isSystemAdmin, requestedWorkspace, router, searchParams, workspace])
const selectWorkspace = (nextWorkspace: PerformanceSurveyAdminWorkspace) => {
if (
nextWorkspace !== workspace
&& configurationDirty
&& typeof window !== 'undefined'
&& !window.confirm('当前配置尚未保存,确定离开吗?')
) {
return
void syncRequestedWorkspace()
return () => {
cancelled = true
}
}, [
confirmLeaveConfiguration,
configurationDirty,
isSystemAdmin,
onConfigurationDirtyChange,
requestedWorkspace,
router,
searchParams,
workspace,
])
const selectWorkspace = async (nextWorkspace: PerformanceSurveyAdminWorkspace) => {
if (nextWorkspace === workspace || !await confirmLeaveConfiguration()) return
setMessage(null)
setConfigurationDirty(false)
onConfigurationDirtyChange(false)
setWorkspace(nextWorkspace)
const query = new URLSearchParams(searchParams.toString())
query.set('tab', 'manage')
@ -2871,14 +2954,13 @@ function ManagePanel({
['positions', '岗位'],
['people', '人员分配'],
['templates', '模板'],
['plans', '岗位考核方案'],
['launch', '发起调研'],
['operations', '运行维护'],
] as const).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => selectWorkspace(id)}
onClick={() => void selectWorkspace(id)}
aria-current={workspace === id ? 'page' : undefined}
className={`min-h-11 shrink-0 rounded px-3 text-[12px] font-bold transition-colors ${
workspace === id
@ -2898,7 +2980,7 @@ function ManagePanel({
canSelectAnyPosition={canSelectAnyPosition}
defaultMonth={adminMonth}
onMonthChange={selectAdminMonth}
onAdminNavigate={isSystemAdmin ? selectWorkspace : undefined}
onAdminNavigate={isSystemAdmin ? (nextWorkspace) => void selectWorkspace(nextWorkspace) : undefined}
onCreated={async () => {
setMessage('已发布调研')
await surveys.refresh()
@ -2921,10 +3003,11 @@ function ManagePanel({
request={apiRequest}
month={adminMonth}
onMonthChange={selectAdminMonth}
onNavigate={(nextWorkspace) => selectWorkspace(nextWorkspace)}
onDirtyChange={setConfigurationDirty}
onNavigate={(nextWorkspace) => void selectWorkspace(nextWorkspace)}
onDirtyChange={onConfigurationDirtyChange}
/>
)}
{manageNavigationDialog}
{message && (
<div className="border border-[#b3c5ff] bg-[#f8faff] px-4 py-3 text-sm font-semibold text-[#0050cb]">
{message}
@ -3309,13 +3392,12 @@ function CreateSurveyForm({
? `${assessmentMonth} 暂无可发起的岗位`
: '所选岗位还没有可用考核模板'}
</div>
<p className="mt-1">请先补齐岗位匹配、人员归属、模板和岗位考核方案,再回来发起调研。</p>
<p className="mt-1">请先补齐岗位设置、人员归属和可用模板,再回来发起调研。</p>
<div className="mt-3 flex flex-wrap gap-2">
{([
['positions', '检查岗位'],
['positions', '检查岗位设置'],
['people', '检查人员'],
['templates', '检查模板'],
['plans', '检查岗位方案'],
] as const).map(([target, label]) => (
<button
key={target}