feat(web): 实现自定义主题功能

支持 6 套预设主题(默认、海洋、森林、日落、玫瑰、紫罗兰)和自定义主色色相滑块,
通过动态注入 CSS 变量实现主题切换,使用 localStorage 持久化存储,
添加 SSR 初始化脚本避免首次加载颜色闪烁。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-20 18:22:22 +08:00
parent ad847f1e4c
commit 5c1a998192
10 changed files with 761 additions and 6 deletions

View File

@@ -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 (
<html lang="zh-CN" suppressHydrationWarning>
<head>
{/* 主题初始化脚本 - 避免颜色闪烁 */}
<script
dangerouslySetInnerHTML={{ __html: getThemeInitScript() }}
/>
</head>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers>
</body>

View File

@@ -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<HTMLInputElement>) => {
onChange(parseInt(e.target.value, 10));
},
[onChange]
);
// 当前色相对应的颜色预览
const previewColor = useMemo(() => {
return `hsl(${value}, 70%, 50%)`;
}, [value]);
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<div className="flex items-center gap-2">
<div
className="h-5 w-5 rounded-full border border-border"
style={{ backgroundColor: previewColor }}
/>
<span className="text-sm font-medium tabular-nums">{value}°</span>
</div>
</div>
<div className="relative">
<input
type="range"
min={0}
max={359}
value={value}
onChange={handleChange}
className={cn(
'h-3 w-full cursor-pointer appearance-none rounded-full',
// 彩虹渐变背景
'[background:linear-gradient(to_right,hsl(0,70%,50%),hsl(60,70%,50%),hsl(120,70%,50%),hsl(180,70%,50%),hsl(240,70%,50%),hsl(300,70%,50%),hsl(360,70%,50%))]',
// 滑块样式 - Webkit (Chrome, Safari)
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2',
'[&::-webkit-slider-thumb]:border-gray-300 [&::-webkit-slider-thumb]:shadow-md',
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing',
'[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:transition-transform',
// 滑块样式 - Firefox
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5',
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full',
'[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2',
'[&::-moz-range-thumb]:border-gray-300 [&::-moz-range-thumb]:shadow-md',
'[&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:active:cursor-grabbing'
)}
/>
</div>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-6">
{/* 主题模式 */}
<div className="space-y-4">
<Label></Label>
<div className="grid grid-cols-3 gap-4">
{themes.map((item) => {
{themeModes.map((item) => {
const Icon = item.icon;
const isActive = theme === item.value;
@@ -67,6 +103,58 @@ export function ThemeSettings() {
})}
</div>
</div>
<Separator />
{/* 预设主题 */}
<div className="space-y-4">
<Label></Label>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{THEME_PRESETS.map((preset) => {
const isActive = isPreset && themeConfig.presetId === preset.id;
const previewColor = `hsl(${preset.hue}, 70%, 50%)`;
return (
<button
key={preset.id}
onClick={() => handlePresetSelect(preset.id)}
className={cn(
'group relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all',
'hover:bg-accent',
isActive ? 'border-primary' : 'border-transparent'
)}
title={preset.description}
>
<div
className="h-8 w-8 rounded-full border border-border shadow-sm transition-transform group-hover:scale-110"
style={{ backgroundColor: previewColor }}
>
{isActive && (
<div className="flex h-full w-full items-center justify-center">
<Check className="h-4 w-4 text-white drop-shadow-md" />
</div>
)}
</div>
<span className="text-xs font-medium">{preset.name}</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* 自定义主色 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-muted-foreground" />
<Label></Label>
</div>
<p className="text-sm text-muted-foreground">
</p>
<HueSlider value={currentHue} onChange={handleHueChange} />
</div>
</CardContent>
</Card>
);

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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}%)`;
}

View File

@@ -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();
}

View File

@@ -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<keyof ThemePalette, string> = {
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;
}

View File

@@ -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;

View File

@@ -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<UIStore>()(
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<UIStore>()(
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);