mirror of https://github.com/Mai-with-u/MaiBot.git
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 componentsr-dev
parent
698b8355a4
commit
1fec4c3b9a
|
|
@ -60,6 +60,7 @@
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"jotai": "^2.16.0",
|
"jotai": "^2.16.0",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
|
|
|
||||||
|
|
@ -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<string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetStoreContext = createContext<AssetStoreContextType | null>(null)
|
||||||
|
|
||||||
|
type AssetStoreProviderProps = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetStoreProvider({ children }: AssetStoreProviderProps) {
|
||||||
|
const urlCache = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
const getAssetUrl = async (assetId: string): Promise<string | undefined> => {
|
||||||
|
// 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 <AssetStoreContext.Provider value={value}>{children}</AssetStoreContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssetStore() {
|
||||||
|
const context = useContext(AssetStoreContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAssetStore must be used within AssetStoreProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
@ -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<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
onChange({
|
||||||
|
...effects,
|
||||||
|
gradientOverlay: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置为默认值
|
||||||
|
const handleReset = () => {
|
||||||
|
onChange(defaultBackgroundEffects)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium">背景效果调节</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.blur]}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
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">
|
||||||
|
<div className="h-9 w-9 overflow-hidden rounded-md border shadow-sm">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hslToHex(effects.overlayColor)}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
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
|
||||||
|
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>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{Math.round(effects.overlayOpacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.overlayOpacity * 100]}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
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>
|
||||||
|
<SelectValue placeholder="选择位置" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="cover">覆盖 (Cover)</SelectItem>
|
||||||
|
<SelectItem value="contain">包含 (Contain)</SelectItem>
|
||||||
|
<SelectItem value="center">居中 (Center)</SelectItem>
|
||||||
|
<SelectItem value="stretch">拉伸 (Stretch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.brightness]}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.contrast]}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.saturate]}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
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}
|
||||||
|
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
可选:输入有效的 CSS gradient 字符串
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<string | undefined>()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(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 (
|
||||||
|
<div
|
||||||
|
key={layerId}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.type === 'image' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
backgroundImage: blobUrl ? `url(${blobUrl})` : undefined,
|
||||||
|
backgroundSize: getBackgroundSize(config.effects.position),
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
filter: filterString || undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === 'video' && blobUrl && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={blobUrl}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: getObjectFit(config.effects.position),
|
||||||
|
filter: filterString || undefined,
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overlayOpacity > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
backgroundColor: `hsl(${overlayColor} / ${overlayOpacity})`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gradientOverlay && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
background: gradientOverlay,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, Loader2, Trash2, Upload } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useAssetStore } from '@/components/asset-provider'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { addAsset, getAsset } from '@/lib/asset-store'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type BackgroundUploaderProps = {
|
||||||
|
assetId?: string
|
||||||
|
onAssetSelect: (id: string | undefined) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundUploader({ assetId, onAssetSelect, className }: BackgroundUploaderProps) {
|
||||||
|
const { getAssetUrl } = useAssetStore()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
|
||||||
|
const [assetType, setAssetType] = useState<'image' | 'video' | undefined>(undefined)
|
||||||
|
const [urlInput, setUrlInput] = useState('')
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 加载预览
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const loadPreview = async () => {
|
||||||
|
if (!assetId) {
|
||||||
|
setPreviewUrl(undefined)
|
||||||
|
setAssetType(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await getAssetUrl(assetId)
|
||||||
|
const record = await getAsset(assetId)
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
if (url && record) {
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setAssetType(record.type)
|
||||||
|
} else {
|
||||||
|
// 如果找不到资源,可能是被删除了
|
||||||
|
onAssetSelect(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load asset preview:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [assetId, getAssetUrl, onAssetSelect])
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||||
|
throw new Error('不支持的文件类型。请上传图片或视频。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小 (例如限制 50MB)
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
throw new Error('文件过大。请上传小于 50MB 的文件。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await addAsset(file)
|
||||||
|
onAssetSelect(id)
|
||||||
|
setUrlInput('') // 清空 URL 输入框
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '上传失败')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUrlUpload = async () => {
|
||||||
|
if (!urlInput) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(urlInput)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
// 尝试从 Content-Type 或 URL 推断文件名和类型
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const urlFilename = urlInput.split('/').pop() || 'downloaded-file'
|
||||||
|
const filename = urlFilename.includes('.') ? urlFilename : `${urlFilename}.${contentType.split('/')[1] || 'bin'}`
|
||||||
|
|
||||||
|
const file = new File([blob], filename, { type: contentType })
|
||||||
|
await handleFile(file)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '从 URL 上传失败')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽处理
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true)
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragActive(false)
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
handleFile(e.dataTransfer.files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onAssetSelect(undefined)
|
||||||
|
setPreviewUrl(undefined)
|
||||||
|
setAssetType(undefined)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>背景资源</Label>
|
||||||
|
|
||||||
|
{/* 预览区域 / 上传区域 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
||||||
|
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
||||||
|
error ? "border-destructive/50 bg-destructive/5" : "",
|
||||||
|
assetId ? "border-solid" : ""
|
||||||
|
)}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-sm">处理中...</p>
|
||||||
|
</div>
|
||||||
|
) : assetId && previewUrl ? (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{assetType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={previewUrl}
|
||||||
|
className="h-full max-h-[300px] w-full rounded-md object-contain"
|
||||||
|
controls={false}
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Background preview"
|
||||||
|
className="h-full max-h-[300px] w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute right-2 top-2 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shadow-sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-2 left-2 rounded bg-black/50 px-2 py-1 text-xs text-white backdrop-blur">
|
||||||
|
{assetType === 'video' ? '视频' : '图片'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">点击或拖拽上传</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持 JPG, PNG, GIF, MP4, WebM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
选择文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*,video/mp4,video/webm"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.[0]) {
|
||||||
|
handleFile(e.target.files[0])
|
||||||
|
}
|
||||||
|
// 重置 value,允许重复选择同一文件
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL 上传 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">或从 URL 获取</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Link className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
className="pl-9"
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleUrlUpload()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleUrlUpload}
|
||||||
|
disabled={!urlInput || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CodeEditor } from '@/components/CodeEditor'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { sanitizeCSS } from '@/lib/theme/sanitizer'
|
||||||
|
|
||||||
|
export type ComponentCSSEditorProps = {
|
||||||
|
/** 组件唯一标识符 */
|
||||||
|
componentId: string
|
||||||
|
/** 当前 CSS 内容 */
|
||||||
|
value: string
|
||||||
|
/** CSS 内容变更回调 */
|
||||||
|
onChange: (css: string) => void
|
||||||
|
/** 编辑器标签文字 */
|
||||||
|
label?: string
|
||||||
|
/** 编辑器高度,默认 200px */
|
||||||
|
height?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件级 CSS 编辑器
|
||||||
|
* 提供 CSS 代码编辑、语法高亮和安全过滤警告功能
|
||||||
|
*/
|
||||||
|
export function ComponentCSSEditor({
|
||||||
|
componentId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
height = '200px',
|
||||||
|
}: ComponentCSSEditorProps) {
|
||||||
|
// 实时计算 CSS 警告
|
||||||
|
const { warnings } = sanitizeCSS(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{label || '自定义 CSS'}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
disabled={!value}
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
title="清除所有 CSS"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-card overflow-hidden">
|
||||||
|
<CodeEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
language="css"
|
||||||
|
height={height}
|
||||||
|
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="border-t border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/30 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-xs font-medium mb-1">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
检测到不安全的 CSS 规则:
|
||||||
|
</div>
|
||||||
|
<ul className="text-[10px] sm:text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-5 list-disc">
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,9 @@ import { cn } from '@/lib/utils'
|
||||||
import { formatVersion } from '@/lib/version'
|
import { formatVersion } from '@/lib/version'
|
||||||
import type { ReactNode, ComponentType } from 'react'
|
import type { ReactNode, ComponentType } from 'react'
|
||||||
import type { LucideProps } from 'lucide-react'
|
import type { LucideProps } from 'lucide-react'
|
||||||
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
|
|
||||||
|
import { useBackground } from '@/hooks/use-background'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
@ -140,6 +143,10 @@ export function Layout({ children }: LayoutProps) {
|
||||||
|
|
||||||
const actualTheme = getActualTheme()
|
const actualTheme = getActualTheme()
|
||||||
|
|
||||||
|
const pageBg = useBackground('page')
|
||||||
|
const sidebarBg = useBackground('sidebar')
|
||||||
|
const headerBg = useBackground('header')
|
||||||
|
|
||||||
// 登出处理
|
// 登出处理
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
|
|
@ -158,6 +165,7 @@ export function Layout({ children }: LayoutProps) {
|
||||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<div className="flex h-16 items-center border-b px-4">
|
<div className="flex h-16 items-center border-b px-4">
|
||||||
<div
|
<div
|
||||||
|
|
@ -306,6 +314,7 @@ export function Layout({ children }: LayoutProps) {
|
||||||
|
|
||||||
{/* Topbar */}
|
{/* Topbar */}
|
||||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
{/* 移动端菜单按钮 */}
|
{/* 移动端菜单按钮 */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -398,7 +407,10 @@ export function Layout({ children }: LayoutProps) {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 overflow-hidden bg-background">{children}</main>
|
<main className="relative flex-1 overflow-hidden bg-background">
|
||||||
|
<BackgroundLayer config={pageBg} layerId="page" />
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* Back to Top Button */}
|
{/* Back to Top Button */}
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
|
import { useBackground } from '@/hooks/use-background'
|
||||||
|
|
||||||
|
type CardWithBackgroundProps = ComponentPropsWithoutRef<typeof Card>
|
||||||
|
|
||||||
|
export const CardWithBackground = forwardRef<
|
||||||
|
ElementRef<typeof Card>,
|
||||||
|
CardWithBackgroundProps
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const bg = useBackground('card')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={cn('relative', className)} {...props}>
|
||||||
|
<BackgroundLayer config={bg} layerId="card" />
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
CardWithBackground.displayName = 'CardWithBackground'
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
|
import { DialogContent } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
import { useBackground } from '@/hooks/use-background'
|
||||||
|
|
||||||
|
type DialogContentWithBackgroundProps = ComponentPropsWithoutRef<typeof DialogContent>
|
||||||
|
|
||||||
|
export const DialogContentWithBackground = forwardRef<
|
||||||
|
ElementRef<typeof DialogContent>,
|
||||||
|
DialogContentWithBackgroundProps
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const bg = useBackground('dialog')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent ref={ref} className={cn('relative', className)} {...props}>
|
||||||
|
<BackgroundLayer config={bg} layerId="dialog" />
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
DialogContentWithBackground.displayName = 'DialogContentWithBackground'
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useTheme } from '@/components/use-theme'
|
||||||
|
|
||||||
|
import type { BackgroundConfig } from '@/lib/theme/tokens'
|
||||||
|
import { defaultBackgroundConfig } from '@/lib/theme/tokens'
|
||||||
|
|
||||||
|
type BackgroundLayerId = 'page' | 'sidebar' | 'header' | 'card' | 'dialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定层级的背景配置
|
||||||
|
* 处理继承逻辑:如果 inherit 为 true,返回页面级别配置
|
||||||
|
* @param layerId - 背景层级标识
|
||||||
|
* @returns 对应层级的背景配置
|
||||||
|
*/
|
||||||
|
export function useBackground(layerId: BackgroundLayerId): BackgroundConfig {
|
||||||
|
const { themeConfig } = useTheme()
|
||||||
|
const bgMap = themeConfig.backgroundConfig ?? {}
|
||||||
|
|
||||||
|
const config = bgMap[layerId] ?? defaultBackgroundConfig
|
||||||
|
|
||||||
|
// 处理继承逻辑:非 page 层级且 inherit 为 true,返回 page 配置
|
||||||
|
if (layerId !== 'page' && config.inherit) {
|
||||||
|
return bgMap.page ?? defaultBackgroundConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* IndexedDB 资源存储模块
|
||||||
|
* 使用 idb 库封装所有 IndexedDB 操作,用于存储图片和视频资源
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openDB, type IDBPDatabase } from 'idb'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源记录的类型定义
|
||||||
|
*/
|
||||||
|
export type AssetRecord = {
|
||||||
|
/** 资源唯一标识符 (UUID v4) */
|
||||||
|
id: string
|
||||||
|
/** 文件名 */
|
||||||
|
filename: string
|
||||||
|
/** 资源类型 */
|
||||||
|
type: 'image' | 'video'
|
||||||
|
/** MIME 类型 */
|
||||||
|
mimeType: string
|
||||||
|
/** 文件内容 */
|
||||||
|
blob: Blob
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
size: number
|
||||||
|
/** 创建时间戳 */
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常量定义
|
||||||
|
const DB_NAME = 'maibot-assets'
|
||||||
|
const STORE_NAME = 'assets'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开或创建资源数据库
|
||||||
|
* 初始化 IndexedDB 数据库,如需要则创建 object store
|
||||||
|
*
|
||||||
|
* @returns 打开的数据库实例
|
||||||
|
*/
|
||||||
|
export async function openAssetDB(): Promise<IDBPDatabase<unknown>> {
|
||||||
|
return openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储文件到 IndexedDB
|
||||||
|
* 根据文件 MIME 类型自动判断资源类型,使用 UUID v4 作为资源 ID
|
||||||
|
*
|
||||||
|
* @param file - 要存储的文件
|
||||||
|
* @returns 生成的资源 ID (UUID v4)
|
||||||
|
*/
|
||||||
|
export async function addAsset(file: File): Promise<string> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
|
||||||
|
// 根据 file.type 判断资源类型
|
||||||
|
const type: 'image' | 'video' = file.type.startsWith('video/') ? 'video' : 'image'
|
||||||
|
|
||||||
|
const asset: AssetRecord = {
|
||||||
|
id,
|
||||||
|
filename: file.name,
|
||||||
|
type,
|
||||||
|
mimeType: file.type,
|
||||||
|
blob: file,
|
||||||
|
size: file.size,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.add(STORE_NAME, asset)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 ID 的资源记录
|
||||||
|
* 如果资源不存在,返回 undefined
|
||||||
|
*
|
||||||
|
* @param id - 资源 ID
|
||||||
|
* @returns 资源记录或 undefined
|
||||||
|
*/
|
||||||
|
export async function getAsset(id: string): Promise<AssetRecord | undefined> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
return (await db.get(STORE_NAME, id)) as AssetRecord | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定 ID 的资源
|
||||||
|
* 如果资源不存在,该操作不会抛出错误
|
||||||
|
*
|
||||||
|
* @param id - 资源 ID
|
||||||
|
*/
|
||||||
|
export async function deleteAsset(id: string): Promise<void> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
await db.delete(STORE_NAME, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有资源记录列表
|
||||||
|
* 返回按创建时间倒序排列的资源列表
|
||||||
|
*
|
||||||
|
* @returns 资源记录数组
|
||||||
|
*/
|
||||||
|
export async function listAssets(): Promise<AssetRecord[]> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
const assets = (await db.getAll(STORE_NAME)) as AssetRecord[]
|
||||||
|
// 按创建时间倒序排列(最新的在前)
|
||||||
|
return assets.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import { sanitizeCSS } from './sanitizer'
|
||||||
import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens'
|
import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens'
|
||||||
|
|
||||||
const CUSTOM_CSS_ID = 'maibot-custom-css'
|
const CUSTOM_CSS_ID = 'maibot-custom-css'
|
||||||
|
const COMPONENT_CSS_ID_PREFIX = 'maibot-bg-css-'
|
||||||
|
const COMPONENT_IDS = ['page', 'sidebar', 'header', 'card', 'dialog'] as const
|
||||||
|
|
||||||
const mergeTokens = (base: ThemeTokens, overrides: Partial<ThemeTokens>): ThemeTokens => {
|
const mergeTokens = (base: ThemeTokens, overrides: Partial<ThemeTokens>): ThemeTokens => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -106,19 +108,87 @@ export function removeCustomCSS(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定组件注入自定义 CSS
|
||||||
|
* 使用独立的 style 标签,CSS 经过 sanitize 处理
|
||||||
|
* @param css - 要注入的 CSS 字符串
|
||||||
|
* @param componentId - 组件标识符 (page/sidebar/header/card/dialog)
|
||||||
|
*/
|
||||||
|
export function injectComponentCSS(css: string, componentId: string): void {
|
||||||
|
const styleId = `${COMPONENT_CSS_ID_PREFIX}${componentId}`
|
||||||
|
|
||||||
|
if (css.trim().length === 0) {
|
||||||
|
removeComponentCSS(componentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeCSS(css)
|
||||||
|
const sanitizedCss = sanitized.css
|
||||||
|
|
||||||
|
if (sanitizedCss.trim().length === 0) {
|
||||||
|
removeComponentCSS(componentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.getElementById(styleId)
|
||||||
|
if (existing) {
|
||||||
|
existing.textContent = sanitizedCss
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = styleId
|
||||||
|
style.textContent = sanitizedCss
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除指定组件的自定义 CSS
|
||||||
|
*/
|
||||||
|
export function removeComponentCSS(componentId: string): void {
|
||||||
|
const styleId = `${COMPONENT_CSS_ID_PREFIX}${componentId}`
|
||||||
|
document.getElementById(styleId)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除所有组件的自定义 CSS
|
||||||
|
*/
|
||||||
|
export function removeAllComponentCSS(): void {
|
||||||
|
COMPONENT_IDS.forEach(removeComponentCSS)
|
||||||
|
}
|
||||||
|
|
||||||
export function applyThemePipeline(config: UserThemeConfig, isDark: boolean): void {
|
export function applyThemePipeline(config: UserThemeConfig, isDark: boolean): void {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const tokens = buildTokens(config, isDark)
|
const tokens = buildTokens(config, isDark)
|
||||||
|
|
||||||
injectTokensAsCSS(tokens, root)
|
injectTokensAsCSS(tokens, root)
|
||||||
|
|
||||||
if (config.customCSS) {
|
if (config.customCSS) {
|
||||||
const sanitized = sanitizeCSS(config.customCSS)
|
const sanitized = sanitizeCSS(config.customCSS)
|
||||||
if (sanitized.css.trim().length > 0) {
|
if (sanitized.css.trim().length > 0) {
|
||||||
injectCustomCSS(sanitized.css)
|
injectCustomCSS(sanitized.css)
|
||||||
return
|
} else {
|
||||||
|
removeCustomCSS()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
removeCustomCSS()
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCustomCSS()
|
// 应用组件级 CSS(注入顺序在全局 CSS 之后)
|
||||||
|
if (config.backgroundConfig) {
|
||||||
|
const { page, sidebar, header, card, dialog } = config.backgroundConfig
|
||||||
|
;[
|
||||||
|
['page', page],
|
||||||
|
['sidebar', sidebar],
|
||||||
|
['header', header],
|
||||||
|
['card', card],
|
||||||
|
['dialog', dialog],
|
||||||
|
].forEach(([id, cfg]) => {
|
||||||
|
if (cfg && typeof cfg === 'object' && 'customCSS' in cfg && cfg.customCSS) {
|
||||||
|
injectComponentCSS(cfg.customCSS, id as string)
|
||||||
|
} else {
|
||||||
|
removeComponentCSS(id as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
removeAllComponentCSS()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UserThemeConfig } from './tokens'
|
import type { BackgroundConfigMap, UserThemeConfig } from './tokens'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题存储 key 定义
|
* 主题存储 key 定义
|
||||||
|
|
@ -15,6 +15,7 @@ export const THEME_STORAGE_KEYS = {
|
||||||
ACCENT: 'maibot-theme-accent',
|
ACCENT: 'maibot-theme-accent',
|
||||||
OVERRIDES: 'maibot-theme-overrides',
|
OVERRIDES: 'maibot-theme-overrides',
|
||||||
CUSTOM_CSS: 'maibot-theme-custom-css',
|
CUSTOM_CSS: 'maibot-theme-custom-css',
|
||||||
|
BACKGROUND_CONFIG: 'maibot-theme-background',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +26,7 @@ const DEFAULT_THEME_CONFIG: UserThemeConfig = {
|
||||||
accentColor: 'blue',
|
accentColor: 'blue',
|
||||||
tokenOverrides: {},
|
tokenOverrides: {},
|
||||||
customCSS: '',
|
customCSS: '',
|
||||||
|
backgroundConfig: {} as BackgroundConfigMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,11 +52,23 @@ export function loadThemeConfig(): UserThemeConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载 backgroundConfig
|
||||||
|
const backgroundConfigStr = localStorage.getItem(THEME_STORAGE_KEYS.BACKGROUND_CONFIG)
|
||||||
|
let backgroundConfig: BackgroundConfigMap = {}
|
||||||
|
if (backgroundConfigStr) {
|
||||||
|
try {
|
||||||
|
backgroundConfig = JSON.parse(backgroundConfigStr)
|
||||||
|
} catch {
|
||||||
|
backgroundConfig = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
||||||
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
|
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
|
||||||
tokenOverrides,
|
tokenOverrides,
|
||||||
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
||||||
|
backgroundConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +82,11 @@ export function saveThemeConfig(config: UserThemeConfig): void {
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
||||||
|
if (config.backgroundConfig) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.BACKGROUND_CONFIG, JSON.stringify(config.backgroundConfig))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(THEME_STORAGE_KEYS.BACKGROUND_CONFIG)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -152,6 +171,7 @@ export function importThemeJSON(
|
||||||
accentColor: configObj.accentColor as string,
|
accentColor: configObj.accentColor as string,
|
||||||
tokenOverrides: (configObj.tokenOverrides as Partial<any>) || {},
|
tokenOverrides: (configObj.tokenOverrides as Partial<any>) || {},
|
||||||
customCSS: configObj.customCSS as string,
|
customCSS: configObj.customCSS as string,
|
||||||
|
backgroundConfig: (configObj.backgroundConfig as BackgroundConfigMap) ?? {},
|
||||||
}
|
}
|
||||||
|
|
||||||
saveThemeConfig(validConfig)
|
saveThemeConfig(validConfig)
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export type UserThemeConfig = {
|
||||||
accentColor: string
|
accentColor: string
|
||||||
tokenOverrides: Partial<ThemeTokens>
|
tokenOverrides: Partial<ThemeTokens>
|
||||||
customCSS: string
|
customCSS: string
|
||||||
|
backgroundConfig?: BackgroundConfigMap
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -351,3 +352,50 @@ export function tokenToCSSVarName(
|
||||||
): string {
|
): string {
|
||||||
return `--${category}-${key}`
|
return `--${category}-${key}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Background Config Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type BackgroundEffects = {
|
||||||
|
blur: number // px, 0-50
|
||||||
|
overlayColor: string // HSL string,如 '0 0% 0%'
|
||||||
|
overlayOpacity: number // 0-1
|
||||||
|
position: 'cover' | 'contain' | 'center' | 'stretch'
|
||||||
|
brightness: number // 0-200, default 100
|
||||||
|
contrast: number // 0-200, default 100
|
||||||
|
saturate: number // 0-200, default 100
|
||||||
|
gradientOverlay?: string // CSS gradient string(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackgroundConfig = {
|
||||||
|
type: 'none' | 'image' | 'video'
|
||||||
|
assetId?: string // IndexedDB asset ID
|
||||||
|
inherit?: boolean // true = 继承页面背景
|
||||||
|
effects: BackgroundEffects
|
||||||
|
customCSS: string // 组件级自定义 CSS
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackgroundConfigMap = {
|
||||||
|
page?: BackgroundConfig
|
||||||
|
sidebar?: BackgroundConfig
|
||||||
|
header?: BackgroundConfig
|
||||||
|
card?: BackgroundConfig
|
||||||
|
dialog?: BackgroundConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBackgroundEffects: BackgroundEffects = {
|
||||||
|
blur: 0,
|
||||||
|
overlayColor: '0 0% 0%',
|
||||||
|
overlayOpacity: 0,
|
||||||
|
position: 'cover',
|
||||||
|
brightness: 100,
|
||||||
|
contrast: 100,
|
||||||
|
saturate: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBackgroundConfig: BackgroundConfig = {
|
||||||
|
type: 'none',
|
||||||
|
effects: defaultBackgroundEffects,
|
||||||
|
customCSS: '',
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
import { AssetStoreProvider } from './components/asset-provider'
|
||||||
import { ThemeProvider } from './components/theme-provider'
|
import { ThemeProvider } from './components/theme-provider'
|
||||||
import { AnimationProvider } from './components/animation-provider'
|
import { AnimationProvider } from './components/animation-provider'
|
||||||
import { TourProvider, TourRenderer } from './components/tour'
|
import { TourProvider, TourRenderer } from './components/tour'
|
||||||
|
|
@ -12,6 +13,7 @@ import { ErrorBoundary } from './components/error-boundary'
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<AssetStoreProvider>
|
||||||
<ThemeProvider defaultTheme="system">
|
<ThemeProvider defaultTheme="system">
|
||||||
<AnimationProvider>
|
<AnimationProvider>
|
||||||
<TourProvider>
|
<TourProvider>
|
||||||
|
|
@ -21,6 +23,7 @@ createRoot(document.getElementById('root')!).render(
|
||||||
</TourProvider>
|
</TourProvider>
|
||||||
</AnimationProvider>
|
</AnimationProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</AssetStoreProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,9 @@ import {
|
||||||
|
|
||||||
import { getComputedTokens } from '@/lib/theme/pipeline'
|
import { getComputedTokens } from '@/lib/theme/pipeline'
|
||||||
import { hexToHSL } from '@/lib/theme/palette'
|
import { hexToHSL } from '@/lib/theme/palette'
|
||||||
import { defaultLightTokens } from '@/lib/theme/tokens'
|
import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens'
|
||||||
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
|
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
|
||||||
import type { ThemeTokens } from '@/lib/theme/tokens'
|
import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
|
|
@ -59,6 +59,9 @@ import {
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/components/ui/accordion'
|
} from '@/components/ui/accordion'
|
||||||
import { CodeEditor } from '@/components/CodeEditor'
|
import { CodeEditor } from '@/components/CodeEditor'
|
||||||
|
import { BackgroundEffectsControls } from '@/components/background-effects-controls'
|
||||||
|
import { BackgroundUploader } from '@/components/background-uploader'
|
||||||
|
import { ComponentCSSEditor } from '@/components/component-css-editor'
|
||||||
import { sanitizeCSS } from '@/lib/theme/sanitizer'
|
import { sanitizeCSS } from '@/lib/theme/sanitizer'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -167,6 +170,7 @@ function AppearanceTab() {
|
||||||
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
|
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
|
||||||
const [cssWarnings, setCssWarnings] = useState<string[]>([])
|
const [cssWarnings, setCssWarnings] = useState<string[]>([])
|
||||||
const cssDebounceRef = 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 fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const updateTokenSection = useCallback(
|
const updateTokenSection = useCallback(
|
||||||
|
|
@ -264,6 +268,39 @@ function AppearanceTab() {
|
||||||
return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
|
return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
|
||||||
}, [themeConfig, resolvedTheme])
|
}, [themeConfig, resolvedTheme])
|
||||||
|
|
||||||
|
const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {}
|
||||||
|
|
||||||
|
const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => {
|
||||||
|
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||||
|
const newMap: BackgroundConfigMap = {
|
||||||
|
...bgConfig,
|
||||||
|
[layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' },
|
||||||
|
}
|
||||||
|
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||||
|
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 sm:space-y-8">
|
<div className="space-y-6 sm:space-y-8">
|
||||||
{/* 主题模式 */}
|
{/* 主题模式 */}
|
||||||
|
|
@ -360,6 +397,8 @@ function AppearanceTab() {
|
||||||
{/* 样式微调 */}
|
{/* 样式微调 */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">界面样式微调</h3>
|
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">界面样式微调</h3>
|
||||||
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
{/* 1. 字体排版 (Typography) */}
|
{/* 1. 字体排版 (Typography) */}
|
||||||
<AccordionItem value="typography">
|
<AccordionItem value="typography">
|
||||||
|
|
@ -680,6 +719,54 @@ function AppearanceTab() {
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 5. 背景设置 (Backgrounds) */}
|
||||||
|
<AccordionItem value="backgrounds">
|
||||||
|
<AccordionTrigger>背景设置 (Backgrounds)</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Tabs defaultValue="page">
|
||||||
|
<TabsList className="w-full grid grid-cols-5">
|
||||||
|
<TabsTrigger value="page">页面</TabsTrigger>
|
||||||
|
<TabsTrigger value="sidebar">侧边栏</TabsTrigger>
|
||||||
|
<TabsTrigger value="header">Header</TabsTrigger>
|
||||||
|
<TabsTrigger value="card">Card</TabsTrigger>
|
||||||
|
<TabsTrigger value="dialog">Dialog</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
|
||||||
|
<TabsContent key={layerId} value={layerId} className="space-y-4 mt-4">
|
||||||
|
{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">
|
||||||
|
<Label className="text-sm font-medium">继承上级背景</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">开启后将使用上级层级的背景配置</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={bgConfig[layerId]?.inherit ?? false}
|
||||||
|
onCheckedChange={(v) => handleBgInheritChange(layerId, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<BackgroundUploader
|
||||||
|
assetId={bgConfig[layerId]?.assetId}
|
||||||
|
onAssetSelect={(id) => handleBgAssetChange(layerId, id)}
|
||||||
|
/>
|
||||||
|
<BackgroundEffectsControls
|
||||||
|
effects={bgConfig[layerId]?.effects ?? defaultBackgroundEffects}
|
||||||
|
onChange={(effects) => handleBgEffectsChange(layerId, effects)}
|
||||||
|
/>
|
||||||
|
<ComponentCSSEditor
|
||||||
|
componentId={layerId}
|
||||||
|
value={bgConfig[layerId]?.customCSS ?? ''}
|
||||||
|
onChange={(css) => handleBgCSSChange(layerId, css)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue