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

+12 -46
View File
@@ -1,7 +1,8 @@
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { menuItems, permissionAliasMap, menuIdForPath, type UserRole } from '@/constants/menu';
import { menuItems, type UserRole } from '@/constants/menu';
import { instanceApi } from '@/api/instance';
import { STORAGE_KEYS, readStorage, writeStorage, removeStorage } from '@/lib/storage';
import { canAccessMenu, canPerformAction } from '@/lib/permissions';
const defaultUser = {
id: 0,
@@ -111,8 +112,6 @@ const roleWeightMap: Record<UserRole, number> = {
auditor: 40,
};
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
export const actionPermissionMap: Record<string, UserRole[]> = {
'ticket.create': ['admin', 'dba', 'developer'],
'ticket.approve': ['admin', 'dba', 'developer'],
@@ -356,28 +355,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return cache.menu.get(menuId)!;
}
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
const allowedRoles = menuItem?.roles;
if (!allowedRoles) {
cache.menu.set(menuId, false);
return false;
}
const targetMenuId = menuItem?.id || menuId;
if (user.role === 'developer' && dataChangePermissionIds.has(targetMenuId) && !hasWritableDataSourceAccess) {
cache.menu.set(menuId, false);
cache.timestamp = Date.now();
return false;
}
const permissionKeys = [
menuId,
menuItem?.permission,
...((permissionAliasMap[menuId] || [])),
...((menuItem?.permission && permissionAliasMap[menuItem.permission]) || []),
].filter(Boolean) as string[];
const customPermission = permissionKeys.find(key => user.customPermissions && key in user.customPermissions);
const result = allowedRoles.includes(user.role)
&& (customPermission ? Boolean(user.customPermissions?.[customPermission]) : true);
const result = canAccessMenu(menuId, {
role: user.role,
customPermissions: user.customPermissions,
hasWritableDataSourceAccess,
});
cache.menu.set(menuId, result);
cache.timestamp = Date.now();
return result;
@@ -392,27 +374,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return cache.action.get(action)!;
}
if (user.role === 'developer' && ['ticket.create', 'ticket.approve'].includes(String(action)) && !hasWritableDataSourceAccess) {
cache.action.set(action, false);
cache.timestamp = Date.now();
return false;
}
// 优先检查自定义权限
if (user.customPermissions && action in user.customPermissions) {
const customResult = user.customPermissions[action];
cache.action.set(action, customResult);
cache.timestamp = Date.now();
return customResult;
}
const allowedRoles = actionPermissionMap[action];
if (!allowedRoles) {
cache.action.set(action, false);
return false;
}
const result = allowedRoles.includes(user.role);
const result = canPerformAction(action, {
role: user.role,
customPermissions: user.customPermissions,
hasWritableDataSourceAccess,
}, actionPermissionMap);
cache.action.set(action, result);
cache.timestamp = Date.now();
return result;
+1 -6
View File
@@ -4,6 +4,7 @@ import { useAuth } from '@/hooks/useAuth';
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
import api from '@/api/request';
import { writeStorage } from '@/lib/storage';
import { getSafeRedirect } from '@/lib/permissions';
export interface LoginState {
username: string;
@@ -15,12 +16,6 @@ export interface LoginState {
handleLogin: (e: React.FormEvent) => Promise<void>;
}
/** 只放行同源相对路径(/ 开头且非 // 开头),防开放重定向 */
function getSafeRedirect(redirect: string | null): string | null {
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return null;
return redirect;
}
export function useLogin(): LoginState {
const navigate = useNavigate();
const location = useLocation();
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
/**
* URL query 作为筛选/分页状态的唯一源(指南 03 章:筛选/分页/Tab 状态走 URL query,
* 永不复制进 store)。刷新、分享链接、浏览器回退均可恢复状态。
*
* 用法:
* const [page, setPage] = useQueryState('page', 1, asNumber);
* const [keyword, setKeyword] = useQueryState('q', '', asString);
* 更新走 history replace,不产生历史记录;值等于默认值时从 URL 移除保持简洁。
*/
export function useQueryState<T extends string | number>(
key: string,
defaultValue: T,
parse: (v: string) => T
): [T, (value: T | null) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const value = useMemo<T>(() => {
const raw = searchParams.get(key);
if (raw === null || raw === '') return defaultValue;
try {
const parsed = parse(raw);
return parsed;
} catch {
return defaultValue;
}
}, [searchParams, key, defaultValue, parse]);
const setValue = useCallback(
(next: T | null) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
const resolved = next === null ? defaultValue : next;
if (String(resolved) === String(defaultValue)) {
params.delete(key);
} else {
params.set(key, String(resolved));
}
return params;
},
{ replace: true }
);
},
[setSearchParams, key, defaultValue]
);
return [value, setValue];
}
/** 常用解析器 */
export const asString = (v: string) => v;
export const asNumber = (v: string) => {
const n = Number(v);
if (Number.isNaN(n)) throw new TypeError(`not a number: ${v}`);
return n;
};