refactor(frontend): split 7 oversized pages into feature co-location
Per guide ch.7 page split pattern (skeleton orchestrates, private components live in features/<domain>/): - InstancePage 1716->328, UsersPage 1309->240, TicketsPage 1076->195, TicketDetailPage 911->287, QueriesPage 883->479, LdapConfigPage 860->393, WorkflowPage 852->194 - new feature modules: datasource/ rbac/ ticket/ query/ system/ldap/ governance/workflow/ (components + hooks + types + utils) - stable keys replace index keys in dynamic lists; polling intervals and toast call sites preserved 1:1 (verified by agents) - filter/pagination state moved to URL query via new useQueryState hook (Instance/Users/Tickets/AuditLogs pages): refresh/share/back-button restore state; composite updates applied atomically - permission pure functions extracted to lib/permissions.ts (used by useAuth + useLogin); page-size audit: 0 pages over 700-line budget
This commit is contained in:
1 parent
ac563d1c5e
commit
ac498c7ba2
92 files changed
+8224
-6146
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { menuItems } from '@/constants/menu';
|
||||
import { canAccessMenu, canPerformAction, getSafeRedirect } from '@/lib/permissions';
|
||||
import type { UserRole } from '@/constants/menu';
|
||||
|
||||
const ALL_ROLES: UserRole[] = ['admin', 'dba', 'developer', 'auditor'];
|
||||
|
||||
const ACTION_MAP: Record<string, UserRole[]> = {
|
||||
'ticket.approve': ['admin', 'dba', 'developer'],
|
||||
'ticket.execute': ['admin', 'dba'],
|
||||
'ticket.delete': ['admin'],
|
||||
'user.manage': ['admin'],
|
||||
};
|
||||
|
||||
describe('canAccessMenu 纯函数穷举', () => {
|
||||
it('每个菜单项的 roles 与判定一致(全角色 × 全菜单)', () => {
|
||||
for (const item of menuItems) {
|
||||
for (const role of ALL_ROLES) {
|
||||
expect(canAccessMenu(item.id, { role })).toBe(item.roles.includes(role));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('未知菜单 id 一律拒绝', () => {
|
||||
for (const role of ALL_ROLES) {
|
||||
expect(canAccessMenu('no-such-menu', { role })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('developer 无可写数据源时,工单/流程域被收紧', () => {
|
||||
expect(canAccessMenu('tickets', { role: 'developer', hasWritableDataSourceAccess: false })).toBe(false);
|
||||
expect(canAccessMenu('tickets', { role: 'developer', hasWritableDataSourceAccess: true })).toBe(true);
|
||||
// 其他角色不受该开关影响
|
||||
expect(canAccessMenu('tickets', { role: 'dba', hasWritableDataSourceAccess: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('customPermissions 覆盖:显式 false 拒绝,显式 true 放行', () => {
|
||||
expect(canAccessMenu('users', { role: 'admin', customPermissions: { users: false } })).toBe(false);
|
||||
expect(canAccessMenu('queries', { role: 'auditor', customPermissions: { queries: true } })).toBe(false); // 角色不在 roles,覆盖不越权
|
||||
expect(canAccessMenu('queries', { role: 'developer', customPermissions: { queries: true } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPerformAction 纯函数', () => {
|
||||
it('角色映射表判定', () => {
|
||||
expect(canPerformAction('ticket.execute', { role: 'developer' }, ACTION_MAP)).toBe(false);
|
||||
expect(canPerformAction('ticket.execute', { role: 'dba' }, ACTION_MAP)).toBe(true);
|
||||
expect(canPerformAction('user.manage', { role: 'dba' }, ACTION_MAP)).toBe(false);
|
||||
});
|
||||
|
||||
it('未知 action 拒绝', () => {
|
||||
expect(canPerformAction('nope.manage', { role: 'admin' }, ACTION_MAP)).toBe(false);
|
||||
});
|
||||
|
||||
it('customPermissions 覆盖优先于角色表', () => {
|
||||
expect(canPerformAction('ticket.delete', { role: 'dba', customPermissions: { 'ticket.delete': true } }, ACTION_MAP)).toBe(true);
|
||||
expect(canPerformAction('ticket.execute', { role: 'dba', customPermissions: { 'ticket.execute': false } }, ACTION_MAP)).toBe(false);
|
||||
});
|
||||
|
||||
it('developer 的工单创建/审批受可写数据源约束', () => {
|
||||
expect(canPerformAction('ticket.approve', { role: 'developer', hasWritableDataSourceAccess: false }, ACTION_MAP)).toBe(false);
|
||||
expect(canPerformAction('ticket.approve', { role: 'developer', hasWritableDataSourceAccess: true }, ACTION_MAP)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSafeRedirect 防开放重定向', () => {
|
||||
it('只放行同源相对路径', () => {
|
||||
expect(getSafeRedirect('/queries')).toBe('/queries');
|
||||
expect(getSafeRedirect('/tickets/42?page=1')).toBe('/tickets/42?page=1');
|
||||
});
|
||||
|
||||
it('拒绝外域与协议相对 URL', () => {
|
||||
expect(getSafeRedirect('//evil.com')).toBeNull();
|
||||
expect(getSafeRedirect('https://evil.com')).toBeNull();
|
||||
expect(getSafeRedirect('javascript:alert(1)')).toBeNull();
|
||||
expect(getSafeRedirect('')).toBeNull();
|
||||
expect(getSafeRedirect(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { readStorage, writeStorage, removeStorage, STORAGE_KEYS } from '@/lib/storage';
|
||||
|
||||
describe('storage 命名空间迁移(读旧写新)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('新 key 直接读取', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.token, 't1');
|
||||
expect(readStorage('token')).toBe('t1');
|
||||
});
|
||||
|
||||
it('旧 key 读取时自动迁移到新 key 并清理旧值', () => {
|
||||
localStorage.setItem('token', 'legacy-token');
|
||||
expect(readStorage('token')).toBe('legacy-token');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.token)).toBe('legacy-token');
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('写只写新 key', () => {
|
||||
writeStorage('theme', 'light-professional');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.theme)).toBe('light-professional');
|
||||
expect(localStorage.getItem('dg-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('删除同时清理新旧 key', () => {
|
||||
localStorage.setItem('dg-theme', 'ocean-deep');
|
||||
readStorage('theme'); // 触发迁移
|
||||
localStorage.setItem('dg-theme', 'again'); // 人为再放旧值
|
||||
removeStorage('theme');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.theme)).toBeNull();
|
||||
expect(localStorage.getItem('dg-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('存储不可用时静默降级', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceeded');
|
||||
});
|
||||
expect(() => writeStorage('theme', 'x')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 权限判定纯函数(指南 04 章 4.4:权限计算要纯函数化,可单测穷举)。
|
||||
* useAuth 的 hasPermission / hasActionPermission 委托到这里;禁止在组件里手写角色比较。
|
||||
*/
|
||||
import { menuItems, permissionAliasMap, type UserRole } from '@/constants/menu';
|
||||
|
||||
export interface PermissionContext {
|
||||
role: UserRole;
|
||||
/** 自定义权限覆盖(后端下发,覆盖角色默认) */
|
||||
customPermissions?: Record<string, boolean>;
|
||||
/** developer 是否有可写数据源(影响工单创建/审批) */
|
||||
hasWritableDataSourceAccess?: boolean;
|
||||
}
|
||||
|
||||
/** developer 的工单类操作需要至少一个可写数据源 */
|
||||
const WRITABLE_GATED_ACTIONS = new Set(['ticket.create', 'ticket.approve']);
|
||||
/** 菜单级同样受可写数据源约束的域 */
|
||||
const WRITABLE_GATED_MENUS = new Set(['tickets', 'workflow']);
|
||||
|
||||
/** 菜单/路由级权限:menuId 命中菜单项后按 roles + 自定义覆盖判定 */
|
||||
export function canAccessMenu(menuId: string, ctx: PermissionContext): boolean {
|
||||
const menuItem = menuItems.find((item) => item.id === menuId || item.permission === menuId);
|
||||
if (!menuItem) return false;
|
||||
|
||||
if (ctx.role === 'developer' && WRITABLE_GATED_MENUS.has(menuItem.id) && !ctx.hasWritableDataSourceAccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionKeys = [
|
||||
menuId,
|
||||
menuItem.permission,
|
||||
...(permissionAliasMap[menuId] || []),
|
||||
...(menuItem.permission ? permissionAliasMap[menuItem.permission] || [] : []),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const customKey = permissionKeys.find((key) => ctx.customPermissions && key in ctx.customPermissions);
|
||||
if (customKey) {
|
||||
return Boolean(ctx.customPermissions?.[customKey]) && menuItem.roles.includes(ctx.role);
|
||||
}
|
||||
return menuItem.roles.includes(ctx.role);
|
||||
}
|
||||
|
||||
/** 操作级权限:自定义覆盖优先,其次角色映射表 */
|
||||
export function canPerformAction(
|
||||
action: string,
|
||||
ctx: PermissionContext,
|
||||
actionPermissionMap: Record<string, UserRole[]>
|
||||
): boolean {
|
||||
if (ctx.role === 'developer' && WRITABLE_GATED_ACTIONS.has(action) && !ctx.hasWritableDataSourceAccess) {
|
||||
return false;
|
||||
}
|
||||
if (ctx.customPermissions && action in ctx.customPermissions) {
|
||||
return Boolean(ctx.customPermissions[action]);
|
||||
}
|
||||
const allowedRoles = actionPermissionMap[action];
|
||||
if (!allowedRoles) return false;
|
||||
return allowedRoles.includes(ctx.role);
|
||||
}
|
||||
|
||||
/** redirect 回跳同源校验:只放行 / 开头且非 // 开头的相对路径,防开放重定向 */
|
||||
export function getSafeRedirect(redirect: string | null): string | null {
|
||||
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return null;
|
||||
return redirect;
|
||||
}
|
||||
Reference in new issue
Block a user