diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md
index 85467a6..c0e67a4 100644
--- a/docs/design/m5-iot-energy.md
+++ b/docs/design/m5-iot-energy.md
@@ -258,7 +258,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
### M5-T01 — 侧边栏布局重构 `[structural]`
-- **Status**: `todo` · **Depends**: none
+- **Status**: `done` · **Depends**: none
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
- **Steps**:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 16e195f..c2aa217 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,24 +6,19 @@
*
* Route tree:
* /login → LoginPage (public)
- * /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
- * / → ProtectedRoute → AppLayout → HomePage (T09)
- * /config → ProtectedRoute → AppLayout → ConfigPage (T08)
+ * /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate)
+ * / → ProtectedRoute → AppLayout → HomePage
+ * /config → ProtectedRoute → AppLayout → ConfigPage
+ * /records → ProtectedRoute → AppLayout → RecordsPage
*
- * AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
+ * AppLayout renders a sidebar (AppSidebar) on the left; page content via on the right.
+ * /login and /change-password have no sidebar layout.
*/
+import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
-import {
- MantineProvider,
- Group,
- ActionIcon,
- Tooltip,
- useMantineColorScheme,
- useComputedColorScheme,
-} from '@mantine/core'
-import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
+import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
+import { MantineProvider, AppShell, Burger } from '@mantine/core'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
@@ -35,8 +30,7 @@ import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage'
-import apiClient from './api/client'
-import { useQueryClient } from '@tanstack/react-query'
+import { AppSidebar } from './components/AppSidebar'
// ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation)
@@ -57,120 +51,42 @@ const queryClient = new QueryClient({
},
})
-// ---------------------------------------------------------------------------
-// Logout button component (needs navigate + queryClient hooks, so it's a component)
-// ---------------------------------------------------------------------------
-
-function LogoutButton() {
- const navigate = useNavigate()
- const qc = useQueryClient()
-
- async function handleLogout() {
- try {
- await apiClient.POST('/api/auth/logout')
- } catch {
- // Ignore errors on logout — we clear the session regardless.
- }
- // Invalidate session so SessionProvider becomes unauthenticated.
- await qc.invalidateQueries({ queryKey: ['session'] })
- navigate('/login', { replace: true })
- }
-
- return (
-
-
-
-
-
- )
-}
-
-// ---------------------------------------------------------------------------
-// Dark-mode toggle (sits next to the gear / settings icon)
-// ---------------------------------------------------------------------------
-
-function ColorSchemeToggle() {
- const { setColorScheme } = useMantineColorScheme()
- const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
- const isDark = computed === 'dark'
- return (
-
- setColorScheme(isDark ? 'light' : 'dark')}
- data-testid="color-scheme-toggle"
- >
- {isDark ? : }
-
-
- )
-}
-
// ---------------------------------------------------------------------------
// App shell layout (used by all protected pages)
// ---------------------------------------------------------------------------
function AppLayout() {
+ // Mobile navbar open/close state — controlled here so the Burger and
+ // AppShell share the same state.
+ const [navbarOpen, setNavbarOpen] = useState(false)
+
return (
-
- {/* Top nav */}
-
+
setNavbarOpen(false)} />
- {/* Page content */}
-
+
-
-
+
+
)
}
@@ -198,7 +114,7 @@ export default function App() {
}
/>
- {/* Protected routes — all nested under AppLayout */}
+ {/* Protected routes — all nested under AppLayout (sidebar) */}
diff --git a/frontend/src/components/AppSidebar.test.tsx b/frontend/src/components/AppSidebar.test.tsx
new file mode 100644
index 0000000..88aef44
--- /dev/null
+++ b/frontend/src/components/AppSidebar.test.tsx
@@ -0,0 +1,126 @@
+/**
+ * Tests for AppSidebar (M5-T01).
+ *
+ * Strategy: render AppSidebar inside a minimal AppShell (required by
+ * AppShell.Navbar) + MemoryRouter so useLocation() works.
+ *
+ * Coverage:
+ * 1. All three nav items render (Home, Records, Config).
+ * 2. Home nav item is active when pathname is '/'.
+ * 3. Records nav item is active when pathname is '/records'.
+ * 4. Config nav item is active when pathname is '/config'.
+ * 5. Home is NOT active when on '/records'.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { screen } from '@testing-library/react'
+import { render } from '@testing-library/react'
+import { MantineProvider, AppShell } from '@mantine/core'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import { AppSidebar } from './AppSidebar'
+
+// ---------------------------------------------------------------------------
+// Mock apiClient (LogoutButton calls POST /api/auth/logout)
+// ---------------------------------------------------------------------------
+
+vi.mock('../api/client', () => ({
+ default: {
+ POST: vi.fn(),
+ GET: vi.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Helper: render AppSidebar inside the required AppShell + router providers
+// ---------------------------------------------------------------------------
+
+function renderSidebar(initialPath = '/') {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('AppSidebar', () => {
+ // -------------------------------------------------------------------------
+ // 1. All nav items render
+ // -------------------------------------------------------------------------
+
+ it('renders Home, Records, and Config nav items', () => {
+ renderSidebar('/')
+ expect(screen.getByTestId('nav-home')).toBeInTheDocument()
+ expect(screen.getByTestId('nav-records')).toBeInTheDocument()
+ expect(screen.getByTestId('nav-config')).toBeInTheDocument()
+ })
+
+ // -------------------------------------------------------------------------
+ // 2. Home active on '/'
+ // -------------------------------------------------------------------------
+
+ it('marks Home nav item as active when on "/"', () => {
+ renderSidebar('/')
+ // Mantine NavLink sets data-active="true" on the active item
+ const homeLink = screen.getByTestId('nav-home')
+ expect(homeLink).toHaveAttribute('data-active', 'true')
+ })
+
+ // -------------------------------------------------------------------------
+ // 3. Records active on '/records'
+ // -------------------------------------------------------------------------
+
+ it('marks Records nav item as active when on "/records"', () => {
+ renderSidebar('/records')
+ const recordsLink = screen.getByTestId('nav-records')
+ expect(recordsLink).toHaveAttribute('data-active', 'true')
+ })
+
+ // -------------------------------------------------------------------------
+ // 4. Config active on '/config'
+ // -------------------------------------------------------------------------
+
+ it('marks Config nav item as active when on "/config"', () => {
+ renderSidebar('/config')
+ const configLink = screen.getByTestId('nav-config')
+ expect(configLink).toHaveAttribute('data-active', 'true')
+ })
+
+ // -------------------------------------------------------------------------
+ // 5. Home NOT active on '/records' (exact match guard)
+ // -------------------------------------------------------------------------
+
+ it('does NOT mark Home as active when on "/records"', () => {
+ renderSidebar('/records')
+ const homeLink = screen.getByTestId('nav-home')
+ expect(homeLink).not.toHaveAttribute('data-active', 'true')
+ })
+})
diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx
new file mode 100644
index 0000000..c2e0f94
--- /dev/null
+++ b/frontend/src/components/AppSidebar.tsx
@@ -0,0 +1,167 @@
+/**
+ * AppSidebar — vertical sidebar navigation for all protected pages.
+ *
+ * Nav items: Home / Records / Config
+ * Utilities: ColorSchemeToggle + LogoutButton (at bottom)
+ *
+ * Current route is highlighted via useLocation().
+ * Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
+ *
+ * NOTE: Energy nav item is intentionally absent — added in M5-T06 once /energy exists.
+ */
+
+import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { Home, List, Settings, Sun, Moon, LogOut } from 'react-feather'
+import { useQueryClient } from '@tanstack/react-query'
+import apiClient from '../api/client'
+
+// ---------------------------------------------------------------------------
+// Individual nav entries
+// ---------------------------------------------------------------------------
+
+interface NavEntry {
+ to: string
+ label: string
+ icon: React.ReactNode
+ testId: string
+}
+
+const NAV_ENTRIES: NavEntry[] = [
+ {
+ to: '/',
+ label: 'Home',
+ icon: ,
+ testId: 'nav-home',
+ },
+ {
+ to: '/records',
+ label: 'Records',
+ icon:
,
+ testId: 'nav-records',
+ },
+ {
+ to: '/config',
+ label: 'Config',
+ icon: ,
+ testId: 'nav-config',
+ },
+]
+
+// ---------------------------------------------------------------------------
+// Colour-scheme toggle
+// ---------------------------------------------------------------------------
+
+function ColorSchemeToggle() {
+ const { setColorScheme } = useMantineColorScheme()
+ const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
+ const isDark = computed === 'dark'
+ return (
+
+ setColorScheme(isDark ? 'light' : 'dark')}
+ data-testid="color-scheme-toggle"
+ >
+ {isDark ? : }
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Logout button
+// ---------------------------------------------------------------------------
+
+function LogoutButton() {
+ const navigate = useNavigate()
+ const qc = useQueryClient()
+
+ async function handleLogout() {
+ try {
+ await apiClient.POST('/api/auth/logout')
+ } catch {
+ // Ignore errors — clear session regardless.
+ }
+ await qc.invalidateQueries({ queryKey: ['session'] })
+ navigate('/login', { replace: true })
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Sidebar
+// ---------------------------------------------------------------------------
+
+interface AppSidebarProps {
+ /** Called when a nav link is clicked on mobile (to close the navbar). */
+ onNavClick?: () => void
+}
+
+export function AppSidebar({ onNavClick }: AppSidebarProps) {
+ const location = useLocation()
+
+ return (
+
+ {/* App title */}
+
+
+
+ Home Automation
+
+
+
+
+
+ {/* Navigation links */}
+
+
+ {NAV_ENTRIES.map(({ to, label, icon, testId }) => {
+ // For the home route "/" we need an exact match; others prefix-match is fine.
+ const isActive =
+ to === '/'
+ ? location.pathname === '/'
+ : location.pathname.startsWith(to)
+
+ return (
+
+ )
+ })}
+
+
+
+ {/* Bottom utilities: theme toggle + logout */}
+
+
+
+
+
+
+
+
+ )
+}