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:
111
dashboard/src/lib/asset-store.ts
Normal file
111
dashboard/src/lib/asset-store.ts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user