feat(web): 实现自定义主题功能
支持 6 套预设主题(默认、海洋、森林、日落、玫瑰、紫罗兰)和自定义主色色相滑块, 通过动态注入 CSS 变量实现主题切换,使用 localStorage 持久化存储, 添加 SSR 初始化脚本避免首次加载颜色闪烁。 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
71
apps/web/src/components/settings/HueSlider.tsx
Normal file
71
apps/web/src/components/settings/HueSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
115
apps/web/src/config/theme-presets.ts
Normal file
115
apps/web/src/config/theme-presets.ts
Normal 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;
|
||||
}
|
||||
25
apps/web/src/hooks/useThemeInit.ts
Normal file
25
apps/web/src/hooks/useThemeInit.ts
Normal 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 };
|
||||
}
|
||||
139
apps/web/src/lib/theme-generator.ts
Normal file
139
apps/web/src/lib/theme-generator.ts
Normal 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}%)`;
|
||||
}
|
||||
140
apps/web/src/lib/theme-init-script.ts
Normal file
140
apps/web/src/lib/theme-init-script.ts
Normal 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();
|
||||
}
|
||||
124
apps/web/src/lib/theme-service.ts
Normal file
124
apps/web/src/lib/theme-service.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user