diff --git a/app/api/organization/permission-groups/route.test.ts b/app/api/organization/permission-groups/route.test.ts
index e1e95b8..d7368ac 100644
--- a/app/api/organization/permission-groups/route.test.ts
+++ b/app/api/organization/permission-groups/route.test.ts
@@ -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()
+ })
})
diff --git a/app/api/organization/permission-groups/route.ts b/app/api/organization/permission-groups/route.ts
index d084310..bf204b6 100644
--- a/app/api/organization/permission-groups/route.ts
+++ b/app/api/organization/permission-groups/route.ts
@@ -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') {
diff --git a/components/department-dashboard/PermissionGroupsDashboard.test.tsx b/components/department-dashboard/PermissionGroupsDashboard.test.tsx
index 67a4c7b..a2440d1 100644
--- a/components/department-dashboard/PermissionGroupsDashboard.test.tsx
+++ b/components/department-dashboard/PermissionGroupsDashboard.test.tsx
@@ -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 }) =>
{children}
,
}))
+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
+ 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 {
+ 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 | ((init?: RequestInit) => Promise)>) {
+ 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()
+ 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()
+ 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()
- 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()
- 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)
+ })
})
diff --git a/components/department-dashboard/PermissionGroupsDashboard.tsx b/components/department-dashboard/PermissionGroupsDashboard.tsx
index e3bcb27..b414791 100644
--- a/components/department-dashboard/PermissionGroupsDashboard.tsx
+++ b/components/department-dashboard/PermissionGroupsDashboard.tsx
@@ -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([])
const [searching, setSearching] = useState(false)
+ const mutationActive = useRef(false)
+ const mounted = useRef(true)
+ const controllers = useRef(new Set())
- 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, 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() {
一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。
-
- 按人看权限
-
+
+ {isSuperAdmin && (
+
+ )}
+
+ 按人看权限
+
+
- {status === 'loading' || loading ? (
+ {status === 'loading' || (loading && !payload) ? (
加载中…
) : !isSuperAdmin ? (
@@ -387,13 +455,13 @@ export default function PermissionGroupsDashboard() {
) : (
<>
{error && (
-
{error}
+
{error}
)}
{notice && (
-
{notice}
+
{notice}
)}
-
+
>
)}