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
This commit is contained in:
DrSmoothl
2026-02-23 18:08:01 +08:00
parent 698b8355a4
commit 1fec4c3b9a
16 changed files with 1322 additions and 17 deletions

View File

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

View File

@@ -6,6 +6,8 @@ import { sanitizeCSS } from './sanitizer'
import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens'
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 => {
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 {
const root = document.documentElement
const tokens = buildTokens(config, isDark)
injectTokensAsCSS(tokens, root)
if (config.customCSS) {
const sanitized = sanitizeCSS(config.customCSS)
if (sanitized.css.trim().length > 0) {
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()
}
}

View File

@@ -3,7 +3,7 @@
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
*/
import type { UserThemeConfig } from './tokens'
import type { BackgroundConfigMap, UserThemeConfig } from './tokens'
/**
* 主题存储 key 定义
@@ -15,6 +15,7 @@ export const THEME_STORAGE_KEYS = {
ACCENT: 'maibot-theme-accent',
OVERRIDES: 'maibot-theme-overrides',
CUSTOM_CSS: 'maibot-theme-custom-css',
BACKGROUND_CONFIG: 'maibot-theme-background',
} as const
/**
@@ -25,6 +26,7 @@ const DEFAULT_THEME_CONFIG: UserThemeConfig = {
accentColor: 'blue',
tokenOverrides: {},
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 {
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
tokenOverrides,
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.OVERRIDES, JSON.stringify(config.tokenOverrides))
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,
tokenOverrides: (configObj.tokenOverrides as Partial<any>) || {},
customCSS: configObj.customCSS as string,
backgroundConfig: (configObj.backgroundConfig as BackgroundConfigMap) ?? {},
}
saveThemeConfig(validConfig)

View File

@@ -145,6 +145,7 @@ export type UserThemeConfig = {
accentColor: string
tokenOverrides: Partial<ThemeTokens>
customCSS: string
backgroundConfig?: BackgroundConfigMap
}
// ============================================================================
@@ -351,3 +352,50 @@ export function tokenToCSSVarName(
): string {
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: '',
}