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}
)} -
+
+ )}