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:
Logan.Li committed 2026-10-26 17:54:15 +08:00
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();
});
});
+64
View File
@@ -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;
}