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 { Providers } from './providers';
|
||||||
|
|
||||||
|
import { getThemeInitScript } from '@/lib/theme-init-script';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -19,6 +21,12 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" suppressHydrationWarning>
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{/* 主题初始化脚本 - 避免颜色闪烁 */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{ __html: getThemeInitScript() }}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
import { Moon, Sun, Monitor, Check, Palette } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { HueSlider } from './HueSlider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,9 +14,14 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { cn } from '@/lib/utils';
|
||||||
|
import { useUIStore, useThemeConfig } from '@/stores/uiStore';
|
||||||
|
|
||||||
const themes = [
|
|
||||||
|
// 主题模式选项
|
||||||
|
const themeModes = [
|
||||||
{
|
{
|
||||||
value: 'light',
|
value: 'light',
|
||||||
label: '浅色',
|
label: '浅色',
|
||||||
@@ -33,18 +41,46 @@ const themes = [
|
|||||||
|
|
||||||
export function ThemeSettings() {
|
export function ThemeSettings() {
|
||||||
const { theme, setTheme } = useTheme();
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>外观</CardTitle>
|
<CardTitle>外观</CardTitle>
|
||||||
<CardDescription>自定义应用的外观主题。</CardDescription>
|
<CardDescription>自定义应用的外观主题和配色。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
|
{/* 主题模式 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Label>主题模式</Label>
|
<Label>主题模式</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{themes.map((item) => {
|
{themeModes.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = theme === item.value;
|
const isActive = theme === item.value;
|
||||||
|
|
||||||
@@ -67,6 +103,58 @@ export function ThemeSettings() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</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 { AuthUser } from '@seclusion/shared';
|
||||||
|
|
||||||
|
import type { ThemeConfig } from '@/config/theme-presets';
|
||||||
|
|
||||||
// Auth Store 状态类型
|
// Auth Store 状态类型
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -39,6 +41,7 @@ export type Theme = 'light' | 'dark' | 'system';
|
|||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
themeConfig: ThemeConfig;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,9 @@ export interface UIState {
|
|||||||
// UI Store Actions 类型
|
// UI Store Actions 类型
|
||||||
export interface UIActions {
|
export interface UIActions {
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
|
setThemeConfig: (config: ThemeConfig) => void;
|
||||||
|
applyPreset: (presetId: string) => void;
|
||||||
|
applyCustomPrimaryHue: (hue: number) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
|||||||
@@ -4,16 +4,46 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import type { UIStore } from './types';
|
import type { UIStore } from './types';
|
||||||
|
|
||||||
import { STORAGE_KEYS } from '@/config/constants';
|
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>()(
|
export const useUIStore = create<UIStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
|
themeConfig: DEFAULT_THEME_CONFIG,
|
||||||
sidebarOpen: false, // 移动端侧边栏默认关闭
|
sidebarOpen: false, // 移动端侧边栏默认关闭
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
|
|
||||||
setTheme: (theme) => set({ theme }),
|
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: () =>
|
toggleSidebar: () =>
|
||||||
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
|
||||||
@@ -27,14 +57,23 @@ export const useUIStore = create<UIStore>()(
|
|||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
|
themeConfig: state.themeConfig,
|
||||||
sidebarCollapsed: state.sidebarCollapsed,
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
}),
|
}),
|
||||||
|
// hydration 完成后应用主题
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (state?.themeConfig) {
|
||||||
|
const hue = getHueFromConfig(state.themeConfig);
|
||||||
|
applyTheme(hue);
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Selector hooks
|
// Selector hooks
|
||||||
export const useTheme = () => useUIStore((state) => state.theme);
|
export const useTheme = () => useUIStore((state) => state.theme);
|
||||||
|
export const useThemeConfig = () => useUIStore((state) => state.themeConfig);
|
||||||
export const useSidebarOpen = () => useUIStore((state) => state.sidebarOpen);
|
export const useSidebarOpen = () => useUIStore((state) => state.sidebarOpen);
|
||||||
export const useSidebarCollapsed = () =>
|
export const useSidebarCollapsed = () =>
|
||||||
useUIStore((state) => state.sidebarCollapsed);
|
useUIStore((state) => state.sidebarCollapsed);
|
||||||
|
|||||||
Reference in New Issue
Block a user