Compare commits

...

14 Commits

Author SHA1 Message Date
江林铖
3329cc8948 fix: 修复视觉调研草稿上传权限 2026-07-30 14:54:42 +08:00
江林铖
3618d61a97 feat: 支持继续编辑未发布调研草稿 2026-07-30 13:50:20 +08:00
江林铖
1859d03760 fix: 显示调研草稿发布反馈 2026-07-30 12:10:11 +08:00
江林铖
89878b7880 fix: 调整新增模板入口位置 2026-07-30 09:57:54 +08:00
江林铖
363aa2b3b6 refactor: 考核模板改为单配置模式 2026-07-29 19:36:07 +08:00
江林铖
0e80c15274 fix: 同步发起页模板名称 2026-07-29 18:53:12 +08:00
江林铖
2466bb2b02 fix: 发起调研自动关联人员岗位 2026-07-29 18:36:43 +08:00
江林铖
64f3fb211e feat: 支持复制考核模板 2026-07-29 18:05:35 +08:00
江林铖
93cb33aecb feat: 已发布岗位增加修改模式 2026-07-29 17:29:25 +08:00
江林铖
8d33f10368 fix: 取消岗位模板生效月份限制 2026-07-29 16:59:46 +08:00
江林铖
26e111979a fix: 精简配置页面月份入口 2026-07-29 13:44:16 +08:00
江林铖
70421a4dc5 fix: 同步岗位配置中的模板名称 2026-07-29 13:23:41 +08:00
江林铖
c45d77c48c fix: 修正评分项默认分值显示 2026-07-29 09:31:59 +08:00
江林铖
60d249e876 fix: M级人员获得考核调研管理权限 2026-07-28 19:52:42 +08:00
13 changed files with 1650 additions and 459 deletions

View File

@ -101,6 +101,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@smithy ./node_modul
COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
# 创建必要目录并设置权限
RUN mkdir -p /app/public /app/uploads && \

View File

@ -36,7 +36,7 @@ import {
savePerformanceSurveyAdminPositionGroup,
publishPerformanceSurveyAdminPositionGroup,
savePerformanceSurveyAdminTemplate,
clonePerformanceSurveyAdminTemplateVersion,
copyPerformanceSurveyAdminTemplateVersion,
publishPerformanceSurveyAdminTemplateVersion,
archivePerformanceSurveyAdminPositionAssignment,
copyPerformanceSurveyAdminPositionGroup,
@ -294,8 +294,8 @@ export async function POST(request: Request, context: RouteContext) {
return ok(await savePerformanceSurveyAdminTemplate(session, body), { status: 201 })
}
if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path[3] === 'clone') {
return ok(await clonePerformanceSurveyAdminTemplateVersion(session, path[2]), { status: 201 })
if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path[3] === 'copy') {
return ok(await copyPerformanceSurveyAdminTemplateVersion(session, path[2]), { status: 201 })
}
if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path[3] === 'publish') {

View File

@ -142,7 +142,12 @@ function createRequest(configuration = config) {
if (path === '/admin/templates/template-1' && options?.method === 'DELETE') {
return { id: 'template-1', action: 'deleted' }
}
if (path.endsWith('/clone')) return { id: 'draft-version-2' }
if (path === '/admin/templates/version-1' && options?.method === 'PATCH') {
return { id: 'version-1' }
}
if (path.endsWith('/copy')) {
return { id: 'copied-version-1', templateId: 'copied-template-1', name: '视觉案例评分(副本)' }
}
return {}
})
}
@ -209,17 +214,31 @@ describe('PerformanceSurveyAdminConfig', () => {
)
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument())
expect(screen.getByText(/1\/2 个岗位设置已生效,.*1 个沿用较早设置/)).toBeInTheDocument()
expect(screen.getByText(/1\/2 个岗位设置已生效/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09'))
})
test.each([
['positions', '岗位管理'],
['people', '人员分配'],
['templates', '模板管理'],
] as const)('%s 管理页面不显示月份查看入口', async (mode, heading) => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode={mode} request={request} month="2026-07" />)
await waitFor(() => expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument())
expect(screen.queryByLabelText('查看月份')).not.toBeInTheDocument()
expect(request).toHaveBeenCalledWith(`/admin/config?month=2026-07&scope=${mode}`)
})
test('岗位编辑产生 dirty 状态并保护岗位切换', async () => {
const request = createRequest()
const onDirtyChange = jest.fn()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} onDirtyChange={onDirtyChange} />)
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师'))
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
fireEvent.change(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } })
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
@ -233,6 +252,28 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('AI 技术设计师'))
})
test('已发布岗位默认只读,并通过修改岗位进入编辑', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: '修改岗位' })).toBeInTheDocument())
expect(screen.getByLabelText('岗位名称')).toBeDisabled()
expect(screen.getByLabelText('排序')).toBeDisabled()
expect(screen.getByLabelText('添加考核项')).toBeDisabled()
expect(screen.queryByRole('button', { name: '移除' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存草稿' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '发布岗位设置' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
expect(screen.getByLabelText('岗位名称')).toBeEnabled()
expect(screen.getByLabelText('排序')).toBeEnabled()
expect(screen.getByLabelText('添加考核项')).toBeEnabled()
expect(screen.getByRole('button', { name: '移除' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled()
expect(screen.getByRole('button', { name: '发布岗位设置' })).toBeDisabled()
})
test('删除岗位使用统一弹窗收集必填原因并提交', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
@ -294,13 +335,52 @@ describe('PerformanceSurveyAdminConfig', () => {
name: '品牌设计师',
sortOrder: 10,
templateVersionIds: ['version-2', 'version-1'],
effectiveMonth: '2026-07',
})
})
expect(screen.queryByText('人员匹配规则')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '复制岗位' })).not.toBeInTheDocument()
})
test('岗位模板搜索和已选考核项同步显示模板栏的最新名称', async () => {
const configuration = {
...config,
groups: config.groups.map((group: any) => group.id === 'group-1'
? {
...group,
effectivePlan: {
...group.effectivePlan,
templateVersionIds: [],
},
}
: group),
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: [{
...template.versions[0],
definition: {
...template.versions[0].definition,
name: '品牌创意部门',
},
}],
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
fireEvent.change(screen.getByLabelText('添加考核项'), { target: { value: '品牌创意部门' } })
const option = screen.getByRole('button', { name: /品牌创意部门.*已发布/ })
expect(option).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /视觉案例评分.*已发布/ })).not.toBeInTheDocument()
fireEvent.click(option)
expect(screen.getByText('品牌创意部门')).toBeInTheDocument()
})
test('新增岗位使用确定的 collection POST 契约', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
@ -313,7 +393,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: '新设计岗位', effectiveMonth: '2026-07' }),
body: JSON.stringify({ name: '新设计岗位' }),
})))
})
@ -343,22 +423,55 @@ describe('PerformanceSurveyAdminConfig', () => {
})
})
test('已发布模板通过修改按钮生成草稿,并使用统一的删除入口', async () => {
test('已发布模板通过修改按钮直接更新原版本,并使用统一的删除入口', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改模板' }))
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/templates/version-1/clone', expect.objectContaining({ method: 'POST' })))
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '新视觉评分' } })
fireEvent.click(screen.getByRole('button', { name: /发布至/ }))
expect(await screen.findByText('请先保存模板草稿,再执行发布')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1',
expect.objectContaining({ method: 'PATCH' }),
))
expect(request.mock.calls.some(([path]) => path === '/admin/templates/version-1/clone')).toBe(false)
expect(screen.getByRole('button', { name: '删除模板' })).toBeInTheDocument()
expect(screen.queryByText('使用场景')).not.toBeInTheDocument()
})
test('模板列表使用低噪音状态点区分可用、待生效和草稿状态', async () => {
test('新增模板按钮位于模板列表顶部,与岗位管理入口布局一致', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
const createButton = screen.getByRole('button', { name: '新增模板' })
const deleteButton = screen.getByRole('button', { name: '删除模板' })
const templateSidebar = createButton.closest('aside')
expect(templateSidebar).not.toBeNull()
expect(createButton.parentElement).toBe(templateSidebar?.firstElementChild)
expect(within(templateSidebar as HTMLElement).getByRole('button', { name: '新增模板' })).toBe(createButton)
expect(createButton.nextElementSibling).not.toBe(deleteButton)
})
test('已发布模板可通过修改按钮左侧的复制按钮创建独立模板草稿', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
const copyButton = screen.getByRole('button', { name: '复制模板' })
const modifyButton = screen.getByRole('button', { name: '修改模板' })
expect(copyButton.nextElementSibling).toBe(modifyButton)
fireEvent.click(copyButton)
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1/copy',
expect.objectContaining({ method: 'POST', body: '{}' }),
))
})
test('模板列表只展示单一当前配置状态', async () => {
const configuration = {
...config,
templates: [{
@ -412,10 +525,10 @@ describe('PerformanceSurveyAdminConfig', () => {
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
const changedTemplate = await screen.findByRole('button', { name: /视觉案例评分.*有待发布修改/ })
expect(within(changedTemplate).getByRole('img', { name: '有待发布修改' })).toHaveAttribute('data-status-dot', 'warning')
const futureTemplate = screen.getByRole('button', { name: /课程分享.*待生效/ })
expect(within(futureTemplate).getByRole('img', { name: '待生效' })).toHaveAttribute('data-status-dot', 'warning')
const changedTemplate = await screen.findByRole('button', { name: /视觉案例评分.*可用/ })
expect(within(changedTemplate).getByRole('img', { name: '可用' })).toHaveAttribute('data-status-dot', 'success')
const publishedTemplate = screen.getByRole('button', { name: /课程分享.*可用/ })
expect(within(publishedTemplate).getByRole('img', { name: '可用' })).toHaveAttribute('data-status-dot', 'success')
const draftTemplate = screen.getByRole('button', { name: /草稿模板.*待发布/ })
expect(within(draftTemplate).getByRole('img', { name: '待发布' })).toHaveAttribute('data-status-dot', 'warning')
const availableTemplate = screen.getByRole('button', { name: /当前可用模板.*可用/ })
@ -446,7 +559,7 @@ describe('PerformanceSurveyAdminConfig', () => {
return baseRequest(path, options)
})
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '删除模板' }))
const dialog = await screen.findByRole('alertdialog', { name: '删除模板“视觉案例评分”?' })
@ -473,7 +586,7 @@ describe('PerformanceSurveyAdminConfig', () => {
expect(deleteAttempts).toBe(2)
})
test('发布模板通过统一弹窗确认模板和生效月份', async () => {
test('发布模板不再要求选择生效月份', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
@ -491,16 +604,17 @@ describe('PerformanceSurveyAdminConfig', () => {
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分,待发布/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /发布至 2026-07/ }))
fireEvent.click(screen.getByRole('button', { name: '发布模板' }))
const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' })
expect(dialog).toHaveTextContent('模板将从 2026-07 起成为可用模板')
fireEvent.click(within(dialog).getByRole('button', { name: '发布至 2026-07' }))
expect(dialog).toHaveTextContent('模板发布后立即可用')
expect(dialog).not.toHaveTextContent('2026-07')
fireEvent.click(within(dialog).getByRole('button', { name: '确认发布' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1/publish',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ effectiveMonth: '2026-07' }),
body: '{}',
}),
))
})
@ -650,6 +764,32 @@ describe('PerformanceSurveyAdminConfig', () => {
expect(screen.getByRole('button', { name: '添加评分项' })).toBeInTheDocument()
})
test('旧模板缺少单项满分时无需编辑即可显示模板默认分值', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({
...version,
definition: {
...version.definition,
questions: version.definition.questions.map(({ maxScore: _maxScore, ...question }: any) => question),
},
})),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
fireEvent.click(await screen.findByRole('button', { name: '2. 评分项' }))
expect(screen.getByRole('button', { name: /评分项 1.*作品质量.*20 分.*完整/ })).toBeInTheDocument()
expect(screen.getByLabelText('满分')).toHaveValue(20)
expect(screen.getByText('已分配 20 × 3 个作品 = 60 / 60 分')).toBeInTheDocument()
})
test('发布前检查会定位问题,并持续反馈草稿保存状态', async () => {
const configuration = {
...config,
@ -666,7 +806,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByLabelText('模板名称')).toHaveValue('视觉案例评分'))
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '' } })
expect(screen.getByText('请填写模板名称')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '发布至 2026-07' })).toBeDisabled()
expect(screen.getByRole('button', { name: '发布模板' })).toBeDisabled()
expect(screen.getByText('未保存')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '视觉案例评分新版' } })
@ -734,7 +874,6 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups/group-1/publish', expect.objectContaining({
method: 'POST',
body: JSON.stringify({
effectiveMonth: '2026-07',
draftPlanId: 'plan-1',
draftVersion: 2,
draftUpdatedAt: '2026-07-23T10:00:00.000Z',

View File

@ -153,7 +153,7 @@ const adminConfigApi = {
`/admin/config-audit-logs?month=${encodeURIComponent(month)}&pageSize=20`,
)
},
createPositionGroup(request: RequestFn, input: { name: string; effectiveMonth: string }) {
createPositionGroup(request: RequestFn, input: { name: string }) {
return request<{ id?: string; group?: { id?: string } }>('/admin/position-groups', {
method: 'POST',
body: JSON.stringify(input),
@ -232,6 +232,14 @@ function surveyTypeLabel(type: TemplateDefinition['surveyType']) {
}[type]
}
function getEffectiveQuestionMaxScore(
definition: TemplateDefinition,
question: TemplateDefinition['questions'][number],
) {
const value = Number(question.maxScore ?? definition.questionMaxScore ?? 20)
return Number.isFinite(value) ? value : 0
}
function StatusDot({
label,
tone,
@ -272,31 +280,28 @@ function getPositionGroupState(group: PositionGroupRecord): {
return { label: '待配置', tone: 'warning' }
}
function getTemplateAvailability(template: TemplateRecord, month: string) {
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)
.sort((left, right) => right.version - left.version)[0] || null
const draftVersion = availableVersion
? null
: template.versions
.filter((version) => version.status === 'draft')
.sort((left, right) => right.version - left.version)[0] || null
return {
isAvailable: template.isActive && Boolean(availableVersion),
availableVersion,
futureVersion,
draftVersion,
hasPublishedVersionWithoutMonth,
}
}
function getTemplateDisplayName(template: TemplateRecord) {
const current = getTemplateAvailability(template, '').availableVersion
|| getTemplateAvailability(template, '').draftVersion
return current?.definition.name?.trim() || template.name
}
function getTemplateListState(template: TemplateRecord, month: string): {
label: string
tone: StatusTone
@ -308,31 +313,16 @@ function getTemplateListState(template: TemplateRecord, month: string): {
}
if (availability.draftVersion) {
return {
label: availability.availableVersion ? '有待发布修改' : '待发布',
label: '待发布',
tone: 'warning',
meta: `v${availability.draftVersion.version} · 草稿`,
meta: '草稿',
}
}
if (availability.availableVersion) {
return {
label: '可用',
tone: 'success',
meta: `v${availability.availableVersion.version} · ${availability.availableVersion.effectiveMonth}`,
}
}
if (availability.futureVersion) {
return {
label: '待生效',
tone: 'warning',
meta: `v${availability.futureVersion.version} · ${availability.futureVersion.effectiveMonth}`,
}
}
if (availability.hasPublishedVersionWithoutMonth) {
const version = template.versions.find((item) => item.status === 'published')
return {
label: '待配置',
tone: 'warning',
meta: version ? `v${version.version}` : '',
meta: '已发布',
}
}
return { label: '不可用', tone: 'neutral', meta: '' }
@ -343,13 +333,13 @@ function getTemplateReadiness(definition: TemplateDefinition): TemplateReadiness
? Math.max(1, Number(definition.requiredWorkItemCount || 1))
: 1
const questionTotal = definition.questions.reduce(
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
(sum, question) => sum + getEffectiveQuestionMaxScore(definition, question),
0,
)
const allocatedTotal = questionTotal * workItemCount
const configuredTotal = Number(definition.maxTotal || 0)
const hasQuestionError = definition.questions.some((question) => (
!question.title.trim() || Number(question.maxScore || definition.questionMaxScore || 0) <= 0
!question.title.trim() || getEffectiveQuestionMaxScore(definition, question) <= 0
))
const scoreMatches = Math.round(allocatedTotal * 100) === Math.round(configuredTotal * 100)
const stages = definition.workflow.stages
@ -537,15 +527,17 @@ export function PerformanceSurveyAdminConfig({
</h2>
{dirty && <StatusDot label="未保存" tone="warning" />}
</div>
<label className="flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
查看月份
<input
type="month"
value={month}
onChange={(event) => void changeMonth(event.target.value)}
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
{mode === 'overview' && (
<label className="flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
查看月份
<input
type="month"
value={month}
onChange={(event) => void changeMonth(event.target.value)}
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</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]">
@ -636,9 +628,6 @@ function AdminOverviewWorkspace({
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 unmatchedCount = getAdminIssueCount(data, 'unmatched')
const conflictCount = getAdminIssueCount(data, 'conflicts')
@ -665,7 +654,7 @@ function AdminOverviewWorkspace({
status: positionsReady ? (draftPlanCount > 0 ? 'attention' : 'done') : 'blocked',
summary: activeGroups.length === 0
? '还没有岗位,请先创建岗位并选择考核项'
: `${effectivePlanCount}/${activeGroups.length} 个岗位设置已生效${inheritedPlanCount > 0 ? `,其中 ${inheritedPlanCount} 个沿用较早设置` : ''}${draftPlanCount > 0 ? `;${draftPlanCount} 个岗位有待发布修改` : ''}${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`,
: `${effectivePlanCount}/${activeGroups.length} 个岗位设置已生效${draftPlanCount > 0 ? `;${draftPlanCount} 个岗位有待发布修改` : ''}${disabledGroups > 0 ? `,另有 ${disabledGroups} 个已删除` : ''}`,
action: '配置岗位',
target: 'positions' as const,
},
@ -674,8 +663,8 @@ function AdminOverviewWorkspace({
label: '准备考核模板',
status: templatesReady ? (draftTemplates > 0 ? 'attention' : 'done') : 'blocked',
summary: templatesReady
? `${availablePublishedTemplates} 个模板可用于 ${month}${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}`
: `还没有可用于 ${month} 的已发布模板${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个其它月份版本)` : ''}`,
? `${availablePublishedTemplates} 个已发布模板可用${draftTemplates > 0 ? `,${draftTemplates} 个草稿待确认` : ''}`
: `还没有可用的已发布模板${publishedTemplates > 0 ? `(另有 ${publishedTemplates} 个模板尚不可用)` : ''}`,
action: '管理模板',
target: 'templates' as const,
},
@ -845,6 +834,7 @@ function PositionConfigWorkspace({
)
const [templateSearch, setTemplateSearch] = useState('')
const [saving, setSaving] = useState(false)
const [editing, setEditing] = useState(Boolean(selected?.draftPlan) || !selected?.effectivePlan)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const editorRef = useRef<HTMLDivElement>(null)
@ -855,6 +845,7 @@ function PositionConfigWorkspace({
setSortOrder(selected.sortOrder)
setTemplateVersionIds(selected.draftPlan?.templateVersionIds || selected.effectivePlan?.templateVersionIds || [])
setTemplateSearch('')
setEditing(Boolean(selected.draftPlan) || !selected.effectivePlan)
onDirtyChange(false)
}, [onDirtyChange, selected])
@ -883,7 +874,9 @@ function PositionConfigWorkspace({
return availableTemplateVersions.filter(({ template, version }) => (
!templateVersionIds.includes(version.id)
&& !selectedTemplateIds.has(template.id)
&& `${version.definition.name} ${template.name}`.toLocaleLowerCase('zh-CN').includes(keyword)
&& `${getTemplateDisplayName(template)} ${version.definition.name} ${template.name}`
.toLocaleLowerCase('zh-CN')
.includes(keyword)
)).slice(0, 8)
}, [availableTemplateVersions, templateSearch, templateVersionIds, versionById])
@ -895,7 +888,7 @@ function PositionConfigWorkspace({
try {
await request(`/admin/position-groups/${selected.id}`, {
method: 'PATCH',
body: JSON.stringify({ name, sortOrder, templateVersionIds, effectiveMonth: month }),
body: JSON.stringify({ name, sortOrder, templateVersionIds }),
})
await onChanged('岗位设置草稿已保存,可直接在当前页面发布')
} catch (actionError) {
@ -953,7 +946,7 @@ function PositionConfigWorkspace({
description: (
<div className="grid gap-1">
<p>包含 {templateVersionIds.length} 个考核项,预计影响 {memberCount} 人。</p>
<p>发布后从 {month} 起持续生效,后续月份无需重复设置。</p>
<p>发布后立即生效,后续无需重复设置。</p>
</div>
),
confirmLabel: '确认发布',
@ -962,13 +955,12 @@ function PositionConfigWorkspace({
await request(`/admin/position-groups/${selected.id}/publish`, {
method: 'POST',
body: JSON.stringify({
effectiveMonth: month,
draftPlanId,
draftVersion,
draftUpdatedAt,
}),
})
await onChanged(`${selected.name} 的岗位设置已发布,从 ${month} 起持续生效`)
await onChanged(`${selected.name} 的岗位设置已发布并立即生效`)
},
})
} catch (actionError) {
@ -988,7 +980,6 @@ function PositionConfigWorkspace({
try {
const created = await adminConfigApi.createPositionGroup(request, {
name: newName.trim(),
effectiveMonth: month,
})
const next = await onChanged(`岗位“${newName.trim()}”已创建`)
const nextSelectedId = created.id || created.group?.id || next?.groups.find((group) => group.name === newName.trim())?.id
@ -1130,8 +1121,8 @@ function PositionConfigWorkspace({
</button>
</div>
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_140px]">
<AdminField label="岗位名称" value={name} onChange={(value) => { setName(value); markDirty() }} />
<AdminField label="排序" value={String(sortOrder)} onChange={(value) => { setSortOrder(Number(value) || 0); markDirty() }} type="number" />
<AdminField label="岗位名称" value={name} disabled={!editing} onChange={(value) => { setName(value); markDirty() }} />
<AdminField label="排序" value={String(sortOrder)} disabled={!editing} onChange={(value) => { setSortOrder(Number(value) || 0); markDirty() }} type="number" />
</div>
<div className="mt-6 border-t border-[#eeeeee] pt-5">
@ -1141,12 +1132,13 @@ function PositionConfigWorkspace({
添加考核项
<input
value={templateSearch}
disabled={!editing}
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="输入模板名称搜索"
className="h-11 w-full rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]"
className="h-11 w-full rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4] disabled:text-[#8a8a8a]"
/>
</label>
{templateSearch.trim() && (
{editing && templateSearch.trim() && (
<div className="absolute z-30 mt-1 max-h-64 w-full overflow-y-auto border border-[#c2c6d8] bg-white shadow-[0_4px_8px_rgba(0,0,0,0.12)]">
{searchResults.map(({ template, version }) => (
<button
@ -1156,9 +1148,9 @@ function PositionConfigWorkspace({
className="flex min-h-11 w-full items-center justify-between gap-3 border-b border-[#eeeeee] px-3 py-2 text-left last:border-b-0 hover:bg-[#f4f6ff]"
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{version.definition.name}</span>
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{getTemplateDisplayName(template)}</span>
</span>
<span className="shrink-0 text-[11px] text-[#5f5e5e]">v{version.version}</span>
<span className="shrink-0 text-[11px] text-[#5f5e5e]">已发布</span>
</button>
))}
{searchResults.length === 0 && (
@ -1173,16 +1165,18 @@ function PositionConfigWorkspace({
return (
<div key={id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-[#c2c6d8] px-3 py-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-bold text-[#1a1c1c]">{item?.version.definition.name || item?.template.name || '历史模板'}</div>
<div className="truncate text-[13px] font-bold text-[#1a1c1c]">{item ? getTemplateDisplayName(item.template) : '历史模板'}</div>
<div className="mt-0.5 text-[11px] text-[#727687]">
第 {index + 1} 项{item ? ` · v${item.version.version}` : ''}
第 {index + 1} 项
</div>
</div>
<div className="flex flex-wrap justify-end gap-1">
<button type="button" disabled={index === 0} onClick={() => moveTemplateVersion(index, -1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">上移</button>
<button type="button" disabled={index === templateVersionIds.length - 1} onClick={() => moveTemplateVersion(index, 1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">下移</button>
<button type="button" onClick={() => removeTemplateVersion(id)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">移除</button>
</div>
{editing && (
<div className="flex flex-wrap justify-end gap-1">
<button type="button" disabled={index === 0} onClick={() => moveTemplateVersion(index, -1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">上移</button>
<button type="button" disabled={index === templateVersionIds.length - 1} onClick={() => moveTemplateVersion(index, 1)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#0050cb] disabled:text-[#aaaaaa]">下移</button>
<button type="button" onClick={() => removeTemplateVersion(id)} className="min-h-10 rounded px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5]">移除</button>
</div>
)}
</div>
)
})}
@ -1202,18 +1196,32 @@ function PositionConfigWorkspace({
/>
{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>
{!editing && selected.effectivePlan && !selected.draftPlan && (
<button
type="button"
disabled={saving || selected.status === 'disabled'}
onClick={() => setEditing(true)}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white hover:bg-[#0042a8] disabled:cursor-not-allowed disabled:opacity-50"
>
修改岗位
</button>
)}
{editing && (
<>
<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>
</div>
@ -1244,13 +1252,16 @@ function TemplateConfigWorkspace({
const firstTemplate = data.templates[0]
const [selectedTemplateId, setSelectedTemplateId] = useState(firstTemplate?.id || 'new')
const selectedTemplate = data.templates.find((template) => template.id === selectedTemplateId) || null
const selectedVersion = selectedTemplate?.versions.find((version) => version.status === 'draft')
|| selectedTemplate?.versions[0]
|| null
const selectedVersion = selectedTemplate
? getTemplateAvailability(selectedTemplate, month).availableVersion
|| getTemplateAvailability(selectedTemplate, month).draftVersion
|| null
: null
const [versionId, setVersionId] = useState<string | null>(selectedVersion?.id || null)
const [definition, setDefinition] = useState<TemplateDefinition>(() => clone(selectedVersion?.definition || newTemplateDefinition()))
const [editorStep, setEditorStep] = useState<TemplateEditorStep>('basics')
const [saving, setSaving] = useState(false)
const [editingPublishedVersion, setEditingPublishedVersion] = useState(false)
const [readinessOpen, setReadinessOpen] = useState(false)
const [operationFeedback, setOperationFeedback] = useState<{
state: 'idle' | 'saving' | 'saved' | 'publishing' | 'published' | 'error'
@ -1259,19 +1270,21 @@ function TemplateConfigWorkspace({
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const nextVersion = selectedTemplate?.versions.find((version) => version.status === 'draft')
|| selectedTemplate?.versions[0]
|| null
const availability = selectedTemplate ? getTemplateAvailability(selectedTemplate, month) : null
const nextVersion = availability?.availableVersion || availability?.draftVersion || null
const nextDefinition = clone(nextVersion?.definition || newTemplateDefinition())
setVersionId(nextVersion?.id || null)
setDefinition(nextDefinition)
setEditorStep('basics')
setEditingPublishedVersion(false)
setReadinessOpen(false)
onDirtyChange(false)
}, [onDirtyChange, selectedTemplate])
}, [month, onDirtyChange, selectedTemplate])
const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null
const editable = !version || version.status === 'draft'
const editable = !version
|| version.status === 'draft'
|| (version.status === 'published' && editingPublishedVersion)
const readinessItems = getTemplateReadiness(definition)
const readinessCompleted = readinessItems.filter((item) => item.status === 'done').length
const templateDefinitionReady = readinessCompleted === readinessItems.length
@ -1283,9 +1296,13 @@ function TemplateConfigWorkspace({
}
const save = async () => {
const updatingPublishedVersion = version?.status === 'published'
setSaving(true)
onError(null)
setOperationFeedback({ state: 'saving', message: '正在保存模板草稿…' })
setOperationFeedback({
state: 'saving',
message: updatingPublishedVersion ? '正在保存模板修改…' : '正在保存模板草稿…',
})
try {
const saved = versionId
? await request<{ id: string }>(`/admin/templates/${versionId}`, {
@ -1296,19 +1313,25 @@ function TemplateConfigWorkspace({
method: 'POST',
body: JSON.stringify({ definition }),
})
const next = await onChanged('模板草稿已保存')
const next = await onChanged(updatingPublishedVersion ? '模板修改已保存' : '模板草稿已保存')
const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === saved.id))
if (nextTemplate) setSelectedTemplateId(nextTemplate.id)
setVersionId(saved.id)
setEditingPublishedVersion(false)
onDirtyChange(false)
const savedTime = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date())
setOperationFeedback({ state: 'saved', message: `草稿已保存 · ${savedTime}` })
setOperationFeedback({
state: 'saved',
message: `${updatingPublishedVersion ? '修改' : '草稿'}已保存 · ${savedTime}`,
})
} catch (actionError) {
const message = actionError instanceof Error ? actionError.message : '模板草稿保存失败'
const message = actionError instanceof Error
? actionError.message
: updatingPublishedVersion ? '模板修改保存失败' : '模板草稿保存失败'
onError(message)
setOperationFeedback({ state: 'error', message: `保存失败:${message}` })
} finally {
@ -1316,22 +1339,28 @@ function TemplateConfigWorkspace({
}
}
const cloneVersion = async () => {
if (!versionId || !await confirmDiscard()) return
const copyTemplate = async () => {
if (!versionId || !selectedTemplate || !await confirmDiscard()) return
setSaving(true)
onError(null)
setOperationFeedback({ state: 'saving', message: '正在创建可编辑草稿…' })
setOperationFeedback({ state: 'saving', message: '正在复制模板…' })
try {
const draft = await request<{ id: string }>(`/admin/templates/${versionId}/clone`, { method: 'POST', body: '{}' })
const next = await onChanged('已创建新的模板草稿')
const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === draft.id))
const copied = await request<{ id: string; templateId: string; name: string }>(
`/admin/templates/${versionId}/copy`,
{ method: 'POST', body: '{}' },
)
const next = await onChanged(`已复制为“${copied.name}”草稿`)
const nextTemplate = next?.templates.find((template) => (
template.id === copied.templateId
|| template.versions.some((item) => item.id === copied.id)
))
if (nextTemplate) setSelectedTemplateId(nextTemplate.id)
setVersionId(draft.id)
setOperationFeedback({ state: 'saved', message: '新的可编辑草稿已创建' })
setVersionId(copied.id)
setOperationFeedback({ state: 'saved', message: `已复制为“${copied.name}”草稿` })
} catch (actionError) {
const message = actionError instanceof Error ? actionError.message : '模板草稿创建失败'
const message = actionError instanceof Error ? actionError.message : '模板复制失败'
onError(message)
setOperationFeedback({ state: 'error', message: `创建草稿失败:${message}` })
setOperationFeedback({ state: 'error', message: `复制失败:${message}` })
} finally {
setSaving(false)
}
@ -1354,27 +1383,27 @@ function TemplateConfigWorkspace({
title: `发布模板“${definition.name}”?`,
description: (
<div className="grid gap-1">
<p>模板将从 {month} 起成为可用模板,可直接添加到岗位考核项。</p>
<p>模板发布后立即可用,可直接添加到岗位考核项。</p>
<p>
{surveyTypeLabel(definition.surveyType)} · {definition.questions.length} 个评分项 ·
{' '}{definition.workflow.stages.length} 个评分阶段 · 总分 {Number(definition.maxTotal || 0)}
</p>
<p>发布后的版本不可直接修改;后续调整需要创建新的草稿版本。</p>
<p>发布后仍可通过“修改模板”更新当前模板;已经发起的调研会保留原始模板快照。</p>
</div>
),
confirmLabel: `发布至 ${month}`,
confirmLabel: '确认发布',
loadingLabel: '发布中...',
action: async () => {
setSaving(true)
onError(null)
setOperationFeedback({ state: 'publishing', message: `正在发布至 ${month}…` })
setOperationFeedback({ state: 'publishing', message: '正在发布…' })
try {
await request(`/admin/templates/${versionId}/publish`, {
method: 'POST',
body: JSON.stringify({ effectiveMonth: month }),
body: '{}',
})
await onChanged(`${definition.name} 已从 ${month} 起发布`)
setOperationFeedback({ state: 'published', message: `${definition.name} 已从 ${month} 起生效` })
await onChanged(`${definition.name} 已发布并立即可用`)
setOperationFeedback({ state: 'published', message: `${definition.name} 已发布` })
} catch (actionError) {
const message = actionError instanceof Error ? actionError.message : '模板发布失败'
onError(message)
@ -1430,18 +1459,16 @@ function TemplateConfigWorkspace({
}
}
const selectVersion = async (nextVersion: TemplateVersion) => {
if (nextVersion.id === versionId) return
if (!await confirmDiscard('切换版本会放弃当前未保存修改,确定继续吗?')) return
const cancelPublishedEdit = async () => {
if (dirty && !await confirmDiscard('取消修改会放弃当前未保存内容,确定继续吗?')) return
if (version) setDefinition(clone(version.definition))
setEditingPublishedVersion(false)
onDirtyChange(false)
setOperationFeedback({ state: 'idle', message: '' })
setVersionId(nextVersion.id)
setDefinition(clone(nextVersion.definition))
setEditorStep('basics')
}
const selectedTemplateName = selectedTemplate
? selectedTemplate.versions[0]?.definition.name || selectedTemplate.name
? getTemplateDisplayName(selectedTemplate)
: '新建模板'
const selectedTemplateState = selectedTemplate
? getTemplateListState(selectedTemplate, month)
@ -1450,7 +1477,7 @@ function TemplateConfigWorkspace({
? operationFeedback.message || '处理中'
: dirty
? '未保存'
: operationFeedback.message || (editable ? '已保存' : '只读')
: operationFeedback.message || (editingPublishedVersion ? '可修改' : editable ? '已保存' : '只读')
const feedbackTone: StatusTone = operationFeedback.state === 'error'
? 'danger'
: dirty || saving
@ -1462,6 +1489,16 @@ function TemplateConfigWorkspace({
return (
<div className="grid min-h-[680px] md:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-b border-[#e2e2e2] md:border-b-0 md:border-r">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
disabled={saving}
onClick={() => void selectTemplate('new')}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-3 text-[13px] font-bold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
>
新增模板
</button>
</div>
<div className="p-3 md:hidden">
<label className="grid gap-2 text-[13px] font-bold text-[#1a1c1c]">
<span className="sr-only">选择模板</span>
@ -1474,7 +1511,7 @@ function TemplateConfigWorkspace({
<option value="new">+ 新建模板</option>
{data.templates.map((template) => (
<option key={template.id} value={template.id}>
{template.versions[0]?.definition.name || template.name}
{getTemplateDisplayName(template)}
</option>
))}
</select>
@ -1487,18 +1524,9 @@ function TemplateConfigWorkspace({
</div>
</div>
<div className="hidden md:block">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
onClick={() => void selectTemplate('new')}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-4 text-center text-[13px] font-bold text-white hover:bg-black"
>
新建模板
</button>
</div>
{data.templates.map((template) => {
const state = getTemplateListState(template, month)
const templateName = template.versions[0]?.definition.name || template.name
const templateName = getTemplateDisplayName(template)
return (
<button
key={template.id}
@ -1523,17 +1551,6 @@ function TemplateConfigWorkspace({
<div ref={editorRef} className="min-w-0 scroll-mt-4 p-4 sm:p-5">
{selectedTemplate && (
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-[#eeeeee] pb-3">
{selectedTemplate.versions.map((item) => (
<button
key={item.id}
type="button"
onClick={() => void selectVersion(item)}
className={`min-h-10 rounded border px-3 text-left text-[12px] font-bold ${versionId === item.id ? 'border-[#0050cb] bg-[#f1f4ff] text-[#0050cb]' : 'border-[#e2e2e2] text-[#5f5e5e]'}`}
>
<span className="block">v{item.version} · {item.status === 'draft' ? '草稿' : item.status === 'published' ? '已发布' : '已归档'}</span>
{item.effectiveMonth && <span className="mt-0.5 block text-[10px] font-semibold opacity-80">{item.effectiveMonth} 起</span>}
</button>
))}
<button
type="button"
disabled={saving}
@ -1698,26 +1715,53 @@ function TemplateConfigWorkspace({
</div>
<div className="flex flex-wrap justify-end gap-2">
{!editable && (
<button
type="button"
disabled={saving}
onClick={() => void cloneVersion()}
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#0042a8] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? '处理中…' : '修改模板'}
</button>
<>
<button
type="button"
disabled={saving}
onClick={() => void copyTemplate()}
className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? '处理中…' : '复制模板'}
</button>
<button
type="button"
disabled={saving}
onClick={() => {
setEditingPublishedVersion(true)
setOperationFeedback({ state: 'idle', message: '可修改' })
}}
className="min-h-11 rounded bg-[#0050cb] px-4 text-[13px] font-bold text-white hover:bg-[#0042a8] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? '处理中…' : '修改模板'}
</button>
</>
)}
{editable && (
<button
type="button"
disabled={saving}
onClick={() => void save()}
className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && operationFeedback.state === 'saving' ? '保存中…' : '保存草稿'}
</button>
<>
{version?.status === 'published' && (
<button
type="button"
disabled={saving}
onClick={() => void cancelPublishedEdit()}
className="min-h-11 rounded border border-[#c2c6d8] px-4 text-[13px] font-bold text-[#5f5e5e] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-50"
>
取消修改
</button>
)}
<button
type="button"
disabled={saving || (version?.status === 'published' && !dirty)}
onClick={() => void save()}
className="min-h-11 rounded border border-[#1a1c1c] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f4f4f4] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && operationFeedback.state === 'saving'
? '保存中…'
: version?.status === 'published' ? '保存修改' : '保存草稿'}
</button>
</>
)}
{editable && versionId && (
{editable && versionId && version?.status === 'draft' && (
<button
type="button"
disabled={saving || !templateDefinitionReady}
@ -1727,7 +1771,7 @@ function TemplateConfigWorkspace({
? undefined
: readinessItems.find((item) => item.status !== 'done')?.summary}
>
{saving && operationFeedback.state === 'publishing' ? '发布中…' : `发布至 ${month}`}
{saving && operationFeedback.state === 'publishing' ? '发布中…' : '发布模板'}
</button>
)}
</div>
@ -1747,7 +1791,7 @@ function QuestionEditor({
onChange: (definition: TemplateDefinition) => void
}) {
const perItemTotal = definition.questions.reduce(
(sum, question) => sum + Number(question.maxScore || definition.questionMaxScore || 20),
(sum, question) => sum + getEffectiveQuestionMaxScore(definition, question),
0,
)
const workItemCount = definition.surveyType === 'visual_case'
@ -1889,7 +1933,8 @@ function QuestionEditor({
)}
{definition.questions.map((question, index) => {
const expanded = expandedQuestionId === question.id
const complete = Boolean(question.title.trim()) && Number(question.maxScore || 0) > 0
const effectiveMaxScore = getEffectiveQuestionMaxScore(definition, question)
const complete = Boolean(question.title.trim()) && effectiveMaxScore > 0
const contentId = `question-editor-${question.id}`
return (
<div key={question.id} className={`border ${expanded ? 'border-[#9fb8ff]' : 'border-[#e2e2e2]'}`}>
@ -1908,7 +1953,7 @@ function QuestionEditor({
</span>
</span>
<span className="shrink-0 text-[12px] font-bold text-[#1a1c1c]">
{Number(question.maxScore || 0)} 分
{effectiveMaxScore} 分
</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
complete ? 'bg-[#eaf7ef] text-[#16794b]' : 'bg-[#fff0c7] text-[#7a4b00]'
@ -1944,7 +1989,7 @@ function QuestionEditor({
<input
type="number"
min="1"
value={question.maxScore || 20}
value={effectiveMaxScore}
disabled={!editable}
onChange={(event) => onChange({ ...definition, questions: definition.questions.map((item, itemIndex) => itemIndex === index ? { ...item, maxScore: Number(event.target.value) || 1 } : item) })}
className="h-11 min-w-0 w-full rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb] disabled:bg-[#f4f4f4]"

View File

@ -104,6 +104,16 @@ function jsonResponse(data: unknown) {
} as unknown as Response
}
function jsonErrorResponse(error: string, status = 409) {
return {
ok: false,
status,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error }),
text: async () => '',
} as unknown as Response
}
describe('考核调研顶层导航保护', () => {
const originalFetch = global.fetch
@ -174,4 +184,256 @@ describe('考核调研顶层导航保护', () => {
{ scroll: false },
))
})
test('发起调研按当前被考核人的人员分配自动关联岗位', async () => {
mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=launch&adminMonth=2026-07')
global.fetch = jest.fn(async (input) => {
const url = String(input)
if (url.endsWith('/me')) {
return jsonResponse({
user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' },
employee: {
id: 'employee-admin',
displayName: '管理员',
departmentName: '品牌创意部',
positionLevel: 'M1',
},
permissions: {
canCreate: true,
isSystemAdmin: true,
isDepartmentAdmin: true,
isLeadership: true,
},
})
}
if (url.includes('/position-groups?month=2026-07')) {
return jsonResponse({
month: '2026-07',
groups: [{
id: 'group-1',
code: 'brand_designer',
name: '品牌设计师',
status: 'ready',
templateCount: 1,
}],
membership: {
status: 'matched',
groupCode: 'brand_designer',
groupName: '品牌设计师',
conflictGroupNames: [],
},
})
}
if (url.includes('/templates?month=2026-07&positionGroupCode=brand_designer')) {
return jsonResponse([{
...templateDefinition,
name: '品牌调性视觉评分(综合主管)',
shortName: '视觉评分',
templateVersionId: 'version-1',
templateOptionKey: 'visual_review:version-1',
templateVariantName: '视觉评分(综合主管)',
}])
}
if (url.includes('/departments?purpose=evaluator')) return jsonResponse([])
if (url.endsWith('/surveys')) return jsonResponse([])
return jsonResponse([])
}) as jest.Mock
render(<PerformanceSurveyPage />)
expect(await screen.findByText('品牌设计师')).toBeInTheDocument()
expect(screen.queryByRole('combobox', { name: '考核岗位' })).not.toBeInTheDocument()
expect(screen.getByText('已按被考核人的人员分配自动关联,发起时无需选择。')).toBeInTheDocument()
expect(await screen.findByRole('option', { name: '品牌调性视觉评分(综合主管)' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '视觉评分(综合主管)' })).not.toBeInTheDocument()
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/performance-survey/templates?month=2026-07&positionGroupCode=brand_designer',
expect.anything(),
))
})
test('我发起的草稿发布失败时展示发布进度和后端原因', async () => {
mockSearchParams = new URLSearchParams('tab=personal&workspace=created')
let resolvePublish: (response: Response) => void = () => {}
const publishResponse = new Promise<Response>((resolve) => {
resolvePublish = resolve
})
global.fetch = jest.fn(async (input, init) => {
const url = String(input)
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: true,
isLeadership: true,
},
})
}
if (url.endsWith('/surveys') && (!init?.method || init.method === 'GET')) {
return jsonResponse({
summary: {
pendingCount: 0,
submittedCount: 0,
createdCount: 1,
openCreatedCount: 0,
},
pending: [],
submitted: [],
created: [{
id: 'survey-draft',
title: '2026年7月 · AI作品评审',
status: 'draft',
departmentName: '综合支持部',
dueAt: null,
counts: { targets: 1, assignments: 3, responses: 0 },
}],
})
}
if (url.endsWith('/surveys/survey-draft') && init?.method === 'PATCH') {
return publishResponse
}
return jsonResponse([])
}) as jest.Mock
render(<PerformanceSurveyPage />)
const publishButtons = await screen.findAllByRole('button', { name: '发布调研:2026年7月 · AI作品评审' })
expect(screen.getAllByRole('link', { name: '继续编辑草稿:2026年7月 · AI作品评审' })[0]).toHaveAttribute(
'href',
'/performance-survey?tab=manage&adminWorkspace=launch&draftId=survey-draft',
)
fireEvent.click(publishButtons[0])
expect(await screen.findAllByText('发布中...')).not.toHaveLength(0)
resolvePublish(jsonErrorResponse('视觉评分作品尚未上传完整:无邪(0/3)'))
expect(await screen.findAllByRole('alert')).not.toHaveLength(0)
expect(await screen.findAllByText('发布失败:视觉评分作品尚未上传完整:无邪(0/3)')).not.toHaveLength(0)
expect(screen.getAllByRole('link', { name: '继续上传作品' })[0]).toHaveAttribute(
'href',
'/performance-survey/survey/survey-draft',
)
expect(screen.getAllByRole('button', { name: '发布调研:2026年7月 · AI作品评审' })[0]).toBeEnabled()
})
test('继续编辑草稿时恢复原模板和已上传作品', async () => {
mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=launch&adminMonth=2026-07&draftId=survey-draft')
global.fetch = jest.fn(async (input) => {
const url = String(input)
if (url.endsWith('/me')) {
return jsonResponse({
user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' },
employee: {
id: 'employee-admin',
displayName: '管理员',
departmentName: '品牌创意部',
positionLevel: 'M1',
},
permissions: {
canCreate: true,
isSystemAdmin: true,
isDepartmentAdmin: true,
isLeadership: true,
},
})
}
if (url.endsWith('/surveys/survey-draft')) {
return jsonResponse({
id: 'survey-draft',
title: '2026年7月 · AI作品评审',
description: '草稿说明',
templateCode: 'visual_review',
template: {
...templateDefinition,
name: 'AI作品评审',
shortName: 'AI作品评审',
requiredWorkItemCount: 3,
},
status: 'draft',
canManage: true,
assessmentMonth: '2026-07',
dueAt: null,
targets: [{
id: 'target-1',
targetType: 'employee',
targetName: '管理员',
targetSnapshot: {
id: 'employee-admin',
displayName: '管理员',
departmentName: '品牌创意部',
positionLevel: 'M1',
},
canCreateWorkItem: true,
canUploadWorkItems: true,
workItemUploadRemaining: 2,
workItems: [{
id: 'work-item-1',
title: '已上传作品',
description: '第一版',
imageUrls: ['https://s3.jiansun.vip/example.webp'],
}],
assignments: [{
id: 'assignment-1',
evaluatorEmployeeId: 'evaluator-1',
evaluatorName: '元泽',
evaluatorSnapshot: { id: 'evaluator-1', displayName: '元泽' },
reviewerSource: 'fixed_employee',
status: 'pending',
}],
}],
})
}
if (url.includes('/position-groups?month=2026-07')) {
return jsonResponse({
month: '2026-07',
groups: [{
id: 'group-1',
code: 'brand_designer',
name: '品牌设计师',
status: 'ready',
templateCount: 1,
}],
membership: {
status: 'matched',
groupCode: 'brand_designer',
groupName: '品牌设计师',
conflictGroupNames: [],
},
})
}
if (url.includes('/templates?month=2026-07&positionGroupCode=brand_designer')) {
return jsonResponse([{
...templateDefinition,
name: 'AI作品评审',
shortName: 'AI作品评审',
templateVersionId: 'version-1',
templateOptionKey: 'visual_review:version-1',
requiredWorkItemCount: 3,
}])
}
if (url.includes('/departments?purpose=evaluator')) return jsonResponse([])
if (url.endsWith('/surveys')) {
return jsonResponse({
summary: {},
pending: [],
submitted: [],
created: [],
})
}
return jsonResponse([])
}) as jest.Mock
render(<PerformanceSurveyPage />)
expect(await screen.findByText('继续编辑未发布草稿')).toBeInTheDocument()
expect(screen.getByText('已恢复 1/3 个作品;补齐内容后可直接发布。被考核人与评分流程沿用原草稿。')).toBeInTheDocument()
expect(screen.getByText('已上传作品')).toBeInTheDocument()
expect(screen.getByLabelText('考评模板')).toBeDisabled()
expect(screen.getByLabelText('案例 2 名称')).toBeInTheDocument()
expect(screen.getByLabelText('案例 3 名称')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '上传剩余作品并发布' })).toBeDisabled()
})
})

View File

@ -293,6 +293,17 @@ function createVisualCaseDrafts(count = DEFAULT_VISUAL_CASE_COUNT): VisualCaseDr
}))
}
function formatDateTimeLocalInput(value?: string | Date | null) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (part: number) => String(part).padStart(2, '0')
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`${pad(date.getHours())}:${pad(date.getMinutes())}`,
].join('T')
}
function normalizePerformanceSurveyImageUrl(url?: string | null): string {
const trimmed = String(url || '').trim()
if (!trimmed) return ''
@ -855,11 +866,6 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
<ManagePanel
currentEmployee={me?.employee || null}
isSystemAdmin={Boolean(me?.permissions?.isSystemAdmin)}
canSelectAnyPosition={Boolean(
me?.permissions?.isSystemAdmin
|| me?.permissions?.isDepartmentAdmin
|| me?.permissions?.isLeadership,
)}
configurationDirty={manageConfigurationDirty}
onConfigurationDirtyChange={setManageConfigurationDirty}
/>
@ -1219,7 +1225,6 @@ function PerformanceDashboardBoard({
params.set('tab', 'manage')
params.set('prefillMonth', targetMonth)
params.set('prefillTemplate', templateCode)
if (selectedPositionGroup?.code) params.set('prefillPositionGroup', selectedPositionGroup.code)
router.push(`/performance-survey?${params.toString()}`)
} : undefined}
/>
@ -2341,15 +2346,29 @@ function CreatedList({
resultBackHref?: string
}) {
const [busyId, setBusyId] = useState<string | null>(null)
const [actionErrors, setActionErrors] = useState<Record<string, string>>({})
const changeStatus = async (id: string, status: string) => {
const actionLabel = status === 'open' ? '发布' : '关闭'
setBusyId(id)
setActionErrors((current) => {
if (!current[id]) return current
const next = { ...current }
delete next[id]
return next
})
try {
await apiRequest(`/surveys/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
await onRefresh()
} catch (error) {
const message = error instanceof Error ? error.message : `${actionLabel}失败,请稍后重试`
setActionErrors((current) => ({
...current,
[id]: `${actionLabel}失败:${message}`,
}))
} finally {
setBusyId(null)
}
@ -2388,6 +2407,8 @@ function CreatedList({
const progressText = assignmentCount > 0
? `已提交 ${responseCount}/${assignmentCount}`
: '尚未生成评价任务'
const actionError = actionErrors[item.id]
const needsWorkItemUpload = actionError?.includes('作品尚未上传完整')
return (
<article
@ -2409,6 +2430,15 @@ function CreatedList({
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
{item.status === 'draft' && (
<Link
href={`/performance-survey?tab=manage&adminWorkspace=launch&draftId=${encodeURIComponent(item.id)}`}
aria-label={`继续编辑草稿:${item.title}`}
className="inline-flex min-h-11 items-center justify-center rounded border border-[#c2c6d8] bg-white px-4 text-sm font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
继续编辑
</Link>
)}
{item.status === 'draft' && (
<button
type="button"
@ -2417,7 +2447,7 @@ function CreatedList({
aria-label={`发布调研:${item.title}`}
className="min-h-11 rounded border border-[#b8d0ff] bg-[#edf4ff] px-4 text-sm font-bold text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
发布
{busyId === item.id ? '发布中...' : '发布'}
</button>
)}
{item.status !== 'closed' && item.status !== 'cancelled' && (
@ -2428,7 +2458,7 @@ function CreatedList({
aria-label={`关闭调研:${item.title}`}
className="min-h-11 rounded border border-[#c2c6d8] bg-white px-4 text-sm font-bold text-[#1a1c1c] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
关闭
{busyId === item.id ? '处理中...' : '关闭'}
</button>
)}
{item.canCancel && (
@ -2452,6 +2482,22 @@ function CreatedList({
</Link>
)}
</div>
{actionError && (
<div
role="alert"
className="mt-3 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-[13px] font-medium leading-5 text-[#b42318]"
>
{actionError}
{needsWorkItemUpload && (
<Link
href={`/performance-survey/survey/${item.id}`}
className="ml-2 inline-flex min-h-8 items-center rounded border border-[#f0b8b8] bg-white px-2.5 font-bold text-[#b42318] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
>
继续上传作品
</Link>
)}
</div>
)}
</article>
)
})}
@ -2473,6 +2519,8 @@ function CreatedList({
const targetCount = Number(item.counts?.targets || 0)
const assignmentCount = Number(item.counts?.assignments || 0)
const responseCount = Number(item.counts?.responses || 0)
const actionError = actionErrors[item.id]
const needsWorkItemUpload = actionError?.includes('作品尚未上传完整')
return (
<tr key={item.id} className="text-sm text-[#1a1c1c] hover:bg-[#fcfcfc]">
@ -2490,6 +2538,15 @@ function CreatedList({
<td className="px-4 py-4 text-[13px] font-medium text-[#5f5e5e]">{item.dueAt ? formatDate(item.dueAt) : '无截止'}</td>
<td className="px-4 py-4">
<div className="flex flex-wrap justify-end gap-2">
{item.status === 'draft' && (
<Link
href={`/performance-survey?tab=manage&adminWorkspace=launch&draftId=${encodeURIComponent(item.id)}`}
aria-label={`继续编辑草稿:${item.title}`}
className="inline-flex min-h-9 items-center justify-center rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
>
继续编辑
</Link>
)}
{item.status === 'draft' && (
<button
type="button"
@ -2498,7 +2555,7 @@ function CreatedList({
aria-label={`发布调研:${item.title}`}
className="min-h-9 rounded border border-[#b8d0ff] bg-[#edf4ff] px-3 text-[13px] font-bold text-[#0050cb] hover:bg-[#dae8ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
发布
{busyId === item.id ? '发布中...' : '发布'}
</button>
)}
{item.status !== 'closed' && item.status !== 'cancelled' && (
@ -2509,7 +2566,7 @@ function CreatedList({
aria-label={`关闭调研:${item.title}`}
className="min-h-9 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] font-bold text-[#1a1c1c] hover:border-[#0050cb] hover:text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:opacity-50"
>
关闭
{busyId === item.id ? '处理中...' : '关闭'}
</button>
)}
{item.canCancel && (
@ -2533,6 +2590,22 @@ function CreatedList({
</Link>
)}
</div>
{actionError && (
<div
role="alert"
className="mt-2 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-left text-[12px] font-medium leading-5 text-[#b42318]"
>
{actionError}
{needsWorkItemUpload && (
<Link
href={`/performance-survey/survey/${item.id}`}
className="ml-2 inline-flex min-h-8 items-center rounded border border-[#f0b8b8] bg-white px-2.5 font-bold text-[#b42318] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
>
继续上传作品
</Link>
)}
</div>
)}
</td>
</tr>
)
@ -2828,13 +2901,11 @@ const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
function ManagePanel({
currentEmployee,
isSystemAdmin,
canSelectAnyPosition,
configurationDirty,
onConfigurationDirtyChange,
}: {
currentEmployee: Employee | null
isSystemAdmin: boolean
canSelectAnyPosition: boolean
configurationDirty: boolean
onConfigurationDirtyChange: (dirty: boolean) => void
}) {
@ -2984,7 +3055,6 @@ function ManagePanel({
<CreateSurveyForm
departments={departments.data || []}
currentEmployee={currentEmployee}
canSelectAnyPosition={canSelectAnyPosition}
defaultMonth={adminMonth}
onMonthChange={selectAdminMonth}
onAdminNavigate={isSystemAdmin ? (nextWorkspace) => void selectWorkspace(nextWorkspace) : undefined}
@ -3079,7 +3149,6 @@ function TemplateStatsPanel({ items }: { items: any[] }) {
function CreateSurveyForm({
departments,
currentEmployee,
canSelectAnyPosition,
defaultMonth,
onMonthChange,
onAdminNavigate,
@ -3087,7 +3156,6 @@ function CreateSurveyForm({
}: {
departments: DepartmentOption[]
currentEmployee: Employee | null
canSelectAnyPosition: boolean
defaultMonth?: string
onMonthChange?: (month: string) => void
onAdminNavigate?: (workspace: PerformanceSurveyAdminWorkspace) => void
@ -3097,12 +3165,11 @@ function CreateSurveyForm({
const searchParams = useSearchParams()
const prefillMonth = searchParams.get('prefillMonth') || ''
const prefillTemplate = searchParams.get('prefillTemplate') || ''
const prefillPositionGroup = searchParams.get('prefillPositionGroup') || ''
const draftSurveyId = String(searchParams.get('draftId') || '').trim()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [templateOptionKey, setTemplateOptionKey] = useState(prefillTemplate)
const [assessmentMonth, setAssessmentMonth] = useState(prefillMonth || defaultMonth || getCurrentMonthValue())
const [positionGroupCode, setPositionGroupCode] = useState(prefillPositionGroup)
const [deadlineAt, setDeadlineAt] = useState('')
const openNow = true
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
@ -3116,33 +3183,90 @@ function CreateSurveyForm({
const [saving, setSaving] = useState(false)
const [publishingLabel, setPublishingLabel] = useState('')
const [error, setError] = useState<string | null>(null)
const hydratedDraftIdRef = useRef<string | null>(null)
const draftState = useApi<any>(
draftSurveyId ? `/surveys/${encodeURIComponent(draftSurveyId)}` : null,
[draftSurveyId],
)
const editingDraft = draftState.data?.status === 'draft' && draftState.data?.canManage
? draftState.data
: null
const draftLoadIssue = draftSurveyId
? draftState.error
|| (!draftState.loading && draftState.data?.status !== 'draft' ? '该调研已不是可编辑草稿' : null)
|| (!draftState.loading && draftState.data && !draftState.data.canManage ? '你没有编辑该草稿的权限' : null)
: null
const positionGroupsPath = `/position-groups?month=${encodeURIComponent(assessmentMonth || getCurrentMonthValue())}`
const positionGroupsState = useApi<any>(positionGroupsPath, [positionGroupsPath])
const availablePositionGroups = positionGroupsState.data?.groups || []
const membershipStatus = positionGroupsState.data?.membership?.status as 'matched' | 'unmatched' | 'conflict' | undefined
const selectedPositionGroupCode = positionGroupCode
|| positionGroupsState.data?.membership?.groupCode
|| ''
const templatesPath = selectedPositionGroupCode
const selectedPositionGroupCode = membershipStatus === 'matched'
? positionGroupsState.data?.membership?.groupCode || ''
: ''
const selectedPositionGroup = availablePositionGroups.find((group: any) => group.code === selectedPositionGroupCode)
const templatesPath = selectedPositionGroup?.status === 'ready'
? `/templates?month=${encodeURIComponent(assessmentMonth)}&positionGroupCode=${encodeURIComponent(selectedPositionGroupCode)}`
: null
const templatesState = useApi<Template[]>(templatesPath, [templatesPath])
const templates = useMemo(() => templatesState.data || [], [templatesState.data])
useEffect(() => {
if (!draftSurveyId || !editingDraft || hydratedDraftIdRef.current === editingDraft.id) return
const draftTemplate = editingDraft.template as Template | undefined
const draftTargets = Array.isArray(editingDraft.targets) ? editingDraft.targets : []
const targetEmployeesFromDraft = draftTargets
.filter((target: any) => target.targetType === 'employee' && target.targetSnapshot?.id)
.map((target: any) => target.targetSnapshot as Employee)
.filter((employee: Employee) => employee.id !== currentEmployee?.id)
const manualEvaluatorsFromDraft = draftTargets
.flatMap((target: any) => Array.isArray(target.assignments) ? target.assignments : [])
.filter((assignment: any) => (
assignment.reviewerSource === 'manual_selection'
&& assignment.evaluatorSnapshot?.id
))
.map((assignment: any) => assignment.evaluatorSnapshot as Employee)
.filter((employee: Employee) => employee.id !== currentEmployee?.id)
const existingWorkItemCount = draftTargets.reduce(
(sum: number, target: any) => sum + (Array.isArray(target.workItems) ? target.workItems.length : 0),
0,
)
const requiredWorkItemCount = Math.max(
1,
Number(draftTemplate?.requiredWorkItemCount || DEFAULT_VISUAL_CASE_COUNT),
)
setTitle(String(editingDraft.title || ''))
setDescription(String(editingDraft.description || ''))
setAssessmentMonth(String(editingDraft.assessmentMonth || defaultMonth || getCurrentMonthValue()))
setDeadlineAt(formatDateTimeLocalInput(editingDraft.dueAt))
setTemplateOptionKey(
String(draftTemplate?.templateOptionKey || draftTemplate?.code || editingDraft.templateCode || ''),
)
setTargetEmployees(targetEmployeesFromDraft)
setManualEvaluatorEmployees(mergeEmployeesById(manualEvaluatorsFromDraft))
setEvaluatorDepartmentValues([])
setEvaluatorDepartmentEmployees({})
setVisualCases(createVisualCaseDrafts(Math.max(0, requiredWorkItemCount - existingWorkItemCount)))
setCreatedSurvey(null)
setError(null)
hydratedDraftIdRef.current = editingDraft.id
}, [currentEmployee?.id, defaultMonth, draftSurveyId, editingDraft])
useEffect(() => {
if (draftSurveyId) return
if (prefillMonth && /^\d{4}-\d{2}$/.test(prefillMonth)) setAssessmentMonth(prefillMonth)
else if (defaultMonth && /^\d{4}-\d{2}$/.test(defaultMonth)) setAssessmentMonth(defaultMonth)
if (prefillPositionGroup) setPositionGroupCode(prefillPositionGroup)
if (prefillTemplate) {
const matched = templates.find((template) => template.templateOptionKey === prefillTemplate)
|| templates.find((template) => template.code === prefillTemplate)
if (matched) setTemplateOptionKey(matched.templateOptionKey || matched.code)
}
}, [defaultMonth, prefillMonth, prefillPositionGroup, prefillTemplate, templates])
}, [defaultMonth, draftSurveyId, prefillMonth, prefillTemplate, templates])
const selectedTemplate = templateOptionKey || templates[0]?.templateOptionKey || templates[0]?.code || ''
const selectedTemplateInfo = templates.find((template) => (template.templateOptionKey || template.code) === selectedTemplate)
|| (editingDraft?.template as Template | undefined)
const selectedTemplateCode = selectedTemplateInfo?.code || ''
const surveyKind = selectedTemplateInfo?.surveyType || 'person_period'
const visualTemplateSelected = isVisualTemplate(selectedTemplateInfo)
@ -3154,6 +3278,13 @@ function CreateSurveyForm({
1,
Number(selectedTemplateInfo?.requiredWorkItemCount || DEFAULT_VISUAL_CASE_COUNT),
)
const existingVisualWorkItems = useMemo(
() => editingDraft
? (editingDraft.targets || []).flatMap((target: any) => target.workItems || [])
: [],
[editingDraft],
)
const remainingVisualCaseCount = Math.max(0, visualRequiredCount - existingVisualWorkItems.length)
const effectiveTargetEmployees = useMemo(
() => surveyKind === 'item_course'
? []
@ -3163,6 +3294,9 @@ function CreateSurveyForm({
const currentTargetSelected = Boolean(
currentEmployee?.id && effectiveTargetEmployees.some((employee) => employee.id === currentEmployee.id),
)
const canUploadVisualCases = visualTemplateSelected && (
currentTargetSelected || Boolean(editingDraft?.canManage)
)
const createdUploadTarget = visualTemplateSelected
? (createdSurvey?.targets || []).find((target: any) => target.canUploadWorkItems)
: null
@ -3187,20 +3321,28 @@ function CreateSurveyForm({
publishValidationIssues.push('确认考核对象')
}
if (
!editingDraft
&&
requiresManualEvaluators
&& evaluatorEmployees.length === 0
) publishValidationIssues.push('至少选择 1 名评分人')
if (!selectedPositionGroupCode) publishValidationIssues.push('选择考核岗位')
if (!canSelectAnyPosition && membershipStatus === 'unmatched') {
if (!editingDraft && membershipStatus === 'unmatched') {
publishValidationIssues.push('联系管理员配置本人考核岗位')
}
if (!canSelectAnyPosition && membershipStatus === 'conflict') {
if (!editingDraft && membershipStatus === 'conflict') {
publishValidationIssues.push('联系管理员处理本人岗位重复匹配')
}
if (visualTemplateSelected && currentTargetSelected) {
const visibleVisualCases = visualCases.slice(0, visualRequiredCount)
if (visibleVisualCases.length < visualRequiredCount) {
publishValidationIssues.push(`准备 ${visualRequiredCount} 个案例作品`)
if (!editingDraft && membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready') {
publishValidationIssues.push('联系管理员完成本人岗位考核项配置')
}
if (draftSurveyId && (draftState.loading || draftLoadIssue)) {
publishValidationIssues.push(draftState.loading ? '等待草稿加载完成' : draftLoadIssue || '草稿不可编辑')
}
if (visualTemplateSelected && canUploadVisualCases) {
const requiredNewCaseCount = editingDraft ? remainingVisualCaseCount : visualRequiredCount
const visibleVisualCases = visualCases.slice(0, requiredNewCaseCount)
if (visibleVisualCases.length < requiredNewCaseCount) {
publishValidationIssues.push(`准备剩余 ${requiredNewCaseCount} 个案例作品`)
}
visibleVisualCases.forEach((draft, index) => {
const validationError = validatePerformanceSurveyImageFiles(draft.files)
@ -3245,13 +3387,14 @@ function CreateSurveyForm({
if (surveyKind !== 'item_course' && effectiveTargetEmployees.length === 0) {
throw new Error('请至少选择 1 个考核对象')
}
if (requiresManualEvaluators && evaluatorEmployees.length === 0) {
if (!editingDraft && requiresManualEvaluators && evaluatorEmployees.length === 0) {
throw new Error('请至少选择 1 名评分人')
}
const visualCasesToSubmit = visualCases.slice(0, visualRequiredCount)
if (visualTemplateSelected && currentTargetSelected) {
if (visualCasesToSubmit.length < visualRequiredCount) {
throw new Error(`请提交 ${visualRequiredCount} 个案例作品`)
const requiredNewVisualCaseCount = editingDraft ? remainingVisualCaseCount : visualRequiredCount
const visualCasesToSubmit = visualCases.slice(0, requiredNewVisualCaseCount)
if (visualTemplateSelected && canUploadVisualCases) {
if (visualCasesToSubmit.length < requiredNewVisualCaseCount) {
throw new Error(`请提交剩余 ${requiredNewVisualCaseCount} 个案例作品`)
}
for (let index = 0; index < visualCasesToSubmit.length; index += 1) {
const validationError = validatePerformanceSurveyImageFiles(visualCasesToSubmit[index].files)
@ -3264,29 +3407,40 @@ function CreateSurveyForm({
const submitTitle = title.trim()
|| targetItemsToSubmit[0]?.name
|| `${formatMonthLabel(selectedAssessmentMonth)} · ${getTemplateDisplayName(selectedTemplateInfo)}`
const created = await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
title: submitTitle,
description,
surveyKind,
positionGroupCode: selectedPositionGroupCode,
templateCode: selectedTemplateCode,
templateVersionId: selectedTemplateInfo?.templateVersionId || null,
assessmentMonth: selectedAssessmentMonth,
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
openNow: visualTemplateSelected ? false : openNow,
targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
}),
})
const created = editingDraft
? await apiRequest<any>(`/surveys/${editingDraft.id}`, {
method: 'PATCH',
body: JSON.stringify({
title: submitTitle,
description,
assessmentMonth: selectedAssessmentMonth,
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
}),
})
: await apiRequest<any>('/surveys', {
method: 'POST',
body: JSON.stringify({
title: submitTitle,
description,
surveyKind,
templateCode: selectedTemplateCode,
templateVersionId: selectedTemplateInfo?.templateVersionId || null,
assessmentMonth: selectedAssessmentMonth,
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
openNow: visualTemplateSelected ? false : openNow,
targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id),
targetItems: targetItemsToSubmit,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
}),
})
createdForAttempt = created
setCreatedSurvey(created)
if (!editingDraft) setCreatedSurvey(created)
let uploadTarget: any | null = null
if (visualTemplateSelected && currentTargetSelected) {
uploadTarget = (created.targets || []).find((target: any) => target.canUploadWorkItems)
if (visualTemplateSelected && canUploadVisualCases) {
uploadTarget = (created.targets || []).find((target: any) => (
target.canCreateWorkItem || target.canUploadWorkItems || target.workItemUploadRemaining > 0
))
if (!uploadTarget?.id) throw new Error('未找到当前账户对应的作品上传入口')
for (let index = 0; index < visualCasesToSubmit.length; index += 1) {
@ -3317,7 +3471,6 @@ function CreateSurveyForm({
setTitle('')
setDescription('')
setTemplateOptionKey('')
setPositionGroupCode('')
setAssessmentMonth(getCurrentMonthValue())
setDeadlineAt('')
setTargetEmployees([])
@ -3332,6 +3485,8 @@ function CreateSurveyForm({
if (uploadTarget?.id) {
router.push(`/performance-survey/survey/${created.id}?targetId=${encodeURIComponent(uploadTarget.id)}`)
} else if (editingDraft) {
router.push('/performance-survey?tab=personal&workspace=created')
}
} catch (err) {
const message = err instanceof Error ? err.message : '创建失败'
@ -3348,6 +3503,32 @@ function CreateSurveyForm({
return (
<section className="grid gap-6 lg:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.25fr)]">
{draftSurveyId && (
<div
className={`lg:col-span-2 ${
draftLoadIssue
? 'border border-[#ffdad6] bg-[#fff6f4] text-[#93000a]'
: 'border border-[#b3c5ff] bg-[#f8faff] text-[#0050cb]'
} px-4 py-3`}
role={draftLoadIssue ? 'alert' : 'status'}
aria-live="polite"
>
<div className="text-[14px] font-bold">
{draftState.loading
? '正在恢复草稿...'
: draftLoadIssue
? '无法继续编辑草稿'
: '继续编辑未发布草稿'}
</div>
<p className="mt-1 text-[12px] font-medium leading-5">
{draftState.loading
? '正在读取原有模板、作品和发布设置。'
: draftLoadIssue
? `${draftLoadIssue}。请返回“我发起的”刷新后重试。`
: `已恢复 ${existingVisualWorkItems.length}/${visualRequiredCount} 个作品;补齐内容后可直接发布。被考核人与评分流程沿用原草稿。`}
</p>
</div>
)}
<div>
<div className="border border-[#eeeeee] bg-white p-4 sm:p-6">
<div className="space-y-6">
@ -3360,46 +3541,77 @@ function CreateSurveyForm({
}}
type="month"
/>
<label className="grid gap-2 text-sm">
<div className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考核岗位</span>
<select
value={selectedPositionGroupCode}
onChange={(event) => {
setPositionGroupCode(event.target.value)
setTemplateOptionKey('')
setTargetEmployees([])
}}
className="h-11 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
<div
aria-live="polite"
className="flex min-h-11 min-w-0 items-center justify-between gap-3 rounded border border-[#eeeeee] bg-[#f9f9f9] px-3 py-2"
>
<option value="">选择考核岗位</option>
{availablePositionGroups.map((group: any) => (
<option key={group.code} value={group.code} disabled={group.status !== 'ready'}>
{group.name}{group.status === 'ready' ? ` · ${group.templateCount}项` : ' · 待配置'}
</option>
))}
</select>
{!canSelectAnyPosition && positionGroupsState.data?.membership?.status === 'conflict' && (
<span className="min-w-0 break-words text-[14px] font-semibold text-[#1a1c1c]">
{editingDraft
? editingDraft.positionGroup?.name || editingDraft.positionGroupCode || '沿用草稿岗位'
: positionGroupsState.loading
? '正在关联人员岗位...'
: positionGroupsState.error
? '岗位关联失败'
: membershipStatus === 'matched'
? positionGroupsState.data?.membership?.groupName || '已关联岗位'
: membershipStatus === 'conflict'
? '岗位配置冲突'
: '尚未配置岗位'}
</span>
<span className="shrink-0 text-[11px] font-semibold text-[#5f5e5e]">自动关联</span>
</div>
{!editingDraft && positionGroupsState.error && (
<div
role="alert"
className="flex flex-wrap items-center justify-between gap-2 border border-[#ffdad6] bg-[#fff6f4] px-3 py-2 text-[12px] font-semibold text-[#93000a]"
>
<span className="min-w-0 break-words">岗位关联失败:{positionGroupsState.error}</span>
<button
type="button"
onClick={() => void positionGroupsState.refresh()}
className="min-h-9 shrink-0 rounded border border-[#93000a] px-3 hover:bg-[#93000a] hover:text-white"
>
重新加载
</button>
</div>
)}
{!editingDraft && membershipStatus === 'matched' && (
<div className="text-[12px] leading-5 text-[#5f5e5e]">
已按被考核人的人员分配自动关联,发起时无需选择。
</div>
)}
{!editingDraft && positionGroupsState.data?.membership?.status === 'conflict' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前人员同时命中多个岗位:{(positionGroupsState.data.membership.conflictGroupNames || []).join('、')}
</div>
)}
{!canSelectAnyPosition && positionGroupsState.data?.membership?.status === 'unmatched' && (
{!editingDraft && positionGroupsState.data?.membership?.status === 'unmatched' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前人员尚未匹配考核岗位,请联系系统管理员完成岗位配置
</div>
)}
</label>
{onAdminNavigate && positionGroupsState.data && (
availablePositionGroups.every((group: any) => group.status !== 'ready')
{!editingDraft && membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前岗位尚未完成考核项配置,暂时不能发起调研
</div>
)}
</div>
{!editingDraft && onAdminNavigate && positionGroupsState.data && (
membershipStatus !== 'matched'
|| selectedPositionGroup?.status !== 'ready'
|| (selectedPositionGroupCode && !templatesState.loading && templates.length === 0)
) && (
<div className="border border-[#e4b86a] bg-[#fffaf0] px-3 py-3 text-[12px] leading-5 text-[#6f4700]">
<div className="font-bold">
{availablePositionGroups.every((group: any) => group.status !== 'ready')
? `${assessmentMonth} 暂无可发起的岗位`
: '所选岗位还没有可用考核模板'}
{membershipStatus !== 'matched'
? '当前人员还不能自动关联岗位'
: selectedPositionGroup?.status !== 'ready'
? '当前岗位尚未完成配置'
: '当前岗位还没有可用考核模板'}
</div>
<p className="mt-1">请先补齐岗位设置、人员归属和可用模板,再回来发起调研。</p>
<p className="mt-1">请先补齐人员归属、岗位设置和可用模板,再回来发起调研。</p>
<div className="mt-3 flex flex-wrap gap-2">
{([
['positions', '检查岗位设置'],
@ -3422,18 +3634,29 @@ function CreateSurveyForm({
<label className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考评模板</span>
<select
aria-label="考评模板"
value={selectedTemplate}
onChange={(event) => setTemplateOptionKey(event.target.value)}
disabled={!selectedPositionGroupCode || templatesState.loading}
disabled={Boolean(editingDraft) || !selectedPositionGroupCode || templatesState.loading}
className="h-11 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
>
{templates.length === 0 && <option value="">该岗位暂无可用考核项</option>}
{editingDraft && !templates.some((template) => (template.templateOptionKey || template.code) === selectedTemplate) && (
<option value={selectedTemplate}>
{selectedTemplateInfo?.name || selectedTemplateInfo?.shortName || editingDraft.templateCode}
</option>
)}
{templates.map((template) => (
<option key={template.templateOptionKey || template.code} value={template.templateOptionKey || template.code}>
{template.templateVariantName || getTemplateDisplayName(template)}
{template.name || template.shortName || template.code}
</option>
))}
</select>
{editingDraft && (
<div className="text-[12px] leading-5 text-[#5f5e5e]">
草稿已生成评价任务,模板、被考核人与评分流程保持原设置。
</div>
)}
{selectedTemplateInfo?.templateVariant && (
<div className="border border-[#dce5f3] bg-[#f8faff] px-3 py-3">
<div className="text-[12px] font-bold text-[#1a1c1c]">
@ -3458,7 +3681,26 @@ function CreateSurveyForm({
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">
{selectedTemplateInfo?.ownerRoleName || '被考核人'}
</div>
{currentEmployee ? (
{editingDraft ? (
<div className="mt-2 divide-y divide-[#dce5f3] border border-[#dce5f3] bg-[#f8faff]">
{(editingDraft.targets || []).map((target: any) => (
<div key={target.id} className="flex min-h-12 items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#0050cb] text-[13px] font-bold text-white">
{getSurveyInitials(target.targetName)}
</div>
<div className="min-w-0 flex-1">
<div className="break-words text-[14px] font-bold text-[#1a1c1c]">{target.targetName}</div>
<div className="mt-0.5 break-words text-[12px] text-[#5f5e5e]">
{[target.targetSnapshot?.departmentName, target.targetSnapshot?.positionLevel || target.targetSnapshot?.levelType]
.filter(Boolean)
.join(' · ') || '沿用草稿人员'}
</div>
</div>
<span className="shrink-0 text-[11px] font-bold text-[#5f5e5e]">已锁定</span>
</div>
))}
</div>
) : currentEmployee ? (
<div className="mt-2 flex items-center gap-3 border border-[#dce5f3] bg-[#f8faff] px-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#0050cb] text-[13px] font-bold text-white">
{getSurveyInitials(currentEmployee.displayName)}
@ -3481,15 +3723,17 @@ function CreateSurveyForm({
</div>
)}
</div>
<EmployeePicker
title="添加同岗位被考核人(可选)"
purpose="target"
positionGroupCode={selectedPositionGroupCode}
assessmentMonth={assessmentMonth}
selected={targetEmployees}
excludedIds={currentEmployee?.id ? [currentEmployee.id] : []}
onChange={setTargetEmployees}
/>
{!editingDraft && (
<EmployeePicker
title="添加同岗位被考核人(可选)"
purpose="target"
positionGroupCode={selectedPositionGroupCode}
assessmentMonth={assessmentMonth}
selected={targetEmployees}
excludedIds={currentEmployee?.id ? [currentEmployee.id] : []}
onChange={setTargetEmployees}
/>
)}
{delegatedTargetCount > 0 && (
<div className="text-[12px] font-semibold text-[#0050cb]">
将向 {delegatedTargetCount} 位被考核人发送钉钉确认
@ -3508,7 +3752,19 @@ function CreateSurveyForm({
</div>
)}
</div>
{!requiresManualEvaluators ? (
{editingDraft ? (
<div className="border border-[#dce5f3] bg-[#f8faff] px-4 py-4">
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">评分流程</div>
<div className="mt-2 grid gap-2">
{workflowStages.map((stage) => (
<div key={stage.key} className="text-[13px] font-semibold text-[#1a1c1c]">
{stage.order + 1}. {stage.label} · {stage.reviewers.map((reviewer) => reviewer.employeeName || reviewer.label).join('、')}
</div>
))}
</div>
<p className="mt-2 text-[12px] leading-5 text-[#5f5e5e]">草稿恢复编辑时沿用已生成的评分人员与阶段设置。</p>
</div>
) : !requiresManualEvaluators ? (
<div className="border border-[#dce5f3] bg-[#f8faff] px-4 py-4">
<div className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">评审流程</div>
<div className="mt-2 grid gap-2">
@ -3560,8 +3816,11 @@ function CreateSurveyForm({
<div className={`min-w-0 lg:col-span-2 ${visualTemplateSelected ? '' : 'hidden'}`}>
<VisualCaseDraftUploader
expanded={visualTemplateSelected}
enabled={currentTargetSelected}
requiredCount={visualRequiredCount}
enabled={canUploadVisualCases}
requiredCount={editingDraft ? remainingVisualCaseCount : visualRequiredCount}
totalRequiredCount={visualRequiredCount}
completedOffset={existingVisualWorkItems.length}
existingItems={existingVisualWorkItems}
cases={visualCases}
onChange={setVisualCases}
/>
@ -3593,6 +3852,10 @@ function CreateSurveyForm({
>
{saving
? publishingLabel || '发布中...'
: editingDraft
? remainingVisualCaseCount > 0
? '上传剩余作品并发布'
: '保存修改并发布'
: createdSurvey?.status === 'open'
? '调研已发布'
: createdSurvey
@ -3745,12 +4008,18 @@ function VisualCaseDraftUploader({
expanded,
enabled,
requiredCount,
totalRequiredCount = requiredCount,
completedOffset = 0,
existingItems = [],
cases,
onChange,
}: {
expanded: boolean
enabled: boolean
requiredCount: number
totalRequiredCount?: number
completedOffset?: number
existingItems?: any[]
cases: VisualCaseDraft[]
onChange: (cases: VisualCaseDraft[]) => void
}) {
@ -3774,7 +4043,9 @@ function VisualCaseDraftUploader({
<section className="border border-[#dfe6f0] bg-white p-4 shadow-[0_12px_30px_rgba(15,23,42,0.05)]">
<div className="flex items-center justify-between gap-3">
<h2 className="text-[15px] font-bold text-[#111827]">作品上传</h2>
<span className="text-[12px] font-semibold text-[#667085]">{completedCount}/{requiredCount}</span>
<span className="text-[12px] font-semibold text-[#667085]">
{completedOffset + completedCount}/{totalRequiredCount}
</span>
</div>
{!enabled ? (
@ -3782,17 +4053,46 @@ function VisualCaseDraftUploader({
由被考核人通过钉钉提交作品
</div>
) : (
<div className="mt-3 grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleCases.map((draft, index) => (
<VisualCaseDraftEditor
key={draft.id}
index={index}
draft={draft}
disabled={!expanded}
onChange={(next) => updateCase(index, next)}
/>
))}
</div>
<>
{existingItems.length > 0 && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{existingItems.map((item: any, index: number) => (
<div key={item.id} className="border border-[#d9e7ff] bg-[#f8fbff] px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="break-words text-[13px] font-bold text-[#111827]">
{item.title || `案例 ${index + 1}`}
</div>
{item.description && (
<div className="mt-1 line-clamp-2 break-words text-[11px] leading-4 text-[#667085]">
{item.description}
</div>
)}
</div>
<span className="shrink-0 text-[11px] font-bold text-[#087a3c]">已上传</span>
</div>
</div>
))}
</div>
)}
{visibleCases.length > 0 ? (
<div className="mt-3 grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleCases.map((draft, index) => (
<VisualCaseDraftEditor
key={draft.id}
index={completedOffset + index}
draft={draft}
disabled={!expanded}
onChange={(next) => updateCase(index, next)}
/>
))}
</div>
) : (
<div className="mt-3 border border-[#b9ead0] bg-[#f0fff6] px-3 py-3 text-[13px] font-semibold text-[#087a3c]">
所有作品均已上传,可以保存并发布草稿。
</div>
)}
</>
)}
</section>
</div>

View File

@ -3,12 +3,14 @@
import { prisma } from '@/lib/prisma'
import {
assertPerformanceSurveyPositionPlanPreviewMatches,
copyPerformanceSurveyTemplateVersion,
createPerformanceSurveyManualPositionAssignments,
getPerformanceSurveyPositionAdminConfig,
listPerformanceSurveyManualPositionAssignments,
listPerformanceSurveyPositionGroups,
removePerformanceSurveyTemplate,
savePerformanceSurveyPositionGroupDraft,
savePerformanceSurveyTemplateDraft,
} from '@/lib/performance-survey-position-config'
import { DEFAULT_PERFORMANCE_SURVEY_TEMPLATES } from '@/lib/performance-survey-templates'
@ -25,9 +27,11 @@ jest.mock('@/lib/prisma', () => ({
update: jest.fn(),
},
performanceSurveyTemplateVersion: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
performanceSurveyTemplate: {
create: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
},
@ -295,6 +299,143 @@ describe('performance survey position admin', () => {
expect(prisma.performanceSurveyEmployeeProfile.findMany).not.toHaveBeenCalled()
})
test('复制模板会创建独立模板和完整草稿版本', async () => {
const definition = structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0])
;(prisma.performanceSurveyTemplateVersion.findUnique as jest.Mock).mockResolvedValue({
id: 'version-1',
templateId: 'template-1',
version: 2,
status: 'published',
definition,
template: {
id: 'template-1',
code: definition.code,
name: definition.name,
archivedAt: null,
},
})
const tx = {
performanceSurveyTemplate: {
create: jest.fn().mockImplementation(async ({ data }) => ({ id: 'template-copy', ...data })),
},
performanceSurveyTemplateVersion: {
create: jest.fn().mockImplementation(async ({ data }) => ({ id: 'version-copy', ...data })),
},
performanceSurveyConfigAuditLog: {
create: jest.fn().mockResolvedValue({ id: 'audit-copy' }),
},
}
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
await expect(copyPerformanceSurveyTemplateVersion({
versionId: 'version-1',
actorUserId: 'admin-1',
actorName: '管理员',
})).resolves.toMatchObject({
id: 'version-copy',
templateId: 'template-copy',
name: `${definition.name}(副本)`,
})
const templateData = tx.performanceSurveyTemplate.create.mock.calls[0][0].data
const versionData = tx.performanceSurveyTemplateVersion.create.mock.calls[0][0].data
expect(templateData.code).toMatch(/^template_copy_[a-f0-9]{32}$/)
expect(templateData.name).toBe(`${definition.name}(副本)`)
expect(versionData).toMatchObject({
templateId: 'template-copy',
version: 1,
status: 'draft',
definition: expect.objectContaining({
code: templateData.code,
name: `${definition.name}(副本)`,
shortName: `${definition.name}(副本)`,
questions: definition.questions,
workflow: definition.workflow,
}),
})
expect(tx.performanceSurveyConfigAuditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'template.copied',
targetId: 'template-copy',
}),
})
})
test('修改已发布模板直接更新原记录并同步模板资料', async () => {
const definition = {
...structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]),
name: '更新后的模板名称',
shortName: '更新后的模板名称',
}
const existing = {
id: 'version-1',
templateId: 'template-1',
version: 2,
status: 'published',
definition: structuredClone(DEFAULT_PERFORMANCE_SURVEY_TEMPLATES[0]),
updatedAt: new Date('2026-07-29T08:00:00.000Z'),
template: {
id: 'template-1',
code: definition.code,
name: '原模板名称',
archivedAt: null,
},
}
const saved = {
...existing,
definition,
updatedAt: new Date('2026-07-29T09:00:00.000Z'),
}
const tx = {
performanceSurveyTemplateVersion: {
findUnique: jest.fn()
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(saved),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
performanceSurveyTemplate: {
update: jest.fn().mockResolvedValue({ id: 'template-1' }),
},
performanceSurveyConfigAuditLog: {
create: jest.fn().mockResolvedValue({ id: 'audit-update' }),
},
$queryRawUnsafe: jest.fn().mockResolvedValue([{ locked: '1' }]),
}
;(prisma.performanceSurveyTemplateVersion.findUnique as jest.Mock).mockResolvedValue(existing)
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
await expect(savePerformanceSurveyTemplateDraft({
versionId: existing.id,
definition,
actorUserId: 'admin-1',
actorName: '管理员',
})).resolves.toMatchObject({
id: existing.id,
status: 'published',
definition: expect.objectContaining({ name: '更新后的模板名称' }),
})
expect(tx.performanceSurveyTemplateVersion.updateMany).toHaveBeenCalledWith({
where: {
id: existing.id,
status: 'published',
updatedAt: existing.updatedAt,
},
data: {
definition,
createdByUserId: 'admin-1',
createdByName: '管理员',
},
})
expect(tx.performanceSurveyTemplate.update).toHaveBeenCalledWith({
where: { id: 'template-1' },
data: expect.objectContaining({ name: '更新后的模板名称' }),
})
expect(tx.performanceSurveyConfigAuditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'template.version_updated' }),
})
})
test('当前或未来岗位方案仍在使用模板时拒绝删除', async () => {
;(prisma.performanceSurveyTemplate.findUnique as jest.Mock).mockResolvedValue({
id: 'template-1',

View File

@ -210,7 +210,7 @@ describe('performance survey position configuration', () => {
)).toBe(true)
})
test('岗位方案拒绝重复模板、草稿版本和晚于方案生效月的模板版本', () => {
test('岗位方案拒绝重复和未发布模板,但不限制模板与岗位的生效月份先后', () => {
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
{ id: 'version-1', templateId: 'template-1', status: 'published', effectiveMonth: '2026-01' },
{ id: 'version-2', templateId: 'template-1', status: 'published', effectiveMonth: '2026-02' },
@ -222,7 +222,7 @@ describe('performance survey position configuration', () => {
expect(() => validatePerformanceSurveyPositionPlanTemplateVersions([
{ id: 'version-1', templateId: 'template-1', status: 'published', effectiveMonth: '2026-04' },
], '2026-03')).toThrow('生效月份不能晚于岗位方案生效月份')
], '2026-03')).not.toThrow()
})
test('看板考核项固定每页最多四项并钳制越界页码', () => {

View File

@ -293,12 +293,33 @@ describe('performance survey service policies', () => {
expect(previewPerformanceSurveyEmployeeSync).not.toHaveBeenCalled()
})
test('系统管理员可执行只读人员同步预检', async () => {
test('素材库系统管理员但考核档案不是M级时不能执行人员同步预检', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
isSuperAdmin: true,
})
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
levelType: 'P',
positionLevel: 'P5',
isLeadership: false,
})
await expect(previewPerformanceSurveyEmployeeProfiles(session, { limit: 200 }))
.rejects.toMatchObject({
message: '需要系统管理员权限',
status: 403,
})
expect(previewPerformanceSurveyEmployeeSync).not.toHaveBeenCalled()
expect(ensurePerformanceSurveyEmployeeProfileForUser).not.toHaveBeenCalled()
})
test('M级人员在考核调研内可执行系统管理员的只读预检', async () => {
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(previewPerformanceSurveyEmployeeSync as jest.Mock).mockResolvedValue({
mode: 'preview',
dryRun: true,
@ -314,10 +335,10 @@ describe('performance survey service policies', () => {
})
test('人员同步预检超时会返回中文错误且强调未写入', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
isSuperAdmin: true,
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue({
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
;(previewPerformanceSurveyEmployeeSync as jest.Mock).mockRejectedValue(
Object.assign(new Error('request timeout'), { code: 'ETIMEOUT' }),
@ -537,7 +558,7 @@ describe('performance survey service policies', () => {
expect(getPerformanceSurveyAccountProfileWhere).not.toHaveBeenCalled()
})
test('M级详情权限只按完整组织路径及其子路径判断,不再按重名部门叶子判断', async () => {
test('M级作为考核系统管理员可跨组织查看人员考核明细', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
@ -557,33 +578,15 @@ describe('performance survey service policies', () => {
templateCode: 'course_share',
})
await expect(getPerformanceSurveyDashboardDetail(session, request)).rejects.toEqual(
new PerformanceSurveyApiError('无权限查看该人员考核明细', 403),
await expect(getPerformanceSurveyDashboardDetail(session, request)).resolves.toEqual(
expect.objectContaining({
employee: expect.objectContaining({ id: 'employee-other' }),
}),
)
const scopeWhere = (prisma.performanceSurveyEmployeeProfile.count as jest.Mock).mock.calls[0][0].where.AND[1]
expect(scopeWhere).toEqual({
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
})
expect(JSON.stringify(scopeWhere)).not.toContain('departmentName')
expect(JSON.stringify(scopeWhere)).not.toContain('contains')
expect(JSON.stringify(scopeWhere)).not.toContain('endsWith')
expect(prisma.performanceSurveyEmployeeProfile.count).not.toHaveBeenCalled()
})
test('M级档案缺少完整组织路径时默认拒绝打开他人详情', async () => {
test('M级作为考核系统管理员时不依赖本人组织路径查看他人详情', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentPath: null,
@ -602,7 +605,9 @@ describe('performance survey service policies', () => {
month: '2026-07',
employeeId: 'employee-other',
templateCode: 'course_share',
}))).rejects.toThrow('无权限查看该人员考核明细')
}))).resolves.toEqual(expect.objectContaining({
employee: expect.objectContaining({ id: 'employee-other' }),
}))
expect(prisma.performanceSurveyEmployeeProfile.count).not.toHaveBeenCalled()
})
@ -663,7 +668,7 @@ describe('performance survey service policies', () => {
consoleError.mockRestore()
})
test('M级不会因本组织员工只是评价人而获得整份调研管理权', async () => {
test('M级作为考核系统管理员可管理其他人员发起的调研', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
@ -675,51 +680,50 @@ describe('performance survey service policies', () => {
}))
await expect(
updatePerformanceSurvey(session, 'survey-1', { title: '试图越权修改' }),
).rejects.toThrow('无权限管理该考核调研')
updatePerformanceSurvey(session, 'survey-1', { title: '管理员修改' }),
).resolves.toEqual(expect.objectContaining({ id: 'survey-1' }))
const managerScope = (prisma.performanceSurvey.count as jest.Mock).mock.calls[0][0].where.AND[1]
expect(managerScope).toEqual({
AND: [
{ targets: { some: { targetType: 'employee' } } },
{
targets: {
none: {
targetType: 'employee',
OR: [
{ employeeProfileId: null },
{
employeeProfile: {
isNot: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团A/品牌中心/策划部' },
{ departmentPath: { startsWith: '集团A/品牌中心/策划部/' } },
],
},
},
},
],
},
},
},
],
},
},
},
],
})
expect(JSON.stringify(managerScope)).not.toContain('assignments')
expect(JSON.stringify(managerScope)).not.toContain('evaluatorEmployee')
expect(JSON.stringify(managerScope)).not.toContain('creatorEmployeeId')
expect(prisma.performanceSurvey.count).not.toHaveBeenCalled()
expect(transactionClient.performanceSurvey.update).toHaveBeenCalled()
})
test('系统管理员身份不会扩大个人调研详情到其他被考核人', async () => {
test('发起人可为非旧代码的视觉模板草稿继续上传作品', async () => {
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'ai_work_review',
templateSnapshot: {
code: 'ai_work_review',
name: 'AI作品评审',
shortName: 'AI作品评审',
surveyType: 'visual_case',
requiredWorkItemCount: 3,
questions: [],
},
targets: [{
id: 'target-ai-designer',
employeeProfileId: 'employee-ai-designer',
targetType: 'employee',
targetName: 'AI设计师',
targetSnapshot: {},
responses: [],
assignments: [],
workItems: [],
}],
}))
const detail = await getPerformanceSurveyDetail(session, 'survey-1')
expect(detail.targets).toEqual([
expect.objectContaining({
id: 'target-ai-designer',
canUploadWorkItems: false,
canCreateWorkItem: true,
requiredWorkItemCount: 3,
workItemUploadRemaining: 3,
}),
])
})
test('素材库管理员身份不会扩大个人调研详情到其他被考核人', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
@ -904,7 +908,6 @@ describe('performance survey service policies', () => {
await createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
@ -923,6 +926,29 @@ describe('performance survey service policies', () => {
expect(transactionClient.performanceSurveyTarget.findMany.mock.invocationCallOrder[0]).toBeLessThan(
transactionClient.performanceSurvey.create.mock.invocationCallOrder[0],
)
expect(getPerformanceSurveyPositionGroupByCode).not.toHaveBeenCalled()
expect(transactionClient.performanceSurvey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
positionGroupCode: 'brand_designer',
positionGroupId: 'position-group-brand',
}),
})
})
test('客户端不能用旧岗位参数覆盖被考核人的自动岗位', async () => {
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'ai_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
openNow: false,
})).rejects.toEqual(
new PerformanceSurveyApiError('考核岗位已根据被考核人的人员分配自动关联,请刷新页面后重试', 409),
)
expect(getPerformanceSurveyPositionGroupByCode).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('发起人不能手动选择自己作为评分人员', async () => {
@ -1095,15 +1121,14 @@ describe('performance survey service policies', () => {
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('系统管理员即使不在指定组织也可发起,canCreate 与创建接口保持一致', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
role: 'ADMIN',
})
test('考核档案M级即使不在指定组织也可发起,canCreate 与创建接口保持一致', async () => {
const adminEmployee = {
...baseEmployee,
departmentName: '视觉一部',
departmentPath: '集团A/其他中心/视觉一部',
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
}
const evaluator = {
...baseEmployee,
@ -1138,6 +1163,24 @@ describe('performance survey service policies', () => {
expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalled()
})
test('M级人员在考核调研内按系统管理员授权', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({
permissions: expect.objectContaining({
isSystemAdmin: true,
isDepartmentAdmin: true,
isLeadership: true,
canCreate: true,
}),
}))
})
test.each(['商品企划部', '空间视觉部'])(
'%s不再由部门名称硬编码禁止发起,资格交给岗位匹配规则',
async (departmentName) => {
@ -1153,12 +1196,14 @@ describe('performance survey service policies', () => {
},
)
test('系统管理员属于品牌中心未开放部门时仍保留全局发起能力', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ ...baseUser, role: 'ADMIN' })
test('考核档案M级属于品牌中心未开放部门时仍保留全局发起能力', async () => {
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue({
...baseEmployee,
departmentName: '商品企划部',
departmentPath: '集团A/品牌中心/商品企划部',
levelType: 'M',
positionLevel: 'M1',
isLeadership: true,
})
await expect(getPerformanceSurveyMe(session)).resolves.toEqual(expect.objectContaining({

View File

@ -1183,7 +1183,7 @@ export function validatePerformanceSurveyPositionPlanTemplateVersions(
effectiveMonth?: string | null
template?: { archivedAt?: Date | string | null }
}>,
effectiveMonth?: string | null,
_effectiveMonth?: string | null,
) {
const templateIds = new Set<string>()
for (const version of versions) {
@ -1197,9 +1197,6 @@ export function validatePerformanceSurveyPositionPlanTemplateVersions(
if (version.template?.archivedAt) {
throw new PerformanceSurveyPositionConfigError('岗位方案包含已归档模板')
}
if (effectiveMonth && (!version.effectiveMonth || version.effectiveMonth > effectiveMonth)) {
throw new PerformanceSurveyPositionConfigError('模板版本生效月份不能晚于岗位方案生效月份')
}
}
}
@ -1461,7 +1458,7 @@ async function assertPositionPlanHasNoConflicts(
export async function publishPerformanceSurveyPositionPlan(input: {
groupId: string
effectiveMonth: string
effectiveMonth?: string | null
draftPlanId?: string | null
draftVersion?: number | string | null
draftUpdatedAt?: Date | string | null
@ -1470,7 +1467,7 @@ export async function publishPerformanceSurveyPositionPlan(input: {
reason?: string | null
}) {
const effectiveMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth)
if (!effectiveMonth) throw new PerformanceSurveyPositionConfigError('生效月份格式无效')
|| getCurrentAssessmentMonth()
return prisma.$transaction(async (tx) => {
await lockPerformanceSurveyAdminScope(tx, `performance-survey-position-group:${input.groupId}`)
@ -1618,8 +1615,8 @@ export async function savePerformanceSurveyTemplateDraft(input: {
include: { template: true },
})
: null
if (existingVersion && existingVersion.status !== 'draft') {
throw new PerformanceSurveyPositionConfigError('已发布模板不可直接修改,请创建新草稿', 409)
if (existingVersion?.status === 'archived') {
throw new PerformanceSurveyPositionConfigError('已归档模板版本不能修改', 409)
}
if (existingVersion && existingVersion.template.code !== definition.code) {
throw new PerformanceSurveyPositionConfigError('模板代码创建后不可在版本草稿中修改', 409)
@ -1640,8 +1637,8 @@ export async function savePerformanceSurveyTemplateDraft(input: {
include: { template: true },
})
if (!currentVersion) throw new PerformanceSurveyPositionConfigError('模板版本不存在', 404)
if (currentVersion.status !== 'draft') {
throw new PerformanceSurveyPositionConfigError('已发布模板不可直接修改,请创建新草稿', 409)
if (currentVersion.status === 'archived') {
throw new PerformanceSurveyPositionConfigError('已归档模板版本不能修改', 409)
}
if (currentVersion.template.code !== definition.code) {
throw new PerformanceSurveyPositionConfigError('模板代码创建后不可在版本草稿中修改', 409)
@ -1649,6 +1646,9 @@ export async function savePerformanceSurveyTemplateDraft(input: {
if (currentVersion.template.archivedAt) {
throw new PerformanceSurveyPositionConfigError('已归档模板不能继续编辑', 409)
}
if (currentVersion.status === 'published') {
validatePerformanceSurveyTemplateDefinition(definition)
}
}
let template = currentVersion?.template || await tx.performanceSurveyTemplate.findUnique({
where: { code: definition.code },
@ -1669,20 +1669,28 @@ export async function savePerformanceSurveyTemplateDraft(input: {
isActive: true,
},
})
} else if (!currentVersion) {
const existingTemplateVersion = await tx.performanceSurveyTemplateVersion.findFirst({
where: { templateId: template.id },
select: { id: true },
})
if (existingTemplateVersion) {
throw new PerformanceSurveyPositionConfigError('模板已存在,请直接修改当前模板', 409)
}
}
let saved
if (currentVersion) {
const updateResult = await tx.performanceSurveyTemplateVersion.updateMany({
where: {
id: currentVersion.id,
status: 'draft',
status: currentVersion.status,
updatedAt: currentVersion.updatedAt,
},
data: { definition: definition as any, createdByUserId: input.actorUserId, createdByName: input.actorName },
})
if (updateResult.count !== 1) {
throw new PerformanceSurveyPositionConfigError(
'模板草稿已发生变化,请重新加载后再保存',
'模板已发生变化,请重新加载后再保存',
409,
)
}
@ -1690,16 +1698,25 @@ export async function savePerformanceSurveyTemplateDraft(input: {
where: { id: currentVersion.id },
})
if (!saved) throw new PerformanceSurveyPositionConfigError('模板草稿保存失败', 409)
if (currentVersion.status === 'published') {
await tx.performanceSurveyTemplate.update({
where: { id: template.id },
data: {
name: definition.name,
description: definition.description || '',
surveyType: definition.surveyType,
weight: Number(definition.weight || 1),
questions: definition.questions as any,
scoreBands: definition.scoreBands as any,
isActive: true,
},
})
}
} else {
const latest = await tx.performanceSurveyTemplateVersion.findFirst({
where: { templateId: template.id },
orderBy: { version: 'desc' },
select: { version: true },
})
saved = await tx.performanceSurveyTemplateVersion.create({
data: {
templateId: template.id,
version: Number(latest?.version || 0) + 1,
version: 1,
status: 'draft',
definition: definition as any,
createdByUserId: input.actorUserId,
@ -1708,7 +1725,9 @@ export async function savePerformanceSurveyTemplateDraft(input: {
})
}
await writePerformanceSurveyConfigAuditLog(tx, {
action: 'template.draft_saved',
action: currentVersion?.status === 'published'
? 'template.version_updated'
: 'template.draft_saved',
targetType: 'template',
targetId: template.id,
actorUserId: input.actorUserId,
@ -1716,13 +1735,13 @@ export async function savePerformanceSurveyTemplateDraft(input: {
reason: input.reason,
beforeSnapshot: currentVersion,
afterSnapshot: saved,
metadata: { versionId: saved.id },
metadata: { versionId: saved.id, status: currentVersion?.status || 'draft' },
})
return saved
})
}
export async function createPerformanceSurveyTemplateDraftFromVersion(input: {
export async function copyPerformanceSurveyTemplateVersion(input: {
versionId: string
actorUserId: string
actorName: string
@ -1733,52 +1752,73 @@ export async function createPerformanceSurveyTemplateDraftFromVersion(input: {
include: { template: true },
})
if (!source) throw new PerformanceSurveyPositionConfigError('模板版本不存在', 404)
if (source.template.archivedAt) throw new PerformanceSurveyPositionConfigError('已归档模板不能复制草稿', 409)
const existingDraft = await prisma.performanceSurveyTemplateVersion.findFirst({
where: { templateId: source.templateId, status: 'draft' },
orderBy: { version: 'desc' },
})
if (existingDraft) return existingDraft
const latest = await prisma.performanceSurveyTemplateVersion.findFirst({
where: { templateId: source.templateId },
orderBy: { version: 'desc' },
select: { version: true },
})
if (source.template.archivedAt) throw new PerformanceSurveyPositionConfigError('已归档模板不能复制', 409)
const sourceDefinition = readTemplateDefinition(source.definition)
const sourceName = clean(sourceDefinition.name) || source.template.name
const copyName = `${sourceName}(副本)`
const copyCode = `template_copy_${randomUUID().replace(/-/g, '')}`
const copiedDefinition: PerformanceSurveyTemplateShape = {
...sourceDefinition,
code: copyCode,
name: copyName,
shortName: copyName,
}
return prisma.$transaction(async (tx) => {
const created = await tx.performanceSurveyTemplateVersion.create({
const template = await tx.performanceSurveyTemplate.create({
data: {
templateId: source.templateId,
version: Number(latest?.version || 0) + 1,
code: copyCode,
name: copyName,
description: copiedDefinition.description,
surveyType: copiedDefinition.surveyType,
weight: copiedDefinition.weight,
questions: copiedDefinition.questions as any,
scoreBands: copiedDefinition.scoreBands as any,
isActive: true,
},
})
const version = await tx.performanceSurveyTemplateVersion.create({
data: {
templateId: template.id,
version: 1,
status: 'draft',
definition: source.definition as any,
definition: copiedDefinition as any,
createdByUserId: input.actorUserId,
createdByName: input.actorName,
},
})
await writePerformanceSurveyConfigAuditLog(tx, {
action: 'template.version_cloned',
action: 'template.copied',
targetType: 'template',
targetId: source.templateId,
targetId: template.id,
actorUserId: input.actorUserId,
actorName: input.actorName,
reason: input.reason,
beforeSnapshot: source,
afterSnapshot: created,
metadata: { sourceVersionId: source.id },
afterSnapshot: { template, version },
metadata: {
sourceTemplateId: source.templateId,
sourceVersionId: source.id,
},
})
return created
return {
id: version.id,
templateId: template.id,
name: copyName,
}
})
}
export async function publishPerformanceSurveyTemplateVersion(input: {
versionId: string
effectiveMonth: string
effectiveMonth?: string | null
actorUserId: string
actorName: string
reason?: string | null
}) {
const effectiveMonth = normalizePerformanceSurveyAssessmentMonth(input.effectiveMonth)
if (!effectiveMonth) throw new PerformanceSurveyPositionConfigError('生效月份格式无效')
|| getCurrentAssessmentMonth()
return prisma.$transaction(async (tx) => {
await lockPerformanceSurveyAdminScope(
tx,

View File

@ -28,7 +28,7 @@ import {
copyPerformanceSurveyPositionGroup,
createPerformanceSurveyManualPositionAssignments,
createPerformanceSurveyPositionGroup,
createPerformanceSurveyTemplateDraftFromVersion,
copyPerformanceSurveyTemplateVersion,
getAvailablePerformanceSurveyPositionTemplates,
getManualReviewerStages,
getPerformanceSurveyPositionAdminConfig,
@ -703,7 +703,7 @@ async function assertAssignmentStageOpen(survey: any, assignment: any) {
}
return
}
if (!isVisualCaseTemplate(survey.templateCode)) return
if (!isVisualCaseTemplate(survey.templateSnapshot || survey.templateCode)) return
if (isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot))) return
if (!(await hasCompletedVisualSelfAssessment(survey.id, assignment.targetId))) {
throw new PerformanceSurveyApiError('被考核人完成自评后才会开放主管与元泽评分', 409)
@ -715,7 +715,7 @@ async function canSubmitCurrentVisualStage(survey: any, assignments: any[]) {
if (configuredAssignments.length > 0) {
return configuredAssignments.some((assignment) => assignment.releasedAt && assignment.status !== 'submitted')
}
if (!isVisualCaseTemplate(survey.templateCode)) return true
if (!isVisualCaseTemplate(survey.templateSnapshot || survey.templateCode)) return true
if (assignments.some((assignment) => isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot)))) return true
const targetIds = Array.from(new Set(assignments.map((assignment) => clean(assignment.targetId)).filter(Boolean))) as string[]
if (targetIds.length === 0) return false
@ -889,14 +889,38 @@ async function ensureReadOnlySystemAdministrator(session: Session | null) {
const user = await prisma.user.findUnique({
where: { id: sessionUserId },
select: {
role: true,
isSuperAdmin: true,
dingtalkUserId: true,
email: true,
},
})
if (!user) {
throw new PerformanceSurveyApiError('登录用户不存在', 401)
}
if (!user.isSuperAdmin && user.role !== 'ADMIN' && user.role !== 'SYSTEM_ADMIN') {
const identityWhere: any[] = []
if (clean(user.dingtalkUserId)) {
identityWhere.push({ dingtalkUserId: clean(user.dingtalkUserId) })
}
if (clean(user.email)) {
identityWhere.push({
dingEmail: {
equals: clean(user.email),
mode: 'insensitive',
},
})
}
const employee = identityWhere.length > 0
? await prisma.performanceSurveyEmployeeProfile.findFirst({
where: { OR: identityWhere },
select: {
levelType: true,
positionLevel: true,
isLeadership: true,
},
})
: null
if (!isLeadershipProfile(employee)) {
throw new PerformanceSurveyApiError('需要系统管理员权限', 403)
}
}
@ -944,12 +968,10 @@ function getPersonalSurveyTargets<T extends {
targets: T[],
) {
const assignedTargetIds = new Set((survey.assignments || []).map((assignment) => assignment.targetId))
const isCreator = survey.creatorUserId === ctx.user.id
return targets.filter((target) => (
assignedTargetIds.has(target.id)
|| Boolean(ctx.employee?.id && target.employeeProfileId === ctx.employee.id)
|| (isCreator && target.targetType !== 'employee')
))
}
@ -1064,9 +1086,11 @@ export async function getPerformanceSurveyContext(session: Session | null): Prom
displayName: getDisplayName(user),
})
const isSystemAdmin = Boolean(user.isSuperAdmin || user.role === 'ADMIN' || user.role === 'SYSTEM_ADMIN')
const isLeadership = isLeadershipProfile(employee)
const isDepartmentAdmin = Boolean(isSystemAdmin || user.role === 'DEPT_ADMIN' || isLeadership)
// 考核调研与素材库使用独立人员权限。这里只按考核人员档案判定,
// 不继承素材库 users.role / isSuperAdmin。
const isSystemAdmin = isLeadership
const isDepartmentAdmin = isLeadership
const managedDepartment = clean(user.managedDepartment)
const managedDepartmentParts = parseDepartmentPathParts(managedDepartment)
const scopeDepartment = managedDepartment
@ -1208,7 +1232,7 @@ export async function publishPerformanceSurveyAdminPositionGroup(
try {
return await publishPerformanceSurveyPositionPlan({
groupId,
effectiveMonth: clean(body?.effectiveMonth) || '',
effectiveMonth: clean(body?.effectiveMonth) || null,
draftPlanId: clean(body?.draftPlanId),
draftVersion: body?.draftVersion,
draftUpdatedAt: body?.draftUpdatedAt,
@ -1237,14 +1261,14 @@ export async function savePerformanceSurveyAdminTemplate(session: Session | null
}
}
export async function clonePerformanceSurveyAdminTemplateVersion(
export async function copyPerformanceSurveyAdminTemplateVersion(
session: Session | null,
versionId: string,
) {
const ctx = await getPerformanceSurveyContext(session)
ensureSystemAdministrator(ctx)
try {
return await createPerformanceSurveyTemplateDraftFromVersion({
return await copyPerformanceSurveyTemplateVersion({
versionId,
reason: null,
actorUserId: ctx.user.id,
@ -1265,7 +1289,7 @@ export async function publishPerformanceSurveyAdminTemplateVersion(
try {
return await publishPerformanceSurveyTemplateVersion({
versionId,
effectiveMonth: clean(body?.effectiveMonth) || '',
effectiveMonth: clean(body?.effectiveMonth) || null,
reason: body?.reason,
actorUserId: ctx.user.id,
actorName: getDisplayName(ctx.user),
@ -2776,28 +2800,26 @@ export async function createPerformanceSurvey(session: Session | null, body: any
const assessmentMonthInput = normalizePerformanceSurveyAssessmentMonth(body?.assessmentMonth)
if (!assessmentMonthInput) throw new PerformanceSurveyApiError('请选择有效的考核月份')
const positionGroupCode = clean(body?.positionGroupCode)
if (!positionGroupCode) throw new PerformanceSurveyApiError('请选择考核岗位组')
let positionGroup
if (!ctx.employee) throw new PerformanceSurveyApiError('当前账户尚未匹配人员档案', 409)
let creatorMembership
try {
positionGroup = await getPerformanceSurveyPositionGroupByCode(positionGroupCode, assessmentMonthInput)
creatorMembership = await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, assessmentMonthInput)
} catch (error) {
return rethrowPositionConfigError(error)
}
if (creatorMembership.status !== 'matched' || !creatorMembership.group) {
throw new PerformanceSurveyApiError(
creatorMembership.status === 'conflict'
? '当前被考核人命中多个考核岗位组,请联系管理员处理'
: '当前被考核人尚未配置考核岗位组',
409,
)
}
const positionGroup = creatorMembership.group
if (positionGroup.status !== 'ready') throw new PerformanceSurveyApiError('该岗位组尚未完成配置,不能发起调研', 409)
if (!ctx.isSystemAdmin && !ctx.isLeadership && !ctx.isDepartmentAdmin) {
if (!ctx.employee) throw new PerformanceSurveyApiError('当前账户尚未匹配人员档案', 409)
const creatorMembership = await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, assessmentMonthInput)
if (creatorMembership.status !== 'matched') {
throw new PerformanceSurveyApiError(
creatorMembership.status === 'conflict' ? '当前账户命中多个考核岗位组,请联系管理员处理' : '当前账户尚未配置考核岗位组',
409,
)
}
if (creatorMembership.group?.code !== positionGroup.code) {
throw new PerformanceSurveyApiError('普通员工只能发起本人岗位组的考核调研', 403)
}
const legacyRequestedPositionGroupCode = clean(body?.positionGroupCode)
if (legacyRequestedPositionGroupCode && legacyRequestedPositionGroupCode !== positionGroup.code) {
throw new PerformanceSurveyApiError('考核岗位已根据被考核人的人员分配自动关联,请刷新页面后重试', 409)
}
const templateVersionId = clean(body?.templateVersionId)
@ -3042,12 +3064,14 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
throw new PerformanceSurveyApiError('调研不存在或无权限', 404)
}
const visibleTargets = getPersonalSurveyTargets(ctx, survey, survey.targets)
const isCreator = survey.creatorUserId === ctx.user.id
const visibleTargets = isCreator
? survey.targets
: getPersonalSurveyTargets(ctx, survey, survey.targets)
const isAssigned = survey.assignments.length > 0
const isTarget = Boolean(
ctx.employee && visibleTargets.some((target) => target.employeeProfileId === ctx.employee?.id),
)
const isCreator = survey.creatorUserId === ctx.user.id
const canManage = isCreator
if (visibleTargets.length === 0 || (!isCreator && !isAssigned && !isTarget)) {
@ -3077,6 +3101,8 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
surveyKind: survey.surveyKind,
templateCode: survey.templateCode,
template: survey.templateSnapshot,
positionGroupCode: survey.positionGroupCode,
positionGroup: survey.positionGroupSnapshot,
status: survey.status,
creatorName: survey.creatorName,
departmentName: survey.departmentName,
@ -3099,7 +3125,9 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
targets: visibleTargets.map((target) => {
const currentAssignment = currentAssignments.get(target.id)
const response = currentAssignment?.response
const isVisualTemplate = isVisualCaseTemplate(survey.templateCode)
const isVisualTemplate = isVisualCaseTemplate(
(survey.templateSnapshot as PerformanceSurveyTemplateShape) || survey.templateCode,
)
const requiredWorkItemCount = isVisualTemplate
? Math.max(1, Number((survey.templateSnapshot as PerformanceSurveyTemplateShape).requiredWorkItemCount || 3))
: 0
@ -3183,7 +3211,10 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
assignments: canManage
? target.assignments.map((assignment) => ({
id: assignment.id,
evaluatorEmployeeId: assignment.evaluatorEmployeeId,
evaluatorName: assignment.evaluatorName,
evaluatorSnapshot: assignment.evaluatorSnapshot,
reviewerSource: assignment.reviewerSource,
status: assignment.status,
submittedAt: assignment.submittedAt,
}))

View File

@ -15,6 +15,7 @@
"performance-survey:test-data:audit": "tsx scripts/performance-survey-test-data.ts --mode=audit",
"performance-survey:test-data:clear": "tsx scripts/performance-survey-test-data.ts --mode=clear",
"performance-survey:position-config:seed": "tsx scripts/seed-performance-survey-position-config.ts",
"performance-survey:templates:collapse": "node scripts/collapse-performance-survey-template-versions.mjs",
"history:scan": "tsx scripts/scan-history-materials.ts",
"history:import": "tsx scripts/import-history-materials.ts",
"prisma:generate": "prisma generate",

View File

@ -0,0 +1,186 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const applyChanges = process.argv.includes('--apply')
const actorUserId = 'system-template-consolidation'
const actorName = '系统模板归并'
function chooseRetainedVersion(versions) {
const byNewest = [...versions].sort((left, right) => (
Number(right.version || 0) - Number(left.version || 0)
|| new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
))
return byNewest.find((version) => version.status === 'published')
|| byNewest.find((version) => version.status === 'draft')
|| byNewest[0]
|| null
}
function readDefinition(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
async function buildPlan() {
const templates = await prisma.performanceSurveyTemplate.findMany({
include: {
versions: {
orderBy: [{ version: 'desc' }, { updatedAt: 'desc' }],
},
},
orderBy: { createdAt: 'asc' },
})
return templates.map((template) => {
const retained = chooseRetainedVersion(template.versions)
const removed = retained
? template.versions.filter((version) => version.id !== retained.id)
: []
return {
template,
retained,
removed,
}
})
}
async function applyPlan(plan) {
return prisma.$transaction(async (tx) => {
const summary = {
templates: 0,
retainedVersions: 0,
removedVersions: 0,
reassignedPlanItems: 0,
deduplicatedPlanItems: 0,
}
for (const item of plan) {
const { template, retained, removed } = item
if (!retained) continue
summary.templates += 1
summary.retainedVersions += 1
const allVersionIds = template.versions.map((version) => version.id)
const planItems = await tx.performanceSurveyPositionPlanItem.findMany({
where: { templateVersionId: { in: allVersionIds } },
orderBy: [{ positionPlanId: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
const itemsByPlan = new Map()
for (const planItem of planItems) {
const current = itemsByPlan.get(planItem.positionPlanId) || []
current.push(planItem)
itemsByPlan.set(planItem.positionPlanId, current)
}
for (const groupedItems of itemsByPlan.values()) {
const retainedItem = groupedItems.find((planItem) => planItem.templateVersionId === retained.id)
const primary = retainedItem || groupedItems[0]
const redundantIds = groupedItems
.filter((planItem) => planItem.id !== primary.id)
.map((planItem) => planItem.id)
if (redundantIds.length > 0) {
await tx.performanceSurveyPositionPlanItem.deleteMany({
where: { id: { in: redundantIds } },
})
summary.deduplicatedPlanItems += redundantIds.length
}
const nextSortOrder = Math.min(...groupedItems.map((planItem) => planItem.sortOrder))
const nextEnabled = groupedItems.some((planItem) => planItem.isEnabled)
if (
primary.templateVersionId !== retained.id
|| primary.sortOrder !== nextSortOrder
|| primary.isEnabled !== nextEnabled
) {
await tx.performanceSurveyPositionPlanItem.update({
where: { id: primary.id },
data: {
templateVersionId: retained.id,
sortOrder: nextSortOrder,
isEnabled: nextEnabled,
},
})
summary.reassignedPlanItems += 1
}
}
if (removed.length > 0) {
await tx.performanceSurveyTemplateVersion.deleteMany({
where: { id: { in: removed.map((version) => version.id) } },
})
summary.removedVersions += removed.length
}
if (retained.version !== 1) {
await tx.performanceSurveyTemplateVersion.update({
where: { id: retained.id },
data: { version: 1 },
})
}
const definition = readDefinition(retained.definition)
await tx.performanceSurveyTemplate.update({
where: { id: template.id },
data: {
name: String(definition.name || template.name),
description: String(definition.description || ''),
surveyType: String(definition.surveyType || template.surveyType),
weight: Number(definition.weight || template.weight || 1),
questions: Array.isArray(definition.questions) ? definition.questions : [],
scoreBands: Array.isArray(definition.scoreBands) ? definition.scoreBands : [],
},
})
await tx.performanceSurveyConfigAuditLog.create({
data: {
action: 'template.versions_collapsed',
targetType: 'template',
targetId: template.id,
actorUserId,
actorName,
reason: '模板改为单配置模式',
beforeSnapshot: {
versionIds: template.versions.map((version) => version.id),
versions: template.versions.map((version) => ({
id: version.id,
version: version.version,
status: version.status,
})),
},
afterSnapshot: {
retainedVersionId: retained.id,
status: retained.status,
},
metadata: {
removedVersionIds: removed.map((version) => version.id),
},
},
})
}
return summary
}, { maxWait: 10_000, timeout: 60_000 })
}
try {
const plan = await buildPlan()
const preview = plan.map(({ template, retained, removed }) => ({
templateId: template.id,
templateCode: template.code,
templateName: template.name,
retained: retained
? { id: retained.id, version: retained.version, status: retained.status }
: null,
removed: removed.map((version) => ({
id: version.id,
version: version.version,
status: version.status,
})),
}))
if (!applyChanges) {
console.log(JSON.stringify({ mode: 'preview', templates: preview }, null, 2))
} else {
const summary = await applyPlan(plan)
console.log(JSON.stringify({ mode: 'applied', summary }, null, 2))
}
} finally {
await prisma.$disconnect()
}