-
- 欢迎使用 {APP.title}
- 在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):
- {health && {JSON.stringify(health, null, 2)}}
-
+
+
+
+
+ {APP.title}
+ {pageTitle}
-
+
+ ({ label: option.label, value: option.value }))}
+ theme="button"
+ value={themePreference}
+ variant="default-filled"
+ />
+
+
+
+
+
+
+
+
+
+
);
}
-
-async function fetchHealth(): Promise
{
- const response = await fetch("/health");
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- return response.json() as Promise;
-}
diff --git a/src/web/components/Sidebar/index.tsx b/src/web/components/Sidebar/index.tsx
new file mode 100644
index 0000000..e801b3c
--- /dev/null
+++ b/src/web/components/Sidebar/index.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/web/hooks/use-sidebar-collapsed.ts b/src/web/hooks/use-sidebar-collapsed.ts
new file mode 100644
index 0000000..8ca5ff8
--- /dev/null
+++ b/src/web/hooks/use-sidebar-collapsed.ts
@@ -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(() => 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 {
+ // 存储不可用时仅使用当前内存状态
+ }
+}
diff --git a/src/web/main.tsx b/src/web/main.tsx
index 1baa0bd..5705de5 100644
--- a/src/web/main.tsx
+++ b/src/web/main.tsx
@@ -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(
-
+
+
+
diff --git a/src/web/menu.tsx b/src/web/menu.tsx
new file mode 100644
index 0000000..62d0c23
--- /dev/null
+++ b/src/web/menu.tsx
@@ -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;
diff --git a/src/web/pages/404/index.tsx b/src/web/pages/404/index.tsx
new file mode 100644
index 0000000..937f956
--- /dev/null
+++ b/src/web/pages/404/index.tsx
@@ -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 (
+
+
+ 404
+ 您访问的页面不存在
+
+
+ );
+}
diff --git a/src/web/pages/dashboard/index.tsx b/src/web/pages/dashboard/index.tsx
new file mode 100644
index 0000000..1475782
--- /dev/null
+++ b/src/web/pages/dashboard/index.tsx
@@ -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 (
+
+ 欢迎使用 {APP.title}
+ 在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):
+ {health && {JSON.stringify(health, null, 2)}}
+
+ );
+}
+
+async function fetchHealth(): Promise {
+ const response = await fetch("/health");
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json() as Promise;
+}
diff --git a/src/web/pages/settings/index.tsx b/src/web/pages/settings/index.tsx
new file mode 100644
index 0000000..2557c9e
--- /dev/null
+++ b/src/web/pages/settings/index.tsx
@@ -0,0 +1,12 @@
+import { Card, Space } from "tdesign-react";
+
+export function SettingsPage() {
+ return (
+
+ 系统设置
+
+ 页面建设中...
+
+
+ );
+}
diff --git a/src/web/pages/users/index.tsx b/src/web/pages/users/index.tsx
new file mode 100644
index 0000000..28fac96
--- /dev/null
+++ b/src/web/pages/users/index.tsx
@@ -0,0 +1,12 @@
+import { Card, Space } from "tdesign-react";
+
+export function UsersPage() {
+ return (
+
+ 用户管理
+
+ 页面建设中...
+
+
+ );
+}
diff --git a/src/web/routes.tsx b/src/web/routes.tsx
new file mode 100644
index 0000000..ab9eaf3
--- /dev/null
+++ b/src/web/routes.tsx
@@ -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 (
+
+ } path="/" />
+ } path="/users" />
+ } path="/settings" />
+ } path="*" />
+
+ );
+}
diff --git a/src/web/styles.css b/src/web/styles.css
index a309ef4..01a3d07 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -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 {
diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx
index f95e273..e4f6952 100644
--- a/tests/web/App.test.tsx
+++ b/tests/web/App.test.tsx
@@ -1,43 +1,14 @@
/* eslint-disable @typescript-eslint/require-await */
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen } from "@testing-library/react";
+import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
-import { createElement, StrictMode } from "react";
+import { createElement } from "react";
import { APP } from "../../src/shared/app";
import { App } from "../../src/web/app";
-import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
-
-function createTestQueryClient() {
- return new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- staleTime: 0,
- },
- },
- });
-}
-
-function renderApp() {
- const queryClient = createTestQueryClient();
-
- return render(
- createElement(
- StrictMode,
- null,
- createElement(
- ErrorBoundary,
- null,
- createElement(QueryClientProvider, { client: queryClient }, createElement(App)),
- ),
- ),
- );
-}
+import { renderWithProviders } from "./test-utils";
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
- // mock /health fetch 避免网络错误
window.fetch = (async () => {
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
@@ -45,11 +16,26 @@ describe("App", () => {
});
}) as unknown as typeof fetch;
- renderApp();
+ renderWithProviders(createElement(App));
expect(screen.getByText(APP.title)).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
+
+ test("渲染侧边栏菜单项", () => {
+ window.fetch = (async () => {
+ return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ });
+ }) as unknown as typeof fetch;
+
+ renderWithProviders(createElement(App));
+
+ expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
+ });
});
diff --git a/tests/web/components/Sidebar/index.test.tsx b/tests/web/components/Sidebar/index.test.tsx
new file mode 100644
index 0000000..3436c1b
--- /dev/null
+++ b/tests/web/components/Sidebar/index.test.tsx
@@ -0,0 +1,24 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, test } from "bun:test";
+import { createElement } from "react";
+
+import { Sidebar } from "../../../../src/web/components/Sidebar";
+import { renderWithProviders } from "../../test-utils";
+
+describe("Sidebar", () => {
+ test("渲染菜单项", () => {
+ renderWithProviders(createElement(Sidebar, { collapsed: false }));
+
+ expect(screen.getByText("仪表盘")).not.toBeNull();
+ expect(screen.getByText("用户管理")).not.toBeNull();
+ expect(screen.getByText("系统设置")).not.toBeNull();
+ });
+
+ test("折叠状态下仍渲染菜单项", () => {
+ renderWithProviders(createElement(Sidebar, { collapsed: true }));
+
+ expect(screen.getByText("仪表盘")).not.toBeNull();
+ expect(screen.getByText("用户管理")).not.toBeNull();
+ expect(screen.getByText("系统设置")).not.toBeNull();
+ });
+});
diff --git a/tests/web/routes/404.test.tsx b/tests/web/routes/404.test.tsx
new file mode 100644
index 0000000..3047c49
--- /dev/null
+++ b/tests/web/routes/404.test.tsx
@@ -0,0 +1,16 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, test } from "bun:test";
+import { createElement } from "react";
+
+import { NotFoundPage } from "../../../src/web/pages/404";
+import { renderWithProviders } from "../test-utils";
+
+describe("NotFoundPage", () => {
+ test("渲染 404 页面", () => {
+ renderWithProviders(createElement(NotFoundPage));
+
+ expect(screen.getByText("404")).not.toBeNull();
+ expect(screen.getByText("您访问的页面不存在")).not.toBeNull();
+ expect(screen.getByText("返回首页")).not.toBeNull();
+ });
+});
diff --git a/tests/web/routes/dashboard.test.tsx b/tests/web/routes/dashboard.test.tsx
new file mode 100644
index 0000000..932d704
--- /dev/null
+++ b/tests/web/routes/dashboard.test.tsx
@@ -0,0 +1,22 @@
+/* eslint-disable @typescript-eslint/require-await */
+import { screen } from "@testing-library/react";
+import { describe, expect, test } from "bun:test";
+import { createElement } from "react";
+
+import { DashboardPage } from "../../../src/web/pages/dashboard";
+import { renderWithProviders } from "../test-utils";
+
+describe("DashboardPage", () => {
+ test("渲染欢迎信息", () => {
+ window.fetch = (async () => {
+ return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ });
+ }) as unknown as typeof fetch;
+
+ renderWithProviders(createElement(DashboardPage));
+
+ expect(screen.getByText(/欢迎使用/)).not.toBeNull();
+ });
+});
diff --git a/tests/web/routes/settings.test.tsx b/tests/web/routes/settings.test.tsx
new file mode 100644
index 0000000..b5b38bb
--- /dev/null
+++ b/tests/web/routes/settings.test.tsx
@@ -0,0 +1,15 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, test } from "bun:test";
+import { createElement } from "react";
+
+import { SettingsPage } from "../../../src/web/pages/settings";
+import { renderWithProviders } from "../test-utils";
+
+describe("SettingsPage", () => {
+ test("渲染系统设置页面", () => {
+ renderWithProviders(createElement(SettingsPage));
+
+ expect(screen.getByText("系统设置")).not.toBeNull();
+ expect(screen.getByText("页面建设中...")).not.toBeNull();
+ });
+});
diff --git a/tests/web/routes/users.test.tsx b/tests/web/routes/users.test.tsx
new file mode 100644
index 0000000..5ac3402
--- /dev/null
+++ b/tests/web/routes/users.test.tsx
@@ -0,0 +1,15 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, test } from "bun:test";
+import { createElement } from "react";
+
+import { UsersPage } from "../../../src/web/pages/users";
+import { renderWithProviders } from "../test-utils";
+
+describe("UsersPage", () => {
+ test("渲染用户管理页面", () => {
+ renderWithProviders(createElement(UsersPage));
+
+ expect(screen.getByText("用户管理")).not.toBeNull();
+ expect(screen.getByText("页面建设中...")).not.toBeNull();
+ });
+});
diff --git a/tests/web/test-utils.tsx b/tests/web/test-utils.tsx
index 2baa56f..d337be6 100644
--- a/tests/web/test-utils.tsx
+++ b/tests/web/test-utils.tsx
@@ -1,7 +1,10 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render } from "@testing-library/react";
import { mock } from "bun:test";
+import { createElement, StrictMode } from "react";
+import { MemoryRouter } from "react-router";
-// Note: jsdom and polyfills are now set up in tests/setup.ts
-// This file only contains component-specific mocks
+import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
// Mock recharts BEFORE any component imports
void mock.module("recharts", () => ({
@@ -15,6 +18,42 @@ void mock.module("recharts", () => ({
YAxis: () => null,
}));
+export interface RenderWithProvidersOptions {
+ initialRoute?: string;
+}
+
+export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
+ const queryClient = createTestQueryClient();
+ const initialRoute = options?.initialRoute ?? "/";
+
+ return render(
+ createElement(
+ StrictMode,
+ null,
+ createElement(
+ ErrorBoundary,
+ null,
+ createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ createElement(MemoryRouter, { initialEntries: [initialRoute] }, ui),
+ ),
+ ),
+ ),
+ );
+}
+
+function createTestQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ staleTime: 0,
+ },
+ },
+ });
+}
+
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {
diff --git a/vite.config.ts b/vite.config.ts
index 33e72c8..d38855c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,6 +13,10 @@ export default defineConfig({
name: "vendor-react",
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
},
+ {
+ name: "vendor-router",
+ test: /[\\/]node_modules[\\/](react-router)[\\/]/,
+ },
{
name: "vendor-tdesign",
test: /[\\/]node_modules[\\/](tdesign-react|tdesign-icons-react)[\\/]/,