Fullscreen background
约 2,400 字
8 分钟

博客视觉增强功能开发报告

摘要

一、项目背景

为 Flare Stack Blog(基于 React + TanStack Start + Cloudflare Workers 的 Fuwari 主题博客)新增视觉增强功能,要求:

- 半透明卡片:调节卡片背景透明度,保留主题色相

- 反重力粒子背景:Three.js 实现的洛伦兹吸引子粒子动画,跟随鼠标/手势

- 全屏背景图:支持亮/暗双模式背景图,与粒子分层叠加

- 后台配置:所有参数可通过站点设置面板调节,支持实时预览

- 亮暗适配:粒子颜色和背景图均支持亮/暗模式独立配置

二、技术架构

层级

技术选型

说明

数据库

D1 (SQLite) + Drizzle ORM

存储卡片透明度、粒子参数、背景图路径等配置

配置管理

SystemConfig + resolveSiteConfig

统一配置解析与默认值合并

粒子引擎

Three.js + @react-three/fiber

基于洛伦兹吸引子的粒子系统

前端框架

React 19 + TanStack Start

服务端渲染 + 客户端交互

表单管理

react-hook-form

后台设置表单,嵌套对象脏检查

状态管理

TanStack Query

配置数据缓存与失效

样式方案

Tailwind CSS + CSS 变量

动态主题色与透明度控制

三、核心难点与试错过程

3.1 嵌套对象脏检查失效(半透明卡片保存按钮不亮)

现象:后台设置中修改卡片透明度滑块,保存按钮不亮起isDirtyfalse)。

根因分析

- 卡片透明度配置存储在嵌套对象 site.theme.default.glass

- react-hook-formisDirty 对深层嵌套对象的变更检测不稳定

- settings 从数据库加载后直接 reset(settings),而数据库可能缺少 glass 字段,导致 defaultValues 中该路径不存在

- useControllerdefaultValue 会覆盖 reset 设置的值,使 dirty 状态无法触发

试错经历

尝试次数

方案

结果

1

使用 setValue + shouldDirty: true

字段未注册dirtyFields 为空

2

使用 useController 绑定字段

defaultValue 覆盖了 reset 的值

3

移除 useControllerdefaultValue

字段未注册,仍不触发 dirty

4

手动 deepEqual 深度比较 + watch 监听

成功检测变化,按钮亮起

最终方案

TSX
// settings/index.tsx - 手动脏检查
function deepEqual(obj1: any, obj2: any): boolean {
  // 递归深度比较两个对象
}
const [hasChanges, setHasChanges] = useState(false);
const defaultValuesRef = useRef<SystemConfig | null>(null);
const currentValues = watch();
useEffect(() => {
  if (defaultValuesRef.current && currentValues) {
    const changed = !deepEqual(defaultValuesRef.current, currentValues);
    setHasChanges(changed);
  }
}, [currentValues]);
// 按钮禁用条件改为 !hasChanges

关键洞察:react-hook-formisDirty 对于深层嵌套对象不可靠,手动深度比较是最稳定的方案。问题的根源不在视图层,而在表单状态管理层的默认值同步机制。

3.2 后台预览与前台全屏的层级冲突

现象:粒子背景在后台预览时无法正确显示在小容器中,或前台被内容遮挡。

根因分析:

粒子组件使用 fixed inset-0 全屏定位,无法适配后台预览的小容器

前台页面 z-index 层级混乱,粒子 z-[-1] 被背景遮挡,z-11 遮挡按钮

移动端卡片容器有 bg-(--fuwari-card-bg) 背景色遮挡粒子

最终方案:

TSX
// antigravity.tsx
const containerClassName = isPreview
  ? "w-full h-full relative z-10"
  : "fixed inset-0 z-0 overflow-hidden pointer-events-none";

// 预览模式:监听容器尺寸,精确跟随鼠标
useEffect(() => {
  if (!isPreview || !containerRef.current) return;
  const observer = new ResizeObserver(() => {
    const rect = containerRef.current?.getBoundingClientRect();
    if (rect) setContainerSize({ width: rect.width, height: rect.height });
  });
  observer.observe(containerRef.current);
  return () => observer.disconnect();
}, [isPreview]);

层级设计(从下到上):

最底层:z-[-2] 全屏背景图

第二层:z-0 反重力粒子

第三层:z-10 页面内容容器

第四层:z-30 Banner 区域

最上层:z-50 导航栏

关键洞察:单一组件在不同场景下需要不同的定位策略。通过 isPreview 模式注入容器尺寸,实现"一处定义,多处适配"。

3.3 配置字段在不同层级丢失(darkColor 保存后消失)

现象:darkColor 在后台设置中修改并保存后,刷新页面恢复为默认值。

根因分析:

darkColor 在 site-config.schema.ts 的多个 Schema 中缺失

blog.config.ts 中 chaos 默认值缺少 darkColor 字段

config.service.ts 的 resolveSiteConfig 中 chaos 默认值缺少 darkColor

settings/index.tsx 的 fixedSettings 中 chaos 默认值缺少 darkColor

最终方案:在所有涉及 chaos 默认值的位置统一添加 darkColor 字段。

关键洞察:配置字段的完整性依赖数据流全程,Schema、默认配置、服务端解析、表单初始化四个层级必须同步更新。

3.4 粒子颜色固化问题

现象:无论色相设为多少,亮色模式都显示深蓝及褐色粒子,暗色模式都显示天蓝及白色粒子。

根因分析:

Antigravity 组件的 colors 和 darkColors 是硬编码数组

用户配置的 color 只作为单一颜色传入,未生成同色系渐变

最终方案:根据用户选择的主色自动生成渐变色数组。

TSX
// chaos-background.tsx
function generateColorPalette(hex: string, count: number = 3): string[] {
  // 将 hex 转换为 HSL
  // 生成同色系渐变:调整明度和饱和度
  const colors: string[] = [];
  for (let i = 0; i < count; i++) {
    const variation = i / (count - 1);
    const lightness = Math.min(90, hsl.l + 20 - variation * 30);
    const saturation = Math.min(100, hsl.s + 10 - variation * 15);
    colors.pushhsl(${hsl.h}, ${saturation}%, ${lightness}%));
  }
  return colors;
}

const lightColors = generateColorPalette(color, 3);
const darkColors = generateColorPalette(darkColor, 3);

关键洞察:用户配置的是"主色",而非完整的颜色列表。通过算法生成同色系渐变,既保留了用户意图,又丰富了视觉效果。

3.5 移动端卡片遮挡粒子

现象:移动端主页滑到文章列表时,粒子不可见。

根因分析:

HomePage 组件的外层容器有 bg-(--fuwari-card-bg) 背景色

桌面端通过 md:bg-transparent 透明,但移动端保留背景色遮挡粒子

最终方案:移除移动端的卡片背景色。

TSX
// HomePage.tsx
<div className="flex flex-col rounded-(--fuwari-radius-large) py-1 md:py-0 md:gap-4 bg-transparent">

四、技术实现亮点

4.1 CSS 变量驱动的主题色系统

TSX
// __root.tsx
const htmlStyle = {
  '--fuwari-hue-light': hueLight,
  '--fuwari-hue-dark': hueDark,
  '--card-bg': currentBg,
  '--fuwari-hue': isDark ? hueDark : hueLight,
} as React.CSSProperties;
CSS
/* markdown.css */

html.card-opacity-enabled .fuwari-card-base {
  background-color: var(--card-bg) !important;
}

4.2 洛伦兹吸引子粒子系统

TSX
// 粒子运动方程(洛伦兹系统)
const dx = sigma  (p.y - p.x)  dt;
const dy = (p.x  (rho - p.z) - p.y)  dt;
const dz = (p.x  p.y - beta  p.z) * dt;

基于混沌理论的粒子运动,轨迹不可预测但有序

鼠标/手势位置作为吸引子中心,产生交互感

30 FPS 帧率限制 + 粒子数量自适应,保证移动端流畅

4.3 双模式背景图系统

半屏模式:homeBg / darkHomeBg

全屏模式:fullscreenBg.light / fullscreenBg.dark

层级:背景图 → 粒子 → 卡片内容

4.4 配置合并与默认值补全

TypeScript
// config.service.ts
export function resolveSystemConfig(config: SystemConfig | null | undefined) {
  const base = { ...DEFAULT_CONFIG, ...config };
  if (config?.site?.theme?.default) {
    base.site.theme.default = deepMerge(
      base.site.theme.default,
      config.site.theme.default
    );
  }
  return base;
}

五、最终成果

卡片透明度:滑块控制(0.3~1),保留主题色相,亮暗模式独立生效

反重力粒子:洛伦兹吸引子动画,30 FPS 流畅运行,鼠标/手势交互

粒子配置:数量、速度、亮暗颜色、大小、聚集半径、磁力范围,全部可调

全屏背景:开关控制,亮/暗双模式背景图上传,与粒子分层叠加

后台预览:所有设置项带实时预览,粒子和卡片效果即调即见

移动端适配:卡片透明、粒子数量自适应、间距优化

数据持久化:所有配置正确保存,刷新不丢失

六、经验总结

表单脏检查在嵌套对象上不可靠:react-hook-form 的 isDirty 对深层嵌套对象支持有限,手动深度比较是最稳定的方案。

单一组件多场景定位:通过 isPreview 模式 + 容器尺寸监听,实现组件在不同容器中的自适应渲染。

配置字段完整性依赖数据流全程:Schema、默认配置、服务端解析、表单初始化四个层级必须同步更新,缺一不可。

用户配置的是"主色",不是完整色板:通过算法生成同色系渐变,既保留用户意图,又丰富视觉效果。

CSS 变量是主题切换的最佳方案:单一变量驱动所有样式,切换时无需重新渲染组件。

层级设计决定交互体验:清晰的分层是良好体验的基础。

试错是理解系统边界的必经之路:每一次失败都在揭示框架或架构的边界。



:反粒子组件来自Besty0728,以下为仓库链接。

Besty0728
/fuwari
加载中...
★ …⑂ …⚖ …


END