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:
DrSmoothl
2026-03-16 22:19:05 +08:00
parent a5a6d2cb26
commit 0811213db0
16 changed files with 359 additions and 170 deletions

View File

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

View File

@@ -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',
}}
/>

View File

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

View File

@@ -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; } */`}
/>

View File

@@ -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() && (
<>

View File

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

View File

@@ -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"
)}>

View File

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

View File

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