fix: finish permission saves without blocking on reload

This commit is contained in:
chenpei 2026-08-31 17:54:03 +08:00
parent b7592fa687
commit 5a35321b3a
4 changed files with 414 additions and 98 deletions

View File

@ -28,7 +28,7 @@ jest.mock('@/lib/department-permission', () => ({
jest.mock('@/lib/user-permission-inherit', () => ({
ensureDefaultPermissionPacks: jest.fn(),
packItemsToMap: jest.fn(),
packItemsToMap: jest.fn((items: Array<{ materialType: string }>) => Object.fromEntries(items.map(({ materialType, ...flags }) => [materialType, flags]))),
savePermissionPackItems: jest.fn(),
}))
@ -78,6 +78,13 @@ describe('/api/organization/permission-groups 保存权限', () => {
} as never)
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.objectContaining({
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()
})
})

View File

@ -267,22 +267,21 @@ export async function PUT(request: NextRequest) {
if (incoming.length === 0) {
return NextResponse.json({ error: '素材类型列表不能为空' }, { status: 400 })
}
await savePermissionPackItems(
slug,
incoming.map((item) => {
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
return {
materialType: item.materialType,
canView: Boolean(item.canView),
canDownload: Boolean(item.canDownload),
canDownloadSource: Boolean(item.canDownloadSource),
canShare: true,
canEditAssets: taskDetailOnlyFlags.canEditAssets,
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
}
}),
)
return NextResponse.json({ ok: true })
const permissions = incoming.filter((item) => item.materialType).map((item) => {
const taskDetailOnlyFlags = normalizeTaskDetailOnlyFlags(item.materialType, item)
return {
materialType: item.materialType,
canView: Boolean(item.canView),
canDownload: Boolean(item.canDownload),
canDownloadSource: Boolean(item.canDownloadSource),
canShare: true,
canEditAssets: taskDetailOnlyFlags.canEditAssets,
canAccessPrivate: taskDetailOnlyFlags.canAccessPrivate,
}
})
await savePermissionPackItems(slug, permissions)
// 在事务提交后返回实际标准化的权限;客户端无需再加载整棵组织树才能确认保存。
return NextResponse.json({ ok: true, permissions: packItemsToMap(permissions) })
}
if (action === 'add-department' || action === 'remove-department') {

View File

@ -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 PermissionGroupsDashboard from './PermissionGroupsDashboard'
@ -14,66 +14,176 @@ jest.mock('./DepartmentShell', () => ({
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', () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
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
afterEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
})
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(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /运营/ })).toBeInTheDocument()
expect(screen.getByText('已在本组')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '可见' })).toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: '分享' })).not.toBeInTheDocument()
expect(getTaskDetailCheckbox('编辑图片')).not.toBeChecked()
expect(getTaskDetailCheckbox('私密素材查看/下载')).not.toBeChecked()
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
'href',
'/workspaces/governance/user-permissions',
@ -81,20 +191,28 @@ describe('PermissionGroupsDashboard', () => {
})
it('已在别组的部门会标明占用,不能再加入当前组', async () => {
render(<PermissionGroupsDashboard />)
await screen.findByRole('navigation', { name: '花名册组织树' })
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
await userEvent.click(
within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }),
)
expect(await screen.findByText('已在「运营」')).toBeInTheDocument()
expect(screen.getByText('占用')).toBeInTheDocument()
})
it('可以把部门从组里拿掉', async () => {
render(<PermissionGroupsDashboard />)
const remove = await screen.findByRole('button', { name: '拿掉' })
await userEvent.click(remove)
expect(global.fetch).toHaveBeenCalledWith(
const fetchMock = installFetchSequence(
jsonResponse(createPayload()),
jsonResponse({ ok: true }),
jsonResponse(createPayload()),
)
await renderDashboard()
await userEvent.click(await screen.findByRole('button', { name: '拿掉' }))
expect(fetchMock).toHaveBeenCalledWith(
'/api/organization/permission-groups',
expect.objectContaining({
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)
})
})

View File

@ -1,6 +1,6 @@
'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 { useSession } from 'next-auth/react'
import { ChevronRight } from 'lucide-react'
@ -98,14 +98,37 @@ export default function PermissionGroupsDashboard() {
const [personKeyword, setPersonKeyword] = useState('')
const [candidates, setCandidates] = useState<Candidate[]>([])
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)
setError(null)
try {
const response = await fetch('/api/organization/permission-groups', { cache: 'no-store' })
const body = await response.json().catch(() => null)
const { response, body } = await requestJson({ cache: 'no-store' })
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
setPayload(next)
setDirty(false)
@ -118,10 +141,25 @@ export default function PermissionGroupsDashboard() {
const first = next.tree[0]
return first ? new Set([first.id]) : new Set()
})
return true
} 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 {
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])
const mutate = async (body: Record<string, unknown>, success: string) => {
if (mutationActive.current) return false
mutationActive.current = true
setSaving(true)
setError(null)
setNotice(null)
try {
const response = await fetch('/api/organization/permission-groups', {
const { response, body: payloadBody } = await requestJson({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const payloadBody = await response.json().catch(() => null)
if (!response.ok) throw new Error(payloadBody?.error || '保存失败')
if (payloadBody?.ok !== true) throw new Error('保存结果尚未确认,请刷新列表核对,不要重复保存。')
if (!mounted.current) return false
setNotice(success)
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) {
setError(saveError instanceof Error ? saveError.message : '保存失败')
if (mounted.current) setError(saveError instanceof Error && saveError.name === 'AbortError'
? '保存请求超时,结果尚未确认。请刷新列表核对,不要重复保存。'
: saveError instanceof Error ? saveError.message : '保存失败')
return false
} finally {
setSaving(false)
mutationActive.current = false
if (mounted.current) setSaving(false)
}
}
@ -264,8 +323,7 @@ export default function PermissionGroupsDashboard() {
const createGroup = async () => {
const name = newName.trim()
if (!name) return
await mutate({ action: 'create', name }, `已创建「${name}」`)
setNewName('')
if (await mutate({ action: 'create', name }, `已创建「${name}」`)) setNewName('')
}
const renameGroup = async () => {
@ -369,16 +427,26 @@ export default function PermissionGroupsDashboard() {
一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。
</p>
</div>
<Link
href="/workspaces/governance/user-permissions"
className="text-sm font-semibold text-[#1677ff] hover:underline"
>
按人看权限
</Link>
<div className="flex items-center gap-4">
{isSuperAdmin && (
<button type="button" disabled={saving || loading} onClick={() => {
if (dirty && !window.confirm('当前勾选尚未确认保存,刷新后将以服务器数据为准。继续?')) return
void load()
}} 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>
</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>
) : !isSuperAdmin ? (
<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 && (
<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 && (
<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">
<form
className="shrink-0 border-b border-[#e2e8f0] p-3"
@ -697,7 +765,7 @@ export default function PermissionGroupsDashboard() {
</div>
)}
</section>
</div>
</fieldset>
</>
)}
</main>