refactor: 侧边栏折叠按钮从 Header 移至侧边栏底部
- 移除 Header 左侧折叠按钮,改用 TDesign Menu operations 渲染底部折叠按钮 - Header 品牌名与版本号使用 brand-group 基线对齐 - 侧边栏折叠宽度从 80px 对齐 TDesign 默认 64px - 更新相关测试和 DEVELOPMENT.md 文档
This commit is contained in:
@@ -38,7 +38,7 @@ src/
|
|||||||
app.ts 应用全局常量(name、title、subtitle、description)
|
app.ts 应用全局常量(name、title、subtitle、description)
|
||||||
web/ React 前端(通过 Vite 构建)
|
web/ React 前端(通过 Vite 构建)
|
||||||
index.html HTML 入口
|
index.html HTML 入口
|
||||||
app.tsx 根组件(Admin 布局:Header + Sidebar + Content)
|
app.tsx 根组件(Admin 布局:Header + Sidebar + Content + 版本号展示)
|
||||||
main.tsx 入口(BrowserRouter + QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools + TDesign CSS 导入)
|
main.tsx 入口(BrowserRouter + QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools + TDesign CSS 导入)
|
||||||
routes.tsx 路由配置(定义所有页面路由)
|
routes.tsx 路由配置(定义所有页面路由)
|
||||||
styles.css 全局样式与自定义 CSS 变量
|
styles.css 全局样式与自定义 CSS 变量
|
||||||
@@ -55,7 +55,7 @@ src/
|
|||||||
components/ UI 组件
|
components/ UI 组件
|
||||||
ErrorBoundary.tsx React 错误边界,捕获渲染异常并展示降级 UI
|
ErrorBoundary.tsx React 错误边界,捕获渲染异常并展示降级 UI
|
||||||
Sidebar/
|
Sidebar/
|
||||||
index.tsx 侧边栏菜单组件(TDesign Menu + 折叠控制)
|
index.tsx 侧边栏菜单组件(TDesign Menu + 底部折叠按钮)
|
||||||
hooks/ React hooks
|
hooks/ React hooks
|
||||||
use-theme-preference.ts 主题偏好 hook(system/light/dark,localStorage 记忆 + matchMedia 监听)
|
use-theme-preference.ts 主题偏好 hook(system/light/dark,localStorage 记忆 + matchMedia 监听)
|
||||||
use-sidebar-collapsed.ts 侧边栏折叠状态 hook(localStorage 记忆)
|
use-sidebar-collapsed.ts 侧边栏折叠状态 hook(localStorage 记忆)
|
||||||
@@ -268,10 +268,10 @@ main.tsx
|
|||||||
│ ├── useThemePreference() ── Header 主题模式 RadioGroup(系统/明亮/黑暗)
|
│ ├── useThemePreference() ── Header 主题模式 RadioGroup(系统/明亮/黑暗)
|
||||||
│ ├── useSidebarCollapsed() ── 侧边栏折叠状态(localStorage 记忆)
|
│ ├── useSidebarCollapsed() ── 侧边栏折叠状态(localStorage 记忆)
|
||||||
│ ├── Layout
|
│ ├── Layout
|
||||||
│ │ ├── Header(折叠按钮 + 品牌名 + 页标题 + 主题切换)
|
│ │ ├── Header(品牌名 + 版本号 + 页标题 + 主题切换)
|
||||||
│ │ └── Layout(嵌套)
|
│ │ └── Layout(嵌套)
|
||||||
│ │ ├── Aside
|
│ │ ├── Aside
|
||||||
│ │ │ └── Sidebar(TDesign Menu,菜单项点击导航)
|
│ │ │ └── Sidebar(TDesign Menu + 底部折叠按钮,菜单项点击导航)
|
||||||
│ │ └── Content
|
│ │ └── Content
|
||||||
│ │ └── AppRoutes(路由配置)
|
│ │ └── AppRoutes(路由配置)
|
||||||
│ │ ├── / → DashboardPage(欢迎语 + /api/meta 联调)
|
│ │ ├── / → DashboardPage(欢迎语 + /api/meta 联调)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
import { Layout, RadioGroup } from "tdesign-react";
|
||||||
import { Button, Layout, RadioGroup } from "tdesign-react";
|
|
||||||
|
|
||||||
import type { MetaResponse } from "../shared/api";
|
import type { MetaResponse } from "../shared/api";
|
||||||
|
|
||||||
@@ -50,11 +49,10 @@ export function App() {
|
|||||||
<Layout className="app-layout">
|
<Layout className="app-layout">
|
||||||
<Header className="app-header">
|
<Header className="app-header">
|
||||||
<div className="app-header-left">
|
<div className="app-header-left">
|
||||||
<Button className="app-sidebar-toggle" onClick={toggleCollapsed} shape="square" variant="text">
|
<span className="app-brand-group">
|
||||||
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
<span className="app-brand">{APP.title}</span>
|
||||||
</Button>
|
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||||
<span className="app-brand">{APP.title}</span>
|
</span>
|
||||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
|
||||||
<span className="app-page-title">{pageTitle}</span>
|
<span className="app-page-title">{pageTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-header-right">
|
<div className="app-header-right">
|
||||||
@@ -68,8 +66,8 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Aside className="app-sidebar" width={collapsed ? "80px" : "232px"}>
|
<Aside className="app-sidebar" width={collapsed ? "64px" : "232px"}>
|
||||||
<Sidebar collapsed={collapsed} />
|
<Sidebar collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
|
||||||
</Aside>
|
</Aside>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Content className="app-content">
|
<Content className="app-content">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { Menu } from "tdesign-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||||
|
import { Button, Menu } from "tdesign-react";
|
||||||
|
|
||||||
import { MENU_ITEMS } from "../../menu";
|
import { MENU_ITEMS } from "../../menu";
|
||||||
|
|
||||||
@@ -7,9 +8,10 @@ const { MenuItem } = Menu;
|
|||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
onToggleCollapsed: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ collapsed }: SidebarProps) {
|
export function Sidebar({ collapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -29,8 +31,17 @@ export function Sidebar({ collapsed }: SidebarProps) {
|
|||||||
className="app-sidebar-menu"
|
className="app-sidebar-menu"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onChange={handleMenuChange}
|
onChange={handleMenuChange}
|
||||||
|
operations={
|
||||||
|
<Button
|
||||||
|
className="app-sidebar-collapse-btn"
|
||||||
|
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
|
onClick={onToggleCollapsed}
|
||||||
|
shape="square"
|
||||||
|
variant="text"
|
||||||
|
/>
|
||||||
|
}
|
||||||
value={activeValue}
|
value={activeValue}
|
||||||
width={collapsed ? "80px" : "232px"}
|
width={collapsed ? "64px" : "232px"}
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => (
|
{MENU_ITEMS.map((item) => (
|
||||||
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
||||||
|
|||||||
@@ -30,8 +30,10 @@
|
|||||||
gap: var(--td-comp-margin-s);
|
gap: var(--td-comp-margin-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-toggle {
|
.app-brand-group {
|
||||||
padding: var(--td-comp-paddingTB-s) var(--td-comp-paddingLR-s);
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--td-comp-margin-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-brand {
|
.app-brand {
|
||||||
@@ -39,14 +41,20 @@
|
|||||||
color: var(--td-text-color-primary);
|
color: var(--td-text-color-primary);
|
||||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 64px;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-version {
|
.app-version {
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
font-size: var(--td-font-size-body-small);
|
font-size: var(--td-font-size-body-small);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 64px;
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-collapse-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-page-title {
|
.app-page-title {
|
||||||
|
|||||||
@@ -44,4 +44,21 @@ describe("App", () => {
|
|||||||
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Header 不包含侧边栏折叠按钮", () => {
|
||||||
|
window.fetch = (async () => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
renderWithProviders(createElement(App));
|
||||||
|
|
||||||
|
const toggleButtons = document.querySelectorAll(".app-sidebar-toggle");
|
||||||
|
expect(toggleButtons.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
@@ -7,7 +8,7 @@ import { renderWithProviders } from "../../test-utils";
|
|||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
test("渲染菜单项", () => {
|
test("渲染菜单项", () => {
|
||||||
renderWithProviders(createElement(Sidebar, { collapsed: false }));
|
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
|
||||||
|
|
||||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||||
@@ -15,7 +16,7 @@ describe("Sidebar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("折叠状态下仍渲染菜单项", () => {
|
test("折叠状态下仍渲染菜单项", () => {
|
||||||
renderWithProviders(createElement(Sidebar, { collapsed: true }));
|
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }));
|
||||||
|
|
||||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||||
@@ -23,7 +24,42 @@ describe("Sidebar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("高亮当前路由对应的菜单项", () => {
|
test("高亮当前路由对应的菜单项", () => {
|
||||||
renderWithProviders(createElement(Sidebar, { collapsed: false }), { initialRoute: "/users" });
|
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
|
||||||
|
initialRoute: "/users",
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeItem = document.querySelector(".t-is-active");
|
||||||
|
expect(activeItem).not.toBeNull();
|
||||||
|
expect(activeItem?.textContent).toContain("用户管理");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("展开态底部渲染折叠按钮", () => {
|
||||||
|
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
|
||||||
|
|
||||||
|
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
|
||||||
|
expect(collapseBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("点击底部按钮调用 onToggleCollapsed", () => {
|
||||||
|
let called = false;
|
||||||
|
const onToggle = () => {
|
||||||
|
called = true;
|
||||||
|
};
|
||||||
|
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: onToggle }));
|
||||||
|
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>(".app-sidebar-collapse-btn");
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
btn!.click();
|
||||||
|
expect(called).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("折叠态底部按钮仍渲染且菜单项高亮不变", () => {
|
||||||
|
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }), {
|
||||||
|
initialRoute: "/users",
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
|
||||||
|
expect(collapseBtn).not.toBeNull();
|
||||||
|
|
||||||
const activeItem = document.querySelector(".t-is-active");
|
const activeItem = document.querySelector(".t-is-active");
|
||||||
expect(activeItem).not.toBeNull();
|
expect(activeItem).not.toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user