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', () => ({
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user