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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user