diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 71fc66c..5216eb5 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import { Providers } from './providers';
+import { getThemeInitScript } from '@/lib/theme-init-script';
+
import './globals.css';
export const metadata: Metadata = {
@@ -19,6 +21,12 @@ export default function RootLayout({
}>) {
return (
+
+ {/* 主题初始化脚本 - 避免颜色闪烁 */}
+
+
{children}
diff --git a/apps/web/src/components/settings/HueSlider.tsx b/apps/web/src/components/settings/HueSlider.tsx
new file mode 100644
index 0000000..88ae9ef
--- /dev/null
+++ b/apps/web/src/components/settings/HueSlider.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+
+import { cn } from '@/lib/utils';
+
+interface HueSliderProps {
+ value: number;
+ onChange: (hue: number) => void;
+ className?: string;
+}
+
+/**
+ * 色相滑块组件
+ * 拖拽选择 0-360 色相值,背景显示彩虹渐变
+ */
+export function HueSlider({ value, onChange, className }: HueSliderProps) {
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ onChange(parseInt(e.target.value, 10));
+ },
+ [onChange]
+ );
+
+ // 当前色相对应的颜色预览
+ const previewColor = useMemo(() => {
+ return `hsl(${value}, 70%, 50%)`;
+ }, [value]);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx
index 8ed3b28..abf9181 100644
--- a/apps/web/src/components/settings/ThemeSettings.tsx
+++ b/apps/web/src/components/settings/ThemeSettings.tsx
@@ -1,7 +1,10 @@
'use client';
-import { Moon, Sun, Monitor } from 'lucide-react';
+import { Moon, Sun, Monitor, Check, Palette } from 'lucide-react';
import { useTheme } from 'next-themes';
+import { useMemo, useCallback } from 'react';
+
+import { HueSlider } from './HueSlider';
import {
Card,
@@ -11,9 +14,14 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
+import { Separator } from '@/components/ui/separator';
+import { THEME_PRESETS, getHueFromConfig } from '@/config/theme-presets';
import { cn } from '@/lib/utils';
+import { useUIStore, useThemeConfig } from '@/stores/uiStore';
-const themes = [
+
+// 主题模式选项
+const themeModes = [
{
value: 'light',
label: '浅色',
@@ -33,18 +41,46 @@ const themes = [
export function ThemeSettings() {
const { theme, setTheme } = useTheme();
+ const themeConfig = useThemeConfig();
+ const applyPreset = useUIStore((state) => state.applyPreset);
+ const applyCustomPrimaryHue = useUIStore(
+ (state) => state.applyCustomPrimaryHue
+ );
+
+ // 当前是否使用预设主题
+ const isPreset = themeConfig.type === 'preset';
+
+ // 当前色相值(预设或自定义)
+ const currentHue = useMemo(() => getHueFromConfig(themeConfig), [themeConfig]);
+
+ // 处理自定义色相变化
+ const handleHueChange = useCallback(
+ (hue: number) => {
+ applyCustomPrimaryHue(hue);
+ },
+ [applyCustomPrimaryHue]
+ );
+
+ // 处理预设选择
+ const handlePresetSelect = useCallback(
+ (presetId: string) => {
+ applyPreset(presetId);
+ },
+ [applyPreset]
+ );
return (
外观
- 自定义应用的外观主题。
+ 自定义应用的外观主题和配色。
-
+
+ {/* 主题模式 */}
- {themes.map((item) => {
+ {themeModes.map((item) => {
const Icon = item.icon;
const isActive = theme === item.value;
@@ -67,6 +103,58 @@ export function ThemeSettings() {
})}
+
+
+
+ {/* 预设主题 */}
+
+
+
+ {THEME_PRESETS.map((preset) => {
+ const isActive = isPreset && themeConfig.presetId === preset.id;
+ const previewColor = `hsl(${preset.hue}, 70%, 50%)`;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {/* 自定义主色 */}
+
+
+
+ 拖动滑块选择你喜欢的主色调,系统将自动生成配套的完整色板。
+
+
+
);
diff --git a/apps/web/src/config/theme-presets.ts b/apps/web/src/config/theme-presets.ts
new file mode 100644
index 0000000..01599e2
--- /dev/null
+++ b/apps/web/src/config/theme-presets.ts
@@ -0,0 +1,115 @@
+/**
+ * 主题预设配置
+ * 定义预设主题和类型
+ */
+
+// HSL 颜色类型
+export interface HSLColor {
+ h: number; // 色相 0-360
+ s: number; // 饱和度 0-100
+ l: number; // 亮度 0-100
+}
+
+// 主题色板类型
+export interface ThemePalette {
+ background: HSLColor;
+ foreground: HSLColor;
+ card: HSLColor;
+ cardForeground: HSLColor;
+ popover: HSLColor;
+ popoverForeground: HSLColor;
+ primary: HSLColor;
+ primaryForeground: HSLColor;
+ secondary: HSLColor;
+ secondaryForeground: HSLColor;
+ muted: HSLColor;
+ mutedForeground: HSLColor;
+ accent: HSLColor;
+ accentForeground: HSLColor;
+ destructive: HSLColor;
+ destructiveForeground: HSLColor;
+ border: HSLColor;
+ input: HSLColor;
+ ring: HSLColor;
+ sidebar: HSLColor;
+ sidebarForeground: HSLColor;
+ sidebarPrimary: HSLColor;
+ sidebarPrimaryForeground: HSLColor;
+ sidebarAccent: HSLColor;
+ sidebarAccentForeground: HSLColor;
+ sidebarBorder: HSLColor;
+ sidebarRing: HSLColor;
+}
+
+// 主题预设类型
+export interface ThemePreset {
+ id: string;
+ name: string;
+ hue: number;
+ description?: string;
+}
+
+// 主题配置类型 - 预设或自定义
+export type ThemeConfig =
+ | { type: 'preset'; presetId: string }
+ | { type: 'custom'; primaryHue: number };
+
+// 默认主题配置
+export const DEFAULT_THEME_CONFIG: ThemeConfig = {
+ type: 'preset',
+ presetId: 'default',
+};
+
+// 6 套预设主题
+export const THEME_PRESETS: ThemePreset[] = [
+ {
+ id: 'default',
+ name: '默认',
+ hue: 222,
+ description: '经典灰黑色调',
+ },
+ {
+ id: 'ocean',
+ name: '海洋',
+ hue: 210,
+ description: '清新蓝色',
+ },
+ {
+ id: 'forest',
+ name: '森林',
+ hue: 142,
+ description: '自然绿色',
+ },
+ {
+ id: 'sunset',
+ name: '日落',
+ hue: 24,
+ description: '温暖橙色',
+ },
+ {
+ id: 'rose',
+ name: '玫瑰',
+ hue: 346,
+ description: '优雅粉色',
+ },
+ {
+ id: 'violet',
+ name: '紫罗兰',
+ hue: 262,
+ description: '高贵紫色',
+ },
+];
+
+// 根据 ID 获取预设主题
+export function getPresetById(id: string): ThemePreset | undefined {
+ return THEME_PRESETS.find((preset) => preset.id === id);
+}
+
+// 获取主题配置对应的色相值
+export function getHueFromConfig(config: ThemeConfig): number {
+ if (config.type === 'preset') {
+ const preset = getPresetById(config.presetId);
+ return preset?.hue ?? 222; // 回退到默认色相
+ }
+ return config.primaryHue;
+}
diff --git a/apps/web/src/hooks/useThemeInit.ts b/apps/web/src/hooks/useThemeInit.ts
new file mode 100644
index 0000000..d8143c7
--- /dev/null
+++ b/apps/web/src/hooks/useThemeInit.ts
@@ -0,0 +1,25 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { getHueFromConfig } from '@/config/theme-presets';
+import { applyTheme } from '@/lib/theme-service';
+import { useUIStore } from '@/stores/uiStore';
+
+/**
+ * 主题初始化 Hook
+ * 确保客户端 hydration 后主题正确应用
+ */
+export function useThemeInit() {
+ const [isInitialized, setIsInitialized] = useState(false);
+ const themeConfig = useUIStore((state) => state.themeConfig);
+
+ useEffect(() => {
+ // 在客户端 hydration 后重新应用主题,确保状态一致
+ const hue = getHueFromConfig(themeConfig);
+ applyTheme(hue);
+ setIsInitialized(true);
+ }, [themeConfig]);
+
+ return { isInitialized };
+}
diff --git a/apps/web/src/lib/theme-generator.ts b/apps/web/src/lib/theme-generator.ts
new file mode 100644
index 0000000..5d5c466
--- /dev/null
+++ b/apps/web/src/lib/theme-generator.ts
@@ -0,0 +1,139 @@
+/**
+ * 主题色板生成器
+ * 根据主色色相自动生成完整的浅色/深色主题色板
+ */
+
+import type { HSLColor, ThemePalette } from '@/config/theme-presets';
+
+// 创建 HSL 颜色
+function hsl(h: number, s: number, l: number): HSLColor {
+ return { h, s, l };
+}
+
+// 确保色相在 0-360 范围内
+function normalizeHue(hue: number): number {
+ return ((hue % 360) + 360) % 360;
+}
+
+/**
+ * 生成浅色主题色板
+ * @param primaryHue 主色色相 (0-360)
+ */
+export function generateLightPalette(primaryHue: number): ThemePalette {
+ const hue = normalizeHue(primaryHue);
+
+ return {
+ // 背景色系 - 纯白到微灰
+ background: hsl(0, 0, 100),
+ foreground: hsl(hue, 84, 4.9),
+
+ // 卡片
+ card: hsl(0, 0, 100),
+ cardForeground: hsl(hue, 84, 4.9),
+
+ // 弹出层
+ popover: hsl(0, 0, 100),
+ popoverForeground: hsl(hue, 84, 4.9),
+
+ // 主色 - 深色调
+ primary: hsl(hue, 47.4, 11.2),
+ primaryForeground: hsl(hue - 12, 40, 98),
+
+ // 次要色 - 浅灰
+ secondary: hsl(hue - 12, 40, 96.1),
+ secondaryForeground: hsl(hue, 47.4, 11.2),
+
+ // 静音色
+ muted: hsl(hue - 12, 40, 96.1),
+ mutedForeground: hsl(hue - 7, 16.3, 46.9),
+
+ // 强调色
+ accent: hsl(hue - 12, 40, 96.1),
+ accentForeground: hsl(hue, 47.4, 11.2),
+
+ // 危险色 - 红色系
+ destructive: hsl(0, 84.2, 60.2),
+ destructiveForeground: hsl(hue - 12, 40, 98),
+
+ // 边框和输入框
+ border: hsl(hue - 8, 31.8, 91.4),
+ input: hsl(hue - 8, 31.8, 91.4),
+
+ // 聚焦环
+ ring: hsl(hue, 84, 4.9),
+
+ // 侧边栏 - 微带主色调的浅灰
+ sidebar: hsl(0, 0, 98),
+ sidebarForeground: hsl(hue + 18, 5.3, 26.1),
+ sidebarPrimary: hsl(hue + 18, 5.9, 10),
+ sidebarPrimaryForeground: hsl(0, 0, 98),
+ sidebarAccent: hsl(hue + 18, 4.8, 95.9),
+ sidebarAccentForeground: hsl(hue + 18, 5.9, 10),
+ sidebarBorder: hsl(hue - 2, 13, 91),
+ sidebarRing: hsl(hue - 5, 91.2, 59.8),
+ };
+}
+
+/**
+ * 生成深色主题色板
+ * @param primaryHue 主色色相 (0-360)
+ */
+export function generateDarkPalette(primaryHue: number): ThemePalette {
+ const hue = normalizeHue(primaryHue);
+
+ return {
+ // 背景色系 - 深色
+ background: hsl(hue, 84, 4.9),
+ foreground: hsl(hue - 12, 40, 98),
+
+ // 卡片
+ card: hsl(hue, 84, 4.9),
+ cardForeground: hsl(hue - 12, 40, 98),
+
+ // 弹出层
+ popover: hsl(hue, 84, 4.9),
+ popoverForeground: hsl(hue - 12, 40, 98),
+
+ // 主色 - 浅色调(深色模式下主色要亮)
+ primary: hsl(hue - 12, 40, 98),
+ primaryForeground: hsl(hue, 47.4, 11.2),
+
+ // 次要色 - 深灰
+ secondary: hsl(hue - 5, 32.6, 17.5),
+ secondaryForeground: hsl(hue - 12, 40, 98),
+
+ // 静音色
+ muted: hsl(hue - 5, 32.6, 17.5),
+ mutedForeground: hsl(hue - 7, 20.2, 65.1),
+
+ // 强调色
+ accent: hsl(hue - 5, 32.6, 17.5),
+ accentForeground: hsl(hue - 12, 40, 98),
+
+ // 危险色 - 深色模式下更暗
+ destructive: hsl(0, 62.8, 30.6),
+ destructiveForeground: hsl(hue - 12, 40, 98),
+
+ // 边框和输入框
+ border: hsl(hue - 5, 32.6, 17.5),
+ input: hsl(hue - 5, 32.6, 17.5),
+
+ // 聚焦环
+ ring: hsl(hue - 10, 26.8, 83.9),
+
+ // 侧边栏
+ sidebar: hsl(hue + 18, 5.9, 10),
+ sidebarForeground: hsl(hue + 18, 4.8, 95.9),
+ sidebarPrimary: hsl(hue + 2, 76.3, 48),
+ sidebarPrimaryForeground: hsl(0, 0, 100),
+ sidebarAccent: hsl(hue + 18, 3.7, 15.9),
+ sidebarAccentForeground: hsl(hue + 18, 4.8, 95.9),
+ sidebarBorder: hsl(hue + 18, 3.7, 15.9),
+ sidebarRing: hsl(hue - 5, 91.2, 59.8),
+ };
+}
+
+// HSL 颜色转换为 CSS 值字符串
+export function hslToString(color: HSLColor): string {
+ return `hsl(${color.h} ${color.s}% ${color.l}%)`;
+}
diff --git a/apps/web/src/lib/theme-init-script.ts b/apps/web/src/lib/theme-init-script.ts
new file mode 100644
index 0000000..a5841de
--- /dev/null
+++ b/apps/web/src/lib/theme-init-script.ts
@@ -0,0 +1,140 @@
+/**
+ * 主题初始化脚本
+ * 生成在 HTML head 中执行的内联脚本,避免主题颜色闪烁
+ */
+
+import { STORAGE_KEYS } from '@/config/constants';
+
+/**
+ * 生成主题初始化脚本代码
+ * 该脚本在 HTML 解析时立即执行,从 localStorage 读取主题配置并应用
+ */
+export function getThemeInitScript(): string {
+ // 为了在 SSR 时能正确生成脚本,使用硬编码的 storage key
+ const storageKey = STORAGE_KEYS.UI;
+
+ // 内联脚本 - 会在浏览器解析 HTML 时立即执行
+ return `
+(function() {
+ try {
+ var stored = localStorage.getItem('${storageKey}');
+ if (!stored) return;
+
+ var data = JSON.parse(stored);
+ var state = data.state;
+ if (!state || !state.themeConfig) return;
+
+ var config = state.themeConfig;
+ var hue;
+
+ if (config.type === 'preset') {
+ // 预设主题色相映射
+ var presets = {
+ 'default': 222,
+ 'ocean': 210,
+ 'forest': 142,
+ 'sunset': 24,
+ 'rose': 346,
+ 'violet': 262
+ };
+ hue = presets[config.presetId] || 222;
+ } else if (config.type === 'custom') {
+ hue = config.primaryHue || 222;
+ } else {
+ return;
+ }
+
+ // 生成并应用主题 CSS
+ var css = generateThemeCSS(hue);
+ var style = document.createElement('style');
+ style.id = 'seclusion-theme-style';
+ style.textContent = css;
+ document.head.appendChild(style);
+ } catch (e) {
+ // 静默失败,使用默认主题
+ }
+
+ function hsl(h, s, l) {
+ return 'hsl(' + h + ' ' + s + '% ' + l + '%)';
+ }
+
+ function generateThemeCSS(hue) {
+ // 浅色主题变量
+ var light = {
+ '--color-background': hsl(0, 0, 100),
+ '--color-foreground': hsl(hue, 84, 4.9),
+ '--color-card': hsl(0, 0, 100),
+ '--color-card-foreground': hsl(hue, 84, 4.9),
+ '--color-popover': hsl(0, 0, 100),
+ '--color-popover-foreground': hsl(hue, 84, 4.9),
+ '--color-primary': hsl(hue, 47.4, 11.2),
+ '--color-primary-foreground': hsl(hue - 12, 40, 98),
+ '--color-secondary': hsl(hue - 12, 40, 96.1),
+ '--color-secondary-foreground': hsl(hue, 47.4, 11.2),
+ '--color-muted': hsl(hue - 12, 40, 96.1),
+ '--color-muted-foreground': hsl(hue - 7, 16.3, 46.9),
+ '--color-accent': hsl(hue - 12, 40, 96.1),
+ '--color-accent-foreground': hsl(hue, 47.4, 11.2),
+ '--color-destructive': hsl(0, 84.2, 60.2),
+ '--color-destructive-foreground': hsl(hue - 12, 40, 98),
+ '--color-border': hsl(hue - 8, 31.8, 91.4),
+ '--color-input': hsl(hue - 8, 31.8, 91.4),
+ '--color-ring': hsl(hue, 84, 4.9),
+ '--color-sidebar': hsl(0, 0, 98),
+ '--color-sidebar-foreground': hsl(hue + 18, 5.3, 26.1),
+ '--color-sidebar-primary': hsl(hue + 18, 5.9, 10),
+ '--color-sidebar-primary-foreground': hsl(0, 0, 98),
+ '--color-sidebar-accent': hsl(hue + 18, 4.8, 95.9),
+ '--color-sidebar-accent-foreground': hsl(hue + 18, 5.9, 10),
+ '--color-sidebar-border': hsl(hue - 2, 13, 91),
+ '--color-sidebar-ring': hsl(hue - 5, 91.2, 59.8)
+ };
+
+ // 深色主题变量
+ var dark = {
+ '--color-background': hsl(hue, 84, 4.9),
+ '--color-foreground': hsl(hue - 12, 40, 98),
+ '--color-card': hsl(hue, 84, 4.9),
+ '--color-card-foreground': hsl(hue - 12, 40, 98),
+ '--color-popover': hsl(hue, 84, 4.9),
+ '--color-popover-foreground': hsl(hue - 12, 40, 98),
+ '--color-primary': hsl(hue - 12, 40, 98),
+ '--color-primary-foreground': hsl(hue, 47.4, 11.2),
+ '--color-secondary': hsl(hue - 5, 32.6, 17.5),
+ '--color-secondary-foreground': hsl(hue - 12, 40, 98),
+ '--color-muted': hsl(hue - 5, 32.6, 17.5),
+ '--color-muted-foreground': hsl(hue - 7, 20.2, 65.1),
+ '--color-accent': hsl(hue - 5, 32.6, 17.5),
+ '--color-accent-foreground': hsl(hue - 12, 40, 98),
+ '--color-destructive': hsl(0, 62.8, 30.6),
+ '--color-destructive-foreground': hsl(hue - 12, 40, 98),
+ '--color-border': hsl(hue - 5, 32.6, 17.5),
+ '--color-input': hsl(hue - 5, 32.6, 17.5),
+ '--color-ring': hsl(hue - 10, 26.8, 83.9),
+ '--color-sidebar': hsl(hue + 18, 5.9, 10),
+ '--color-sidebar-foreground': hsl(hue + 18, 4.8, 95.9),
+ '--color-sidebar-primary': hsl(hue + 2, 76.3, 48),
+ '--color-sidebar-primary-foreground': hsl(0, 0, 100),
+ '--color-sidebar-accent': hsl(hue + 18, 3.7, 15.9),
+ '--color-sidebar-accent-foreground': hsl(hue + 18, 4.8, 95.9),
+ '--color-sidebar-border': hsl(hue + 18, 3.7, 15.9),
+ '--color-sidebar-ring': hsl(hue - 5, 91.2, 59.8)
+ };
+
+ var lightCSS = ':root {\\n';
+ for (var key in light) {
+ lightCSS += ' ' + key + ': ' + light[key] + ';\\n';
+ }
+ lightCSS += '}';
+
+ var darkCSS = '.dark {\\n';
+ for (var key in dark) {
+ darkCSS += ' ' + key + ': ' + dark[key] + ';\\n';
+ }
+ darkCSS += '}';
+
+ return '/* Seclusion Theme - Primary Hue: ' + hue + ' */\\n' + lightCSS + '\\n\\n' + darkCSS;
+ }
+})();
+`.trim();
+}
diff --git a/apps/web/src/lib/theme-service.ts b/apps/web/src/lib/theme-service.ts
new file mode 100644
index 0000000..bfa8561
--- /dev/null
+++ b/apps/web/src/lib/theme-service.ts
@@ -0,0 +1,124 @@
+/**
+ * 主题应用服务
+ * 负责将主题色板应用到 DOM(通过动态 style 标签注入 CSS 变量)
+ */
+
+import { generateLightPalette, generateDarkPalette, hslToString } from './theme-generator';
+
+import type { ThemePalette } from '@/config/theme-presets';
+
+
+// 动态 style 标签的 ID
+const THEME_STYLE_ID = 'seclusion-theme-style';
+
+// CSS 变量名映射
+const CSS_VAR_MAPPING: Record = {
+ background: '--color-background',
+ foreground: '--color-foreground',
+ card: '--color-card',
+ cardForeground: '--color-card-foreground',
+ popover: '--color-popover',
+ popoverForeground: '--color-popover-foreground',
+ primary: '--color-primary',
+ primaryForeground: '--color-primary-foreground',
+ secondary: '--color-secondary',
+ secondaryForeground: '--color-secondary-foreground',
+ muted: '--color-muted',
+ mutedForeground: '--color-muted-foreground',
+ accent: '--color-accent',
+ accentForeground: '--color-accent-foreground',
+ destructive: '--color-destructive',
+ destructiveForeground: '--color-destructive-foreground',
+ border: '--color-border',
+ input: '--color-input',
+ ring: '--color-ring',
+ sidebar: '--color-sidebar',
+ sidebarForeground: '--color-sidebar-foreground',
+ sidebarPrimary: '--color-sidebar-primary',
+ sidebarPrimaryForeground: '--color-sidebar-primary-foreground',
+ sidebarAccent: '--color-sidebar-accent',
+ sidebarAccentForeground: '--color-sidebar-accent-foreground',
+ sidebarBorder: '--color-sidebar-border',
+ sidebarRing: '--color-sidebar-ring',
+};
+
+// 将色板转换为 CSS 变量字符串
+function paletteToCSS(palette: ThemePalette, selector: string): string {
+ const variables = Object.entries(CSS_VAR_MAPPING)
+ .map(([key, cssVar]) => {
+ const color = palette[key as keyof ThemePalette];
+ return ` ${cssVar}: ${hslToString(color)};`;
+ })
+ .join('\n');
+
+ return `${selector} {\n${variables}\n}`;
+}
+
+// 生成完整的主题 CSS
+function generateThemeCSS(primaryHue: number): string {
+ const lightPalette = generateLightPalette(primaryHue);
+ const darkPalette = generateDarkPalette(primaryHue);
+
+ // 浅色模式::root 和 html:not(.dark)
+ const lightCSS = paletteToCSS(lightPalette, ':root');
+ // 深色模式:.dark 类
+ const darkCSS = paletteToCSS(darkPalette, '.dark');
+
+ return `/* Seclusion Theme - Primary Hue: ${primaryHue} */\n${lightCSS}\n\n${darkCSS}`;
+}
+
+/**
+ * 应用主题色相到页面
+ * @param primaryHue 主色色相 (0-360)
+ */
+export function applyTheme(primaryHue: number): void {
+ if (typeof document === 'undefined') {
+ return; // SSR 环境下不执行
+ }
+
+ // 查找或创建 style 标签
+ let styleElement = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
+
+ if (!styleElement) {
+ styleElement = document.createElement('style');
+ styleElement.id = THEME_STYLE_ID;
+ document.head.appendChild(styleElement);
+ }
+
+ // 生成并应用 CSS
+ const css = generateThemeCSS(primaryHue);
+ styleElement.textContent = css;
+}
+
+/**
+ * 移除自定义主题(恢复默认)
+ */
+export function removeTheme(): void {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const styleElement = document.getElementById(THEME_STYLE_ID);
+ if (styleElement) {
+ styleElement.remove();
+ }
+}
+
+/**
+ * 获取当前应用的主题色相
+ * 如果没有自定义主题则返回 null
+ */
+export function getCurrentThemeHue(): number | null {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ const styleElement = document.getElementById(THEME_STYLE_ID);
+ if (!styleElement?.textContent) {
+ return null;
+ }
+
+ // 从注释中提取色相值
+ const match = styleElement.textContent.match(/Primary Hue: (\d+)/);
+ return match ? parseInt(match[1], 10) : null;
+}
diff --git a/apps/web/src/stores/types.ts b/apps/web/src/stores/types.ts
index 7d6d6e7..bc442f9 100644
--- a/apps/web/src/stores/types.ts
+++ b/apps/web/src/stores/types.ts
@@ -1,5 +1,7 @@
import type { AuthUser } from '@seclusion/shared';
+import type { ThemeConfig } from '@/config/theme-presets';
+
// Auth Store 状态类型
export interface AuthState {
token: string | null;
@@ -39,6 +41,7 @@ export type Theme = 'light' | 'dark' | 'system';
export interface UIState {
theme: Theme;
+ themeConfig: ThemeConfig;
sidebarOpen: boolean;
sidebarCollapsed: boolean;
}
@@ -46,6 +49,9 @@ export interface UIState {
// UI Store Actions 类型
export interface UIActions {
setTheme: (theme: Theme) => void;
+ setThemeConfig: (config: ThemeConfig) => void;
+ applyPreset: (presetId: string) => void;
+ applyCustomPrimaryHue: (hue: number) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarCollapsed: (collapsed: boolean) => void;
diff --git a/apps/web/src/stores/uiStore.ts b/apps/web/src/stores/uiStore.ts
index da5846d..d94c3d8 100644
--- a/apps/web/src/stores/uiStore.ts
+++ b/apps/web/src/stores/uiStore.ts
@@ -4,16 +4,46 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { UIStore } from './types';
import { STORAGE_KEYS } from '@/config/constants';
+import {
+ DEFAULT_THEME_CONFIG,
+ getHueFromConfig,
+ getPresetById,
+} from '@/config/theme-presets';
+import { applyTheme } from '@/lib/theme-service';
export const useUIStore = create()(
persist(
- (set) => ({
+ (set, get) => ({
theme: 'system',
+ themeConfig: DEFAULT_THEME_CONFIG,
sidebarOpen: false, // 移动端侧边栏默认关闭
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }),
+ setThemeConfig: (config) => {
+ set({ themeConfig: config });
+ // 应用主题
+ const hue = getHueFromConfig(config);
+ applyTheme(hue);
+ },
+
+ applyPreset: (presetId) => {
+ // 验证预设是否存在
+ const preset = getPresetById(presetId);
+ if (!preset) {
+ console.warn(`Theme preset "${presetId}" not found, using default`);
+ presetId = 'default';
+ }
+ get().setThemeConfig({ type: 'preset', presetId });
+ },
+
+ applyCustomPrimaryHue: (hue) => {
+ // 确保色相在有效范围内
+ const normalizedHue = ((hue % 360) + 360) % 360;
+ get().setThemeConfig({ type: 'custom', primaryHue: normalizedHue });
+ },
+
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
@@ -27,14 +57,23 @@ export const useUIStore = create()(
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
theme: state.theme,
+ themeConfig: state.themeConfig,
sidebarCollapsed: state.sidebarCollapsed,
}),
+ // hydration 完成后应用主题
+ onRehydrateStorage: () => (state) => {
+ if (state?.themeConfig) {
+ const hue = getHueFromConfig(state.themeConfig);
+ applyTheme(hue);
+ }
+ },
}
)
);
// Selector hooks
export const useTheme = () => useUIStore((state) => state.theme);
+export const useThemeConfig = () => useUIStore((state) => state.themeConfig);
export const useSidebarOpen = () => useUIStore((state) => state.sidebarOpen);
export const useSidebarCollapsed = () =>
useUIStore((state) => state.sidebarCollapsed);