feat: 重构前端为企业 Admin 后台布局,引入 React Router 路由
- 引入 React Router v7 (Declarative mode) 实现 SPA 路由 - 重构 Layout 为 Header + 侧边栏 + 内容区的企业 Admin 布局 - 新增侧边栏菜单组件,支持折叠/展开,状态持久化到 localStorage - 新增示例页面:仪表盘、用户管理、系统设置、404 - 菜单配置与路由统一为单一数据源 (menu.tsx) - Vite code splitting 新增 vendor-router 组 - 更新 DEVELOPMENT.md 和 README.md 文档
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { Layout, Menu, RadioGroup, Space } from "tdesign-react";
|
||||
|
||||
import type { HealthResponse } from "../shared/api";
|
||||
import { useLocation } from "react-router";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||
import { Button, Layout, RadioGroup } from "tdesign-react";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||
import { MENU_ITEMS } from "./menu";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const { Aside, Content, Header } = Layout;
|
||||
|
||||
const { Content, Header } = Layout;
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
{ label: "明亮", value: "light" },
|
||||
@@ -16,12 +20,8 @@ const THEME_OPTIONS = [
|
||||
|
||||
export function App() {
|
||||
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { data: health } = useQuery({
|
||||
queryFn: fetchHealth,
|
||||
queryKey: ["health"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = APP.title;
|
||||
@@ -32,43 +32,40 @@ export function App() {
|
||||
setThemePreference(value);
|
||||
};
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const pageTitle = currentItem?.label ?? APP.title;
|
||||
|
||||
return (
|
||||
<Layout className="dashboard">
|
||||
<Header>
|
||||
<Menu.HeadMenu
|
||||
logo={
|
||||
<span className="dashboard-brand">
|
||||
<span className="dashboard-logo">{APP.title}</span>
|
||||
</span>
|
||||
}
|
||||
operations={
|
||||
<div className="dashboard-header-controls">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
theme="button"
|
||||
value={themePreference}
|
||||
variant="default-filled"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Header>
|
||||
<Content>
|
||||
<div className="dashboard-content">
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<h2>欢迎使用 {APP.title}</h2>
|
||||
<p>在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):</p>
|
||||
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
|
||||
</Space>
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<Button className="app-sidebar-toggle" onClick={toggleCollapsed} shape="square" variant="text">
|
||||
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</Button>
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
<span className="app-page-title">{pageTitle}</span>
|
||||
</div>
|
||||
</Content>
|
||||
<div className="app-header-right">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
theme="button"
|
||||
value={themePreference}
|
||||
variant="default-filled"
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Aside className="app-sidebar" width={collapsed ? "80px" : "232px"}>
|
||||
<Sidebar collapsed={collapsed} />
|
||||
</Aside>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<HealthResponse> {
|
||||
const response = await fetch("/health");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<HealthResponse>;
|
||||
}
|
||||
|
||||
42
src/web/components/Sidebar/index.tsx
Normal file
42
src/web/components/Sidebar/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { Menu } from "tdesign-react";
|
||||
|
||||
import { MENU_ITEMS } from "../../menu";
|
||||
|
||||
const { MenuItem } = Menu;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const activeValue = currentItem?.value ?? "";
|
||||
|
||||
const handleMenuChange = (value: number | string) => {
|
||||
const item = MENU_ITEMS.find((item) => item.value === value);
|
||||
if (item) {
|
||||
void navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className="app-sidebar-menu"
|
||||
collapsed={collapsed}
|
||||
onChange={handleMenuChange}
|
||||
value={activeValue}
|
||||
width={collapsed ? "80px" : "232px"}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
|
||||
|
||||
export function applyInitialSidebarCollapsed() {
|
||||
const collapsed = readSidebarCollapsed();
|
||||
applySidebarCollapsed(collapsed);
|
||||
}
|
||||
|
||||
export function applySidebarCollapsed(collapsed: boolean, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("data-sidebar-collapsed", String(collapsed));
|
||||
}
|
||||
|
||||
export function parseSidebarCollapsed(value: unknown): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function readSidebarCollapsed(storage: Storage = window.localStorage): boolean {
|
||||
try {
|
||||
return parseSidebarCollapsed(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
|
||||
|
||||
useEffect(() => {
|
||||
applySidebarCollapsed(collapsed);
|
||||
}, [collapsed]);
|
||||
|
||||
const setCollapsed = (nextCollapsed: boolean) => {
|
||||
setCollapsedState(nextCollapsed);
|
||||
writeSidebarCollapsed(nextCollapsed);
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
return { collapsed, setCollapsed, toggleCollapsed };
|
||||
}
|
||||
|
||||
export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { applyInitialSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
@@ -28,12 +30,15 @@ if (!rootElement) {
|
||||
}
|
||||
|
||||
applyInitialThemePreference();
|
||||
applyInitialSidebarCollapsed();
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
18
src/web/menu.tsx
Normal file
18
src/web/menu.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: MenuValue;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" },
|
||||
{ icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" },
|
||||
] as const;
|
||||
22
src/web/pages/404/index.tsx
Normal file
22
src/web/pages/404/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { ErrorCircleIcon } from "tdesign-icons-react";
|
||||
import { Button, Space } from "tdesign-react";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = () => {
|
||||
void navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Space align="center" className="not-found-page" direction="vertical" size="large">
|
||||
<ErrorCircleIcon size="64px" style={{ color: "var(--td-warning-color)" }} />
|
||||
<h1>404</h1>
|
||||
<p>您访问的页面不存在</p>
|
||||
<Button onClick={handleGoHome} theme="primary">
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
29
src/web/pages/dashboard/index.tsx
Normal file
29
src/web/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { HealthResponse } from "../../../shared/api";
|
||||
|
||||
import { APP } from "../../../shared/app";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { data: health } = useQuery({
|
||||
queryFn: fetchHealth,
|
||||
queryKey: ["health"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<h2>欢迎使用 {APP.title}</h2>
|
||||
<p>在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):</p>
|
||||
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<HealthResponse> {
|
||||
const response = await fetch("/health");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<HealthResponse>;
|
||||
}
|
||||
12
src/web/pages/settings/index.tsx
Normal file
12
src/web/pages/settings/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<h2>系统设置</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
12
src/web/pages/users/index.tsx
Normal file
12
src/web/pages/users/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function UsersPage() {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<h2>用户管理</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
17
src/web/routes.tsx
Normal file
17
src/web/routes.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Route, Routes } from "react-router";
|
||||
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { UsersPage } from "./pages/users";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -2,40 +2,66 @@
|
||||
--td-brand-color: var(--td-brand-color-7);
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
box-sizing: border-box;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
width: 100%;
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--td-comp-paddingLR-l);
|
||||
background: var(--td-bg-color-container);
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.dashboard-brand {
|
||||
.app-header-left {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
line-height: 1.2;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-l);
|
||||
}
|
||||
|
||||
.dashboard-logo {
|
||||
.app-header-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-sidebar-toggle {
|
||||
padding: var(--td-comp-paddingTB-s) var(--td-comp-paddingLR-s);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-header-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
margin-right: var(--td-comp-margin-xxl);
|
||||
.app-page-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background: var(--td-bg-color-container);
|
||||
border-right: 1px solid var(--td-component-border);
|
||||
height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar-menu {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
box-sizing: border-box;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.health-response {
|
||||
|
||||
Reference in New Issue
Block a user