diff --git a/app/admin/page.test.tsx b/app/admin/page.test.tsx new file mode 100644 index 0000000..c54c61f --- /dev/null +++ b/app/admin/page.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useSession } from 'next-auth/react' +import AdminPage from './page' + +const push = jest.fn() +const router = { push } +const confirmAction = jest.fn() +jest.mock('next-auth/react', () => ({ useSession: jest.fn() })) +jest.mock('next/navigation', () => ({ useRouter: () => router })) +jest.mock('@/components/ConfirmActionDialog', () => ({ useConfirmActionDialog: () => ({ confirmAction, confirmActionDialog: null }) })) +jest.mock('@/components/board/MaterialsHeader', () => () => null) +jest.mock('@/components/DepartmentPermissionsTab', () => () => null) +jest.mock('@/components/admin/MaterialManagement', () => () => null) +jest.mock('@/components/admin/CategoryManagement', () => () => null) +jest.mock('@/components/admin/PublicTrafficTab', () => () => null) +jest.mock('@/components/admin/DataStatisticsTab', () => function TestDataStatisticsTab() { return
统计内容
}) +jest.mock('@/components/SuperAdminsTab', () => function TestSuperAdminsTab({ onStateChange }: { onStateChange: (state: { dirty: boolean; busy: boolean }) => void }) { return
配置内容
}) + +beforeEach(() => { jest.clearAllMocks(); confirmAction.mockResolvedValue({ confirmed: false }) }) +function session(role: string, isSuperAdmin: boolean) { (useSession as jest.Mock).mockReturnValue({ status: 'authenticated', data: { user: { id: 'user', role, isSuperAdmin } } }) } +test('super-admin flag grants the management entry independently of the ordinary role', async () => { + session('USER', true); render() + fireEvent.click(await screen.findByRole('button', { name: '超级管理员' })) + await screen.findByText('配置内容') + expect(push).not.toHaveBeenCalled() +}) +test('ordinary administrators do not see the super-admin configuration entry', async () => { + session('ADMIN', false); render() + await screen.findByText('统计内容') + expect(screen.queryByRole('button', { name: '超级管理员' })).not.toBeInTheDocument() +}) +test('ordinary users remain outside the admin page', async () => { + session('USER', false); render() + await waitFor(() => expect(push).toHaveBeenCalledWith('/')) + expect(screen.queryByRole('button', { name: '超级管理员' })).not.toBeInTheDocument() +}) +test('changing top-level tabs cannot discard a configuration draft without the user choosing to leave', async () => { + session('ADMIN', true); render() + fireEvent.click(await screen.findByRole('button', { name: '超级管理员' })) + fireEvent.click(await screen.findByRole('button', { name: '模拟配置草稿' })) + fireEvent.click(screen.getByRole('button', { name: '数据统计' })) + await waitFor(() => expect(confirmAction).toHaveBeenCalledTimes(1)) + expect(screen.getByText('配置内容')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '模拟保存中' })) + expect(screen.getByRole('button', { name: '数据统计' })).toBeDisabled() +}) + +test.each([false, true])('appearance navigation respects the unsaved configuration decision: confirmed=%s', async (confirmed) => { + session('ADMIN', true); render() + fireEvent.click(await screen.findByRole('button', { name: '超级管理员' })) + fireEvent.click(await screen.findByRole('button', { name: '模拟配置草稿' })) + confirmAction.mockResolvedValueOnce({ confirmed }) + fireEvent.click(screen.getByRole('button', { name: '外观设置' })) + await waitFor(() => expect(confirmAction).toHaveBeenCalledTimes(1)) + if (confirmed) { + await waitFor(() => expect(push).toHaveBeenCalledWith('/admin/settings')) + } else { + expect(push).not.toHaveBeenCalled() + expect(screen.getByText('配置内容')).toBeInTheDocument() + } +}) + +test('appearance navigation is blocked while a configuration save is running', async () => { + session('ADMIN', true); render() + fireEvent.click(await screen.findByRole('button', { name: '超级管理员' })) + fireEvent.click(await screen.findByRole('button', { name: '模拟保存中' })) + const appearance = screen.getByRole('button', { name: '外观设置' }) + expect(appearance).toBeDisabled() + fireEvent.click(appearance) + expect(push).not.toHaveBeenCalled() + expect(confirmAction).not.toHaveBeenCalled() +}) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index accbec1..f6bd6e8 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -11,6 +11,7 @@ import CategoryManagement from '@/components/admin/CategoryManagement' import DataStatisticsTab from '@/components/admin/DataStatisticsTab' import PublicTrafficTab from '@/components/admin/PublicTrafficTab' import ToastBubble from '@/components/ToastBubble' +import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' // 类型定义 interface Material { @@ -91,13 +92,29 @@ export default function AdminPage() { email?: string | null } | undefined const currentUserRole = sessionUser?.role || '' - const canAccessAdminPage = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN' - const canManageSystemTabs = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' + const canAccessAdminPage = sessionUser?.isSuperAdmin === true || currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN' + const canManageSystemTabs = sessionUser?.isSuperAdmin === true || currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' const departmentScopeName = String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() || null const canSelectDepartmentInStats = canManageSystemTabs // Tab状态 const [activeTab, setActiveTab] = useState<'materials' | 'categories' | 'dataStats' | 'permissions' | 'superAdmins' | 'publicTraffic'>('dataStats') + const [superAdminEditing, setSuperAdminEditing] = useState({ dirty: false, busy: false }) + const { confirmAction, confirmActionDialog } = useConfirmActionDialog() + const confirmLeaveConfiguration = async () => { + if (activeTab !== 'superAdmins') return true + if (superAdminEditing.busy) return false + if (!superAdminEditing.dirty) return true + const result = await confirmAction({ title: '离开当前配置?', description: '当前修改尚未保存。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' }) + return result.confirmed + } + const changeTab = async (next: typeof activeTab) => { + if (next === activeTab || !(await confirmLeaveConfiguration())) return + setActiveTab(next) + } + const openAppearanceSettings = async () => { + if (await confirmLeaveConfiguration()) router.push('/admin/settings') + } const [loading, setLoading] = useState(true) // 数据状态 @@ -281,6 +298,7 @@ export default function AdminPage() { return (
+ {confirmActionDialog} {/* Toast通知 */} @@ -297,8 +315,9 @@ export default function AdminPage() { {canManageSystemTabs && ( } + {needsStatusRefresh && } {draft.legacyAvailable && }
{!draft.encryptionConfigured &&

服务端尚未配置密钥加密,暂不能新增或替换 API Key。

} {(error || notice) &&

{error || notice}

} + {recovery &&
{ + event.preventDefault() + void run('reconcile', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/reconcile`, { method: 'POST', body: JSON.stringify({ outcome: recovery.outcome, reference: recovery.reference, note: recovery.note, confirmed: recovery.confirmed }) }), '核查已记录,可以重新测试', { preserveDraft: true }) + }}> +

核查原请求 · {recovery.modelId}

+

先在上游核查并确认请求已结束。此操作不会通过接口测试。

+
+