diff --git a/app/api/performance-survey/[...path]/route.ts b/app/api/performance-survey/[...path]/route.ts index 6932454..6b1d78b 100644 --- a/app/api/performance-survey/[...path]/route.ts +++ b/app/api/performance-survey/[...path]/route.ts @@ -37,6 +37,16 @@ import { savePerformanceSurveyAdminTemplate, clonePerformanceSurveyAdminTemplateVersion, publishPerformanceSurveyAdminTemplateVersion, + archivePerformanceSurveyAdminPositionAssignment, + copyPerformanceSurveyAdminPositionGroup, + createPerformanceSurveyAdminPositionAssignments, + createPerformanceSurveyAdminPositionGroup, + listPerformanceSurveyAdminConfigAuditLogs, + listPerformanceSurveyAdminPositionAssignments, + previewPerformanceSurveyAdminPositionGroup, + removePerformanceSurveyAdminPositionGroup, + removePerformanceSurveyAdminTemplate, + updatePerformanceSurveyAdminPositionAssignment, } from '@/lib/performance-survey-service' export const runtime = 'nodejs' @@ -165,6 +175,24 @@ export async function GET(request: Request, context: RouteContext) { return ok(await getPerformanceSurveyNotificationStatus(session)) } + if ( + path[0] === 'admin' + && path[1] === 'position-groups' + && path[2] + && path[3] === 'preview' + && path.length === 4 + ) { + return ok(await previewPerformanceSurveyAdminPositionGroup(session, path[2], url.searchParams)) + } + + if (path[0] === 'admin' && path[1] === 'position-assignments' && path.length === 2) { + return ok(await listPerformanceSurveyAdminPositionAssignments(session, url.searchParams)) + } + + if (path[0] === 'admin' && path[1] === 'config-audit-logs' && path.length === 2) { + return ok(await listPerformanceSurveyAdminConfigAuditLogs(session, url.searchParams)) + } + if (path[0] === 'admin' && path[1] === 'config') { return ok(await getPerformanceSurveyAdminConfiguration(session, url.searchParams)) } @@ -224,10 +252,34 @@ export async function POST(request: Request, context: RouteContext) { return ok(await syncPerformanceSurveyEmployeeProfiles(session, body)) } - if (path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path[3] === 'publish') { + if (path[0] === 'admin' && path[1] === 'position-groups' && path.length === 2) { + return ok(await createPerformanceSurveyAdminPositionGroup(session, body), { status: 201 }) + } + + if ( + path[0] === 'admin' + && path[1] === 'position-groups' + && path[2] + && path[3] === 'copy' + && path.length === 4 + ) { + return ok(await copyPerformanceSurveyAdminPositionGroup(session, path[2], body), { status: 201 }) + } + + if ( + path[0] === 'admin' + && path[1] === 'position-groups' + && path[2] + && path[3] === 'publish' + && path.length === 4 + ) { return ok(await publishPerformanceSurveyAdminPositionGroup(session, path[2], body)) } + if (path[0] === 'admin' && path[1] === 'position-assignments' && path.length === 2) { + return ok(await createPerformanceSurveyAdminPositionAssignments(session, body), { status: 201 }) + } + if (path[0] === 'admin' && path[1] === 'templates' && path.length === 2) { return ok(await savePerformanceSurveyAdminTemplate(session, body), { status: 201 }) } @@ -272,6 +324,34 @@ export async function PATCH(request: Request, context: RouteContext) { return ok(await savePerformanceSurveyAdminTemplate(session, { ...body, versionId: path[2] })) } + if (path[0] === 'admin' && path[1] === 'position-assignments' && path[2] && path.length === 3) { + return ok(await updatePerformanceSurveyAdminPositionAssignment(session, path[2], body)) + } + + return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 }) + } catch (error) { + return errorResponse(error) + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + const session = await getSession(request) + const path = getPath(context) + const body = await request.json().catch(() => ({})) + + if (path[0] === 'admin' && path[1] === 'position-groups' && path[2] && path.length === 3) { + return ok(await removePerformanceSurveyAdminPositionGroup(session, path[2], body)) + } + + if (path[0] === 'admin' && path[1] === 'templates' && path[2] && path.length === 3) { + return ok(await removePerformanceSurveyAdminTemplate(session, path[2], body)) + } + + if (path[0] === 'admin' && path[1] === 'position-assignments' && path[2] && path.length === 3) { + return ok(await archivePerformanceSurveyAdminPositionAssignment(session, path[2], body)) + } + return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 }) } catch (error) { return errorResponse(error) diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx index 80ff5bb..1e84e84 100644 --- a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx +++ b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx @@ -3,6 +3,30 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig' +const templateDefinition = { + code: 'visual_review', + name: '视觉案例评分', + shortName: '视觉评分', + description: '视觉案例评分', + surveyType: 'visual_case', + weight: 1, + maxTotal: 120, + questionMaxScore: 20, + requiredWorkItemCount: 3, + workItemImageRequired: true, + questions: [{ id: 'quality', title: '作品质量', maxScore: 20 }], + scoreBands: [], + workflow: { + stages: [{ + key: 'review', + label: '终评', + order: 0, + reviewers: [{ source: 'direct_manager', label: '直属主管' }], + }], + scoring: { mode: 'final_reviewer', stageKey: 'review', reviewerSource: 'direct_manager' }, + }, +} as const + const config = { month: '2026-07', groups: [{ @@ -27,6 +51,17 @@ const config = { templateVersionIds: ['version-1'], }, draftPlan: null, + }, { + id: 'group-2', + code: 'ai_designer', + name: 'AI 技术设计师', + sortOrder: 20, + isActive: true, + status: 'pending', + memberCount: 2, + rules: [], + effectivePlan: null, + draftPlan: null, }], templates: [{ id: 'template-1', @@ -40,28 +75,26 @@ const config = { version: 1, status: 'published', effectiveMonth: '2026-07', + definition: templateDefinition, + }], + }, { + id: 'template-2', + code: 'course_share', + name: '课程分享', + description: '课程分享', + surveyType: 'item_course', + isActive: true, + versions: [{ + id: 'version-2', + version: 1, + status: 'published', + effectiveMonth: '2026-07', definition: { - code: 'visual_review', - name: '视觉案例评分', - shortName: '视觉评分', - description: '视觉案例评分', - surveyType: 'visual_case', - weight: 1, - maxTotal: 120, - questionMaxScore: 20, - requiredWorkItemCount: 3, - workItemImageRequired: true, - questions: [{ id: 'quality', title: '作品质量', maxScore: 20 }], - scoreBands: [], - workflow: { - stages: [{ - key: 'review', - label: '终评', - order: 0, - reviewers: [{ source: 'direct_manager', label: '直属主管' }], - }], - scoring: { mode: 'final_reviewer', stageKey: 'review', reviewerSource: 'direct_manager' }, - }, + ...templateDefinition, + code: 'course_share', + name: '课程分享', + shortName: '课程分享', + surveyType: 'item_course', }, }], }], @@ -69,42 +102,161 @@ const config = { unmatched: [{ id: 'employee-1', displayName: '待配置人员', departmentPath: '集团/品牌中心', positionTitle: '设计师' }], conflicts: [], }, -} as const +} as any + +function createRequest() { + return jest.fn(async (path: string, options?: RequestInit) => { + if (path.startsWith('/admin/config?')) return config + if (path.startsWith('/admin/config-audit-logs')) return { items: [] } + if (path.startsWith('/admin/position-assignments?')) { + return { + items: [{ + id: 'employee-1', + employeeId: 'employee-1', + displayName: '张三', + departmentName: '品牌中心', + positionTitle: '设计师', + participation: 'unassigned', + }], + total: 1, + page: 1, + pageSize: 200, + } + } + if (path.endsWith('/preview?month=2026-07')) { + return { + memberCount: 6, + conflictCount: 0, + draftPlanId: 'plan-1', + draftVersion: 2, + draftUpdatedAt: '2026-07-23T10:00:00.000Z', + } + } + if (path === '/admin/position-groups' && options?.method === 'POST') return { id: 'group-3' } + if (path.endsWith('/clone')) return { id: 'draft-version-2' } + return {} + }) +} describe('PerformanceSurveyAdminConfig', () => { - test('岗位配置使用岗位列表和右侧编辑区,并展示配置问题', async () => { - const request = jest.fn().mockResolvedValue(config) - render() + beforeEach(() => { + jest.spyOn(window, 'confirm').mockReturnValue(true) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn().mockReturnValue({ matches: false }), + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('概览展示岗位、人员、模板和月度方案状态', async () => { + const request = createRequest() + render() expect(screen.getByText('配置加载中...')).toBeInTheDocument() - await waitFor(() => expect(screen.getByText('品牌设计师')).toBeInTheDocument()) - expect(screen.getByText('人员匹配规则')).toBeInTheDocument() - expect(screen.getByText('考核项与顺序')).toBeInTheDocument() - expect(screen.getByText(/未匹配 1 人/)).toBeInTheDocument() - expect(screen.getByText('待配置人员')).toBeInTheDocument() - expect(request).toHaveBeenCalledWith(expect.stringContaining('/admin/config?month=')) + await waitFor(() => expect(screen.getByRole('heading', { name: '2026-07 配置概览' })).toBeInTheDocument()) + expect(screen.getByRole('rowheader', { name: '岗位' })).toBeInTheDocument() + expect(screen.getByText('未匹配 1 人')).toBeInTheDocument() + expect(request).toHaveBeenCalledWith(expect.stringContaining('/admin/config-audit-logs')) }) - test('模板管理展示版本状态、评分项和评审流程', async () => { - const request = jest.fn().mockResolvedValue(config) - render() + test('岗位编辑产生 dirty 状态并保护岗位切换', async () => { + const request = createRequest() + const onDirtyChange = jest.fn() + render() - await waitFor(() => expect(screen.getAllByText('视觉案例评分').length).toBeGreaterThan(0)) - expect(screen.getByText('评分项')).toBeInTheDocument() - expect(screen.getByText('评审流程')).toBeInTheDocument() - expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument() + await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师')) + fireEvent.change(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } }) + expect(onDirtyChange).toHaveBeenLastCalledWith(true) + + ;(window.confirm as jest.Mock).mockReturnValueOnce(false) + fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ })) + expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌视觉设计师') + + ;(window.confirm as jest.Mock).mockReturnValueOnce(true) + fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ })) + await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('AI 技术设计师')) }) - test('岗位发布失败时直接展示服务端业务错误', async () => { - const request = jest.fn().mockImplementation((path: string) => ( - path.startsWith('/admin/config') - ? Promise.resolve(config) - : Promise.reject(new Error('岗位规则存在重复匹配人员')) - )) + test('新增岗位使用确定的 collection POST 契约', async () => { + const request = createRequest() render() - await waitFor(() => expect(screen.getByText('品牌设计师')).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: '新增岗位' })) + fireEvent.change(screen.getByLabelText('新岗位代码'), { target: { value: 'new_designer' } }) + fireEvent.change(screen.getByLabelText('新岗位名称'), { target: { value: '新设计岗位' } }) + fireEvent.click(screen.getByRole('button', { name: '确认创建' })) + + await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ code: 'new_designer', name: '新设计岗位', effectiveMonth: '2026-07' }), + }))) + }) + + test('人员批量分配提交最终 REST 请求体', async () => { + const request = createRequest() + render() + + await waitFor(() => expect(screen.getByText('张三')).toBeInTheDocument()) + fireEvent.click(screen.getByLabelText('选择 张三')) + fireEvent.change(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } }) + fireEvent.change(screen.getByLabelText('本次调整原因'), { target: { value: '部门异动' } }) + fireEvent.click(screen.getByRole('button', { name: '应用分配' })) + + await waitFor(() => { + const call = request.mock.calls.find(([path, options]) => ( + path === '/admin/position-assignments' && options?.method === 'POST' + )) + expect(call).toBeDefined() + expect(JSON.parse(String(call?.[1]?.body || '{}'))).toEqual({ + employeeIds: ['employee-1'], + effectiveFromMonth: '2026-07', + effectiveToMonth: null, + assignmentType: 'position', + positionGroupId: 'group-1', + reason: '部门异动', + }) + }) + }) + + test('模板发布前要求先保存 dirty 草稿,并提供复制与归档入口', async () => { + const request = createRequest() + render() + await waitFor(() => expect(screen.getByText(/v1 · 已发布/)).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: /发布至/ })) - await waitFor(() => expect(screen.getByText('岗位规则存在重复匹配人员')).toBeInTheDocument()) + expect(await screen.findByText('请先保存模板草稿,再执行发布')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '归档模板' })).toBeInTheDocument() + }) + + test('月度方案可添加排序模板并经过影响确认后发布', async () => { + const request = createRequest() + render() + await waitFor(() => expect(screen.getByRole('heading', { name: '2026-07 月度方案' })).toBeInTheDocument()) + + const courseLabel = screen.getByText('课程分享').closest('label') + expect(courseLabel).not.toBeNull() + fireEvent.click(courseLabel!.querySelector('input')!) + fireEvent.click(screen.getByRole('button', { name: '发布前检查' })) + + await waitFor(() => expect(screen.getByRole('region', { name: '发布影响确认' })).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: '确认发布至 2026-07' })) + + 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', + }), + }))) }) }) diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.tsx index c5ed914..d4fb291 100644 --- a/app/performance-survey/PerformanceSurveyAdminConfig.tsx +++ b/app/performance-survey/PerformanceSurveyAdminConfig.tsx @@ -1,8 +1,14 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + PerformanceSurveyAdminPeople, + type PerformanceSurveyAdminRequest, +} from './PerformanceSurveyAdminPeople' -type RequestFn = (path: string, options?: RequestInit) => Promise +type RequestFn = PerformanceSurveyAdminRequest + +export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates' | 'plans' type RuleDraft = { departmentPathPrefix: string @@ -54,6 +60,7 @@ type TemplateVersion = { version: number status: 'draft' | 'published' | 'archived' effectiveMonth?: string | null + publishedAt?: string | null definition: TemplateDefinition } @@ -99,6 +106,59 @@ type AdminConfig = { } } +type ConfigAudit = { + id: string + action?: string + resourceType?: string + targetType?: string + resourceName?: string + actorName?: string + createdAt?: string + summary?: string +} + +const adminConfigApi = { + configuration(request: RequestFn, month: string) { + return request(`/admin/config?month=${encodeURIComponent(month)}`) + }, + auditLogs(request: RequestFn, month: string) { + return request( + `/admin/config-audit-logs?month=${encodeURIComponent(month)}&pageSize=20`, + ) + }, + createPositionGroup(request: RequestFn, input: { code: string; name: string; effectiveMonth: string }) { + return request<{ id?: string; group?: { id?: string } }>('/admin/position-groups', { + method: 'POST', + body: JSON.stringify(input), + }) + }, + copyPositionGroup( + request: RequestFn, + groupId: string, + input: { code: string; name: string; month: string; reason: string }, + ) { + return request<{ id?: string; group?: { id?: string } }>(`/admin/position-groups/${groupId}/copy`, { + method: 'POST', + body: JSON.stringify(input), + }) + }, + previewPositionGroup(request: RequestFn, groupId: string, month: string) { + return request(`/admin/position-groups/${groupId}/preview?month=${encodeURIComponent(month)}`) + }, + archivePositionGroup(request: RequestFn, groupId: string, reason: string) { + return request(`/admin/position-groups/${groupId}`, { + method: 'DELETE', + body: JSON.stringify({ mode: 'auto', reason }), + }) + }, + deleteOrArchiveTemplate(request: RequestFn, templateId: string, reason: string) { + return request(`/admin/templates/${templateId}`, { + method: 'DELETE', + body: JSON.stringify({ mode: 'auto', reason }), + }) + }, +} + function currentMonth() { const now = new Date() return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` @@ -143,98 +203,337 @@ function reviewerSourceLabel(source: ReviewerSource) { export function PerformanceSurveyAdminConfig({ mode, request, + onDirtyChange, }: { - mode: 'positions' | 'templates' + mode: PerformanceSurveyAdminMode request: RequestFn + onDirtyChange?: (dirty: boolean) => void }) { const [month, setMonth] = useState(currentMonth()) const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) + const [dirty, setDirty] = useState(false) + const requestSequenceRef = useRef(0) - const load = useCallback(async () => { + const setDirtyState = useCallback((next: boolean) => { + setDirty(next) + onDirtyChange?.(next) + }, [onDirtyChange]) + + const load = useCallback(async (targetMonth: string) => { + const sequence = requestSequenceRef.current + 1 + requestSequenceRef.current = sequence setLoading(true) setError(null) try { - setData(await request(`/admin/config?month=${encodeURIComponent(month)}`)) + const next = await adminConfigApi.configuration(request, targetMonth) + if (requestSequenceRef.current !== sequence) return null + setData(next) + return next } catch (loadError) { + if (requestSequenceRef.current !== sequence) return null setError(loadError instanceof Error ? loadError.message : '配置加载失败') + return null } finally { - setLoading(false) + if (requestSequenceRef.current === sequence) setLoading(false) } - }, [month, request]) + }, [request]) useEffect(() => { - void load() - }, [load]) + void load(month) + return () => { + requestSequenceRef.current += 1 + } + }, [load, month]) + + useEffect(() => { + if (!dirty) return + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault() + event.returnValue = '' + } + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [dirty]) + + const confirmDiscard = useCallback((message = '当前有未保存修改,确定放弃吗?') => ( + !dirty || window.confirm(message) + ), [dirty]) + + const changeMonth = (nextMonth: string) => { + if (!nextMonth || nextMonth === month) return + if (!confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return + setDirtyState(false) + setNotice(null) + setMonth(nextMonth) + } + + const changed = useCallback(async (message: string) => { + setDirtyState(false) + setNotice(message) + setError(null) + return load(month) + }, [load, month, setDirtyState]) return ( -
+
-

{mode === 'positions' ? '岗位配置' : '模板管理'}

-

草稿保存后按月份发布,已发布版本保持不变

+

+ {{ + overview: '维护概览', + positions: '岗位管理', + people: '人员分配', + templates: '模板管理', + plans: '月度方案与发布', + }[mode]} +

+

+ {dirty ? '有未保存修改' : '配置按月份查看;已发布版本保持不变'} +

- {notice &&
{notice}
} - {error &&
{error}
} - {loading &&
配置加载中...
} - {!loading && data && mode === 'positions' && ( + {notice &&
{notice}
} + {error && ( +
+ {error} + {!data && } +
+ )} + {loading && !data &&
配置加载中...
} + {data && ( +
+ {mode === 'overview' && ( + + )} + {mode === 'positions' && ( { - setNotice(message) - await load() - }} + onChanged={changed} + onDirtyChange={setDirtyState} + confirmDiscard={confirmDiscard} /> - )} - {!loading && data && mode === 'templates' && ( + )} + {mode === 'people' && ( + { + setNotice(message) + setError(null) + }} + /> + )} + {mode === 'templates' && ( { - setNotice(message) - await load() - }} + onChanged={changed} + onDirtyChange={setDirtyState} + confirmDiscard={confirmDiscard} + dirty={dirty} /> + )} + {mode === 'plans' && ( + + )} +
)}
) } +function AdminOverviewWorkspace({ + data, + month, + request, +}: { + data: AdminConfig + month: string + request: RequestFn +}) { + const [audits, setAudits] = useState([]) + const [auditError, setAuditError] = useState(null) + const [auditLoading, setAuditLoading] = useState(true) + const readyGroups = data.groups.filter((group) => group.status === 'ready').length + const disabledGroups = data.groups.filter((group) => group.status === 'disabled').length + const draftTemplates = data.templates.flatMap((template) => template.versions).filter((version) => version.status === 'draft').length + const publishedTemplates = data.templates.flatMap((template) => template.versions).filter((version) => version.status === 'published').length + + const loadAudits = useCallback(async () => { + setAuditLoading(true) + setAuditError(null) + try { + const payload = await adminConfigApi.auditLogs(request, month) + setAudits(Array.isArray(payload) ? payload : Array.isArray(payload?.items) ? payload.items : []) + } catch (error) { + setAudits([]) + setAuditError(error instanceof Error ? error.message : '配置记录加载失败') + } finally { + setAuditLoading(false) + } + }, [month, request]) + + useEffect(() => { + void loadAudits() + }, [loadAudits]) + + return ( +
+
+

+ {month} 配置概览 +

+

+ 先处理人员匹配问题,再检查模板草稿,最后进入月度方案完成发布。 +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维护项当前状态需要处理
岗位{readyGroups}/{data.groups.length} 个岗位已就绪{disabledGroups > 0 ? `${disabledGroups} 个已归档` : '无归档岗位'}
人员未匹配 {data.issues.unmatched.length} 人 0 ? 'text-[#b42318]' : 'text-[#16794b]'}`}> + {data.issues.conflicts.length > 0 ? `重复匹配 ${data.issues.conflicts.length} 人` : '无重复匹配'} +
模板{publishedTemplates} 个已发布版本{draftTemplates > 0 ? `${draftTemplates} 个草稿待处理` : '无待发布草稿'}
月度方案 + {data.groups.filter((group) => group.effectivePlan?.effectiveMonth === month).length} 个岗位本月已有方案 + + {data.groups.filter((group) => group.draftPlan).length} 个岗位有未发布草稿 +
+
+ + {(data.issues.unmatched.length > 0 || data.issues.conflicts.length > 0) && ( +
+ 发布前请先处理人员分配:未匹配 {data.issues.unmatched.length} 人,重复匹配 {data.issues.conflicts.length} 人。 +
+ )} + +
+
+

最近配置记录

+ +
+ {auditLoading &&
记录加载中...
} + {auditError && ( +
+ 配置记录暂不可用:{auditError} +
+ )} + {!auditLoading && !auditError && ( +
+ + + + + + + + + + + {audits.map((audit) => ( + + + + + + + ))} + {audits.length === 0 && ( + + + + )} + +
时间操作人对象变更
{audit.createdAt ? new Date(audit.createdAt).toLocaleString('zh-CN') : '-'}{audit.actorName || '-'}{audit.resourceName || audit.resourceType || audit.targetType || '-'}{audit.summary || audit.action || '-'}
暂无配置记录
+
+ )} +
+
+ ) +} + function PositionConfigWorkspace({ data, month, request, onError, onChanged, + onDirtyChange, + confirmDiscard, }: { data: AdminConfig month: string request: RequestFn onError: (message: string | null) => void - onChanged: (message: string) => Promise + onChanged: (message: string) => Promise + onDirtyChange: (dirty: boolean) => void + confirmDiscard: (message?: string) => boolean }) { const [selectedId, setSelectedId] = useState(data.groups[0]?.id || '') const selected = data.groups.find((group) => group.id === selectedId) || data.groups[0] const [name, setName] = useState(selected?.name || '') const [sortOrder, setSortOrder] = useState(selected?.sortOrder || 0) - const [isActive, setIsActive] = useState(selected?.isActive ?? true) const [rules, setRules] = useState(selected?.rules.map((rule) => ({ departmentPathPrefix: rule.departmentPathPrefix || '', positionTitle: rule.positionTitle || '', @@ -243,19 +542,24 @@ function PositionConfigWorkspace({ selected?.draftPlan?.templateVersionIds || selected?.effectivePlan?.templateVersionIds || [], ) const [saving, setSaving] = useState(false) + const [creating, setCreating] = useState(false) + const [newCode, setNewCode] = useState('') + const [newName, setNewName] = useState('') const editorRef = useRef(null) useEffect(() => { if (!selected) return setName(selected.name) setSortOrder(selected.sortOrder) - setIsActive(selected.isActive) setRules(selected.rules.map((rule) => ({ departmentPathPrefix: rule.departmentPathPrefix || '', positionTitle: rule.positionTitle || '', }))) setTemplateVersionIds(selected.draftPlan?.templateVersionIds || selected.effectivePlan?.templateVersionIds || []) - }, [selected]) + onDirtyChange(false) + }, [onDirtyChange, selected]) + + const markDirty = () => onDirtyChange(true) const publishedVersions = useMemo(() => data.templates.flatMap((template) => ( template.versions @@ -271,7 +575,7 @@ function PositionConfigWorkspace({ try { await request(`/admin/position-groups/${selected.id}`, { method: 'PATCH', - body: JSON.stringify({ name, sortOrder, isActive, rules, templateVersionIds }), + body: JSON.stringify({ name, sortOrder, rules, templateVersionIds, effectiveMonth: month }), }) await onChanged('岗位方案草稿已保存') } catch (actionError) { @@ -281,25 +585,84 @@ function PositionConfigWorkspace({ } } - const publish = async () => { + const createGroup = async () => { + if (!newCode.trim() || !newName.trim()) { + onError('请填写岗位代码和岗位名称') + return + } setSaving(true) onError(null) try { - await request(`/admin/position-groups/${selected.id}/publish`, { - method: 'POST', - body: JSON.stringify({ effectiveMonth: month }), + const created = await adminConfigApi.createPositionGroup(request, { + code: newCode.trim(), + name: newName.trim(), + effectiveMonth: month, }) - await onChanged(`${selected.name} 已从 ${month} 起生效`) + const next = await onChanged(`岗位“${newName.trim()}”已创建`) + const nextSelectedId = created.id || created.group?.id || next?.groups.find((group) => group.code === newCode.trim())?.id + if (nextSelectedId) setSelectedId(nextSelectedId) + setCreating(false) + setNewCode('') + setNewName('') } catch (actionError) { - onError(actionError instanceof Error ? actionError.message : '岗位方案发布失败') + onError(actionError instanceof Error ? actionError.message : '岗位创建失败') + } finally { + setSaving(false) + } + } + + const copyGroup = async () => { + if (!selected || !confirmDiscard()) return + const copyName = window.prompt('请输入复制后的岗位名称', `${selected.name}(副本)`)?.trim() + if (!copyName) return + const suggestedCode = `${selected.code}_copy_${Date.now().toString().slice(-6)}` + const copyCode = window.prompt('请输入复制后的岗位代码(英文、数字或下划线)', suggestedCode)?.trim() + if (!copyCode) return + const reason = window.prompt('请输入复制原因', `基于“${selected.name}”创建新岗位`)?.trim() + if (!reason) return + setSaving(true) + onError(null) + try { + const copied = await adminConfigApi.copyPositionGroup(request, selected.id, { + code: copyCode, + name: copyName, + month, + reason, + }) + const next = await onChanged(`已复制“${selected.name}”`) + if (copied.id || copied.group?.id) setSelectedId(copied.id || copied.group?.id || '') + else if (next?.groups.length) setSelectedId(next.groups[next.groups.length - 1].id) + } catch (actionError) { + onError(actionError instanceof Error ? actionError.message : '岗位复制失败') + } finally { + setSaving(false) + } + } + + const archiveGroup = async () => { + if (!selected || !confirmDiscard()) return + if (!window.confirm(`确认归档“${selected.name}”吗?归档前请确认相关人员已完成转岗。`)) return + const reason = window.prompt('请输入删除或归档原因')?.trim() + if (!reason) return + setSaving(true) + onError(null) + try { + await adminConfigApi.archivePositionGroup(request, selected.id, reason) + const next = await onChanged(`“${selected.name}”已归档`) + setSelectedId(next?.groups.find((group) => group.status !== 'disabled')?.id || next?.groups[0]?.id || '') + } catch (actionError) { + onError(actionError instanceof Error ? actionError.message : '岗位归档失败') } finally { setSaving(false) } } const selectGroup = (groupId: string) => { + if (groupId === selected?.id) return + if (!confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return + onDirtyChange(false) setSelectedId(groupId) - if (window.matchMedia('(max-width: 767px)').matches) { + if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) { window.requestAnimationFrame(() => editorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })) } } @@ -307,6 +670,36 @@ function PositionConfigWorkspace({ return (
-
正在编辑:{selected.name}
-
- - setSortOrder(Number(value) || 0)} type="number" /> - +
+
正在编辑:{selected.name}
+
+ + {selected.status !== 'disabled' && ( + + )} +
+
+
+ { setName(value); markDirty() }} /> + { setSortOrder(Number(value) || 0); markDirty() }} type="number" />
@@ -342,8 +741,11 @@ function PositionConfigWorkspace({
@@ -353,22 +755,33 @@ function PositionConfigWorkspace({
setRules((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, departmentPathPrefix: event.target.value } : item))} + onChange={(event) => { + setRules((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, departmentPathPrefix: event.target.value } : item)) + markDirty() + }} placeholder="部门路径前缀,例如 品牌中心/品牌创意A部" - className="h-10 rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb]" + aria-label={`规则 ${index + 1} 部门路径前缀`} + className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]" /> setRules((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, positionTitle: event.target.value } : item))} + onChange={(event) => { + setRules((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, positionTitle: event.target.value } : item)) + markDirty() + }} placeholder="职位名称(精确匹配)" - className="h-10 rounded border border-[#e2e2e2] px-3 text-[13px] outline-none focus:border-[#0050cb]" + aria-label={`规则 ${index + 1} 职位名称`} + className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] outline-none focus:border-[#0050cb]" /> @@ -379,7 +792,8 @@ function PositionConfigWorkspace({
-

考核项与顺序

+

当前月度考核项

+

这里只读展示;添加、移除和排序请前往“月度方案”。

{publishedVersions.map(({ template, version }) => { const checked = templateVersionIds.includes(version.id) @@ -388,9 +802,7 @@ function PositionConfigWorkspace({ setTemplateVersionIds((current) => event.target.checked - ? [...current, version.id] - : current.filter((id) => id !== version.id))} + disabled /> {version.definition.shortName || template.name} v{version.version} @@ -400,9 +812,11 @@ function PositionConfigWorkspace({
-
- - +
+ 月度模板顺序和发布请在“月度方案”完成 +
{(data.issues.conflicts.length > 0 || data.issues.unmatched.length > 0) && ( @@ -451,12 +865,18 @@ function TemplateConfigWorkspace({ request, onError, onChanged, + onDirtyChange, + confirmDiscard, + dirty, }: { data: AdminConfig month: string request: RequestFn onError: (message: string | null) => void - onChanged: (message: string) => Promise + onChanged: (message: string) => Promise + onDirtyChange: (dirty: boolean) => void + confirmDiscard: (message?: string) => boolean + dirty: boolean }) { const firstTemplate = data.templates[0] const [selectedTemplateId, setSelectedTemplateId] = useState(firstTemplate?.id || 'new') @@ -475,10 +895,15 @@ function TemplateConfigWorkspace({ || null setVersionId(nextVersion?.id || null) setDefinition(clone(nextVersion?.definition || newTemplateDefinition())) - }, [selectedTemplate]) + onDirtyChange(false) + }, [onDirtyChange, selectedTemplate]) const version = selectedTemplate?.versions.find((item) => item.id === versionId) || null const editable = !version || version.status === 'draft' + const updateDefinition = (next: TemplateDefinition | ((current: TemplateDefinition) => TemplateDefinition)) => { + setDefinition(next) + onDirtyChange(true) + } const save = async () => { setSaving(true) @@ -493,8 +918,11 @@ function TemplateConfigWorkspace({ method: 'POST', body: JSON.stringify({ definition }), }) + const next = await onChanged('模板草稿已保存') + const nextTemplate = next?.templates.find((template) => template.versions.some((item) => item.id === saved.id)) + if (nextTemplate) setSelectedTemplateId(nextTemplate.id) setVersionId(saved.id) - await onChanged('模板草稿已保存') + onDirtyChange(false) } catch (actionError) { onError(actionError instanceof Error ? actionError.message : '模板草稿保存失败') } finally { @@ -503,13 +931,15 @@ function TemplateConfigWorkspace({ } const cloneVersion = async () => { - if (!versionId) return + if (!versionId || !confirmDiscard()) return setSaving(true) onError(null) 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)) + if (nextTemplate) setSelectedTemplateId(nextTemplate.id) setVersionId(draft.id) - await onChanged('已创建新的模板草稿') } catch (actionError) { onError(actionError instanceof Error ? actionError.message : '模板草稿创建失败') } finally { @@ -519,6 +949,11 @@ function TemplateConfigWorkspace({ const publish = async () => { if (!versionId) return + if (dirty) { + onError('请先保存模板草稿,再执行发布') + return + } + if (!window.confirm(`确认将“${definition.name}”发布至 ${month} 吗?已发布版本不可直接修改。`)) return setSaving(true) onError(null) try { @@ -534,9 +969,32 @@ function TemplateConfigWorkspace({ } } + const deleteOrArchiveTemplate = async () => { + if (!selectedTemplate || !confirmDiscard()) return + const onlyDrafts = selectedTemplate.versions.every((item) => item.status === 'draft') + const actionLabel = onlyDrafts ? '删除' : '归档' + if (!window.confirm(`确认${actionLabel}模板“${selectedTemplate.name}”吗?已被历史调研引用的数据不会删除。`)) return + const reason = window.prompt(`请输入${actionLabel}原因`)?.trim() + if (!reason) return + setSaving(true) + onError(null) + try { + await adminConfigApi.deleteOrArchiveTemplate(request, selectedTemplate.id, reason) + const next = await onChanged(`模板“${selectedTemplate.name}”已${actionLabel}`) + setSelectedTemplateId(next?.templates[0]?.id || 'new') + } catch (actionError) { + onError(actionError instanceof Error ? actionError.message : `模板${actionLabel}失败`) + } finally { + setSaving(false) + } + } + const selectTemplate = (templateId: string) => { + if (templateId === selectedTemplateId) return + if (!confirmDiscard('切换模板会放弃当前未保存修改,确定继续吗?')) return + onDirtyChange(false) setSelectedTemplateId(templateId) - if (window.matchMedia('(max-width: 767px)').matches) { + if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) { window.requestAnimationFrame(() => editorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })) } } @@ -573,28 +1031,40 @@ function TemplateConfigWorkspace({ key={item.id} type="button" onClick={() => { + if (item.id === versionId) return + if (!confirmDiscard('切换版本会放弃当前未保存修改,确定继续吗?')) return + onDirtyChange(false) setVersionId(item.id) setDefinition(clone(item.definition)) }} - className={`h-8 border px-3 text-[12px] font-bold ${versionId === item.id ? 'border-[#0050cb] bg-[#f1f4ff] text-[#0050cb]' : 'border-[#e2e2e2] text-[#5f5e5e]'}`} + 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]'}`} > - v{item.version} · {item.status === 'draft' ? '草稿' : item.status === 'published' ? '已发布' : '已归档'} + v{item.version} · {item.status === 'draft' ? '草稿' : item.status === 'published' ? '已发布' : '已归档'} + {item.effectiveMonth && {item.effectiveMonth} 起} ))} +
)}
- setDefinition((current) => ({ ...current, code: value }))} disabled={Boolean(selectedTemplate)} /> - setDefinition((current) => ({ ...current, name: value }))} disabled={!editable} /> - setDefinition((current) => ({ ...current, shortName: value }))} disabled={!editable} /> + updateDefinition((current) => ({ ...current, code: value }))} disabled={Boolean(selectedTemplate)} /> + updateDefinition((current) => ({ ...current, name: value }))} disabled={!editable} /> + updateDefinition((current) => ({ ...current, shortName: value }))} disabled={!editable} />