feat: enhance background layer handling and uploader functionality
- Introduced automatic overlay opacity and gradient based on layer ID in BackgroundLayer component. - Added disabled state to BackgroundUploader, preventing actions when disabled. - Updated component CSS editor to handle disabled state, preventing changes when disabled. - Modified Header and Layout components to manage background inheritance from the page layer. - Improved Sidebar and Card components to respect background inheritance and layering. - Refactored theme management to include default accent color and normalization functions. - Enhanced AppearanceTab to manage accent color changes with debouncing and validation. - Added UI feedback for inherited background layers in AppearanceTab.
This commit is contained in:
@@ -18,18 +18,9 @@ import {
|
||||
defaultBackgroundEffects,
|
||||
} from '@/lib/theme/tokens'
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 将 HSL 字符串转换为 HEX 格式
|
||||
* (从 settings.tsx 移植)
|
||||
*/
|
||||
function hslToHex(hsl: string): string {
|
||||
if (!hsl) return '#000000'
|
||||
|
||||
// 解析 "221.2 83.2% 53.3%" 格式
|
||||
const parts = hsl.split(' ').filter(Boolean)
|
||||
if (parts.length < 3) return '#000000'
|
||||
|
||||
@@ -39,72 +30,65 @@ function hslToHex(hsl: string): string {
|
||||
|
||||
const sDecimal = s / 100
|
||||
const lDecimal = l / 100
|
||||
|
||||
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||
const m = lDecimal - c / 2
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
let r = 0
|
||||
let g = 0
|
||||
let b = 0
|
||||
|
||||
if (h >= 0 && h < 60) {
|
||||
r = c
|
||||
g = x
|
||||
b = 0
|
||||
} else if (h >= 60 && h < 120) {
|
||||
r = x
|
||||
g = c
|
||||
b = 0
|
||||
} else if (h >= 120 && h < 180) {
|
||||
r = 0
|
||||
g = c
|
||||
b = x
|
||||
} else if (h >= 180 && h < 240) {
|
||||
r = 0
|
||||
g = x
|
||||
b = c
|
||||
} else if (h >= 240 && h < 300) {
|
||||
r = x
|
||||
g = 0
|
||||
b = c
|
||||
} else if (h >= 300 && h < 360) {
|
||||
r = c
|
||||
g = 0
|
||||
b = x
|
||||
}
|
||||
|
||||
const toHex = (n: number) => {
|
||||
const hex = Math.round((n + m) * 255).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
const toHex = (value: number) => {
|
||||
const hex = Math.round((value + m) * 255).toString(16)
|
||||
return hex.length === 1 ? `0${hex}` : hex
|
||||
}
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
type BackgroundEffectsControlsProps = {
|
||||
effects: BackgroundEffects
|
||||
onChange: (effects: BackgroundEffects) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function BackgroundEffectsControls({
|
||||
effects,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: BackgroundEffectsControlsProps) {
|
||||
// 处理数值变更
|
||||
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
|
||||
if (disabled) return
|
||||
|
||||
onChange({
|
||||
...effects,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理颜色变更
|
||||
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
const hex = e.target.value
|
||||
const hsl = hexToHSL(hex)
|
||||
onChange({
|
||||
@@ -113,35 +97,38 @@ export function BackgroundEffectsControls({
|
||||
})
|
||||
}
|
||||
|
||||
// 处理位置变更
|
||||
const handlePositionChange = (value: string) => {
|
||||
if (disabled) return
|
||||
|
||||
onChange({
|
||||
...effects,
|
||||
position: value as BackgroundEffects['position'],
|
||||
})
|
||||
}
|
||||
|
||||
// 处理渐变变更
|
||||
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
onChange({
|
||||
...effects,
|
||||
gradientOverlay: e.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 重置为默认值
|
||||
const handleReset = () => {
|
||||
if (disabled) return
|
||||
onChange(defaultBackgroundEffects)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className={disabled ? 'space-y-6 opacity-50' : 'space-y-6'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">背景效果调节</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={disabled}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
@@ -150,24 +137,21 @@ export function BackgroundEffectsControls({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* 1. Blur (模糊) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>模糊程度 (Blur)</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{effects.blur}px
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{effects.blur}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[effects.blur]}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('blur', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. Overlay Color (遮罩颜色) */}
|
||||
<div className="space-y-3">
|
||||
<Label>遮罩颜色 (Overlay Color)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -176,18 +160,19 @@ export function BackgroundEffectsControls({
|
||||
type="color"
|
||||
value={hslToHex(effects.overlayColor)}
|
||||
onChange={handleColorChange}
|
||||
disabled={disabled}
|
||||
className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={hslToHex(effects.overlayColor)}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
className="flex-1 font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Overlay Opacity (遮罩不透明度) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>遮罩不透明度 (Opacity)</Label>
|
||||
@@ -200,17 +185,15 @@ export function BackgroundEffectsControls({
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={(vals) =>
|
||||
handleValueChange('overlayOpacity', vals[0] / 100)
|
||||
}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('overlayOpacity', vals[0] / 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Position (位置) */}
|
||||
<div className="space-y-3">
|
||||
<Label>背景位置 (Position)</Label>
|
||||
<Select value={effects.position} onValueChange={handlePositionChange}>
|
||||
<SelectTrigger>
|
||||
<Select value={effects.position} onValueChange={handlePositionChange} disabled={disabled}>
|
||||
<SelectTrigger disabled={disabled}>
|
||||
<SelectValue placeholder="选择位置" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -222,69 +205,61 @@ export function BackgroundEffectsControls({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 5. Brightness (亮度) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>亮度 (Brightness)</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{effects.brightness}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{effects.brightness}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[effects.brightness]}
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 6. Contrast (对比度) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>对比度 (Contrast)</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{effects.contrast}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{effects.contrast}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[effects.contrast]}
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 7. Saturate (饱和度) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>饱和度 (Saturate)</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{effects.saturate}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{effects.saturate}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[effects.saturate]}
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 8. Gradient Overlay (渐变叠加) */}
|
||||
<div className="space-y-3">
|
||||
<Label>CSS 渐变叠加 (Gradient Overlay)</Label>
|
||||
<Input
|
||||
value={effects.gradientOverlay || ''}
|
||||
onChange={handleGradientChange}
|
||||
disabled={disabled}
|
||||
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
可选:输入有效的 CSS gradient 字符串
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">可选:输入有效的 CSS gradient 字符串</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,31 @@ type BackgroundLayerProps = {
|
||||
layerId: string
|
||||
}
|
||||
|
||||
function getAutoOverlayOpacity(layerId: string): number {
|
||||
switch (layerId) {
|
||||
case 'page':
|
||||
return 0.62
|
||||
case 'header':
|
||||
return 0.72
|
||||
case 'sidebar':
|
||||
return 0.78
|
||||
case 'card':
|
||||
return 0.82
|
||||
case 'dialog':
|
||||
return 0.88
|
||||
default:
|
||||
return 0.68
|
||||
}
|
||||
}
|
||||
|
||||
function getAutoGradientOverlay(layerId: string): string | undefined {
|
||||
if (layerId !== 'page') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'linear-gradient(to bottom, hsl(var(--background) / 0.82), hsl(var(--background) / 0.52) 28%, hsl(var(--background) / 0.7) 100%)'
|
||||
}
|
||||
|
||||
function buildFilterString(effects: BackgroundConfig['effects']): string {
|
||||
const parts: string[] = []
|
||||
if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`)
|
||||
@@ -84,10 +109,17 @@ export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
|
||||
|
||||
const filterString = buildFilterString(config.effects)
|
||||
const { overlayColor, overlayOpacity, gradientOverlay } = config.effects
|
||||
const hasExplicitOverlay = overlayOpacity > 0
|
||||
const effectiveOverlayOpacity = hasExplicitOverlay ? overlayOpacity : getAutoOverlayOpacity(layerId)
|
||||
const effectiveOverlayColor = hasExplicitOverlay
|
||||
? `hsl(${overlayColor} / ${effectiveOverlayOpacity})`
|
||||
: `hsl(var(--background) / ${effectiveOverlayOpacity})`
|
||||
const effectiveGradientOverlay = gradientOverlay || getAutoGradientOverlay(layerId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
data-background-layer={layerId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -136,25 +168,25 @@ export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlayOpacity > 0 && (
|
||||
{effectiveOverlayOpacity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: `hsl(${overlayColor} / ${overlayOpacity})`,
|
||||
backgroundColor: effectiveOverlayColor,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gradientOverlay && (
|
||||
{effectiveGradientOverlay && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
background: gradientOverlay,
|
||||
background: effectiveGradientOverlay,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,9 +12,10 @@ type BackgroundUploaderProps = {
|
||||
assetId?: string
|
||||
onAssetSelect: (id: string | undefined) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function BackgroundUploader({ assetId, onAssetSelect, className }: BackgroundUploaderProps) {
|
||||
export function BackgroundUploader({ assetId, onAssetSelect, className, disabled = false }: BackgroundUploaderProps) {
|
||||
const { getAssetUrl } = useAssetStore()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -62,6 +63,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
}, [assetId, getAssetUrl, onAssetSelect])
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (disabled) return
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
@@ -87,7 +89,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
}
|
||||
|
||||
const handleUrlUpload = async () => {
|
||||
if (!urlInput) return
|
||||
if (disabled || !urlInput) return
|
||||
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
@@ -118,6 +120,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true)
|
||||
} else if (e.type === 'dragleave') {
|
||||
@@ -130,12 +133,15 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFile(e.dataTransfer.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (disabled) return
|
||||
onAssetSelect(undefined)
|
||||
setPreviewUrl(undefined)
|
||||
setAssetType(undefined)
|
||||
@@ -143,7 +149,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className={cn("space-y-4", disabled && 'opacity-50', className)}>
|
||||
<div className="grid gap-2">
|
||||
<Label>背景资源</Label>
|
||||
|
||||
@@ -151,6 +157,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
||||
disabled && 'pointer-events-none',
|
||||
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
||||
error ? "border-destructive/50 bg-destructive/5" : "",
|
||||
assetId ? "border-solid" : ""
|
||||
@@ -188,6 +195,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow-sm"
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -212,6 +220,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
@@ -224,6 +233,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
className="hidden"
|
||||
accept="image/*,video/mp4,video/webm"
|
||||
onChange={(e) => {
|
||||
if (disabled) return
|
||||
if (e.target.files?.[0]) {
|
||||
handleFile(e.target.files[0])
|
||||
}
|
||||
@@ -250,6 +260,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="pl-9"
|
||||
value={urlInput}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -262,7 +273,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUrlUpload}
|
||||
disabled={!urlInput || isLoading}
|
||||
disabled={disabled || !urlInput || isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ComponentCSSEditorProps = {
|
||||
label?: string
|
||||
/** 编辑器高度,默认 200px */
|
||||
height?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,12 +29,13 @@ export function ComponentCSSEditor({
|
||||
onChange,
|
||||
label,
|
||||
height = '200px',
|
||||
disabled = false,
|
||||
}: ComponentCSSEditorProps) {
|
||||
// 实时计算 CSS 警告
|
||||
const { warnings } = sanitizeCSS(value)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className={disabled ? 'space-y-2 opacity-50' : 'space-y-2'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
{label || '自定义 CSS'}
|
||||
@@ -43,7 +45,7 @@ export function ComponentCSSEditor({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange('')}
|
||||
disabled={!value}
|
||||
disabled={disabled || !value}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
title="清除所有 CSS"
|
||||
>
|
||||
@@ -55,8 +57,9 @@ export function ComponentCSSEditor({
|
||||
<div className="rounded-md border bg-card overflow-hidden">
|
||||
<CodeEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={disabled ? undefined : onChange}
|
||||
language="css"
|
||||
readOnly={disabled}
|
||||
height={height}
|
||||
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
|
||||
/>
|
||||
|
||||
@@ -45,7 +45,8 @@ export function Header({
|
||||
}: HeaderProps) {
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const currentLang = i18nInstance.language || 'zh'
|
||||
const headerBg = useBackground('header')
|
||||
const { config: headerBg, inheritedFrom } = useBackground('header')
|
||||
const inheritsPageBackground = inheritedFrom === 'page'
|
||||
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
|
||||
const [activeBackendName, setActiveBackendName] = useState<string>('')
|
||||
|
||||
@@ -61,9 +62,12 @@ export function Header({
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||
<BackgroundLayer config={headerBg} layerId="header" />
|
||||
<div className="flex items-center gap-4">
|
||||
<header className={cn(
|
||||
'sticky top-0 z-10 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md isolate',
|
||||
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80',
|
||||
)}>
|
||||
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMobileMenuToggle}
|
||||
@@ -87,7 +91,7 @@ export function Header({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
{/* 后端切换按钮(仅 Electron) */}
|
||||
{isElectron() && (
|
||||
<>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
const pageBg = useBackground('page')
|
||||
const { config: pageBg } = useBackground('page')
|
||||
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
@@ -116,7 +116,9 @@ export function Layout({ children }: LayoutProps) {
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<SkipNav />
|
||||
{isElectron() && <TitleBar />}
|
||||
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
||||
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
<div className="relative z-10 flex h-full w-full overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
@@ -155,16 +157,21 @@ export function Layout({ children }: LayoutProps) {
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="relative flex-1 overflow-hidden bg-background outline-none"
|
||||
className={cn(
|
||||
'relative isolate flex-1 overflow-hidden outline-none',
|
||||
pageBg.type === 'none' ? 'bg-background' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
{children}
|
||||
<div className="relative z-10 h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,24 +23,29 @@ export function Sidebar({
|
||||
onMobileMenuClose
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const sidebarBg = useBackground('sidebar')
|
||||
const { config: sidebarBg, inheritedFrom } = useBackground('sidebar')
|
||||
const inheritsPageBackground = inheritedFrom === 'page'
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
|
||||
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0',
|
||||
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-64 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||
{!inheritsPageBackground && <BackgroundLayer config={sidebarBg} layerId="sidebar" />}
|
||||
|
||||
{/* Logo 区域 */}
|
||||
<LogoArea sidebarOpen={sidebarOpen} />
|
||||
<div className="relative z-10">
|
||||
<LogoArea sidebarOpen={sidebarOpen} />
|
||||
</div>
|
||||
|
||||
<ScrollArea className={cn(
|
||||
'relative z-10',
|
||||
"flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}>
|
||||
|
||||
@@ -14,12 +14,14 @@ export const CardWithBackground = forwardRef<
|
||||
ElementRef<typeof Card>,
|
||||
CardWithBackgroundProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const bg = useBackground('card')
|
||||
const { config: bg } = useBackground('card')
|
||||
|
||||
return (
|
||||
<Card ref={ref} className={cn('relative', className)} {...props}>
|
||||
<Card ref={ref} className={cn('relative isolate', className)} {...props}>
|
||||
<BackgroundLayer config={bg} layerId="card" />
|
||||
{children}
|
||||
<div className="relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,12 +14,14 @@ export const DialogContentWithBackground = forwardRef<
|
||||
ElementRef<typeof DialogContent>,
|
||||
DialogContentWithBackgroundProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const bg = useBackground('dialog')
|
||||
const { config: bg } = useBackground('dialog')
|
||||
|
||||
return (
|
||||
<DialogContent ref={ref} className={cn('relative', className)} {...props}>
|
||||
<DialogContent ref={ref} className={cn('relative isolate', className)} {...props}>
|
||||
<BackgroundLayer config={bg} layerId="dialog" />
|
||||
{children}
|
||||
<div className="relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,13 +5,19 @@ import { defaultBackgroundConfig } from '@/lib/theme/tokens'
|
||||
|
||||
type BackgroundLayerId = 'page' | 'sidebar' | 'header' | 'card' | 'dialog'
|
||||
|
||||
type ResolvedBackgroundState = {
|
||||
config: BackgroundConfig
|
||||
inheritEnabled: boolean
|
||||
inheritedFrom: BackgroundLayerId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定层级的背景配置
|
||||
* 处理继承逻辑:如果 inherit 为 true,返回页面级别配置
|
||||
* @param layerId - 背景层级标识
|
||||
* @returns 对应层级的背景配置
|
||||
*/
|
||||
export function useBackground(layerId: BackgroundLayerId): BackgroundConfig {
|
||||
export function useBackground(layerId: BackgroundLayerId): ResolvedBackgroundState {
|
||||
const { themeConfig } = useTheme()
|
||||
const bgMap = themeConfig.backgroundConfig ?? {}
|
||||
|
||||
@@ -19,8 +25,16 @@ export function useBackground(layerId: BackgroundLayerId): BackgroundConfig {
|
||||
|
||||
// 处理继承逻辑:非 page 层级且 inherit 为 true,返回 page 配置
|
||||
if (layerId !== 'page' && config.inherit) {
|
||||
return bgMap.page ?? defaultBackgroundConfig
|
||||
return {
|
||||
config: bgMap.page ?? defaultBackgroundConfig,
|
||||
inheritEnabled: true,
|
||||
inheritedFrom: 'page',
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
return {
|
||||
config,
|
||||
inheritEnabled: !!config.inherit,
|
||||
inheritedFrom: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,27 +112,27 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* Color Tokens */
|
||||
--color-primary: 221.2 83.2% 53.3%;
|
||||
--color-primary: 188.5 100% 45.5%;
|
||||
--color-primary-foreground: 210 40% 98%;
|
||||
--color-primary-gradient: none;
|
||||
--color-secondary: 210 40% 96.1%;
|
||||
--color-secondary: 188.5 35% 96%;
|
||||
--color-secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--color-muted: 210 40% 96.1%;
|
||||
--color-muted-foreground: 215.4 16.3% 40%;
|
||||
--color-accent: 210 40% 96.1%;
|
||||
--color-muted: 188.5 12% 96%;
|
||||
--color-muted-foreground: 188.5 20% 46.9%;
|
||||
--color-accent: 223.5 60% 50.4%;
|
||||
--color-accent-foreground: 222.2 47.4% 11.2%;
|
||||
--color-destructive: 0 84.2% 45%;
|
||||
--color-destructive-foreground: 210 40% 98%;
|
||||
--color-background: 0 0% 100%;
|
||||
--color-foreground: 222.2 84% 4.9%;
|
||||
--color-card: 0 0% 100%;
|
||||
--color-card: 188.5 14% 98.6%;
|
||||
--color-card-foreground: 222.2 84% 4.9%;
|
||||
--color-popover: 0 0% 100%;
|
||||
--color-popover: 188.5 16% 99.3%;
|
||||
--color-popover-foreground: 222.2 84% 4.9%;
|
||||
--color-border: 214.3 31.8% 91.4%;
|
||||
--color-input: 214.3 31.8% 91.4%;
|
||||
--color-ring: 221.2 83.2% 53.3%;
|
||||
--color-chart-1: 221.2 83.2% 53.3%;
|
||||
--color-border: 188.5 20% 91.4%;
|
||||
--color-input: 188.5 20% 91.4%;
|
||||
--color-ring: 188.5 100% 45.5%;
|
||||
--color-chart-1: 188.5 100% 45.5%;
|
||||
--color-chart-2: 160 60% 45%;
|
||||
--color-chart-3: 30 80% 55%;
|
||||
--color-chart-4: 280 65% 60%;
|
||||
@@ -230,27 +230,27 @@
|
||||
|
||||
.dark {
|
||||
/* Color Tokens */
|
||||
--color-primary: 217.2 91.2% 59.8%;
|
||||
--color-primary: 188.5 100% 45.5%;
|
||||
--color-primary-foreground: 210 40% 98%;
|
||||
--color-primary-gradient: none;
|
||||
--color-secondary: 217.2 32.6% 17.5%;
|
||||
--color-secondary: 188.5 35% 17.5%;
|
||||
--color-secondary-foreground: 210 40% 98%;
|
||||
--color-muted: 217.2 32.6% 17.5%;
|
||||
--color-muted-foreground: 215 20.2% 65.1%;
|
||||
--color-accent: 217.2 32.6% 17.5%;
|
||||
--color-muted: 188.5 12% 17.5%;
|
||||
--color-muted-foreground: 188.5 20% 65.1%;
|
||||
--color-accent: 223.5 60% 35.3%;
|
||||
--color-accent-foreground: 210 40% 98%;
|
||||
--color-destructive: 0 62.8% 30.6%;
|
||||
--color-destructive-foreground: 210 40% 98%;
|
||||
--color-background: 222.2 84% 4.9%;
|
||||
--color-foreground: 210 40% 98%;
|
||||
--color-card: 222.2 84% 4.9%;
|
||||
--color-card: 188.5 18% 8.8%;
|
||||
--color-card-foreground: 210 40% 98%;
|
||||
--color-popover: 222.2 84% 4.9%;
|
||||
--color-popover: 188.5 21% 10.5%;
|
||||
--color-popover-foreground: 210 40% 98%;
|
||||
--color-border: 217.2 32.6% 17.5%;
|
||||
--color-input: 217.2 32.6% 17.5%;
|
||||
--color-ring: 224.3 76.3% 48%;
|
||||
--color-chart-1: 217.2 91.2% 59.8%;
|
||||
--color-border: 188.5 20% 17.5%;
|
||||
--color-input: 188.5 20% 17.5%;
|
||||
--color-ring: 188.5 100% 45.5%;
|
||||
--color-chart-1: 188.5 100% 45.5%;
|
||||
--color-chart-2: 160 60% 50%;
|
||||
--color-chart-3: 30 80% 60%;
|
||||
--color-chart-4: 280 65% 65%;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 统一管理所有前端 localStorage 设置
|
||||
*/
|
||||
|
||||
import { DEFAULT_ACCENT_COLOR_HSL } from './theme/palette'
|
||||
|
||||
// 所有设置的 key 定义
|
||||
export const STORAGE_KEYS = {
|
||||
// 外观设置
|
||||
@@ -32,7 +34,7 @@ export const STORAGE_KEYS = {
|
||||
export const DEFAULT_SETTINGS = {
|
||||
// 外观
|
||||
theme: 'system' as 'light' | 'dark' | 'system',
|
||||
accentColor: 'blue',
|
||||
accentColor: DEFAULT_ACCENT_COLOR_HSL,
|
||||
enableAnimations: true,
|
||||
enableWavesBackground: true,
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ type HSL = {
|
||||
l: number
|
||||
}
|
||||
|
||||
export const DEFAULT_ACCENT_COLOR_HSL = '188.5 100% 45.5%'
|
||||
export const DEFAULT_ACCENT_COLOR_HEX = '#00c7e8'
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
if (value < min) return min
|
||||
if (value > max) return max
|
||||
@@ -45,6 +48,11 @@ export const formatHSL = (h: number, s: number, l: number): string => {
|
||||
return `${safeH} ${safeS}% ${safeL}%`
|
||||
}
|
||||
|
||||
export const isValidHSLString = (value: string): boolean => {
|
||||
const cleaned = value.trim()
|
||||
return /^-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?%\s+-?\d+(?:\.\d+)?%$/i.test(cleaned)
|
||||
}
|
||||
|
||||
export const hexToHSL = (hex: string): string => {
|
||||
let cleaned = hex.trim().replace('#', '')
|
||||
if (cleaned.length === 3) {
|
||||
@@ -91,6 +99,25 @@ export const hexToHSL = (hex: string): string => {
|
||||
return formatHSL(h, s * 100, l * 100)
|
||||
}
|
||||
|
||||
export const normalizeAccentColor = (accentColor?: string | null): string => {
|
||||
const trimmed = accentColor?.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return DEFAULT_ACCENT_COLOR_HSL
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#')) {
|
||||
return hexToHSL(trimmed)
|
||||
}
|
||||
|
||||
if (isValidHSLString(trimmed)) {
|
||||
const { h, s, l } = parseHSL(trimmed)
|
||||
return formatHSL(h, s, l)
|
||||
}
|
||||
|
||||
return DEFAULT_ACCENT_COLOR_HSL
|
||||
}
|
||||
|
||||
export const adjustLightness = (hsl: string, amount: number): string => {
|
||||
const { h, s, l } = parseHSL(hsl)
|
||||
return formatHSL(h, s, l + amount)
|
||||
@@ -170,8 +197,13 @@ export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens
|
||||
const chartSteps = [0, 72, 144, 216, 288]
|
||||
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
|
||||
|
||||
const card = adjustLightness(background, isDark ? 2 : -1)
|
||||
const popover = adjustLightness(background, isDark ? 3 : -0.5)
|
||||
const surfaceSaturation = clamp(accent.s * (isDark ? 0.18 : 0.14), isDark ? 10 : 6, isDark ? 24 : 16)
|
||||
const card = formatHSL(accent.h, surfaceSaturation, isDark ? 8.8 : 98.6)
|
||||
const popover = formatHSL(
|
||||
accent.h,
|
||||
clamp(surfaceSaturation + (isDark ? 3 : 2), 0, 100),
|
||||
isDark ? 10.5 : 99.3,
|
||||
)
|
||||
|
||||
return {
|
||||
primary,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
||||
*/
|
||||
|
||||
import { DEFAULT_ACCENT_COLOR_HSL, normalizeAccentColor } from './palette'
|
||||
import type { BackgroundConfigMap, UserThemeConfig } from './tokens'
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,7 @@ export const THEME_STORAGE_KEYS = {
|
||||
*/
|
||||
const DEFAULT_THEME_CONFIG: UserThemeConfig = {
|
||||
selectedPreset: 'light',
|
||||
accentColor: 'blue',
|
||||
accentColor: DEFAULT_ACCENT_COLOR_HSL,
|
||||
tokenOverrides: {},
|
||||
customCSS: '',
|
||||
backgroundConfig: {} as BackgroundConfigMap,
|
||||
@@ -65,7 +66,7 @@ export function loadThemeConfig(): UserThemeConfig {
|
||||
|
||||
return {
|
||||
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
||||
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
|
||||
accentColor: normalizeAccentColor(accent),
|
||||
tokenOverrides,
|
||||
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
||||
backgroundConfig,
|
||||
@@ -79,7 +80,7 @@ export function loadThemeConfig(): UserThemeConfig {
|
||||
*/
|
||||
export function saveThemeConfig(config: UserThemeConfig): void {
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset)
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, normalizeAccentColor(config.accentColor))
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
||||
if (config.backgroundConfig) {
|
||||
@@ -215,7 +216,7 @@ export function migrateOldKeys(): void {
|
||||
const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
||||
|
||||
if (accentColor && !newAccent) {
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, accentColor)
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, normalizeAccentColor(accentColor))
|
||||
}
|
||||
|
||||
// 删除旧 key
|
||||
|
||||
@@ -154,27 +154,27 @@ export type UserThemeConfig = {
|
||||
|
||||
export const defaultLightTokens: ThemeTokens = {
|
||||
color: {
|
||||
primary: '221.2 83.2% 53.3%',
|
||||
primary: '188.5 100% 45.5%',
|
||||
'primary-foreground': '210 40% 98%',
|
||||
'primary-gradient': 'none',
|
||||
secondary: '210 40% 96.1%',
|
||||
secondary: '188.5 35% 96%',
|
||||
'secondary-foreground': '222.2 47.4% 11.2%',
|
||||
muted: '210 40% 96.1%',
|
||||
'muted-foreground': '215.4 16.3% 46.9%',
|
||||
accent: '210 40% 96.1%',
|
||||
muted: '188.5 12% 96%',
|
||||
'muted-foreground': '188.5 20% 46.9%',
|
||||
accent: '223.5 60% 50.4%',
|
||||
'accent-foreground': '222.2 47.4% 11.2%',
|
||||
destructive: '0 84.2% 60.2%',
|
||||
'destructive-foreground': '210 40% 98%',
|
||||
background: '0 0% 100%',
|
||||
foreground: '222.2 84% 4.9%',
|
||||
card: '0 0% 100%',
|
||||
card: '188.5 14% 98.6%',
|
||||
'card-foreground': '222.2 84% 4.9%',
|
||||
popover: '0 0% 100%',
|
||||
popover: '188.5 16% 99.3%',
|
||||
'popover-foreground': '222.2 84% 4.9%',
|
||||
border: '214.3 31.8% 91.4%',
|
||||
input: '214.3 31.8% 91.4%',
|
||||
ring: '221.2 83.2% 53.3%',
|
||||
'chart-1': '221.2 83.2% 53.3%',
|
||||
border: '188.5 20% 91.4%',
|
||||
input: '188.5 20% 91.4%',
|
||||
ring: '188.5 100% 45.5%',
|
||||
'chart-1': '188.5 100% 45.5%',
|
||||
'chart-2': '160 60% 45%',
|
||||
'chart-3': '30 80% 55%',
|
||||
'chart-4': '280 65% 60%',
|
||||
@@ -249,27 +249,27 @@ export const defaultLightTokens: ThemeTokens = {
|
||||
|
||||
export const defaultDarkTokens: ThemeTokens = {
|
||||
color: {
|
||||
primary: '217.2 91.2% 59.8%',
|
||||
primary: '188.5 100% 45.5%',
|
||||
'primary-foreground': '210 40% 98%',
|
||||
'primary-gradient': 'none',
|
||||
secondary: '217.2 32.6% 17.5%',
|
||||
secondary: '188.5 35% 17.5%',
|
||||
'secondary-foreground': '210 40% 98%',
|
||||
muted: '217.2 32.6% 17.5%',
|
||||
'muted-foreground': '215 20.2% 65.1%',
|
||||
accent: '217.2 32.6% 17.5%',
|
||||
muted: '188.5 12% 17.5%',
|
||||
'muted-foreground': '188.5 20% 65.1%',
|
||||
accent: '223.5 60% 35.3%',
|
||||
'accent-foreground': '210 40% 98%',
|
||||
destructive: '0 62.8% 30.6%',
|
||||
'destructive-foreground': '210 40% 98%',
|
||||
background: '222.2 84% 4.9%',
|
||||
foreground: '210 40% 98%',
|
||||
card: '222.2 84% 4.9%',
|
||||
card: '188.5 18% 8.8%',
|
||||
'card-foreground': '210 40% 98%',
|
||||
popover: '222.2 84% 4.9%',
|
||||
popover: '188.5 21% 10.5%',
|
||||
'popover-foreground': '210 40% 98%',
|
||||
border: '217.2 32.6% 17.5%',
|
||||
input: '217.2 32.6% 17.5%',
|
||||
ring: '224.3 76.3% 48%',
|
||||
'chart-1': '217.2 91.2% 59.8%',
|
||||
border: '188.5 20% 17.5%',
|
||||
input: '188.5 20% 17.5%',
|
||||
ring: '188.5 100% 45.5%',
|
||||
'chart-1': '188.5 100% 45.5%',
|
||||
'chart-2': '160 60% 50%',
|
||||
'chart-3': '30 80% 60%',
|
||||
'chart-4': '280 65% 65%',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { getComputedTokens } from '@/lib/theme/pipeline'
|
||||
import { hexToHSL } from '@/lib/theme/palette'
|
||||
import { applyThemePipeline, getComputedTokens } from '@/lib/theme/pipeline'
|
||||
import { DEFAULT_ACCENT_COLOR_HEX, DEFAULT_ACCENT_COLOR_HSL, hexToHSL } from '@/lib/theme/palette'
|
||||
import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens'
|
||||
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
|
||||
import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens'
|
||||
@@ -81,11 +81,37 @@ export function AppearanceTab() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
|
||||
const [accentInputValue, setAccentInputValue] = useState(() => {
|
||||
if (themeConfig.accentColor) {
|
||||
return hslToHex(themeConfig.accentColor)
|
||||
}
|
||||
|
||||
return DEFAULT_ACCENT_COLOR_HEX
|
||||
})
|
||||
const [accentPreviewHex, setAccentPreviewHex] = useState(() => {
|
||||
if (themeConfig.accentColor) {
|
||||
return hslToHex(themeConfig.accentColor)
|
||||
}
|
||||
|
||||
return DEFAULT_ACCENT_COLOR_HEX
|
||||
})
|
||||
const [bgDraftConfig, setBgDraftConfig] = useState<BackgroundConfigMap>(themeConfig.backgroundConfig ?? {})
|
||||
const [cssWarnings, setCssWarnings] = useState<string[]>([])
|
||||
const accentDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const bgDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isValidHexColor = useCallback((value: string) => /^#[0-9A-F]{6}$/i.test(value), [])
|
||||
|
||||
const persistAccentColor = useCallback((hex: string) => {
|
||||
if (accentDebounceRef.current) clearTimeout(accentDebounceRef.current)
|
||||
|
||||
accentDebounceRef.current = setTimeout(() => {
|
||||
updateThemeConfig({ accentColor: hexToHSL(hex) })
|
||||
}, 160)
|
||||
}, [updateThemeConfig])
|
||||
|
||||
const updateTokenSection = useCallback(
|
||||
<K extends keyof ThemeTokens>(section: K, partial: Partial<ThemeTokens[K]>) => {
|
||||
updateThemeConfig({
|
||||
@@ -122,21 +148,71 @@ export function AppearanceTab() {
|
||||
}, 500)
|
||||
}, [updateThemeConfig])
|
||||
|
||||
const currentAccentHex = useMemo(() => {
|
||||
if (themeConfig.accentColor) {
|
||||
return hslToHex(themeConfig.accentColor)
|
||||
const previewAccentHSL = useMemo(() => {
|
||||
if (isValidHexColor(accentPreviewHex)) {
|
||||
return hexToHSL(accentPreviewHex)
|
||||
}
|
||||
return '#3b82f6' // 默认蓝色
|
||||
|
||||
return themeConfig.accentColor || DEFAULT_ACCENT_COLOR_HSL
|
||||
}, [accentPreviewHex, isValidHexColor, themeConfig.accentColor])
|
||||
|
||||
const previewThemeConfig = useMemo(() => {
|
||||
return {
|
||||
...themeConfig,
|
||||
accentColor: previewAccentHSL,
|
||||
}
|
||||
}, [previewAccentHSL, themeConfig])
|
||||
|
||||
useEffect(() => {
|
||||
const persistedHex = themeConfig.accentColor
|
||||
? hslToHex(themeConfig.accentColor)
|
||||
: DEFAULT_ACCENT_COLOR_HEX
|
||||
|
||||
setAccentInputValue(persistedHex)
|
||||
setAccentPreviewHex(persistedHex)
|
||||
}, [themeConfig.accentColor])
|
||||
|
||||
useEffect(() => {
|
||||
setBgDraftConfig(themeConfig.backgroundConfig ?? {})
|
||||
}, [themeConfig.backgroundConfig])
|
||||
|
||||
useEffect(() => {
|
||||
applyThemePipeline(previewThemeConfig, resolvedTheme === 'dark')
|
||||
}, [previewThemeConfig, resolvedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (accentDebounceRef.current) clearTimeout(accentDebounceRef.current)
|
||||
if (cssDebounceRef.current) clearTimeout(cssDebounceRef.current)
|
||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAccentColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const hex = e.target.value
|
||||
const hsl = hexToHSL(hex)
|
||||
updateThemeConfig({ accentColor: hsl })
|
||||
setAccentInputValue(hex)
|
||||
setAccentPreviewHex(hex)
|
||||
persistAccentColor(hex)
|
||||
}
|
||||
|
||||
const handleAccentTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.toUpperCase()
|
||||
setAccentInputValue(value)
|
||||
|
||||
if (!isValidHexColor(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
setAccentPreviewHex(value)
|
||||
persistAccentColor(value)
|
||||
}
|
||||
|
||||
const handleResetAccent = () => {
|
||||
updateThemeConfig({ accentColor: '' })
|
||||
if (accentDebounceRef.current) clearTimeout(accentDebounceRef.current)
|
||||
|
||||
setAccentInputValue(DEFAULT_ACCENT_COLOR_HEX)
|
||||
setAccentPreviewHex(DEFAULT_ACCENT_COLOR_HEX)
|
||||
updateThemeConfig({ accentColor: DEFAULT_ACCENT_COLOR_HSL })
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
@@ -178,10 +254,17 @@ export function AppearanceTab() {
|
||||
}
|
||||
|
||||
const previewTokens = useMemo(() => {
|
||||
return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
|
||||
}, [themeConfig, resolvedTheme])
|
||||
return getComputedTokens(previewThemeConfig, resolvedTheme === 'dark').color
|
||||
}, [previewThemeConfig, resolvedTheme])
|
||||
|
||||
const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {}
|
||||
const bgConfig: BackgroundConfigMap = bgDraftConfig
|
||||
|
||||
const scheduleBackgroundConfigPersist = useCallback((nextConfig: BackgroundConfigMap) => {
|
||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||
bgDebounceRef.current = setTimeout(() => {
|
||||
updateThemeConfig({ backgroundConfig: nextConfig })
|
||||
}, 180)
|
||||
}, [updateThemeConfig])
|
||||
|
||||
const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => {
|
||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||
@@ -189,29 +272,29 @@ export function AppearanceTab() {
|
||||
...bgConfig,
|
||||
[layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' },
|
||||
}
|
||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
||||
setBgDraftConfig(newMap)
|
||||
scheduleBackgroundConfigPersist(newMap)
|
||||
}
|
||||
|
||||
const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => {
|
||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } }
|
||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
||||
setBgDraftConfig(newMap)
|
||||
scheduleBackgroundConfigPersist(newMap)
|
||||
}
|
||||
|
||||
const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => {
|
||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } }
|
||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
||||
setBgDraftConfig(newMap)
|
||||
scheduleBackgroundConfigPersist(newMap)
|
||||
}
|
||||
|
||||
const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => {
|
||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } }
|
||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
||||
setBgDraftConfig(newMap)
|
||||
scheduleBackgroundConfigPersist(newMap)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -252,7 +335,7 @@ export function AppearanceTab() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResetAccent}
|
||||
disabled={!themeConfig.accentColor}
|
||||
disabled={themeConfig.accentColor === DEFAULT_ACCENT_COLOR_HSL}
|
||||
className="h-8"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
@@ -267,7 +350,7 @@ export function AppearanceTab() {
|
||||
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
|
||||
<input
|
||||
type="color"
|
||||
value={currentAccentHex}
|
||||
value={accentPreviewHex}
|
||||
onChange={handleAccentColorChange}
|
||||
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
|
||||
/>
|
||||
@@ -282,8 +365,8 @@ export function AppearanceTab() {
|
||||
<Input
|
||||
id="accent-color-input"
|
||||
type="text"
|
||||
value={currentAccentHex}
|
||||
onChange={handleAccentColorChange}
|
||||
value={accentInputValue}
|
||||
onChange={handleAccentTextChange}
|
||||
className="font-mono uppercase w-32"
|
||||
maxLength={7}
|
||||
/>
|
||||
@@ -661,6 +744,11 @@ export function AppearanceTab() {
|
||||
|
||||
{(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
|
||||
<TabsContent key={layerId} value={layerId} className="space-y-4 mt-4">
|
||||
{(() => {
|
||||
const isInheritedLayer = (layerId === 'sidebar' || layerId === 'header') && (bgConfig[layerId]?.inherit ?? false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{layerId !== 'page' && (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
@@ -673,19 +761,30 @@ export function AppearanceTab() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isInheritedLayer && (
|
||||
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
该层当前直接继承界面背景,下面的资源、效果和 CSS 调节已禁用。
|
||||
</div>
|
||||
)}
|
||||
<BackgroundUploader
|
||||
assetId={bgConfig[layerId]?.assetId}
|
||||
onAssetSelect={(id) => handleBgAssetChange(layerId, id)}
|
||||
disabled={isInheritedLayer}
|
||||
/>
|
||||
<BackgroundEffectsControls
|
||||
effects={bgConfig[layerId]?.effects ?? defaultBackgroundEffects}
|
||||
onChange={(effects) => handleBgEffectsChange(layerId, effects)}
|
||||
disabled={isInheritedLayer}
|
||||
/>
|
||||
<ComponentCSSEditor
|
||||
componentId={layerId}
|
||||
value={bgConfig[layerId]?.customCSS ?? ''}
|
||||
onChange={(css) => handleBgCSSChange(layerId, css)}
|
||||
disabled={isInheritedLayer}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
Reference in New Issue
Block a user