Compare commits
14 Commits
main
...
hotfix/per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3329cc8948 | ||
|
|
3618d61a97 | ||
|
|
1859d03760 | ||
|
|
89878b7880 | ||
|
|
363aa2b3b6 | ||
|
|
0e80c15274 | ||
|
|
2466bb2b02 | ||
|
|
64f3fb211e | ||
|
|
93cb33aecb | ||
|
|
8d33f10368 | ||
|
|
26e111979a | ||
|
|
70421a4dc5 | ||
|
|
c45d77c48c | ||
|
|
60d249e876 |
@ -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 && \
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,6 +527,7 @@ export function PerformanceSurveyAdminConfig({
|
||||
</h2>
|
||||
{dirty && <StatusDot label="未保存" tone="warning" />}
|
||||
</div>
|
||||
{mode === 'overview' && (
|
||||
<label className="flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
|
||||
查看月份
|
||||
<input
|
||||
@ -546,6 +537,7 @@ export function PerformanceSurveyAdminConfig({
|
||||
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>
|
||||
{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,6 +1196,18 @@ function PositionConfigWorkspace({
|
||||
/>
|
||||
{dirty ? '未保存' : selected.draftPlan ? '待发布' : selected.effectivePlan ? '已生效' : '待配置'}
|
||||
</span>
|
||||
{!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>
|
||||
@ -1214,6 +1220,8 @@ function PositionConfigWorkspace({
|
||||
>
|
||||
发布岗位设置
|
||||
</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]
|
||||
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()}
|
||||
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 && (
|
||||
<>
|
||||
{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' ? '保存中…' : '保存草稿'}
|
||||
{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]"
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,13 +3407,22 @@ function CreateSurveyForm({
|
||||
const submitTitle = title.trim()
|
||||
|| targetItemsToSubmit[0]?.name
|
||||
|| `${formatMonthLabel(selectedAssessmentMonth)} · ${getTemplateDisplayName(selectedTemplateInfo)}`
|
||||
const created = await apiRequest<any>('/surveys', {
|
||||
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,
|
||||
positionGroupCode: selectedPositionGroupCode,
|
||||
templateCode: selectedTemplateCode,
|
||||
templateVersionId: selectedTemplateInfo?.templateVersionId || null,
|
||||
assessmentMonth: selectedAssessmentMonth,
|
||||
@ -3282,11 +3434,13 @@ function CreateSurveyForm({
|
||||
}),
|
||||
})
|
||||
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,6 +3723,7 @@ function CreateSurveyForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!editingDraft && (
|
||||
<EmployeePicker
|
||||
title="添加同岗位被考核人(可选)"
|
||||
purpose="target"
|
||||
@ -3490,6 +3733,7 @@ function CreateSurveyForm({
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
{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={index}
|
||||
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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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('看板考核项固定每页最多四项并钳制越界页码', () => {
|
||||
|
||||
@ -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: {
|
||||
expect(prisma.performanceSurvey.count).not.toHaveBeenCalled()
|
||||
expect(transactionClient.performanceSurvey.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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',
|
||||
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')
|
||||
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 () => {
|
||||
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({
|
||||
|
||||
@ -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)
|
||||
} else {
|
||||
const latest = await tx.performanceSurveyTemplateVersion.findFirst({
|
||||
where: { templateId: template.id },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true },
|
||||
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 {
|
||||
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,
|
||||
|
||||
@ -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 (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') {
|
||||
if (creatorMembership.status !== 'matched' || !creatorMembership.group) {
|
||||
throw new PerformanceSurveyApiError(
|
||||
creatorMembership.status === 'conflict' ? '当前账户命中多个考核岗位组,请联系管理员处理' : '当前账户尚未配置考核岗位组',
|
||||
creatorMembership.status === 'conflict'
|
||||
? '当前被考核人命中多个考核岗位组,请联系管理员处理'
|
||||
: '当前被考核人尚未配置考核岗位组',
|
||||
409,
|
||||
)
|
||||
}
|
||||
if (creatorMembership.group?.code !== positionGroup.code) {
|
||||
throw new PerformanceSurveyApiError('普通员工只能发起本人岗位组的考核调研', 403)
|
||||
}
|
||||
const positionGroup = creatorMembership.group
|
||||
if (positionGroup.status !== 'ready') throw new PerformanceSurveyApiError('该岗位组尚未完成配置,不能发起调研', 409)
|
||||
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,
|
||||
}))
|
||||
|
||||
@ -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",
|
||||
|
||||
186
scripts/collapse-performance-survey-template-versions.mjs
Normal file
186
scripts/collapse-performance-survey-template-versions.mjs
Normal 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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user