diff --git a/app/api/organization/performance-salary/positions/route.test.ts b/app/api/organization/performance-salary/positions/route.test.ts
index 64e46bbc..ab7a0814 100644
--- a/app/api/organization/performance-salary/positions/route.test.ts
+++ b/app/api/organization/performance-salary/positions/route.test.ts
@@ -46,3 +46,15 @@ test('跨站写拒绝;错误权重不进事务', async () => {
expect((await POST(req('POST', { name: '', dimensions: [] }))).status).toBe(400)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
+test.each(['same-origin', 'cross-site'])('代理后岗位新增、编辑、删除按%s来源校验,拒绝时不写库', async site => {
+ user.mockResolvedValue({ id: 'admin', isActive: true, status: 'active', archivedAt: null, isSuperAdmin: true })
+ const proxied = (method: string) => new NextRequest('https://0.0.0.0:3000/api/organization/performance-salary/positions', {
+ method, headers: { origin: 'https://sc.jiansun.vip', host: 'sc.jiansun.vip', 'x-forwarded-proto': 'https', 'sec-fetch-site': site }, body: '{}',
+ })
+ const responses = await Promise.all([POST(proxied('POST')), PUT(proxied('PUT'), { params: { id: 'id' } }), DELETE(proxied('DELETE'), { params: { id: 'id' } })])
+ for (const response of responses) {
+ expect(response.status).toBe(site === 'same-origin' ? 400 : 403)
+ expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
+ }
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+})
diff --git a/app/api/organization/personnel-salary/route.test.ts b/app/api/organization/personnel-salary/route.test.ts
index 636ccd4a..0afe3857 100644
--- a/app/api/organization/personnel-salary/route.test.ts
+++ b/app/api/organization/personnel-salary/route.test.ts
@@ -48,3 +48,16 @@ test('跨站写入拒绝,合法超管的非法金额返回字段错误且不
expect((await invalid.json()).fields.baseSalary).toBeTruthy()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
+test.each(['same-origin', 'cross-site'])('代理后人员新增、编辑、删除按%s来源校验,拒绝时不写库', async site => {
+ user.mockResolvedValue({ id: 'admin', isActive: true, status: 'active', archivedAt: null, isSuperAdmin: true })
+ const proxied = (method: string) => new NextRequest('https://0.0.0.0:3000/api/organization/personnel-salary', {
+ method, headers: { origin: 'https://sc.jiansun.vip', host: 'sc.jiansun.vip', 'x-forwarded-proto': 'https', 'sec-fetch-site': site }, body: '{}',
+ })
+ const responses = await Promise.all([POST(proxied('POST')), PUT(proxied('PUT'), { params: { id: 'id' } }), DELETE(proxied('DELETE'), { params: { id: 'id' } })])
+ for (const response of responses) {
+ // Trusted requests reach body validation; cross-site requests stop before it.
+ expect(response.status).toBe(site === 'same-origin' ? 400 : 403)
+ expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
+ }
+ expect(prisma.$transaction).not.toHaveBeenCalled()
+})
diff --git a/components/department-dashboard/PersonnelSalaryPanel.test.tsx b/components/department-dashboard/PersonnelSalaryPanel.test.tsx
index dfeb0a7c..3bc91eb1 100644
--- a/components/department-dashboard/PersonnelSalaryPanel.test.tsx
+++ b/components/department-dashboard/PersonnelSalaryPanel.test.tsx
@@ -45,6 +45,20 @@ test('关闭人员弹窗后焦点回到仍存在的触发按钮', async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(edit).toHaveFocus()
})
+test('保存刷新移除原编辑按钮后,焦点回到人员区域标题', async () => {
+ render()
+ const edit = (await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0]
+ edit.focus()
+ fireEvent.click(edit)
+ let finishRefresh!: (value: ReturnType) => void
+ ;(fetch as jest.Mock).mockResolvedValueOnce(response({ ...row, revision: 2 }))
+ .mockImplementationOnce(() => new Promise(resolve => { finishRefresh = resolve }))
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
+ await waitFor(() => expect(edit).not.toBeInTheDocument())
+ finishRefresh(response({ canEdit: true, records: [{ ...row, revision: 2 }] }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(screen.getByRole('heading', { name: '人员薪资基础部分' })).toHaveFocus()
+})
test('并发冲突保留草稿,核对最新版本后才允许再次保存', async () => {
render()
fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])
@@ -60,6 +74,23 @@ test('并发冲突保留草稿,核对最新版本后才允许再次保存', as
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect((fetch as jest.Mock).mock.calls.find(call => call[1]?.body?.includes('"revision":2'))).toBeTruthy()
})
+test('来源拒绝保留人员草稿,恢复后可原样再次保存并显示成功反馈', async () => {
+ render()
+ fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])
+ fireEvent.change(screen.getByLabelText('基本工资 *'), { target: { value: '12.34' } })
+ ;(fetch as jest.Mock).mockResolvedValueOnce(response({ error: '请求来源不受信任,请在本站重试。' }, 403))
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('请求来源不受信任,请在本站重试。')
+ expect(screen.getByLabelText('基本工资 *')).toHaveValue('12.34')
+ expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
+ ;(fetch as jest.Mock).mockResolvedValueOnce(response({ ...row, baseSalary: '12.34', revision: 2 })).mockResolvedValueOnce(response({ canEdit: true, records: [{ ...row, baseSalary: '12.34', revision: 2 }] }))
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(screen.getByRole('status')).toHaveTextContent('已保存')
+ const writes = (fetch as jest.Mock).mock.calls.filter(call => call[1]?.method === 'PUT')
+ expect(writes).toHaveLength(2)
+ expect(writes[1][1].body).toBe(writes[0][1].body)
+})
test('表单金额错误阻止保存;删除需二次确认', async () => {
render()
fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])
diff --git a/components/department-dashboard/PersonnelSalaryPanel.tsx b/components/department-dashboard/PersonnelSalaryPanel.tsx
index 50b929ef..d41f160d 100644
--- a/components/department-dashboard/PersonnelSalaryPanel.tsx
+++ b/components/department-dashboard/PersonnelSalaryPanel.tsx
@@ -45,7 +45,13 @@ function Dialog({ title, children, onClose, busy = false }: { title: string; chi
dialog.showModal()
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
- return () => { dialog.close(); document.body.style.overflow = previousOverflow; if (previousFocus.current?.isConnected) previousFocus.current.focus() }
+ return () => {
+ dialog.close()
+ document.body.style.overflow = previousOverflow
+ // Saving reloads the list, so its original edit button may have been removed.
+ const target = previousFocus.current?.isConnected ? previousFocus.current : document.getElementById('salary-base-title')
+ target?.focus()
+ }
}, [])
return