fix: finish permission saves without blocking on reload
This commit is contained in:
parent
b7592fa687
commit
5a35321b3a
@ -28,7 +28,7 @@ jest.mock('@/lib/department-permission', () => ({
|
|||||||
|
|
||||||
jest.mock('@/lib/user-permission-inherit', () => ({
|
jest.mock('@/lib/user-permission-inherit', () => ({
|
||||||
ensureDefaultPermissionPacks: jest.fn(),
|
ensureDefaultPermissionPacks: jest.fn(),
|
||||||
packItemsToMap: jest.fn(),
|
packItemsToMap: jest.fn((items: Array<{ materialType: string }>) => Object.fromEntries(items.map(({ materialType, ...flags }) => [materialType, flags]))),
|
||||||
savePermissionPackItems: jest.fn(),
|
savePermissionPackItems: jest.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -78,6 +78,13 @@ describe('/api/organization/permission-groups 保存权限', () => {
|
|||||||
} as never)
|
} as never)
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
permissions: {
|
||||||
|
'任务-详情': { canEditAssets: true, canAccessPrivate: true, canShare: true },
|
||||||
|
'3D场景': { canEditAssets: false, canAccessPrivate: false, canShare: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
expect(savePermissionPackItems).toHaveBeenCalledWith('visual', [
|
expect(savePermissionPackItems).toHaveBeenCalledWith('visual', [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
materialType: '任务-详情',
|
materialType: '任务-详情',
|
||||||
@ -91,4 +98,21 @@ describe('/api/organization/permission-groups 保存权限', () => {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('保存事务失败不返回已保存权限', async () => {
|
||||||
|
const errorLog = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
;(savePermissionPackItems as jest.Mock).mockRejectedValueOnce(new Error('transaction aborted'))
|
||||||
|
try {
|
||||||
|
const response = await PUT({ json: async () => ({ action: 'save-pack', slug: 'visual', permissions: [{ materialType: '任务-详情', canAccessPrivate: true }] }) } as never)
|
||||||
|
expect(response.status).toBe(500)
|
||||||
|
await expect(response.json()).resolves.toEqual({ error: '保存权限组失败' })
|
||||||
|
} finally { errorLog.mockRestore() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('普通用户不能保存权限,也不能获得保存结果', async () => {
|
||||||
|
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||||
|
const response = await PUT({ json: async () => ({ action: 'save-pack', slug: 'visual', permissions: [{ materialType: '任务-详情' }] }) } as never)
|
||||||
|
expect(response.status).toBe(403)
|
||||||
|
expect(savePermissionPackItems).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -267,22 +267,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (incoming.length === 0) {
|
if (incoming.length === 0) {
|
||||||
return NextResponse.json({ error: '素材类型列表不能为空' }, { status: 400 })
|
return NextResponse.json({ error: '素材类型列表不能为空' }, { status: 400 })
|
||||||
}
|
}
|
||||||
await savePermissionPackItems(
|
const permissions = incoming.filter((item) => item.materialType).map((item) => {
|
||||||
slug,
|
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
|
||||||
incoming.map((item) => {
|
return {
|
||||||
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
|
materialType: item.materialType,
|
||||||
return {
|
canView: Boolean(item.canView),
|
||||||
materialType: item.materialType,
|
canDownload: Boolean(item.canDownload),
|
||||||
canView: Boolean(item.canView),
|
canDownloadSource: Boolean(item.canDownloadSource),
|
||||||
canDownload: Boolean(item.canDownload),
|
canShare: true,
|
||||||
canDownloadSource: Boolean(item.canDownloadSource),
|
canEditAssets: taskDetailOnlyFlags.canEditAssets,
|
||||||
canShare: true,
|
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
|
||||||
canEditAssets: taskDetailOnlyFlags.canEditAssets,
|
}
|
||||||
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
|
})
|
||||||
}
|
await savePermissionPackItems(slug, permissions)
|
||||||
}),
|
// 在事务提交后返回实际标准化的权限;客户端无需再加载整棵组织树才能确认保存。
|
||||||
)
|
return NextResponse.json({ ok: true, permissions: packItemsToMap(permissions) })
|
||||||
return NextResponse.json({ ok: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'add-department' || action === 'remove-department') {
|
if (action === 'add-department' || action === 'remove-department') {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, within } from '@testing-library/react'
|
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import PermissionGroupsDashboard from './PermissionGroupsDashboard'
|
import PermissionGroupsDashboard from './PermissionGroupsDashboard'
|
||||||
|
|
||||||
@ -14,66 +14,176 @@ jest.mock('./DepartmentShell', () => ({
|
|||||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
type PermissionFlags = {
|
||||||
|
canView: boolean
|
||||||
|
canDownload: boolean
|
||||||
|
canDownloadSource: boolean
|
||||||
|
canShare: boolean
|
||||||
|
canEditAssets: boolean
|
||||||
|
canAccessPrivate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
materialTypes: string[]
|
||||||
|
tree: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
level: number
|
||||||
|
parentId: string | null
|
||||||
|
children: Payload['tree']
|
||||||
|
}>
|
||||||
|
groups: Array<{
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
permissions: Record<string, PermissionFlags>
|
||||||
|
departments: Array<{ path: string; name: string }>
|
||||||
|
members: Array<{
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
departmentPath: string | null
|
||||||
|
departmentName: string | null
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function flags(overrides: Partial<PermissionFlags> = {}): PermissionFlags {
|
||||||
|
return {
|
||||||
|
canView: true,
|
||||||
|
canDownload: true,
|
||||||
|
canDownloadSource: false,
|
||||||
|
canShare: true,
|
||||||
|
canEditAssets: false,
|
||||||
|
canAccessPrivate: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPayload(): Payload {
|
||||||
|
return {
|
||||||
|
materialTypes: ['3D场景', '任务-详情'],
|
||||||
|
tree: [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
name: '江苏简尚品牌管理有限公司',
|
||||||
|
path: '江苏简尚品牌管理有限公司',
|
||||||
|
level: 1,
|
||||||
|
parentId: null,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'ops',
|
||||||
|
name: '运营中心A',
|
||||||
|
path: '江苏简尚品牌管理有限公司/运营中心A',
|
||||||
|
level: 2,
|
||||||
|
parentId: 'root',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
slug: 'operations',
|
||||||
|
name: '运营',
|
||||||
|
permissions: {
|
||||||
|
'3D场景': flags(),
|
||||||
|
'任务-详情': flags({ canDownloadSource: true }),
|
||||||
|
},
|
||||||
|
departments: [
|
||||||
|
{ path: '江苏简尚品牌管理有限公司/运营中心A', name: '运营中心A' },
|
||||||
|
],
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'visual',
|
||||||
|
name: '视觉',
|
||||||
|
permissions: {
|
||||||
|
'3D场景': flags(),
|
||||||
|
'任务-详情': flags({ canDownloadSource: true }),
|
||||||
|
},
|
||||||
|
departments: [],
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, ok = true) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok,
|
||||||
|
json: async () => body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFetchSequence(...responses: Array<ReturnType<typeof jsonResponse> | ((init?: RequestInit) => Promise<unknown>)>) {
|
||||||
|
const fetchMock = jest.fn()
|
||||||
|
fetchMock.mockImplementation((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const next = responses.shift()
|
||||||
|
if (!next) {
|
||||||
|
return Promise.reject(new Error('unexpected fetch call'))
|
||||||
|
}
|
||||||
|
return typeof next === 'function' ? next(init) : next
|
||||||
|
})
|
||||||
|
global.fetch = fetchMock as unknown as typeof fetch
|
||||||
|
return fetchMock
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingUntilAbort(label: string) {
|
||||||
|
return (init?: RequestInit) => {
|
||||||
|
const signal = init?.signal
|
||||||
|
if (!signal) {
|
||||||
|
return Promise.reject(new Error(`${label} missing AbortSignal`))
|
||||||
|
}
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
const abort = () => {
|
||||||
|
const error = new Error(`${label} aborted`)
|
||||||
|
error.name = 'AbortError'
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', abort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDashboard() {
|
||||||
|
render(<PermissionGroupsDashboard />)
|
||||||
|
await screen.findByRole('navigation', { name: '权限组' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskDetailCheckbox(label: string) {
|
||||||
|
return screen.getByRole('checkbox', { name: `任务-详情 ${label}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceAndFlush(ms: number) {
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(ms)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('PermissionGroupsDashboard', () => {
|
describe('PermissionGroupsDashboard', () => {
|
||||||
beforeEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
jest.useRealTimers()
|
||||||
ok: true,
|
jest.clearAllMocks()
|
||||||
json: async () => ({
|
|
||||||
materialTypes: ['3D场景'],
|
|
||||||
tree: [
|
|
||||||
{
|
|
||||||
id: 'root',
|
|
||||||
name: '江苏简尚品牌管理有限公司',
|
|
||||||
path: '江苏简尚品牌管理有限公司',
|
|
||||||
level: 1,
|
|
||||||
parentId: null,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'ops',
|
|
||||||
name: '运营中心A',
|
|
||||||
path: '江苏简尚品牌管理有限公司/运营中心A',
|
|
||||||
level: 2,
|
|
||||||
parentId: 'root',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
slug: 'operations',
|
|
||||||
name: '运营',
|
|
||||||
permissions: {
|
|
||||||
'3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true },
|
|
||||||
},
|
|
||||||
departments: [
|
|
||||||
{ path: '江苏简尚品牌管理有限公司/运营中心A', name: '运营中心A' },
|
|
||||||
],
|
|
||||||
members: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'visual',
|
|
||||||
name: '视觉',
|
|
||||||
permissions: {
|
|
||||||
'3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true },
|
|
||||||
},
|
|
||||||
departments: [],
|
|
||||||
members: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}) as jest.Mock
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('在治理与设置里展示权限组和部门树', async () => {
|
it('在治理与设置里展示权限组和部门树', async () => {
|
||||||
render(<PermissionGroupsDashboard />)
|
installFetchSequence(jsonResponse(createPayload()))
|
||||||
|
|
||||||
|
await renderDashboard()
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: '权限组' })).toBeInTheDocument()
|
|
||||||
expect(await screen.findByRole('navigation', { name: '花名册组织树' })).toBeInTheDocument()
|
expect(await screen.findByRole('navigation', { name: '花名册组织树' })).toBeInTheDocument()
|
||||||
expect(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /运营/ })).toBeInTheDocument()
|
expect(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /运营/ })).toBeInTheDocument()
|
||||||
expect(screen.getByText('已在本组')).toBeInTheDocument()
|
expect(screen.getByText('已在本组')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('columnheader', { name: '可见' })).toBeInTheDocument()
|
expect(screen.getByRole('columnheader', { name: '可见' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('columnheader', { name: '分享' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('columnheader', { name: '分享' })).not.toBeInTheDocument()
|
||||||
|
expect(getTaskDetailCheckbox('编辑图片')).not.toBeChecked()
|
||||||
|
expect(getTaskDetailCheckbox('私密素材查看/下载')).not.toBeChecked()
|
||||||
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/workspaces/governance/user-permissions',
|
'/workspaces/governance/user-permissions',
|
||||||
@ -81,20 +191,28 @@ describe('PermissionGroupsDashboard', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('已在别组的部门会标明占用,不能再加入当前组', async () => {
|
it('已在别组的部门会标明占用,不能再加入当前组', async () => {
|
||||||
render(<PermissionGroupsDashboard />)
|
installFetchSequence(jsonResponse(createPayload()))
|
||||||
await screen.findByRole('navigation', { name: '花名册组织树' })
|
|
||||||
|
await renderDashboard()
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }),
|
within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(await screen.findByText('已在「运营」')).toBeInTheDocument()
|
expect(await screen.findByText('已在「运营」')).toBeInTheDocument()
|
||||||
expect(screen.getByText('占用')).toBeInTheDocument()
|
expect(screen.getByText('占用')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('可以把部门从组里拿掉', async () => {
|
it('可以把部门从组里拿掉', async () => {
|
||||||
render(<PermissionGroupsDashboard />)
|
const fetchMock = installFetchSequence(
|
||||||
const remove = await screen.findByRole('button', { name: '拿掉' })
|
jsonResponse(createPayload()),
|
||||||
await userEvent.click(remove)
|
jsonResponse({ ok: true }),
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
jsonResponse(createPayload()),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDashboard()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: '拿掉' }))
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/api/organization/permission-groups',
|
'/api/organization/permission-groups',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -106,4 +224,111 @@ describe('PermissionGroupsDashboard', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('save-pack 成功后只发起首屏 GET 和一次 PUT,并直接应用后端返回的标准化权限', async () => {
|
||||||
|
const nextPermissions = {
|
||||||
|
'3D场景': flags(),
|
||||||
|
'任务-详情': flags({
|
||||||
|
canDownloadSource: true,
|
||||||
|
canEditAssets: true,
|
||||||
|
canAccessPrivate: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const fetchMock = installFetchSequence(
|
||||||
|
jsonResponse(createPayload()),
|
||||||
|
jsonResponse({ ok: true, permissions: nextPermissions }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDashboard()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const editAssets = getTaskDetailCheckbox('编辑图片')
|
||||||
|
const privateAccess = getTaskDetailCheckbox('私密素材查看/下载')
|
||||||
|
|
||||||
|
await user.click(editAssets)
|
||||||
|
await user.click(privateAccess)
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled())
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[1]).toEqual([
|
||||||
|
'/api/organization/permission-groups',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: expect.stringContaining('"action":"save-pack"'),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(screen.getByText('这组权限已保存,组和人一起变')).toBeInTheDocument()
|
||||||
|
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
|
||||||
|
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save-pack 超时后退出保存态,保留 draft,且不会自动重发 PUT', async () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const fetchMock = installFetchSequence(
|
||||||
|
jsonResponse(createPayload()),
|
||||||
|
pendingUntilAbort('save-pack PUT'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDashboard()
|
||||||
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
|
||||||
|
await user.click(getTaskDetailCheckbox('私密素材查看/下载'))
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('保存中…')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await advanceAndFlush(15_100)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled())
|
||||||
|
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
|
||||||
|
expect(screen.getByText(/结果尚未确认/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/刷新.*列表.*核对/)).toBeInTheDocument()
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await advanceAndFlush(20_000)
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('其他动作 PUT 成功后即退出保存态,刷新挂起再超时时保留成功提示且不给出再次保存暗示', async () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const fetchMock = installFetchSequence(
|
||||||
|
jsonResponse(createPayload()),
|
||||||
|
jsonResponse({ ok: true }),
|
||||||
|
pendingUntilAbort('permission-groups refresh GET'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDashboard()
|
||||||
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
|
||||||
|
await user.click(await screen.findByRole('button', { name: '拿掉' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('已从本组拿掉 运营中心A')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled()
|
||||||
|
expect(screen.queryByText('保存中…')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('刷新中…')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await advanceAndFlush(15_100)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/已保存,但列表刷新失败/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/不用再次保存/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('已从本组拿掉 运营中心A')).toBeInTheDocument()
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save-pack 失败后保留当前勾选,不自动刷新列表', async () => {
|
||||||
|
const fetchMock = installFetchSequence(
|
||||||
|
jsonResponse(createPayload()),
|
||||||
|
jsonResponse({ error: '保存权限组失败' }, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDashboard()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await user.click(getTaskDetailCheckbox('编辑图片'))
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('保存权限组失败')).toBeInTheDocument()
|
||||||
|
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
|
||||||
|
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled()
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
@ -98,14 +98,37 @@ export default function PermissionGroupsDashboard() {
|
|||||||
const [personKeyword, setPersonKeyword] = useState('')
|
const [personKeyword, setPersonKeyword] = useState('')
|
||||||
const [candidates, setCandidates] = useState<Candidate[]>([])
|
const [candidates, setCandidates] = useState<Candidate[]>([])
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
|
const mutationActive = useRef(false)
|
||||||
|
const mounted = useRef(true)
|
||||||
|
const controllers = useRef(new Set<AbortController>())
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const requestJson = useCallback(async (init?: RequestInit) => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers.current.add(controller)
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15_000)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/organization/permission-groups', { ...init, signal: controller.signal })
|
||||||
|
const body = await response.json().catch((error) => {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') throw error
|
||||||
|
throw new Error('服务器返回了无效响应,请刷新列表核对。')
|
||||||
|
})
|
||||||
|
return { response, body }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
controllers.current.delete(controller)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async (options: { afterSave?: boolean } = {}) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/organization/permission-groups', { cache: 'no-store' })
|
const { response, body } = await requestJson({ cache: 'no-store' })
|
||||||
const body = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) throw new Error(body?.error || '加载失败')
|
if (!response.ok) throw new Error(body?.error || '加载失败')
|
||||||
|
if (!Array.isArray(body?.groups) || !Array.isArray(body?.tree) || !Array.isArray(body?.materialTypes)) {
|
||||||
|
throw new Error('权限组数据格式异常,请重试')
|
||||||
|
}
|
||||||
|
if (!mounted.current) return false
|
||||||
const next = body as Payload
|
const next = body as Payload
|
||||||
setPayload(next)
|
setPayload(next)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
@ -118,10 +141,25 @@ export default function PermissionGroupsDashboard() {
|
|||||||
const first = next.tree[0]
|
const first = next.tree[0]
|
||||||
return first ? new Set([first.id]) : new Set()
|
return first ? new Set([first.id]) : new Set()
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : '加载失败')
|
if (mounted.current) setError(options.afterSave
|
||||||
|
? '变更已保存,但列表刷新失败。请点击“刷新列表”重新读取,不用再次保存。'
|
||||||
|
: loadError instanceof Error && loadError.name === 'AbortError'
|
||||||
|
? '加载权限组超时,请点击“刷新列表”重试。'
|
||||||
|
: loadError instanceof Error ? loadError.message : '加载失败')
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (mounted.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [requestJson])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
const activeControllers = controllers.current
|
||||||
|
return () => {
|
||||||
|
mounted.current = false
|
||||||
|
activeControllers.forEach(controller => controller.abort())
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -200,24 +238,45 @@ export default function PermissionGroupsDashboard() {
|
|||||||
}, [payload, deptKeyword])
|
}, [payload, deptKeyword])
|
||||||
|
|
||||||
const mutate = async (body: Record<string, unknown>, success: string) => {
|
const mutate = async (body: Record<string, unknown>, success: string) => {
|
||||||
|
if (mutationActive.current) return false
|
||||||
|
mutationActive.current = true
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setNotice(null)
|
setNotice(null)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/organization/permission-groups', {
|
const { response, body: payloadBody } = await requestJson({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
const payloadBody = await response.json().catch(() => null)
|
|
||||||
if (!response.ok) throw new Error(payloadBody?.error || '保存失败')
|
if (!response.ok) throw new Error(payloadBody?.error || '保存失败')
|
||||||
|
if (payloadBody?.ok !== true) throw new Error('保存结果尚未确认,请刷新列表核对,不要重复保存。')
|
||||||
|
if (!mounted.current) return false
|
||||||
setNotice(success)
|
setNotice(success)
|
||||||
if (typeof payloadBody?.slug === 'string') setSelectedSlug(payloadBody.slug)
|
if (typeof payloadBody?.slug === 'string') setSelectedSlug(payloadBody.slug)
|
||||||
await load()
|
if (body.action === 'save-pack') {
|
||||||
|
if (!payloadBody.permissions || typeof payloadBody.permissions !== 'object' || Array.isArray(payloadBody.permissions)) {
|
||||||
|
throw new Error('权限已保存,但未收到完整结果,请刷新列表核对。')
|
||||||
|
}
|
||||||
|
setPayload((current) => current && ({ ...current, groups: current.groups.map(group => (
|
||||||
|
group.slug === body.slug ? { ...group, permissions: payloadBody.permissions } : group
|
||||||
|
)) }))
|
||||||
|
setDirty(false)
|
||||||
|
setDraft({})
|
||||||
|
} else {
|
||||||
|
// 保存已完成,后续列表刷新有自己的状态和超时,不能继续显示“保存中”。
|
||||||
|
setSaving(false)
|
||||||
|
await load({ afterSave: true })
|
||||||
|
}
|
||||||
|
return true
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setError(saveError instanceof Error ? saveError.message : '保存失败')
|
if (mounted.current) setError(saveError instanceof Error && saveError.name === 'AbortError'
|
||||||
|
? '保存请求超时,结果尚未确认。请刷新列表核对,不要重复保存。'
|
||||||
|
: saveError instanceof Error ? saveError.message : '保存失败')
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
mutationActive.current = false
|
||||||
|
if (mounted.current) setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,8 +323,7 @@ export default function PermissionGroupsDashboard() {
|
|||||||
const createGroup = async () => {
|
const createGroup = async () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await mutate({ action: 'create', name }, `已创建「${name}」`)
|
if (await mutate({ action: 'create', name }, `已创建「${name}」`)) setNewName('')
|
||||||
setNewName('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renameGroup = async () => {
|
const renameGroup = async () => {
|
||||||
@ -369,16 +427,26 @@ export default function PermissionGroupsDashboard() {
|
|||||||
一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。
|
一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex items-center gap-4">
|
||||||
href="/workspaces/governance/user-permissions"
|
{isSuperAdmin && (
|
||||||
className="text-sm font-semibold text-[#1677ff] hover:underline"
|
<button type="button" disabled={saving || loading} onClick={() => {
|
||||||
>
|
if (dirty && !window.confirm('当前勾选尚未确认保存,刷新后将以服务器数据为准。继续?')) return
|
||||||
按人看权限
|
void load()
|
||||||
</Link>
|
}} className="min-h-10 text-sm font-semibold text-[#1677ff] hover:underline disabled:cursor-wait disabled:opacity-50">
|
||||||
|
{loading ? '刷新中…' : '刷新列表'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/workspaces/governance/user-permissions"
|
||||||
|
className="text-sm font-semibold text-[#1677ff] hover:underline"
|
||||||
|
>
|
||||||
|
按人看权限
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{status === 'loading' || loading ? (
|
{status === 'loading' || (loading && !payload) ? (
|
||||||
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">加载中…</div>
|
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">加载中…</div>
|
||||||
) : !isSuperAdmin ? (
|
) : !isSuperAdmin ? (
|
||||||
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
|
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
|
||||||
@ -387,13 +455,13 @@ export default function PermissionGroupsDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
|
<div role="alert" className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
|
||||||
)}
|
)}
|
||||||
{notice && (
|
{notice && (
|
||||||
<div className="mb-4 rounded-lg border border-[#b7ebc6] bg-[#f0fff4] px-4 py-3 text-sm text-[#087052]">{notice}</div>
|
<div role="status" className="mb-4 rounded-lg border border-[#b7ebc6] bg-[#f0fff4] px-4 py-3 text-sm text-[#087052]">{notice}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 gap-4 pb-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
<fieldset disabled={saving || loading} aria-label="权限组设置" className="m-0 grid min-h-0 min-w-0 flex-1 gap-4 border-0 p-0 pb-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
<aside className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
|
<aside className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
|
||||||
<form
|
<form
|
||||||
className="shrink-0 border-b border-[#e2e8f0] p-3"
|
className="shrink-0 border-b border-[#e2e8f0] p-3"
|
||||||
@ -697,7 +765,7 @@ export default function PermissionGroupsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</fieldset>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user