背景: - 落实 11-前端路由与目录规范:URL、导航、面包屑、页面目录一一对应; - 「平台」更名「管理中心」,模块 key 由 platform 改为 system; - 原导航模块 key 与 URL 首段不一致(script vs /scripts、asset vs /assets), 占位页按 URL 解析不到模块,出现「页面不存在」。 改动: - nav-config:模块 key 改为 URL 首段(scripts / assets / system),平台更名 管理中心,管理中心全部路径收敛到 /system/*;用户页为 /system/users; - routes / router:ROUTES 按模块分组;新增 /system/users、/system/users/new、 /system/users/:id;旧 /users* 与 /platform* 加前端重定向;/system 落到用户列表; - 页面目录迁移:pages/users → pages/system/users,pages/home → pages/dashboard, 懒加载与所有引用同步; - 面包屑、AppLayout、登录回跳、通用占位页(按路径 + 权限解析,无权限进 403) 与单测 / e2e 路径同步; - 顺带修复 /scripts/library、/assets/library 等占位页因模块 key 不一致导致的 404。 影响面: - 仅前端路由与目录;后端 /api/v1/users 等接口与权限点不变; - 旧书签 /users*、/platform* 由前端重定向兼容一个版本。 回滚方式: - 回滚本提交即恢复 /users 与 /platform/* 路由、原页面目录与导航命名。 验证证据: - pnpm --filter @lingxi/web lint / typecheck / test 通过(15 files / 55 tests); - pnpm build 通过; - 浏览器:登录后落在 /system/users,导航显示「管理中心」;/users → /system/users, /platform/roles → /system/roles;有权限的 /scripts/library、/assets/library 显示占位页,无权限显示 403; - A1 e2e 6/6 通过(真实 API + 浏览器)。
This commit is contained in:
1 parent
a1e28daec5
commit
f97f97b0cd
40 files changed
+159
-106
No files matched your search
@@ -28,7 +28,7 @@ function renderGuard(entry: string): void {
|
|||||||
<MemoryRouter initialEntries={[entry]}>
|
<MemoryRouter initialEntries={[entry]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/system/users"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<div>受保护页面</div>
|
<div>受保护页面</div>
|
||||||
@@ -54,7 +54,7 @@ describe('登录守卫', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('未登录时跳登录页并携带 redirect', async () => {
|
it('未登录时跳登录页并携带 redirect', async () => {
|
||||||
renderGuard('/users');
|
renderGuard('/system/users');
|
||||||
expect(await screen.findByTestId('login-route')).toBeTruthy();
|
expect(await screen.findByTestId('login-route')).toBeTruthy();
|
||||||
expect(screen.queryByText('受保护页面')).toBeNull();
|
expect(screen.queryByText('受保护页面')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -70,7 +70,7 @@ describe('登录守卫', () => {
|
|||||||
permissions: ['user:view'],
|
permissions: ['user:view'],
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
});
|
});
|
||||||
renderGuard('/users');
|
renderGuard('/system/users');
|
||||||
expect(await screen.findByTestId('login-route')).toBeTruthy();
|
expect(await screen.findByTestId('login-route')).toBeTruthy();
|
||||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -78,7 +78,7 @@ describe('登录守卫', () => {
|
|||||||
it('已登录(演示会话)时直接渲染受保护内容', () => {
|
it('已登录(演示会话)时直接渲染受保护内容', () => {
|
||||||
useAuthStore.getState().signInDemo();
|
useAuthStore.getState().signInDemo();
|
||||||
expect(useAuthStore.getState().accessToken).not.toBeNull();
|
expect(useAuthStore.getState().accessToken).not.toBeNull();
|
||||||
renderGuard('/users');
|
renderGuard('/system/users');
|
||||||
expect(screen.getByText('受保护页面')).toBeTruthy();
|
expect(screen.getByText('受保护页面')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -16,10 +16,10 @@ function renderGuard(permissions: string[]): void {
|
|||||||
permissions,
|
permissions,
|
||||||
});
|
});
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/users']}>
|
<MemoryRouter initialEntries={['/system/users']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/system/users"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={Permission.USER_VIEW}>
|
<RequirePermission permission={Permission.USER_VIEW}>
|
||||||
<div>用户页面</div>
|
<div>用户页面</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { TopBar } from './TopBar';
|
|||||||
|
|
||||||
/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */
|
/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */
|
||||||
function isFlushRoute(pathname: string): boolean {
|
function isFlushRoute(pathname: string): boolean {
|
||||||
return pathname === ROUTES.users || pathname.startsWith(`${ROUTES.users}/`);
|
return pathname === ROUTES.system.users || pathname.startsWith(`${ROUTES.system.users}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppLayout(): ReactNode {
|
export function AppLayout(): ReactNode {
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
import type { BreadcrumbEntry } from './nav-config';
|
import type { BreadcrumbEntry } from './nav-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /users 下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
|
* 用户模块下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
|
||||||
* 这里补齐面包屑:工作台 / 平台 / 用户与组织 / 当前页。
|
* 这里补齐面包屑:工作台 / 管理中心 / 用户与组织 / 当前页。
|
||||||
*/
|
*/
|
||||||
export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
||||||
if (pathname === '/users/new') {
|
if (pathname === ROUTES.system.userNew) {
|
||||||
return [
|
return [
|
||||||
{ key: 'home', label: '工作台', path: '/' },
|
{ key: 'home', label: '工作台', path: ROUTES.home },
|
||||||
{ key: 'platform', label: '平台' },
|
{ key: 'system', label: '管理中心' },
|
||||||
{ key: 'users', label: '用户与组织', path: '/users' },
|
{ key: 'users', label: '用户与组织', path: ROUTES.system.users },
|
||||||
{ key: 'user-new', label: '新建用户' },
|
{ key: 'user-new', label: '新建用户' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (/^\/users\/[^/]+$/.test(pathname)) {
|
if (/^\/system\/users\/[^/]+$/.test(pathname)) {
|
||||||
return [
|
return [
|
||||||
{ key: 'home', label: '工作台', path: '/' },
|
{ key: 'home', label: '工作台', path: ROUTES.home },
|
||||||
{ key: 'platform', label: '平台' },
|
{ key: 'system', label: '管理中心' },
|
||||||
{ key: 'users', label: '用户与组织', path: '/users' },
|
{ key: 'users', label: '用户与组织', path: ROUTES.system.users },
|
||||||
{ key: 'user-detail', label: '成员详情' },
|
{ key: 'user-detail', label: '成员详情' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
function buildTarget(
|
||||||
|
pathname: string,
|
||||||
|
search: string,
|
||||||
|
hash: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): string {
|
||||||
|
const rest = pathname === from ? '' : pathname.slice(from.length);
|
||||||
|
return `${to}${rest}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧路径 `/users*` → `/system/users*`,保留一个版本后删除 */
|
||||||
|
export function LegacyUsersRedirect(): ReactNode {
|
||||||
|
const { pathname, search, hash } = useLocation();
|
||||||
|
return (
|
||||||
|
<Navigate to={buildTarget(pathname, search, hash, '/users', ROUTES.system.users)} replace />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧路径 `/platform*` → `/system*`;`/platform` 落到用户列表 */
|
||||||
|
export function LegacySystemRedirect(): ReactNode {
|
||||||
|
const { pathname, search, hash } = useLocation();
|
||||||
|
const to = pathname === '/platform' ? ROUTES.system.users : ROUTES.system.root;
|
||||||
|
const from = pathname === '/platform' ? '/platform' : '/platform';
|
||||||
|
return <Navigate to={buildTarget(pathname, search, hash, from, to)} replace />;
|
||||||
|
}
|
||||||
@@ -16,9 +16,9 @@ describe('导航配置', () => {
|
|||||||
|
|
||||||
it('缺少权限点时过滤菜单,模块没有子菜单时整组隐藏', () => {
|
it('缺少权限点时过滤菜单,模块没有子菜单时整组隐藏', () => {
|
||||||
const modules = filterNavByPermissions(NAV_MODULES, new Set(['script:read']));
|
const modules = filterNavByPermissions(NAV_MODULES, new Set(['script:read']));
|
||||||
const script = modules.find((module) => module.key === 'script');
|
const script = modules.find((module) => module.key === 'scripts');
|
||||||
expect(script?.items.map((item) => item.key)).toEqual(['library']);
|
expect(script?.items.map((item) => item.key)).toEqual(['library']);
|
||||||
expect(modules.some((module) => module.key === 'platform')).toBe(false);
|
expect(modules.some((module) => module.key === 'system')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('只有无权限要求的菜单时仍然可见', () => {
|
it('只有无权限要求的菜单时仍然可见', () => {
|
||||||
@@ -26,10 +26,10 @@ describe('导航配置', () => {
|
|||||||
expect(modules.map((module) => module.key)).toEqual(['dashboard']);
|
expect(modules.map((module) => module.key)).toEqual(['dashboard']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('子路由(/users/new、/users/:id)也归属用户与组织', () => {
|
it('子路由(/system/users/new、/system/users/:id)也归属用户与组织', () => {
|
||||||
expect(findActiveNav(NAV_MODULES, '/users/new')?.item?.label).toBe('用户与组织');
|
expect(findActiveNav(NAV_MODULES, '/system/users/new')?.item?.label).toBe('用户与组织');
|
||||||
expect(findActiveNav(NAV_MODULES, '/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
|
expect(findActiveNav(NAV_MODULES, '/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
|
||||||
'platform',
|
'system',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ describe('导航配置', () => {
|
|||||||
|
|
||||||
it('面包屑从导航配置派生', () => {
|
it('面包屑从导航配置派生', () => {
|
||||||
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
||||||
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/users');
|
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users');
|
||||||
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '平台', '用户与组织']);
|
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户与组织']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -68,7 +68,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'script',
|
key: 'scripts',
|
||||||
label: '脚本',
|
label: '脚本',
|
||||||
path: '/scripts/library',
|
path: '/scripts/library',
|
||||||
icon: IconScript,
|
icon: IconScript,
|
||||||
@@ -166,7 +166,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'asset',
|
key: 'assets',
|
||||||
label: '资产',
|
label: '资产',
|
||||||
path: '/assets/library',
|
path: '/assets/library',
|
||||||
icon: IconAsset,
|
icon: IconAsset,
|
||||||
@@ -240,16 +240,16 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'platform',
|
key: 'system',
|
||||||
label: '平台',
|
label: '管理中心',
|
||||||
path: '/users',
|
path: '/system/users',
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
permission: Permission.USER_VIEW,
|
permission: Permission.USER_VIEW,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'users',
|
key: 'users',
|
||||||
label: '用户与组织',
|
label: '用户与组织',
|
||||||
path: '/users',
|
path: '/system/users',
|
||||||
icon: IconUser,
|
icon: IconUser,
|
||||||
permission: Permission.USER_VIEW,
|
permission: Permission.USER_VIEW,
|
||||||
description: '账号、组织与登录状态',
|
description: '账号、组织与登录状态',
|
||||||
@@ -257,7 +257,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'roles',
|
key: 'roles',
|
||||||
label: '角色权限',
|
label: '角色权限',
|
||||||
path: '/platform/roles',
|
path: '/system/roles',
|
||||||
icon: IconShield,
|
icon: IconShield,
|
||||||
permission: Permission.ROLE_MANAGE,
|
permission: Permission.ROLE_MANAGE,
|
||||||
description: '角色、权限点与职责互斥',
|
description: '角色、权限点与职责互斥',
|
||||||
@@ -265,7 +265,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'customers',
|
key: 'customers',
|
||||||
label: '客户',
|
label: '客户',
|
||||||
path: '/platform/customers',
|
path: '/system/customers',
|
||||||
icon: IconGlobal,
|
icon: IconGlobal,
|
||||||
permission: Permission.CUSTOMER_MANAGE,
|
permission: Permission.CUSTOMER_MANAGE,
|
||||||
description: '客户、行业归属与数据范围',
|
description: '客户、行业归属与数据范围',
|
||||||
@@ -273,7 +273,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'industries',
|
key: 'industries',
|
||||||
label: '行业树',
|
label: '行业树',
|
||||||
path: '/platform/industries',
|
path: '/system/industries',
|
||||||
icon: IconStack,
|
icon: IconStack,
|
||||||
permission: Permission.INDUSTRY_MANAGE,
|
permission: Permission.INDUSTRY_MANAGE,
|
||||||
description: '行业分类树与行业包绑定',
|
description: '行业分类树与行业包绑定',
|
||||||
@@ -281,7 +281,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'dicts',
|
key: 'dicts',
|
||||||
label: '字典',
|
label: '字典',
|
||||||
path: '/platform/dicts',
|
path: '/system/dicts',
|
||||||
icon: IconDocs,
|
icon: IconDocs,
|
||||||
permission: Permission.DICT_MANAGE,
|
permission: Permission.DICT_MANAGE,
|
||||||
description: '核心字典与业务枚举',
|
description: '核心字典与业务枚举',
|
||||||
@@ -289,7 +289,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'audit',
|
key: 'audit',
|
||||||
label: '审计日志',
|
label: '审计日志',
|
||||||
path: '/platform/audit',
|
path: '/system/audit-logs',
|
||||||
icon: IconView,
|
icon: IconView,
|
||||||
permission: Permission.AUDIT_READ,
|
permission: Permission.AUDIT_READ,
|
||||||
description: '关键操作留痕与导出记录',
|
description: '关键操作留痕与导出记录',
|
||||||
@@ -297,7 +297,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'ai',
|
key: 'ai',
|
||||||
label: '模型接入',
|
label: '模型接入',
|
||||||
path: '/platform/ai',
|
path: '/system/ai',
|
||||||
icon: IconRobot,
|
icon: IconRobot,
|
||||||
permission: Permission.AI_CONFIGURE,
|
permission: Permission.AI_CONFIGURE,
|
||||||
description: '供应商、路由与预算阈值',
|
description: '供应商、路由与预算阈值',
|
||||||
@@ -305,7 +305,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
label: '系统配置',
|
label: '系统配置',
|
||||||
path: '/platform/settings',
|
path: '/system/settings',
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
permission: Permission.SYSTEM_CONFIGURE,
|
permission: Permission.SYSTEM_CONFIGURE,
|
||||||
description: '阈值、上限与功能开关',
|
description: '阈值、上限与功能开关',
|
||||||
@@ -313,7 +313,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'industry-packs',
|
key: 'industry-packs',
|
||||||
label: '行业包',
|
label: '行业包',
|
||||||
path: '/platform/industry-packs',
|
path: '/system/industry-packs',
|
||||||
icon: IconGrid,
|
icon: IconGrid,
|
||||||
permission: Permission.INDUSTRY_MANAGE,
|
permission: Permission.INDUSTRY_MANAGE,
|
||||||
description: '行业包版本、发布与灰度',
|
description: '行业包版本、发布与灰度',
|
||||||
@@ -321,7 +321,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'forms',
|
key: 'forms',
|
||||||
label: '表单设计器',
|
label: '表单设计器',
|
||||||
path: '/platform/forms',
|
path: '/system/forms',
|
||||||
icon: IconGrid,
|
icon: IconGrid,
|
||||||
permission: Permission.FORM_DESIGN,
|
permission: Permission.FORM_DESIGN,
|
||||||
description: '受控字段白名单与表单布局',
|
description: '受控字段白名单与表单布局',
|
||||||
@@ -329,7 +329,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'customer-fields',
|
key: 'customer-fields',
|
||||||
label: '客户字段',
|
label: '客户字段',
|
||||||
path: '/platform/customer-fields',
|
path: '/system/customer-fields',
|
||||||
icon: IconTask,
|
icon: IconTask,
|
||||||
permission: Permission.CUSTOMER_MANAGE,
|
permission: Permission.CUSTOMER_MANAGE,
|
||||||
description: '客户级扩展字段与审批',
|
description: '客户级扩展字段与审批',
|
||||||
@@ -337,7 +337,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'compliance',
|
key: 'compliance',
|
||||||
label: '合规规则',
|
label: '合规规则',
|
||||||
path: '/platform/compliance',
|
path: '/system/compliance',
|
||||||
icon: IconShield,
|
icon: IconShield,
|
||||||
permission: Permission.SYSTEM_CONFIGURE,
|
permission: Permission.SYSTEM_CONFIGURE,
|
||||||
description: '禁忌词、极限词与合规规则集',
|
description: '禁忌词、极限词与合规规则集',
|
||||||
|
|||||||
File renamed without changes.
@@ -24,7 +24,7 @@ export default function LoginPage(): ReactNode {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const form = useLoginForm();
|
const form = useLoginForm();
|
||||||
const clearSession = useAuthStore((state) => state.clearSession);
|
const clearSession = useAuthStore((state) => state.clearSession);
|
||||||
const target = sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users;
|
const target = sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.system.users;
|
||||||
const accountDisabled = searchParams.get('reason') === 'account-disabled';
|
const accountDisabled = searchParams.get('reason') === 'account-disabled';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function useLoginForm(): LoginFormState {
|
|||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const redirectTarget = (): string =>
|
const redirectTarget = (): string =>
|
||||||
sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users;
|
sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.system.users;
|
||||||
|
|
||||||
const submit = async (): Promise<void> => {
|
const submit = async (): Promise<void> => {
|
||||||
if (submitting || lockedMessage !== null || mustChangePassword) {
|
if (submitting || lockedMessage !== null || mustChangePassword) {
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+5
-3
@@ -213,7 +213,9 @@ export function UserNewPage(): ReactNode {
|
|||||||
createUser.mutate(input, {
|
createUser.mutate(input, {
|
||||||
onSuccess: (userId) => {
|
onSuccess: (userId) => {
|
||||||
notifySuccess('成员已创建,初始密码已生成');
|
notifySuccess('成员已创建,初始密码已生成');
|
||||||
void navigate(userId === null ? ROUTES.users : userDetailPath(userId), { replace: true });
|
void navigate(userId === null ? ROUTES.system.users : userDetailPath(userId), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -241,7 +243,7 @@ export function UserNewPage(): ReactNode {
|
|||||||
<Button
|
<Button
|
||||||
icon={<IconBack size={14} />}
|
icon={<IconBack size={14} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate(ROUTES.users);
|
void navigate(ROUTES.system.users);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
返回列表
|
返回列表
|
||||||
@@ -264,7 +266,7 @@ export function UserNewPage(): ReactNode {
|
|||||||
<footer className="lx-new-user__footer">
|
<footer className="lx-new-user__footer">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate(ROUTES.users);
|
void navigate(ROUTES.system.users);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
File renamed without changes.
+2
-2
@@ -40,7 +40,7 @@ function UsersHeader(): ReactNode {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<IconAdd size={16} />}
|
icon={<IconAdd size={16} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate(ROUTES.userNew);
|
void navigate(ROUTES.system.userNew);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
新建用户
|
新建用户
|
||||||
@@ -94,7 +94,7 @@ export function UsersPage(): ReactNode {
|
|||||||
onNext={list.onNext}
|
onNext={list.onNext}
|
||||||
onRetry={list.onRetry}
|
onRetry={list.onRetry}
|
||||||
onCreate={() => {
|
onCreate={() => {
|
||||||
void navigate(ROUTES.userNew);
|
void navigate(ROUTES.system.userNew);
|
||||||
}}
|
}}
|
||||||
onResetFilters={list.resetFilters}
|
onResetFilters={list.resetFilters}
|
||||||
/>
|
/>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,38 +1,24 @@
|
|||||||
import { PageEmpty } from '@lingxi/ui';
|
import { PageEmpty } from '@lingxi/ui';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Link, useParams } from 'react-router';
|
import { Link, Navigate, useLocation } from 'react-router';
|
||||||
|
|
||||||
import {
|
import { filterNavByPermissions, findActiveNav, NAV_MODULES } from '@/navigation/nav-config';
|
||||||
filterNavByPermissions,
|
|
||||||
findNavModule,
|
|
||||||
NAV_MODULES,
|
|
||||||
type NavItem,
|
|
||||||
} from '@/navigation/nav-config';
|
|
||||||
import { ROUTES } from '@/routes';
|
import { ROUTES } from '@/routes';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
function resolvePage(
|
/** 通用占位页:按当前路径在导航配置里定位模块与功能,未实现的批次先展示空态 */
|
||||||
moduleKey: string | undefined,
|
|
||||||
itemKey: string | undefined,
|
|
||||||
): { moduleLabel: string; item: NavItem } | null {
|
|
||||||
if (moduleKey === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const permissions = new Set(useAuthStore.getState().permissions);
|
|
||||||
const modules = filterNavByPermissions(NAV_MODULES, permissions);
|
|
||||||
const module = findNavModule(modules, moduleKey);
|
|
||||||
if (module === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const item = module.items.find((candidate) => candidate.key === itemKey) ?? module.items[0];
|
|
||||||
return item === undefined ? null : { moduleLabel: module.label, item };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkspacePage(): ReactNode {
|
export default function WorkspacePage(): ReactNode {
|
||||||
const { moduleKey, itemKey } = useParams<{ moduleKey: string; itemKey?: string }>();
|
const { pathname } = useLocation();
|
||||||
const page = resolvePage(moduleKey, itemKey);
|
const permissions = useAuthStore((state) => state.permissions);
|
||||||
|
const exists = findActiveNav(NAV_MODULES, pathname) !== null;
|
||||||
|
const active = findActiveNav(filterNavByPermissions(NAV_MODULES, new Set(permissions)), pathname);
|
||||||
|
|
||||||
if (page === null) {
|
// 路由存在但当前账号无权限:按 API-29 进 403,而不是伪装成 404
|
||||||
|
if (exists && active?.item === undefined) {
|
||||||
|
return <Navigate to={ROUTES.forbidden} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active?.item === undefined) {
|
||||||
return (
|
return (
|
||||||
<section className="lx-panel lx-workspace-missing">
|
<section className="lx-panel lx-workspace-missing">
|
||||||
<p className="lx-eyebrow">404</p>
|
<p className="lx-eyebrow">404</p>
|
||||||
@@ -49,9 +35,9 @@ export default function WorkspacePage(): ReactNode {
|
|||||||
<section className="lx-workspace-page">
|
<section className="lx-workspace-page">
|
||||||
<header className="lx-page-header">
|
<header className="lx-page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="lx-eyebrow">{page.moduleLabel}</p>
|
<p className="lx-eyebrow">{active.module.label}</p>
|
||||||
<h1>{page.item.label}</h1>
|
<h1>{active.item.label}</h1>
|
||||||
<p className="lx-page-header__description">{page.item.description}</p>
|
<p className="lx-page-header__description">{active.item.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+17
-7
@@ -2,11 +2,12 @@
|
|||||||
import { Permission } from '@lingxi/shared';
|
import { Permission } from '@lingxi/shared';
|
||||||
import { PageSkeleton } from '@lingxi/ui';
|
import { PageSkeleton } from '@lingxi/ui';
|
||||||
import { lazy, type ReactNode, Suspense } from 'react';
|
import { lazy, type ReactNode, Suspense } from 'react';
|
||||||
import { createBrowserRouter } from 'react-router';
|
import { createBrowserRouter, Navigate } from 'react-router';
|
||||||
|
|
||||||
import { RequireAuth } from '@/components/auth/RequireAuth';
|
import { RequireAuth } from '@/components/auth/RequireAuth';
|
||||||
import { RequirePermission } from '@/components/auth/RequirePermission';
|
import { RequirePermission } from '@/components/auth/RequirePermission';
|
||||||
import { AppLayout } from '@/layout/AppLayout';
|
import { AppLayout } from '@/layout/AppLayout';
|
||||||
|
import { LegacySystemRedirect, LegacyUsersRedirect } from '@/navigation/legacy-redirect';
|
||||||
import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
|
import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
|
||||||
import { NotFoundPage } from '@/pages/errors/NotFoundPage';
|
import { NotFoundPage } from '@/pages/errors/NotFoundPage';
|
||||||
import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
|
import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
|
||||||
@@ -14,12 +15,12 @@ import { UnauthorizedPage } from '@/pages/errors/UnauthorizedPage';
|
|||||||
import { ROUTES } from '@/routes';
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
const UsersPage = lazy(async () => {
|
const UsersPage = lazy(async () => {
|
||||||
const module = await import('@/pages/users/UsersPage');
|
const module = await import('@/pages/system/users/UsersPage');
|
||||||
return { default: module.UsersPage };
|
return { default: module.UsersPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
const UserNewPage = lazy(async () => {
|
const UserNewPage = lazy(async () => {
|
||||||
const module = await import('@/pages/users/UserNewPage');
|
const module = await import('@/pages/system/users/UserNewPage');
|
||||||
return { default: module.UserNewPage };
|
return { default: module.UserNewPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +29,11 @@ function withSkeleton(node: ReactNode): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
// 旧地址兼容:/users* → /system/users*,/platform* → /system*
|
||||||
|
{ path: 'users', element: <LegacyUsersRedirect /> },
|
||||||
|
{ path: 'users/*', element: <LegacyUsersRedirect /> },
|
||||||
|
{ path: 'platform', element: <LegacySystemRedirect /> },
|
||||||
|
{ path: 'platform/*', element: <LegacySystemRedirect /> },
|
||||||
{
|
{
|
||||||
path: ROUTES.login,
|
path: ROUTES.login,
|
||||||
lazy: async () => ({
|
lazy: async () => ({
|
||||||
@@ -45,11 +51,15 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
lazy: async () => ({
|
lazy: async () => ({
|
||||||
Component: (await import('@/pages/home/HomePage')).default,
|
Component: (await import('@/pages/dashboard/HomePage')).default,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'system',
|
||||||
|
element: <Navigate to={ROUTES.system.users} replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/users',
|
||||||
element: (
|
element: (
|
||||||
<RequirePermission permission={Permission.USER_VIEW}>
|
<RequirePermission permission={Permission.USER_VIEW}>
|
||||||
{withSkeleton(<UsersPage />)}
|
{withSkeleton(<UsersPage />)}
|
||||||
@@ -57,7 +67,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'users/new',
|
path: 'system/users/new',
|
||||||
element: (
|
element: (
|
||||||
<RequirePermission permission={Permission.USER_MANAGE}>
|
<RequirePermission permission={Permission.USER_MANAGE}>
|
||||||
{withSkeleton(<UserNewPage />)}
|
{withSkeleton(<UserNewPage />)}
|
||||||
@@ -65,7 +75,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'users/:id',
|
path: 'system/users/:id',
|
||||||
element: (
|
element: (
|
||||||
<RequirePermission permission={Permission.USER_VIEW}>
|
<RequirePermission permission={Permission.USER_VIEW}>
|
||||||
{withSkeleton(<UsersPage />)}
|
{withSkeleton(<UsersPage />)}
|
||||||
|
|||||||
@@ -3,25 +3,29 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { loginPath, ROUTES, sanitizeRedirect, userDetailPath } from './routes';
|
import { loginPath, ROUTES, sanitizeRedirect, userDetailPath } from './routes';
|
||||||
|
|
||||||
describe('路由常量与登录回跳', () => {
|
describe('路由常量与登录回跳', () => {
|
||||||
it('用户路由按附录 C 使用 /users', () => {
|
it('用户路由遵循 /system/users 模块化约定', () => {
|
||||||
expect(ROUTES.users).toBe('/users');
|
expect(ROUTES.system.users).toBe('/system/users');
|
||||||
expect(ROUTES.userNew).toBe('/users/new');
|
expect(ROUTES.system.userNew).toBe('/system/users/new');
|
||||||
expect(userDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe('/users/01HZZZZZZZZZZZZZZZZZZZZZZZ');
|
expect(userDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
|
||||||
|
'/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sanitizeRedirect 只接受站内路径', () => {
|
it('sanitizeRedirect 只接受站内路径', () => {
|
||||||
expect(sanitizeRedirect('/users/new')).toBe('/users/new');
|
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
|
||||||
expect(sanitizeRedirect(' /users?status=active ')).toBe('/users?status=active');
|
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
|
||||||
expect(sanitizeRedirect('https://evil.example.com')).toBeNull();
|
expect(sanitizeRedirect('https://evil.example.com')).toBeNull();
|
||||||
expect(sanitizeRedirect('//evil.example.com')).toBeNull();
|
expect(sanitizeRedirect('//evil.example.com')).toBeNull();
|
||||||
expect(sanitizeRedirect('javascript:alert(1)')).toBeNull();
|
expect(sanitizeRedirect('javascript:alert(1)')).toBeNull();
|
||||||
expect(sanitizeRedirect('/login?redirect=/users')).toBeNull();
|
expect(sanitizeRedirect('/login?redirect=/system/users')).toBeNull();
|
||||||
expect(sanitizeRedirect(null)).toBeNull();
|
expect(sanitizeRedirect(null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loginPath 编码 redirect 参数', () => {
|
it('loginPath 编码 redirect 参数', () => {
|
||||||
expect(loginPath()).toBe('/login');
|
expect(loginPath()).toBe('/login');
|
||||||
expect(loginPath('/users?status=active')).toBe('/login?redirect=%2Fusers%3Fstatus%3Dactive');
|
expect(loginPath('/system/users?status=active')).toBe(
|
||||||
|
'/login?redirect=%2Fsystem%2Fusers%3Fstatus%3Dactive',
|
||||||
|
);
|
||||||
expect(loginPath('https://evil.example.com')).toBe('/login');
|
expect(loginPath('https://evil.example.com')).toBe('/login');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+25
-7
@@ -1,21 +1,39 @@
|
|||||||
/** 前端路由常量(工程规范 04:路由集中管理,禁止散落字符串) */
|
/**
|
||||||
|
* 前端路由常量(工程规范 04 / 11:路由集中管理,禁止散落字符串)
|
||||||
|
*
|
||||||
|
* 约定:`/<模块>/<功能>`;模块 key、基础路径与页面目录一一对应。
|
||||||
|
* 例如:管理中心(system)→ /system/users → pages/system/users。
|
||||||
|
*/
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
home: '/',
|
home: '/',
|
||||||
login: '/login',
|
login: '/login',
|
||||||
users: '/users',
|
system: {
|
||||||
userNew: '/users/new',
|
root: '/system',
|
||||||
userDetail: '/users/:id',
|
users: '/system/users',
|
||||||
|
userNew: '/system/users/new',
|
||||||
|
userDetail: '/system/users/:id',
|
||||||
|
},
|
||||||
unauthorized: '/401',
|
unauthorized: '/401',
|
||||||
forbidden: '/403',
|
forbidden: '/403',
|
||||||
notFound: '/404',
|
notFound: '/404',
|
||||||
serverError: '/500',
|
serverError: '/500',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
export type RoutePath =
|
||||||
|
| typeof ROUTES.home
|
||||||
|
| typeof ROUTES.login
|
||||||
|
| typeof ROUTES.system.root
|
||||||
|
| typeof ROUTES.system.users
|
||||||
|
| typeof ROUTES.system.userNew
|
||||||
|
| typeof ROUTES.system.userDetail
|
||||||
|
| typeof ROUTES.unauthorized
|
||||||
|
| typeof ROUTES.forbidden
|
||||||
|
| typeof ROUTES.notFound
|
||||||
|
| typeof ROUTES.serverError;
|
||||||
|
|
||||||
/** 用户详情深链:`/users/{id}` */
|
/** 用户详情深链:`/system/users/{id}` */
|
||||||
export function userDetailPath(userId: string): string {
|
export function userDetailPath(userId: string): string {
|
||||||
return `${ROUTES.users}/${encodeURIComponent(userId)}`;
|
return `${ROUTES.system.users}/${encodeURIComponent(userId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ test('① 登录 → 首登强制改密 → 重新登录 → /users 正常', asy
|
|||||||
await page.getByPlaceholder('请输入密码').fill(USER_PASSWORD);
|
await page.getByPlaceholder('请输入密码').fill(USER_PASSWORD);
|
||||||
await submitLogin(page);
|
await submitLogin(page);
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/users/);
|
await expect(page).toHaveURL(/\/system\/users/);
|
||||||
await expectUsersPage(page);
|
await expectUsersPage(page);
|
||||||
await screenshot(page, '01-users-after-relogin');
|
await screenshot(page, '01-users-after-relogin');
|
||||||
});
|
});
|
||||||
@@ -531,7 +531,7 @@ test('② 刷新页面仍在线:access 失效后保留 refresh 触发单飞刷
|
|||||||
|
|
||||||
await fillLogin(page, user.account, USER_PASSWORD);
|
await fillLogin(page, user.account, USER_PASSWORD);
|
||||||
await submitLogin(page);
|
await submitLogin(page);
|
||||||
await expect(page).toHaveURL(/\/users/);
|
await expect(page).toHaveURL(/\/system\/users/);
|
||||||
await expectUsersPage(page);
|
await expectUsersPage(page);
|
||||||
|
|
||||||
const before = await readSession(page);
|
const before = await readSession(page);
|
||||||
@@ -558,7 +558,7 @@ test('② 刷新页面仍在线:access 失效后保留 refresh 触发单飞刷
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(refreshResponse.status(), '/auth/refresh 响应状态').toBe(200);
|
expect(refreshResponse.status(), '/auth/refresh 响应状态').toBe(200);
|
||||||
await expect(page).toHaveURL(/\/users/);
|
await expect(page).toHaveURL(/\/system\/users/);
|
||||||
await expectUsersPage(page);
|
await expectUsersPage(page);
|
||||||
|
|
||||||
const after = await readSession(page);
|
const after = await readSession(page);
|
||||||
@@ -638,7 +638,7 @@ test('④ 停用后旧会话被拒(40301),再登录提示「账号已停
|
|||||||
// 旧标签页带着停用前会话回到 /users:应被拒(当前实现显示错误态而非回登录页)
|
// 旧标签页带着停用前会话回到 /users:应被拒(当前实现显示错误态而非回登录页)
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await writeSession(page, session);
|
await writeSession(page, session);
|
||||||
await page.goto('/users');
|
await page.goto('/system/users');
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
await expect(page.locator('.lx-login__notice')).toContainText('账号已被停用');
|
await expect(page.locator('.lx-login__notice')).toContainText('账号已被停用');
|
||||||
await expect(page.getByRole('heading', { name: '用户与组织' })).toHaveCount(0);
|
await expect(page.getByRole('heading', { name: '用户与组织' })).toHaveCount(0);
|
||||||
@@ -695,7 +695,7 @@ test('⑤ 管理员调整角色后,下一次请求权限立即生效', async (
|
|||||||
const restoredPage = await restored.newPage();
|
const restoredPage = await restored.newPage();
|
||||||
await fillLogin(restoredPage, user.account, USER_PASSWORD);
|
await fillLogin(restoredPage, user.account, USER_PASSWORD);
|
||||||
await submitLogin(restoredPage);
|
await submitLogin(restoredPage);
|
||||||
await expect(restoredPage).toHaveURL(/\/users/);
|
await expect(restoredPage).toHaveURL(/\/system\/users/);
|
||||||
await expectUsersPage(restoredPage);
|
await expectUsersPage(restoredPage);
|
||||||
await screenshot(restoredPage, '05-role-restored');
|
await screenshot(restoredPage, '05-role-restored');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in new issue
Block a user