From 1fec4c3b9ad92d61d41b2c1ccbc0d7cf42e0e23a Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Mon, 23 Feb 2026 18:08:01 +0800 Subject: [PATCH] feat(dashboard): add background customization system Add image/video background support across 5 layout layers (page, sidebar, header, card, dialog) with per-layer effect controls and custom CSS injection. - IndexedDB asset store for blob persistence (idb) - AssetStoreProvider for blob URL lifecycle management - BackgroundLayer component with CSS effects and prefers-reduced-motion support - useBackground hook with inherit logic - BackgroundUploader with local file and remote URL support - BackgroundEffectsControls and ComponentCSSEditor UI components - Background settings integrated into AppearanceTab in settings.tsx - Layout, Card, and Dialog integration via non-breaking wrapper components --- dashboard/package.json | 1 + dashboard/src/components/asset-provider.tsx | 64 ++++ .../background-effects-controls.tsx | 292 ++++++++++++++++++ dashboard/src/components/background-layer.tsx | 164 ++++++++++ .../src/components/background-uploader.tsx | 273 ++++++++++++++++ .../src/components/component-css-editor.tsx | 80 +++++ dashboard/src/components/layout.tsx | 14 +- .../components/ui/card-with-background.tsx | 27 ++ .../components/ui/dialog-with-background.tsx | 27 ++ dashboard/src/hooks/use-background.ts | 26 ++ dashboard/src/lib/asset-store.ts | 111 +++++++ dashboard/src/lib/theme/pipeline.ts | 78 ++++- dashboard/src/lib/theme/storage.ts | 22 +- dashboard/src/lib/theme/tokens.ts | 48 +++ dashboard/src/main.tsx | 21 +- dashboard/src/routes/settings.tsx | 91 +++++- 16 files changed, 1322 insertions(+), 17 deletions(-) create mode 100644 dashboard/src/components/asset-provider.tsx create mode 100644 dashboard/src/components/background-effects-controls.tsx create mode 100644 dashboard/src/components/background-layer.tsx create mode 100644 dashboard/src/components/background-uploader.tsx create mode 100644 dashboard/src/components/component-css-editor.tsx create mode 100644 dashboard/src/components/ui/card-with-background.tsx create mode 100644 dashboard/src/components/ui/dialog-with-background.tsx create mode 100644 dashboard/src/hooks/use-background.ts create mode 100644 dashboard/src/lib/asset-store.ts diff --git a/dashboard/package.json b/dashboard/package.json index 182b9346..5a925629 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -60,6 +60,7 @@ "dagre": "^0.8.5", "date-fns": "^4.1.0", "html-to-image": "^1.11.13", + "idb": "^8.0.3", "jotai": "^2.16.0", "katex": "^0.16.27", "lucide-react": "^0.556.0", diff --git a/dashboard/src/components/asset-provider.tsx b/dashboard/src/components/asset-provider.tsx new file mode 100644 index 00000000..203301c0 --- /dev/null +++ b/dashboard/src/components/asset-provider.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useEffect, useMemo, useRef } from 'react' +import type { ReactNode } from 'react' + +import { getAsset } from '@/lib/asset-store' + +type AssetStoreContextType = { + getAssetUrl: (assetId: string) => Promise +} + +const AssetStoreContext = createContext(null) + +type AssetStoreProviderProps = { + children: ReactNode +} + +export function AssetStoreProvider({ children }: AssetStoreProviderProps) { + const urlCache = useRef>(new Map()) + + const getAssetUrl = async (assetId: string): Promise => { + // Check cache first + const cached = urlCache.current.get(assetId) + if (cached) { + return cached + } + + // Fetch from IndexedDB + const record = await getAsset(assetId) + if (!record) { + return undefined + } + + // Create blob URL and cache it + const url = URL.createObjectURL(record.blob) + urlCache.current.set(assetId, url) + return url + } + + const value = useMemo( + () => ({ + getAssetUrl, + }), + [], + ) + + // Cleanup: revoke all blob URLs on unmount + useEffect(() => { + return () => { + urlCache.current.forEach((url) => { + URL.revokeObjectURL(url) + }) + urlCache.current.clear() + } + }, []) + + return {children} +} + +export function useAssetStore() { + const context = useContext(AssetStoreContext) + if (!context) { + throw new Error('useAssetStore must be used within AssetStoreProvider') + } + return context +} diff --git a/dashboard/src/components/background-effects-controls.tsx b/dashboard/src/components/background-effects-controls.tsx new file mode 100644 index 00000000..3c31b20a --- /dev/null +++ b/dashboard/src/components/background-effects-controls.tsx @@ -0,0 +1,292 @@ +import { RotateCcw } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Slider } from '@/components/ui/slider' +import { hexToHSL } from '@/lib/theme/palette' +import { + type BackgroundEffects, + 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' + + const h = parseFloat(parts[0]) + const s = parseFloat(parts[1].replace('%', '')) + const l = parseFloat(parts[2].replace('%', '')) + + 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 + + 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 + } + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +// ============================================================================ +// Component +// ============================================================================ + +type BackgroundEffectsControlsProps = { + effects: BackgroundEffects + onChange: (effects: BackgroundEffects) => void +} + +export function BackgroundEffectsControls({ + effects, + onChange, +}: BackgroundEffectsControlsProps) { + // 处理数值变更 + const handleValueChange = (key: keyof BackgroundEffects, value: number) => { + onChange({ + ...effects, + [key]: value, + }) + } + + // 处理颜色变更 + const handleColorChange = (e: React.ChangeEvent) => { + const hex = e.target.value + const hsl = hexToHSL(hex) + onChange({ + ...effects, + overlayColor: hsl, + }) + } + + // 处理位置变更 + const handlePositionChange = (value: string) => { + onChange({ + ...effects, + position: value as BackgroundEffects['position'], + }) + } + + // 处理渐变变更 + const handleGradientChange = (e: React.ChangeEvent) => { + onChange({ + ...effects, + gradientOverlay: e.target.value, + }) + } + + // 重置为默认值 + const handleReset = () => { + onChange(defaultBackgroundEffects) + } + + return ( +
+
+

背景效果调节

+ +
+ +
+ {/* 1. Blur (模糊) */} +
+
+ + + {effects.blur}px + +
+ handleValueChange('blur', vals[0])} + /> +
+ + {/* 2. Overlay Color (遮罩颜色) */} +
+ +
+
+ +
+ +
+
+ + {/* 3. Overlay Opacity (遮罩不透明度) */} +
+
+ + + {Math.round(effects.overlayOpacity * 100)}% + +
+ + handleValueChange('overlayOpacity', vals[0] / 100) + } + /> +
+ + {/* 4. Position (位置) */} +
+ + +
+ + {/* 5. Brightness (亮度) */} +
+
+ + + {effects.brightness}% + +
+ handleValueChange('brightness', vals[0])} + /> +
+ + {/* 6. Contrast (对比度) */} +
+
+ + + {effects.contrast}% + +
+ handleValueChange('contrast', vals[0])} + /> +
+ + {/* 7. Saturate (饱和度) */} +
+
+ + + {effects.saturate}% + +
+ handleValueChange('saturate', vals[0])} + /> +
+ + {/* 8. Gradient Overlay (渐变叠加) */} +
+ + +

+ 可选:输入有效的 CSS gradient 字符串 +

+
+
+
+ ) +} diff --git a/dashboard/src/components/background-layer.tsx b/dashboard/src/components/background-layer.tsx new file mode 100644 index 00000000..e1f335ca --- /dev/null +++ b/dashboard/src/components/background-layer.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from 'react' + +import { useAssetStore } from '@/components/asset-provider' +import type { BackgroundConfig } from '@/lib/theme/tokens' + +type BackgroundLayerProps = { + config: BackgroundConfig + layerId: string +} + +function buildFilterString(effects: BackgroundConfig['effects']): string { + const parts: string[] = [] + if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`) + if (effects.brightness !== 100) parts.push(`brightness(${effects.brightness}%)`) + if (effects.contrast !== 100) parts.push(`contrast(${effects.contrast}%)`) + if (effects.saturate !== 100) parts.push(`saturate(${effects.saturate}%)`) + return parts.join(' ') +} + +function getBackgroundSize(position: BackgroundConfig['effects']['position']): string { + switch (position) { + case 'cover': + return 'cover' + case 'contain': + return 'contain' + case 'center': + return 'auto' + case 'stretch': + return '100% 100%' + default: + return 'cover' + } +} + +function getObjectFit(position: BackgroundConfig['effects']['position']): React.CSSProperties['objectFit'] { + switch (position) { + case 'cover': + return 'cover' + case 'contain': + return 'contain' + case 'center': + return 'none' + case 'stretch': + return 'fill' + default: + return 'cover' + } +} + +export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) { + const { getAssetUrl } = useAssetStore() + const [blobUrl, setBlobUrl] = useState() + const videoRef = useRef(null) + + useEffect(() => { + if (!config.assetId) { + setBlobUrl(undefined) + return + } + getAssetUrl(config.assetId).then(setBlobUrl) + }, [config.assetId, getAssetUrl]) + + useEffect(() => { + if (config.type !== 'video' || !videoRef.current) return + + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const apply = () => { + if (videoRef.current) { + if (mq.matches) { + videoRef.current.pause() + } else { + videoRef.current.play().catch(() => {}) + } + } + } + apply() + mq.addEventListener('change', apply) + return () => mq.removeEventListener('change', apply) + }, [config.type]) + + if (config.type === 'none') { + return null + } + + const filterString = buildFilterString(config.effects) + const { overlayColor, overlayOpacity, gradientOverlay } = config.effects + + return ( +
+ {config.type === 'image' && ( +
+ )} + + {config.type === 'video' && blobUrl && ( +