第 24 章 路由、主题、暗色模式

本章目标

  • 补齐 CloudTone 全部页面的路由映射。
  • 实现主题切换(light / dark / system),持久化到本地。
  • 做出平滑的页面过渡动画(可选)。

一、路由表

// src/router.tsx
import { createBrowserRouter } from "react-router-dom";
import AppShell from "@/app/AppShell";
import HomePage from "@/app/home/HomePage";
import LibraryPage from "@/app/library/LibraryPage";
import RecommendPage from "@/app/recommend/RecommendPage";
import PlaylistsPage from "@/app/playlists/PlaylistsPage";
import PlaylistDetail from "@/app/playlists/PlaylistDetail";
import FavoritesPage from "@/app/favorites/FavoritesPage";
import RecentPage from "@/app/recent/RecentPage";
import SearchPage from "@/app/search/SearchPage";
import ArtistPage from "@/app/artist/ArtistPage";
import AlbumPage from "@/app/album/AlbumPage";
import SettingsPage from "@/app/settings/SettingsPage";

export const router = createBrowserRouter([
  {
    path: "/", element: <AppShell />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "recommend", element: <RecommendPage /> },
      { path: "library", element: <LibraryPage /> },
      { path: "playlists", element: <PlaylistsPage /> },
      { path: "playlists/:id", element: <PlaylistDetail /> },
      { path: "favorites", element: <FavoritesPage /> },
      { path: "recent", element: <RecentPage /> },
      { path: "search", element: <SearchPage /> },
      { path: "artists/:id", element: <ArtistPage /> },
      { path: "albums/:id", element: <AlbumPage /> },
      { path: "settings", element: <SettingsPage /> },
    ],
  },
]);

每个页面组件先占位(一个 <div>页面名</div>),后面章节填内容。

二、主题切换

src/features/ui/themeStore.ts:

import { create } from "zustand";
import { persist } from "zustand/middleware";

type Theme = "light" | "dark" | "system";

interface ThemeStore {
  theme: Theme;
  effective: "light" | "dark";
  setTheme: (t: Theme) => void;
  _syncSystem: () => void;
}

const media = matchMedia("(prefers-color-scheme: dark)");

export const useThemeStore = create<ThemeStore>()(
  persist(
    (set, get) => ({
      theme: "dark",
      effective: "dark",
      setTheme: t => {
        set({ theme: t });
        get()._syncSystem();
      },
      _syncSystem: () => {
        const t = get().theme;
        const effective = t === "system" ? (media.matches ? "dark" : "light") : t;
        document.documentElement.classList.toggle("dark", effective === "dark");
        document.documentElement.classList.toggle("light", effective === "light");
        set({ effective });
      },
    }),
    { name: "cloudtone.theme", onRehydrateStorage: () => (s) => s?._syncSystem() },
  ),
);

media.addEventListener("change", () => useThemeStore.getState()._syncSystem());

在 main.tsx 初始化:

import { useThemeStore } from "@/features/ui/themeStore";
useThemeStore.getState()._syncSystem();

三、设置页切换主题

// src/app/settings/SettingsPage.tsx
import { useThemeStore } from "@/features/ui/themeStore";

export default function SettingsPage() {
  const { theme, setTheme } = useThemeStore();
  return (
    <div className="p-6 max-w-2xl">
      <h1 className="text-2xl font-semibold mb-6">设置</h1>
      <section>
        <h2 className="text-sm text-text-secondary mb-2">外观</h2>
        <div className="flex gap-2">
          {(["light","dark","system"] as const).map(t => (
            <button
              key={t}
              onClick={() => setTheme(t)}
              className={`px-4 py-2 rounded border ${theme === t ? "bg-brand-500 border-brand-500" : "border-surface-border hover:bg-surface-hover"}`}
            >{t === "light" ? "浅色" : t === "dark" ? "深色" : "跟随系统"}</button>
          ))}
        </div>
      </section>
    </div>
  );
}

四、浅色主题

Tailwind 默认 dark: 前缀切换。写组件时,默认样式为浅色,dark: 覆盖为深色:

<div className="bg-white text-black dark:bg-surface-bg dark:text-text-primary">

CloudTone 主题以深色为主,浅色作为次要,细节要重新设计(色差、阴影、边框),第 32 章整理主题系统。

五、页面过渡

轻量实现:用 React Router 的 useLocation + Tailwind transition:

import { useLocation } from "react-router-dom";
import { useEffect, useState } from "react";

function FadeRoute({ children }: { children: React.ReactNode }) {
  const loc = useLocation();
  const [show, setShow] = useState(true);
  useEffect(() => {
    setShow(false);
    const t = setTimeout(() => setShow(true), 80);
    return () => clearTimeout(t);
  }, [loc.pathname]);
  return <div className={`transition-opacity duration-150 ${show ? "opacity-100" : "opacity-0"}`}>{children}</div>;
}

在 AppShell 的 <Outlet> 外包一层。生产环境追求高级可以用 framer-motion。

本章小结

路由和主题搭好。CloudTone 现在已经是一个可以「假装」可用的 app 框架。

动手时刻

  • 切换主题并刷新,验证持久化。
  • 每个路由都加一个占位组件。
  • 在 Sidebar 显示当前活动路由的高亮。

下一章:状态管理全貌。