Files
TrueGrowth/docs/CODING_RULES.md

305 KiB
Raw Blame History

Opentu 编码规则详解

本文档包含项目中积累的具体编码规则和常见错误案例。这是 CLAUDE.md 的详细补充,当需要具体的实现指导时参考本文档。

注意:本文档由 CLAUDE.md 拆分而来,包含详细的错误示例和解决方案。基础编码规范请参考 docs/CODING_STANDARDS.md


目录


架构与重构规范

循环依赖防回归

场景: 新增共享类型、服务单例、插件 transform、通用 UI、工具 registry 或 barrel export 时

核心原则:

  • 类型、常量、元数据只能依赖更底层的纯模块
  • 通用 UI 不直接 import 全量业务服务,优先接收回调或使用窄 helper
  • service 之间需要互访时,优先用 runtime bridge而不是静态双向 import
  • 插件的纯 transforms/API 与 React 渲染组件分离
  • 从 barrel import 前先确认 barrel 没有注册副作用或重组件 re-export

提交前至少跑:

NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm check:cycles
NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm check:cycles:types

详细经验见 docs/CYCLE_DEPENDENCY_LESSONS.md


避免过度设计和不必要的抽象层

场景: 在重构或添加新功能时,避免引入不必要的架构模式

核心原则:

  • 优先使用简单的 interface + service 模式
  • 只在有明确需求时才添加抽象层
  • 遵循 YAGNI 原则You Aren't Gonna Need It

错误示例 - 过度使用 DDD 模式:

// 错误:为简单的任务管理引入过多抽象层

// domain/task/task.ts (487 行)
export class Task {
  private constructor(...) {}  // 私有构造函数
  static create(...) {}         // 工厂方法
  start() {}                    // 状态转换方法
  complete() {}
  fail() {}
  // ... 50+ 个方法
}

// domain/task/task-repository.ts
interface TaskRepository {
  findById(id: string): Promise<Task | null>;
  save(task: Task): Promise<void>;
}

// infrastructure/persistence/indexeddb-task-repository.ts
class IndexedDBTaskRepository implements TaskRepository {
  // 只是包装了现有的 taskStorageReader/Writer
}

// infrastructure/execution/execution-strategy.ts
interface TaskExecutionStrategy {
  execute(task: Task): Promise<TaskResult>;
}

// infrastructure/execution/sw-execution-strategy.ts
class SWExecutionStrategy implements TaskExecutionStrategy {
  // 包装了一个简单的条件判断
}

// 结果30 个新文件3000+ 行代码,但没有解决实际问题

正确示例 - 保持简单:

// types/task.types.ts - 简单的接口定义
export interface Task {
  id: string;
  type: TaskType;
  status: TaskStatus;
  params: GenerationParams;
  // ... 其他字段
}

// services/task-queue-service.ts - 直接的服务实现
export class TaskQueueService {
  async createTask(params: GenerationParams): Promise<Task> {
    const task = { /* ... */ };
    await taskStorageWriter.saveTask(task);
    return task;
  }

  async executeTask(task: Task): Promise<void> {
    // 简单的条件判断,不需要 Strategy 模式
    if (shouldUseSWTaskQueue()) {
      await swTaskQueueService.createTask(task.type, task.params);
    } else {
      await taskQueueService.createTask(task.type, task.params);
    }
  }
}

// 结果:清晰、简单、易维护

判断是否需要抽象层的标准:

  1. Repository 模式

    • 不需要:只有一种存储实现(如只用 IndexedDB
    • 需要需要支持多种存储后端IndexedDB、LocalStorage、远程 API
  2. Strategy 模式

    • 不需要:只是简单的 if/else 判断(如 if (shouldUseSW)
    • 需要:有 3+ 种算法需要动态切换,且逻辑复杂
  3. Factory 模式

    • 不需要:对象创建逻辑简单(如 { id, type, status }
    • 需要:创建逻辑复杂,需要根据类型创建不同的对象
  4. Aggregate Root

    • 不需要:简单的数据结构,状态转换逻辑简单
    • 需要:复杂的业务规则,需要保证不变性约束

原因:

  • 过度设计会增加代码量和维护成本
  • 抽象层会增加理解难度和调试复杂度
  • 简单的问题应该用简单的方案解决
  • 只在有明确需求时才引入复杂模式

删除未使用的"基础设施"代码

场景: 创建了"为未来准备"的代码,但实际上没有被使用

核心原则:

  • 代码应该解决当前的实际问题
  • 未使用的代码应该删除,不要"为未来预留"
  • 如果未来真的需要,可以从 Git 历史恢复

错误示例 - 创建未使用的事件系统:

// services/app-events.ts - 创建了统一事件总线
export function publishEvent(event: AppEvent): void { }
export function subscribeToEventType<T>(type: T, handler: Function) { }

// types/events.types.ts - 定义了完整的事件类型
export interface TaskCreatedEvent extends BaseEvent { }
export interface TaskCompletedEvent extends BaseEvent { }
// ... 10+ 个事件类型

// 问题:没有任何代码使用这些文件
// grep -r "from.*app-events" 返回 0 个结果

正确做法 - 删除未使用的代码:

# 检查是否有代码使用
grep -r "from.*app-events" packages/drawnix/src

# 如果没有使用,直接删除
rm src/services/app-events.ts
rm src/types/shared/events.types.ts

# 如果未来需要,可以从 Git 历史恢复
git log --all --full-history -- "**/app-events.ts"

判断是否应该保留代码的标准:

  1. 有实际使用

    • 保留:至少有 1 处代码导入并使用
    • 删除:没有任何地方使用
  2. 解决实际问题

    • 保留:解决了当前存在的问题
    • 删除:只是"可能有用"的基础设施
  3. 维护成本

    • 保留:维护成本低,不会成为负担
    • 删除:需要持续维护,但没有实际价值

原因:

  • 未使用的代码会增加维护负担
  • 会让新开发者困惑("这个是干什么的?"
  • Git 历史可以保存所有删除的代码
  • YAGNI 原则:不要为未来可能的需求编写代码

重构前先问"解决什么问题"

场景: 在进行架构重构时,应该先明确要解决的实际问题

核心原则:

  • 重构应该解决具体的痛点,而不是追求"更好的架构"
  • 先列出当前的实际问题,再设计解决方案
  • 验证重构是否真的解决了问题

错误示例 - 为了架构而重构:

// 问题描述:"代码不够优雅,需要引入 DDD"
// 实际情况:代码工作正常,没有明确的痛点

// 重构方案:
// - 添加 domain 层18 个文件)
// - 添加 application 层3 个文件)
// - 添加 infrastructure 层9 个文件)

// 结果:
// - 新增 30 个文件3000+ 行代码
// - 原有问题没有解决
// - 引入了新的复杂度

正确示例 - 针对问题重构:

// 问题 1类型定义在 3 处重复,需要手动同步
// 解决方案:创建共享类型文件
// 结果:删除 2 处重复定义,统一到 1 个文件

// 问题 2DDD 层增加了 30 个文件但没有解决实际问题
// 解决方案:删除 DDD 层,回归简单的 interface + service
// 结果:删除 30 个文件,减少 3000 行代码

// 验证:
// - 类型定义只有 1 处 ✓
// - 代码量减少 ✓
// - 构建和测试通过 ✓

重构前的检查清单:

  1. 明确问题

    • 列出当前存在的具体问题
    • 问题是否真的影响开发效率或代码质量
    • 问题是否频繁出现
  2. 评估方案

    • 方案是否直接解决问题
    • 方案是否引入新的复杂度
    • 方案的成本是否合理
  3. 验证结果

    • 原有问题是否解决
    • 是否引入新问题
    • 代码是否更简单(文件数、行数)

原因:

  • 架构模式不是目的,解决问题才是
  • 好的架构应该让代码更简单,而不是更复杂
  • 重构应该有明确的收益,而不是"看起来更好"

文件命名规范

  • 组件: PascalCase.tsx (如 ImageCropPopup.tsx)
  • Hooks: camelCase.ts (如 useImageCrop.ts)
  • 工具: kebab-case.ts (如 image-utils.ts)
  • 类型: kebab-case.types.ts (如 image-crop.types.ts)
  • 常量: UPPER_SNAKE_CASE.ts (如 STORAGE_KEYS.ts)

TypeScript 规范

  • 对象类型使用 interface,联合类型使用 type
  • 所有组件 Props 必须有类型定义
  • 避免使用 any,使用具体类型或泛型

元组类型 vs 数组类型

场景: 当函数参数期望固定长度的元组(如 [Point, Point])时

错误示例:

// 错误使用数组类型TypeScript 无法确定长度
const points: [number, number][] = [
  [x1, y1],
  [x2, y2],
];
// 类型错误:类型"[number, number][]"不能赋给类型"[Point, Point]"
// 目标仅允许 2 个元素,但源中的元素可能不够
createShape(board, points, shapeType);

正确示例:

// 正确:显式声明为元组类型
const points: [[number, number], [number, number]] = [
  [x1, y1],
  [x2, y2],
];
createShape(board, points, shapeType);

原因: [T, T][] 表示"T 的二元组的数组(长度不定)",而 [[T, T], [T, T]] 表示"恰好包含两个 T 二元组的元组"。当 API 期望固定数量的点(如矩形的左上角和右下角)时,必须使用精确的元组类型,否则 TypeScript 无法保证数组长度符合要求。

扩展外部库的枚举类型

场景: 需要在外部库的枚举(如 @plait/commonStrokeStyle)基础上添加新值时

错误示例:

// 错误:直接修改外部库的枚举(无法做到)或使用魔术字符串
import { StrokeStyle } from '@plait/common';

// 无法向 StrokeStyle 添加 'hollow' 值
// 使用字符串字面量会导致类型不兼容
const strokeStyle = 'hollow';  // ❌ 类型不匹配
setStrokeStyle(board, strokeStyle);  // 错误:类型 'string' 不能赋给 StrokeStyle

正确示例:

// 正确:创建扩展类型,同时保持与原始枚举的兼容性
import { StrokeStyle } from '@plait/common';

// 1. 使用联合类型扩展
export type FreehandStrokeStyle = StrokeStyle | 'hollow';

// 2. 创建同名常量对象,合并原始枚举值
export const FreehandStrokeStyle = {
  ...StrokeStyle,
  hollow: 'hollow' as const,
};

// 使用时可以访问所有值
const style1 = FreehandStrokeStyle.solid;   // ✅ 原始值
const style2 = FreehandStrokeStyle.hollow;  // ✅ 扩展值

// 函数参数使用扩展类型
export const setFreehandStrokeStyle = (
  board: PlaitBoard, 
  strokeStyle: FreehandStrokeStyle  // ✅ 接受原始值和扩展值
) => { ... };

原因: TypeScript 的枚举是封闭的,无法在外部添加新成员。通过 "类型 + 同名常量对象" 模式可以1) 保持与原始枚举的完全兼容2) 类型安全地添加新值3) 在运行时和编译时都能正确使用。这是扩展第三方库类型的标准模式。

Blob 对象的 MIME 类型获取

场景: 处理 File | Blob 联合类型时获取文件的 MIME 类型

错误示例:

// 错误:假设只有 File 有 type 属性Blob 时使用默认值
async function addAsset(file: File | Blob) {
  const mimeType = file instanceof File 
    ? file.type 
    : 'application/octet-stream';  // ❌ 忽略了 Blob.type
  
  // 如果 Blob 是通过 new Blob([data], { type: 'image/png' }) 创建的
  // 这里会错误地返回 'application/octet-stream'
}

正确示例:

// 正确Blob 也有 type 属性,优先使用
async function addAsset(file: File | Blob) {
  const mimeType = file instanceof File 
    ? file.type 
    : (file.type || 'application/octet-stream');  // ✅ 先检查 Blob.type
}

// 或更简洁的写法File 继承自 Blob都有 type
async function addAsset(file: File | Blob) {
  const mimeType = file.type || 'application/octet-stream';
}

原因: Blob 构造函数支持通过 options.type 设置 MIME 类型,如 new Blob([data], { type: 'image/png' })。在处理从 ZIP 解压的文件、Canvas 导出的图片等场景时,传入的是带有正确 typeBlob 对象。如果忽略 Blob.type,会导致文件类型验证失败。

Import 语句必须放在文件顶部

场景: 添加新的 import 语句时

错误示例:

interface MyInterface {
  name: string;
}

const MAX_SIZE = 100;

// 错误import 在变量声明之后
import { someUtil } from './utils';

正确示例:

import { someUtil } from './utils';

interface MyInterface {
  name: string;
}

const MAX_SIZE = 100;

原因: ESLint 规则 import/first 要求所有 import 语句必须放在模块最顶部JSDoc 注释之后),位于任何变量声明、类型定义或其他代码之前。这样做便于快速了解模块的依赖关系,保持代码结构清晰。

类型可推断时移除显式类型注解

场景: 变量直接赋值为字面量时

错误示例:

// 错误:类型可从字面量推断,显式声明是冗余的
private isEnabled: boolean = false;
private count: number = 0;
private name: string = 'default';

正确示例:

// 正确:让 TypeScript 自动推断类型
private isEnabled = false;
private count = 0;
private name = 'default';

// 注意:联合类型或复杂类型仍需显式声明
private status: 'pending' | 'done' = 'pending';
private config: Config | null = null;

原因: ESLint 规则 @typescript-eslint/no-inferrable-types 要求移除可从初始值推断的冗余类型注解,保持代码简洁。当变量赋值为 falsetrue、数字或字符串字面量时TypeScript 能自动推断类型。

枚举enum不能使用 import type 导入

场景: 当枚举既作为类型又作为运行时值使用时

错误示例:

// 错误:使用 type-only import 导入枚举
import type { TaskType, TaskStatus } from './types';

// 运行时错误TaskType is not defined
const config = {
  [TaskType.IMAGE]: 10000,  // ❌ TaskType 在运行时不存在
  [TaskType.VIDEO]: 20000,
};

正确示例:

// 正确:枚举作为值使用时,必须使用普通 import
import { TaskType } from './types';

// 纯类型可以继续使用 import type
import type { TaskStatus, TaskError } from './types';

const config = {
  [TaskType.IMAGE]: 10000,  // ✅ TaskType 在运行时可用
  [TaskType.VIDEO]: 20000,
};

原因: TypeScript 的 import type 在编译时会被完全移除,不会产生任何运行时代码。而 enum 在 TypeScript 中既是类型也是值(会编译为 JavaScript 对象),当代码中使用枚举成员作为对象键或进行比较时,需要运行时存在该值。如果使用 import type 导入枚举,运行时会抛出 ReferenceError: xxx is not defined

禁止空 catch 块(必须有日志)

场景: 捕获异常后需要记录或处理

错误示例:

// 错误:静默吞掉错误,调试困难
try {
  await swChannelClient.getTask(taskId);
} catch {
  return;  // 什么都不做
}

// 错误catch 块为空
try {
  await someOperation();
} catch {
  // 静默忽略错误
}

正确示例:

// 正确:至少记录 debug 级别日志
try {
  await swChannelClient.getTask(taskId);
} catch (error) {
  console.debug('[SWTaskQueue] getTask failed for', taskId, error);
  return;
}

// 正确:预期的错误用 warn严重错误用 error
try {
  await criticalOperation();
} catch (error) {
  console.warn('[ModuleName] Operation failed:', error);
  return fallbackValue;
}

原因: 空 catch 块会静默吞掉错误,导致问题难以排查。即使是预期的错误(如网络超时),也应记录 debug 级别日志,便于调试。使用 console.debug 可在生产环境中隐藏,但开发时能看到。

Service Worker 与主线程模块不共享

场景: 需要在 Service Worker 和主线程中使用相同逻辑时

错误示例:

// apps/web/src/sw/index.ts
// 错误SW 中直接导入主线程包
import { sanitizeObject } from '@drawnix/drawnix';
// 会导致打包体积膨胀或循环依赖

正确示例:

// 正确SW 和主线程各自维护独立模块

// 主线程版本packages/drawnix/src/utils/sanitize-utils.ts
export function sanitizeObject(data: unknown): unknown { ... }

// SW 版本apps/web/src/sw/task-queue/utils/sanitize-utils.ts
export function sanitizeObject(obj: unknown): unknown { ... }

原因: Service Worker 和主线程是完全隔离的执行环境有各自独立的打包入口。SW 无法直接 import @drawnix/drawnix 包,否则会将整个主线程代码打包进 SW导致体积膨胀。相同逻辑需要在两个环境中分别维护独立的模块副本。

相关文件:

  • 主线程:packages/drawnix/src/utils/sanitize-utils.ts
  • Service Workerapps/web/src/sw/task-queue/utils/sanitize-utils.ts

模块循环依赖导致 TDZ 错误

场景: 多个服务模块相互导入,形成循环依赖链,导致 ES 模块初始化时的 TDZ (Temporal Dead Zone) 错误

错误示例:

// task-queue/index.ts - 导入 sw-task-queue-service
import { swTaskQueueService } from '../sw-task-queue-service';
export function shouldUseSWTaskQueue() { ... }
export { swTaskQueueService };

// sw-task-queue-service.ts - 导入 sw-channel/client
import { swChannelClient } from './sw-channel';
export const swTaskQueueService = SWTaskQueueService.getInstance();

// sw-channel/client.ts - 导入 task-queue循环
import { shouldUseSWTaskQueue } from '../task-queue';  // ❌ 循环依赖
export const swChannelClient = SWChannelClient.getInstance();

// 结果Uncaught ReferenceError: Cannot access 'swChannelClient' before initialization
// chat-workflow-client.ts
const checkAndSubscribe = () => {
  // ❌ ES 模块环境不支持 CommonJS require
  const { swChannelClient: client } = require('./client');
};
// 结果Uncaught ReferenceError: require is not defined

正确示例:

// 1. 将共享函数提取到独立的隔离模块
// task-queue/sw-detection.ts - 不导入任何服务模块
export function shouldUseSWTaskQueue(): boolean {
  if (typeof navigator === 'undefined') return false;
  if (!('serviceWorker' in navigator)) return false;
  return true;
}

// 2. 服务文件从隔离模块导入
// sw-channel/client.ts
import { shouldUseSWTaskQueue } from '../task-queue/sw-detection';  // ✅ 无循环

// 3. 使用服务注册表延迟服务访问
// task-queue/service-registry.ts - 不导入任何服务模块
export const serviceRegistry = {
  swTaskQueueService: null,
  legacyTaskQueueService: null,
};
export function registerService(name, service) {
  serviceRegistry[name] = service;
}

// 4. 服务创建后注册到注册表
// sw-task-queue-service.ts
export const swTaskQueueService = SWTaskQueueService.getInstance();
import { registerService } from './task-queue/service-registry';
registerService('swTaskQueueService', swTaskQueueService);

// 5. 入口文件使用 Proxy 延迟访问
// task-queue/index.ts
import { serviceRegistry } from './service-registry';
export const taskQueueService = new Proxy({}, {
  get(_target, prop) {
    const service = shouldUseSWTaskQueue() 
      ? serviceRegistry.swTaskQueueService 
      : serviceRegistry.legacyTaskQueueService;
    return service[prop];
  },
});

// 6. 单例构造函数中延迟访问其他模块
// sw-task-queue-service.ts
private constructor() {
  // ✅ 使用 queueMicrotask 延迟,等待其他模块初始化完成
  queueMicrotask(() => {
    this.setupSWClientHandlers();  // swChannelClient 此时已初始化
  });
}

原因: ES 模块是静态解析的循环依赖会导致某些模块在被访问时尚未完成初始化。解决方案是将共享代码提取到独立模块打破依赖链并使用延迟访问模式Proxy、queueMicrotask

相关文件:

  • packages/drawnix/src/services/task-queue/sw-detection.ts - 隔离的检测函数
  • packages/drawnix/src/services/task-queue/service-registry.ts - 服务注册表
  • packages/drawnix/src/services/task-queue/index.ts - 使用 Proxy 的入口

Service Worker 枚举值使用小写

场景: 读取 SW 任务队列数据(如 sw-task-queue 数据库)进行过滤时

错误示例:

// sw-debug 或其他外部模块读取 SW 数据
const TaskStatus = {
  COMPLETED: 'COMPLETED',  // ❌ 大写
};
const TaskType = {
  IMAGE: 'IMAGE',  // ❌ 大写
  VIDEO: 'VIDEO',
};

// 过滤已完成任务 - 永远匹配不到!
const completedTasks = tasks.filter(
  task => task.status === TaskStatus.COMPLETED  // 实际数据是 'completed'
);

正确示例:

// 正确:使用小写,与 SW 定义保持一致
const TaskStatus = {
  COMPLETED: 'completed',  // ✅ 小写
};
const TaskType = {
  IMAGE: 'image',  // ✅ 小写
  VIDEO: 'video',
};

// 正确匹配
const completedTasks = tasks.filter(
  task => task.status === TaskStatus.COMPLETED
);

原因: SW 内部的枚举定义使用小写值(见 apps/web/src/sw/task-queue/types.ts),读取 SW 数据时必须使用相同的值进行匹配。大小写不一致会导致过滤或比较失败,但不会报错,难以调试。

相关文件:

  • apps/web/src/sw/task-queue/types.ts - SW 枚举定义

共享模块与统一配置模式

场景: 多个功能模块有相似逻辑但细节不同时(如宫格图和灵感图的拆分)

错误示例:

// image-splitter.ts - 重复的去白边逻辑
function splitGrid(imageUrl: string) {
  const borders = trimBorders(imageData, 0.5, 0.15);
  // ...
}

// photo-wall-splitter.ts - 几乎相同的逻辑
function splitPhotoWall(imageUrl: string) {
  const borders = trimBorders(imageData, 0.5, 0.15);
  // ...
}

正确示例:

// image-split-core.ts - 核心模块,统一配置
export type TrimMode = 'strict' | 'normal' | 'none';

export function getTrimParams(trimMode: TrimMode) {
  switch (trimMode) {
    case 'strict': return { borderRatio: 1.0, maxTrimRatio: 0.05 };
    case 'normal': return { borderRatio: 0.95, maxTrimRatio: 0.05 };
    case 'none': return null;
  }
}

// image-splitter.ts - 使用配置区分行为
const trimMode: TrimMode = isStandardGrid ? 'strict' : 'normal';
const params = getTrimParams(trimMode);
if (params) {
  borders = trimBorders(imageData, params.borderRatio, params.maxTrimRatio);
}

原因:

  1. 避免代码重复,便于统一维护
  2. 通过配置类型区分行为,而非复制代码
  3. 核心模块命名规范:*-core.ts(如 image-split-core.ts

相关文件:

  • packages/drawnix/src/utils/image-split-core.ts - 图片拆分核心模块

工具函数组织与导入

场景: 项目中有通用工具函数需要在多个包之间共享

错误示例:

// packages/drawnix/src/utils/common.ts
// 错误:在业务包中二次导出通用函数
import { generateId, sanitizeObject } from '@aitu/utils';
export { generateId, sanitizeObject };  // ❌ 二次导出

// packages/drawnix/src/services/some-service.ts
// 错误:从业务包导入通用函数
import { generateId } from '../utils/common';  // ❌ 间接导入

正确示例:

// packages/drawnix/src/services/some-service.ts
// 正确:直接从 @aitu/utils 导入
import { generateId, sanitizeObject } from '@aitu/utils';  // ✅ 直接导入

// packages/drawnix/src/utils/common.ts
// 正确:只保留业务特有的函数
import { IS_APPLE, PlaitBoard, toImage } from '@plait/core';

export const safeToImage = async (board: PlaitBoard, options = {}) => {
  // ✅ Plait 导出特有兜底:临时保护内部图片 fetch 失败
  return toImage(board, options);
};

export const boardToImage = (board: PlaitBoard) => {
  return safeToImage(board, { fillStyle: 'transparent' });
};

原因:

  • 二次导出会造成依赖链混乱,增加包体积
  • 修改工具函数时难以追踪所有使用位置
  • 直接导入语义更清晰IDE 跳转更准确

@aitu/utils 包含的通用函数:

  • ID 生成:generateId(), generateUUID()
  • 日期格式化:formatDate(), formatDuration(), formatFileSize()
  • DOM 操作:download(), copyToClipboard()
  • 安全工具:sanitizeObject(), sanitizeUrl(), sanitizeRequestBody(), getSafeErrorMessage()
  • 字符串处理:truncate(), capitalize(), toKebabCase()
  • 异步工具:debounce(), throttle()
  • Blob 转换:blobToBase64(), pureBase64ToBlob(), dataUrlToBlob(), blobToDataUrl()
  • 设备信息:getDeviceId(), getDeviceName(), getDeviceType()
  • 格式化:formatSize(), formatDurationMs(), formatPercent(), formatRelativeTime()
  • IndexedDBopenIndexedDB(), getById(), getAll(), getAllWithCursor(), put(), deleteById()

避免不必要的函数包装/别名

场景: 导入工具函数后,创建了一个简单包装函数

错误示例:

import { truncate, sanitizeRequestBody as sanitizeBody } from '@aitu/utils';

// 错误:不必要的包装,没有添加任何功能
const truncateText = (text: string, maxLength: number) => truncate(text, maxLength);
const sanitizeRequestBody = sanitizeBody;

// 使用
log.prompt = truncateText(prompt, 2000);
log.body = sanitizeRequestBody(body);

正确示例:

import { truncate, sanitizeRequestBody } from '@aitu/utils';

// 正确:直接使用导入的函数
log.prompt = truncate(prompt, 2000);
log.body = sanitizeRequestBody(body);

原因:

  • 不必要的包装增加了代码量和阅读负担
  • 在调用链中增加了一层,调试时更难追踪
  • 如果确实需要别名,使用 import { x as y } 语法即可

React 组件规范

  • 使用函数组件和 Hooks
  • 使用 React.memo 优化重渲染
  • 事件处理器使用 useCallback 包装
  • Hook 顺序:状态 hooks → 副作用 hooks → 事件处理器 → 渲染逻辑

useCallback 定义顺序必须在 useEffect 依赖之前

场景: 当 useEffect 的依赖数组引用某个 useCallback 定义的函数时

错误示例:

// 错误handleResetView 在 useEffect 依赖中被引用,但定义在 useEffect 之后
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === '0') {
      handleResetView(); // 引用了后面才定义的函数
    }
  };
  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleResetView]); // ❌ 运行时错误: Cannot access 'handleResetView' before initialization

const handleResetView = useCallback(() => {
  // 重置逻辑
}, []);

正确示例:

// 正确:被依赖的 useCallback 必须在 useEffect 之前定义
const handleResetView = useCallback(() => {
  // 重置逻辑
}, []);

useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === '0') {
      handleResetView();
    }
  };
  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleResetView]); // ✅ 正常工作

原因: JavaScript 的 const 声明有暂时性死区TDZ在声明语句执行前访问会抛出 ReferenceErroruseEffect 的依赖数组在组件首次渲染时就会被读取,此时如果被依赖的函数还未定义,就会报错。


Hover 延迟操作需要正确的计时器清理

场景: 实现 hover 延迟展开/显示等交互效果时(如工具栏 Popover 延迟展开)

错误示例:

// 错误:没有清理计时器,可能导致内存泄漏和意外行为
const [open, setOpen] = useState(false);

<div
  onPointerEnter={() => {
    setTimeout(() => setOpen(true), 300);  // 计时器没有被追踪
  }}
>

正确示例:

// 正确:使用 ref 追踪计时器,在离开和卸载时清理
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const clearHoverTimeout = useCallback(() => {
  if (hoverTimeoutRef.current) {
    clearTimeout(hoverTimeoutRef.current);
    hoverTimeoutRef.current = null;
  }
}, []);

// 组件卸载时清理
useEffect(() => {
  return () => clearHoverTimeout();
}, [clearHoverTimeout]);

<div
  onPointerEnter={() => {
    clearHoverTimeout();  // 先清除之前的计时器
    hoverTimeoutRef.current = setTimeout(() => setOpen(true), 300);
  }}
  onPointerLeave={() => {
    clearHoverTimeout();  // 离开时取消延迟操作
  }}
  onPointerDown={() => {
    clearHoverTimeout();  // 点击时立即响应,取消延迟
    setOpen(true);
  }}
>

关键点:

  • 使用 useRef 存储计时器 ID不用 state避免不必要的重渲染
  • onPointerLeave 清除计时器(用户离开后取消待执行的操作)
  • onPointerDown 清除计时器(点击时立即响应,不等待延迟)
  • useEffect 清理函数确保组件卸载时清除计时器

单击/双击区分场景的计时器清理

场景: 使用 setTimeout 延迟单击操作以区分单击和双击时

错误示例:

// 错误:没有在组件卸载时清理计时器
const clickTimerRef = useRef<NodeJS.Timeout | null>(null);

// 单击延迟处理
onClick={() => {
  if (clickTimerRef.current) {
    clearTimeout(clickTimerRef.current);
  }
  clickTimerRef.current = setTimeout(() => {
    handleSingleClick(); // 组件卸载后仍可能执行,导致 state 更新到已卸载组件
  }, 200);
}}

// 双击取消单击
onDoubleClick={() => {
  if (clickTimerRef.current) {
    clearTimeout(clickTimerRef.current);
    clickTimerRef.current = null;
  }
  handleDoubleClick();
}}
// ⚠️ 缺少 useEffect 清理!
#### 优先使用项目已有的工具函数

**场景**: 需要使用 debouncethrottle 等常见工具函数时

 **错误示例**:
```typescript
// 错误:在组件内部自己实现 debounce
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
  return ((...args: Parameters<T>) => {
    if (timeoutId) clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  }) as T;
}

正确示例:

const clickTimerRef = useRef<NodeJS.Timeout | null>(null);

// 组件卸载时清理计时器
useEffect(() => {
  return () => {
    if (clickTimerRef.current) {
      clearTimeout(clickTimerRef.current);
      clickTimerRef.current = null;
    }
  };
}, []);

// 单击延迟处理
onClick={() => {
  if (clickTimerRef.current) {
    clearTimeout(clickTimerRef.current);
  }
  clickTimerRef.current = setTimeout(() => {
    handleSingleClick();
  }, 200);
}}

// 双击取消单击
onDoubleClick={() => {
  if (clickTimerRef.current) {
    clearTimeout(clickTimerRef.current);
    clickTimerRef.current = null;
  }
  handleDoubleClick();
}}

原因: 如果用户在计时器等待期间导航离开页面(组件卸载),计时器回调仍会执行,可能导致:

  1. 内存泄漏(闭包引用已卸载组件的状态)
  2. React 警告:"Can't perform a React state update on an unmounted component"
  3. stale callback 访问过期的 props/state // 正确:用项目的 @aitu/utils 包 import { debounce } from '@aitu/utils';

**可用的工具函数来源**:
- `@aitu/utils`: `debounce`、`throttle` 等项目共享工具函数

**原因**: 重复实现常见工具函数会增加代码体积,且可能存在边界情况处理不完善的问题。项目已有的工具函数经过测试和优化,应优先使用。

#### 滑块等连续输入控件的更新策略

**场景**: 滑块拖动时触发昂贵操作(如 SVG pattern 重新生成、Canvas 重绘)

❌ **错误示例**:
```typescript
// 错误 1每次滑块变化都立即触发外部回调导致频繁重绘和抖动
const handleSliderChange = (value: number) => {
  setConfig({ ...config, scale: value });
  onChange?.({ ...config, scale: value }); // 每次都触发,造成性能问题
};

// 错误 2使用 debounce防抖用户停止拖动后才更新响应迟钝
const debouncedOnChange = useMemo(
  () => debounce((config) => onChange?.(config), 150),
  [onChange]
);

正确示例:

// 正确:使用 throttle节流定时触发更新平衡响应性和性能
import { throttle } from '@aitu/utils';

// 节流版本的外部回调
const throttledOnChange = useMemo(
  () => throttle((newConfig: Config) => {
    onChange?.(newConfig);
  }, 100), // 100ms 节流
  [onChange]
);

// 滑块专用的更新函数:立即更新 UI节流触发外部回调
const updateConfigThrottled = useCallback(
  (updates: Partial<Config>) => {
    const newConfig = { ...config, ...updates };
    setConfig(newConfig);        // 立即更新 UI
    throttledOnChange(newConfig); // 节流触发外部回调
  },
  [config, throttledOnChange]
);

<input
  type="range"
  onChange={(e) => updateConfigThrottled({ scale: Number(e.target.value) })}
/>

关键点:

  • 内部状态 (setConfig) 立即更新,保证滑块 UI 的即时响应
  • 外部回调 (onChange) 使用 throttle(节流),减少昂贵操作的执行频率
  • 防抖 vs 节流: 防抖等用户停止操作后才触发(适合搜索框);节流定时触发(适合滑块)
  • 节流时间根据操作开销选择:轻量操作 50-100ms重量操作SVG/Canvas100-200ms
  • 使用 useMemo 包装 throttle 函数,避免每次渲染创建新实例

React Context 回调中必须使用函数式更新

场景: 在 Context 提供的回调函数(如 openDialog, closeDialog)中更新状态时

错误示例:

// 错误:使用闭包中的 context.appState可能是过期的引用
const closeDialog = (dialogType: DialogType) => {
  const newOpenDialogTypes = new Set(context.appState.openDialogTypes);
  newOpenDialogTypes.delete(dialogType);
  context.setAppState({
    ...context.appState,  // 闭包中的旧状态!
    openDialogTypes: newOpenDialogTypes,
  });
};

// 问题场景:
// 1. 打开弹窗 AopenDialogTypes = { A }
// 2. 打开弹窗 BopenDialogTypes = { A, B }
// 3. 关闭弹窗 A 时closeDialog 中的 context.appState 可能仍是 { A }
// 4. 结果openDialogTypes 变成 {},弹窗 B 也被关闭了!

正确示例:

// 正确:使用函数式更新,确保始终使用最新的状态
const closeDialog = (dialogType: DialogType) => {
  context.setAppState((prevState) => {
    const newOpenDialogTypes = new Set(prevState.openDialogTypes);
    newOpenDialogTypes.delete(dialogType);
    return {
      ...prevState,
      openDialogTypes: newOpenDialogTypes,
    };
  });
};

// 同样适用于 openDialog
const openDialog = (dialogType: DialogType) => {
  context.setAppState((prevState) => {
    const newOpenDialogTypes = new Set(prevState.openDialogTypes);
    newOpenDialogTypes.add(dialogType);
    return {
      ...prevState,
      openDialogTypes: newOpenDialogTypes,
    };
  });
};

原因:

  • Context 的回调函数可能被旧的事件处理器或 useCallback 缓存调用
  • 闭包中的 context.appState 是创建回调时的快照,不是最新状态
  • 函数式更新 setState(prev => ...) 保证 prev 始终是最新状态
  • 这个问题在多个弹窗/抽屉同时打开时特别容易出现

模式切换时的状态同步问题

场景: 当 UI 组件(如 Toolbar需要触发模式切换时直接调用底层的 setMode 可能导致相关状态不同步

错误示例:

// ViewerToolbar.tsx - 直接调用 setMode
<button onClick={() => onModeChange('edit')}>
  编辑
</button>

// UnifiedMediaViewer.tsx - 传递底层 setMode
<ViewerToolbar
  onModeChange={actions.setMode}  // 错误:只改变模式,不设置 editingItem
/>

// 结果:模式变成 'edit',但 editingItem 仍为 null
// 后续保存时 editingItem 为 null无法正确覆盖原图

正确示例:

// UnifiedMediaViewer.tsx - 创建包装函数
const handleModeChange = useCallback((newMode: ViewerMode) => {
  if (newMode === 'edit') {
    // 进入编辑模式时,同时设置相关状态
    const currentItem = items[currentIndex];
    if (currentItem && currentItem.type === 'image') {
      updateEditingItem(currentItem);  // 同步更新 editingItem
    }
  }
  actions.setMode(newMode);
}, [items, currentIndex, actions, updateEditingItem]);

<ViewerToolbar
  onModeChange={handleModeChange}  // 正确:使用包装函数
/>

原因:

  • 当多个状态需要联动更新时,直接暴露底层的单一状态更新函数容易导致状态不一致
  • 应该封装成一个函数,确保所有相关状态同步更新
  • 这在模式切换、打开/关闭弹窗等场景中尤其重要

传递 React 组件作为 prop 时必须实例化

场景: 将 React 组件作为 icon 或其他 prop 传递给子组件时

错误示例:

// 错误:传递组件函数本身,而不是 JSX 实例
import { BackgroundColorIcon } from './icons';

const icon = !hexColor ? BackgroundColorIcon : undefined;

// 子组件中渲染时:
// {icon} → React 警告 "Functions are not valid as a React child"

正确示例:

// 正确:传递 JSX 实例
import { BackgroundColorIcon } from './icons';

const icon = !hexColor ? <BackgroundColorIcon /> : undefined;

// 子组件中渲染时:
// {icon} → 正常渲染

原因: React 组件本质上是函数,直接将函数作为子元素传递会导致 React 警告。需要调用组件(<Component />)生成 JSX 元素后再传递。

内联 style 的 undefined 值会覆盖 CSS 类样式

场景: 当需要条件性地应用内联样式,同时使用 CSS 类作为备选样式时

错误示例:

// 错误style 对象中的 undefined 值会覆盖 CSS 类的 background
<label
  className={classNames('fill-label', { 'color-mixed': fill === undefined })}
  style={{
    background: fill ? fill : undefined,  // undefined 会覆盖 .color-mixed 的 background
  }}
/>

正确示例:

// 正确:当需要 CSS 类生效时,不传递 style 对象
<label
  className={classNames('fill-label', { 'color-mixed': fill === undefined })}
  style={
    fill === undefined
      ? undefined  // 不设置 style让 CSS 类的 background 生效
      : { background: fill }
  }
/>

原因: React 的内联 style 优先级高于 CSS 类。即使 background: undefinedReact 仍会在元素上设置空的 style 属性,这可能干扰 CSS 类的样式应用。当需要 CSS 类完全控制样式时,应该不传递 style 对象(style={undefined})。

使用 ResizeObserver 实现组件级别的响应式布局

场景: 当组件位于可调整大小的侧边栏、抽屉或面板中时,使用基于视口宽度的媒体查询 (@media) 无法准确反映组件的实际可用空间。

错误示例:

// 仅依赖视口宽度的媒体查询
@media (max-width: 1200px) {
  .task-item {
    grid-template-areas: "preview prompt" "info info";
  }
}

正确示例:

// TaskItem.tsx
const [isCompactLayout, setIsCompactLayout] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const container = containerRef.current;
  if (!container) return;

  const resizeObserver = new ResizeObserver((entries) => {
    for (const entry of entries) {
      // 根据组件实际宽度切换布局
      setIsCompactLayout(entry.contentRect.width < 500);
    }
  });

  resizeObserver.observe(container);
  return () => resizeObserver.disconnect();
}, []);

return (
  <div ref={containerRef} className={classNames('task-item', { 'task-item--compact': isCompactLayout })}>
    {/* ... */}
  </div>
);

原因: 本项目大量使用可拖拽调整宽度的抽屉(如任务队列、聊天侧栏)。组件的布局应取决于其父容器的宽度,而非整个浏览器的宽度。ResizeObserver 提供了精确的容器级别响应式控制。

避免在子组件中重写布局样式以保持 Grid 一致性

场景: 当多个组件(如 TaskQueuePanelDialogTaskList)复用同一个基础组件(如 TaskItem)时。

错误示例:

// dialog-task-list.scss
.dialog-task-list {
  .task-item {
    // ❌ 错误:在外部强行修改基础组件的布局
    display: flex; 
    flex-direction: row;
    // ... 大量覆盖样式
  }
}

正确示例:

// dialog-task-list.scss
.dialog-task-list {
  .task-item {
    // ✅ 正确:只调整尺寸和细节,复用基础组件自带的响应式布局
    padding: 10px;
    &__preview-wrapper { width: 100px; }
  }
}

原因: 基础组件(如 TaskItem)已经包含了完善的响应式 Grid 布局逻辑。在子组件容器中强行覆盖布局(如从 Grid 改为 Flex会导致维护困难、布局不一致并破坏基础组件原有的响应式能力。应优先通过微调尺寸或传递 Props 让基础组件自我调整。

跨 React Root 状态共享useSyncExternalStore

场景: 需要从主应用向 Plait 文本组件(react-textText)传递状态时(如搜索高亮关键词)

Plait 框架通过 createRoot 将每个文本组件渲染在独立的 React 树中(见 react-board/src/plugins/with-react.tsxReact Context 无法穿透这些独立 Root。

错误示例:

// ❌ 错误:在主应用提供 Context期望 Text 组件能消费
// 主应用
<SearchContext.Provider value={query}>
  <Board /> {/* Board 内的文本通过 createRoot 渲染,无法访问此 Context */}
</SearchContext.Provider>

// react-text/text.tsx
const query = useContext(SearchContext); // 永远是 undefined

正确示例:

// ✅ 正确:使用 useSyncExternalStore + 模块级 store 实现跨 Root 状态共享
// search-highlight.ts
let searchQuery = '';
const listeners = new Set<() => void>();

export function setSearchHighlightQuery(query: string) {
  if (searchQuery === query) return;
  searchQuery = query;
  listeners.forEach(listener => listener());
}

export function useSearchHighlightQuery(): string {
  return useSyncExternalStore(
    (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
    () => searchQuery,
    () => searchQuery,
  );
}

// text.tsx - 在独立 React Root 中也能正确订阅
const query = useSearchHighlightQuery(); // ✅ 正常工作

原因: createRoot 创建的 React 树是完全独立的,不共享父级 Context。useSyncExternalStore 订阅模块级全局 store不依赖 React 树结构,因此能跨 Root 工作。此模式适用于所有需要从主应用向 Plait 文本组件传递状态的场景。

RxJS 事件传递对象引用导致 React.memo 失效

场景: Service 层通过 RxJS Subject 推送任务更新事件,多个 React 组件通过不同方式消费(增量替换 vs 全量快照),但 TaskItem 共用同一个 React.memo 自定义比较

错误示例:

// Service: 原地修改对象再 emit
onProgress: (progress) => {
  const localTask = this.tasks.get(taskId);
  localTask.progress = progress;         // 原地修改
  localTask.updatedAt = Date.now();
  this.emitEvent('taskUpdated', localTask); // 传递同一引用
}

// Hook A增量替换event.task 与数组中旧对象是同一引用
setTasks(prev => prev.map(t => t.id === event.task.id ? event.task : t));
// React.memo: prev.task === next.task同一对象→ 不渲染

// Hook B全量快照getAllTasks() 返回 Map 中的原始对象
setTasks(taskQueueService.getAllTasks());
// React.memo: prev.task.progress === next.task.progress同一对象值已被改过→ 不渲染

正确示例:

// Service: 创建新对象存入 Map 再 emit
onProgress: (progress) => {
  const localTask = this.tasks.get(taskId);
  const updatedTask = { ...localTask, progress, updatedAt: Date.now() };
  this.tasks.set(taskId, updatedTask);         // 新对象存入 Map
  this.emitEvent('taskUpdated', updatedTask);  // 新引用
}

// emitEvent 中额外浅拷贝(双保险)
private emitEvent(type, task) {
  this.taskUpdates$.next({ type, task: { ...task }, timestamp: Date.now() });
}

原因: React.memo 的自定义比较函数比较的是属性值,但如果 prev.tasknext.task 指向同一个被原地修改的对象,所有属性比较都会返回 true(值已经被改过了)。必须确保每次更新都产生新的对象引用,让 prev.task.progress !== next.task.progress 成立。

CSS/SCSS 规范

  • 使用 BEM 命名规范
  • 优先使用设计系统 CSS 变量
  • 属性顺序:定位 → 盒模型 → 外观 → 排版 → 动画

使用 box-shadow 替代 border 实现不影响尺寸的边框

场景: 当需要边框不影响元素实际尺寸时(如裁剪框、选区框等精确定位场景)

错误示例:

// 边框画在元素内部,会占用元素空间
// 导致可视边界比实际区域大 2px
.crop-area {
  position: absolute;
  border: 2px solid #4f46e5;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
}

正确示例:

// 使用 box-shadow 模拟边框,边框画在元素外部
// 元素内边缘即为实际边界
.crop-area {
  position: absolute;
  // 第一个 shadow 是边框,第二个是遮罩
  box-shadow: 0 0 0 2px #4f46e5, 0 0 0 9999px rgba(0, 0, 0, 0.6);
}

原因: CSS border 默认使用 box-sizing: content-box,边框会画在元素边界内部。对于裁剪框等需要精确对应实际区域的场景,使用 box-shadow: 0 0 0 Npx color 可以模拟边框,且边框画在元素外部,不影响元素尺寸。

绝对定位子元素需要正确的父容器设置

场景: 在容器内添加绝对定位的浮层/预览框等元素时

错误示例:

.container {
  // 缺少 position: relative子元素的绝对定位相对于更上层的定位元素
  overflow: hidden; // 会裁切溢出的绝对定位子元素
  
  .floating-preview {
    position: absolute;
    right: 100%; // 想要显示在容器左侧
    // 结果1) 定位参照物可能不对 2) 被 overflow: hidden 裁切掉
  }
}

正确示例:

.container {
  position: relative; // 作为绝对定位子元素的参照物
  overflow: visible;  // 允许子元素溢出显示
  
  .floating-preview {
    position: absolute;
    right: 100%; // 正确显示在容器左侧
  }
}

检查清单:

  • 父容器需要 position: relative(或其他非 static 的定位)
  • 如果子元素需要溢出显示,父容器需要 overflow: visible
  • 多层嵌套时,确认绝对定位的参照元素是正确的

原因: position: absolute 的元素相对于最近的非 static 定位祖先元素定位。如果父容器没有设置定位,子元素会相对于更上层的元素定位,导致位置错误。同时 overflow: hidden 会裁切超出容器边界的内容,包括绝对定位的子元素。

移动端固定定位元素需要考虑工具栏遮挡

场景: 移动端页面底部或顶部的固定定位元素(输入框、提示条等)需要避开左侧工具栏

错误示例:

// 直接居中,没有考虑左侧工具栏
.ai-input-bar {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
}

@media (max-width: 640px) {
  .ai-input-bar {
    // 移动端仍然直接居中,会被工具栏遮挡
    bottom: 16px;
  }
}

正确示例:

.ai-input-bar {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
}

@media (max-width: 640px) {
  .ai-input-bar {
    bottom: 16px;
    // 考虑左侧工具栏宽度 (48px),偏移居中点
    left: calc(50% + 24px); // 工具栏宽度的一半
    max-width: calc(100% - 60px); // 左侧工具栏 + 右侧边距
  }
}

检查清单:

  • 移动端 (@media max-width: 640px/768px) 的固定定位元素
  • 是否会与左侧 unified-toolbar (48px) 重叠
  • 是否会与右上角缩放控件重叠
  • 使用 $toolbar-width 变量而非硬编码数值

相关变量: $toolbar-width: 48px (定义在 styles/_common-variables.scss)

移动端触控需要 touch 事件实现 hover 效果

场景: 桌面端的 hover 预览/提示在移动端没有效果,需要添加 touch 事件支持

错误示例:

// 只有鼠标事件,移动端触控没有预览效果
<canvas
  onMouseEnter={() => setPreviewVisible(true)}
  onMouseLeave={() => setPreviewVisible(false)}
  onMouseMove={(e) => updatePreviewPosition(e)}
/>

正确示例:

// 添加触控状态追踪
const isTouchingRef = useRef(false);

const handleTouchStart = (e: React.TouchEvent) => {
  isTouchingRef.current = true;
  const touch = e.touches[0];
  updatePreviewPosition(touch.clientX, touch.clientY);
  setPreviewVisible(true);
};

const handleTouchMove = (e: React.TouchEvent) => {
  const touch = e.touches[0];
  updatePreviewPosition(touch.clientX, touch.clientY);
  // 触控移动时始终显示预览
  setPreviewVisible(true);
};

const handleTouchEnd = () => {
  isTouchingRef.current = false;
  // 延迟隐藏,让用户看到最终位置
  setTimeout(() => {
    if (!isTouchingRef.current) {
      setPreviewVisible(false);
    }
  }, 500);
};

<canvas
  onMouseEnter={handleMouseEnter}
  onMouseLeave={handleMouseLeave}
  onMouseMove={handleMouseMove}
  onTouchStart={handleTouchStart}
  onTouchMove={handleTouchMove}
  onTouchEnd={handleTouchEnd}
/>

注意事项:

  • 触控时会同时触发 pointerdown,可能导致拖拽状态与预览状态冲突
  • 使用 isTouchingRef 区分移动端触控和桌面端鼠标拖拽
  • 触控结束后延迟隐藏预览,给用户时间查看结果
  • Canvas 元素需要设置 touch-action: none 防止默认滚动行为

TDesign CSS 变量优先级问题

场景: 在组件样式中使用 TDesign CSS 变量(如 --td-brand-color-light)时,发现颜色不生效或显示为默认颜色。

错误示例:

// 在组件 scss 中使用 CSS 变量
.project-drawer-node__row {
  &--selected {
    background: var(--td-brand-color-light);  // 可能被 TDesign 内部样式覆盖
    
    &:hover {
      background: var(--td-brand-color-light-hover);  // 不生效
    }
  }
}

正确示例:

// 直接使用具体颜色值
.project-drawer-node__row {
  &--selected {
    background: rgba(243, 156, 18, 0.12);  // 品牌橙金色 12% 透明度
    
    &:hover {
      background: rgba(243, 156, 18, 0.18);  // 18% 透明度
    }
  }
}

原因: TDesign 组件内部可能在更具体的选择器中定义了同名 CSS 变量的值,导致 :root 中的全局定义被覆盖。在组件级样式中直接使用具体颜色值可以确保样式正确生效。

适用场景:

  • 组件内的选中/高亮/激活状态背景色
  • 需要使用品牌色变体的场景(如透明度变化)
  • 任何发现 CSS 变量不生效的情况

弹出菜单被父容器 overflow 截断

场景: 在设置了 overflow: hidden/auto 的容器内渲染右键菜单、下拉菜单等弹出层时,菜单被截断。

错误示例:

// 菜单直接渲染在组件内部,被父容器 overflow 截断
return (
  <div className="drawer-content" style={{ overflow: 'auto' }}>
    {/* 内容 */}
    {contextMenu && (
      <div 
        className="context-menu"
        style={{ position: 'fixed', left: x, top: y }}
      >
        {/* 菜单项 */}
      </div>
    )}
  </div>
);

正确示例:

import { createPortal } from 'react-dom';

return (
  <div className="drawer-content" style={{ overflow: 'auto' }}>
    {/* 内容 */}
    {/* 使用 Portal 渲染到 body避免被截断 */}
    {contextMenu && createPortal(
      <div 
        className="context-menu"
        style={{ position: 'fixed', left: x, top: y, zIndex: 10000 }}
      >
        {/* 菜单项 */}
      </div>,
      document.body
    )}
  </div>
);

原因: 即使使用 position: fixed,如果元素在 DOM 树中位于设置了 overflow: hidden 的容器内部,该容器仍然会裁切超出其边界的内容。使用 createPortal 将弹出层渲染到 document.body,使其脱离原有容器的裁切上下文。

适用场景:

  • 右键菜单
  • 自定义下拉菜单
  • 悬浮预览框
  • 任何需要显示在容器边界外的浮层

Git 提交规范

  • 格式: <type>(<scope>): <subject>
  • 类型: feat, fix, docs, style, refactor, test, chore, perf, ci

重要规则

  • UI 框架: 使用 TDesign React配置浅色主题
  • Tooltips: 始终使用 theme='light'
  • 品牌色一致性: 覆盖第三方组件(如 TDesign Tag的默认颜色以符合 Opentu 品牌视觉
    • 示例: 处理中状态使用蓝紫色系 (#5A4FCF)
    • CSS: .t-tag--theme-primary { background-color: rgba(90, 79, 207, 0.08); color: #5A4FCF; }
  • 文件大小限制: 单个文件不超过 500 行
  • 文档语言: 规格文档使用中文
  • 概念术语一致性: 使用 /docs/CONCEPTS.md 中定义的标准术语

TDesign Dropdown 的 popupProps 透传

场景: 需要监听 TDesign Dropdown 组件的显示/隐藏状态变化时

错误示例:

// 错误Dropdown 没有直接的 onVisibleChange 属性
<Dropdown
  options={options}
  trigger="context-menu"
  onVisibleChange={(visible) => {  // ❌ 类型错误
    setMenuOpen(visible);
  }}
>
  <Button>触发器</Button>
</Dropdown>

正确示例:

// 正确:通过 popupProps 透传给底层 Popup 组件
<Dropdown
  options={options}
  trigger="context-menu"
  popupProps={{
    onVisibleChange: (visible) => {
      setMenuOpen(visible);
    }
  }}
>
  <Button>触发器</Button>
</Dropdown>

原因: TDesign 的 Dropdown 组件没有直接暴露 onVisibleChange 属性,需要通过 popupProps 透传给底层的 Popup 组件。这是 TDesign 的组合式 API 设计,很多底层能力需要通过 xxxProps 透传。

强制重绘使用 void 前缀

场景: 需要触发浏览器强制重绘reflow以确保 CSS 变更立即生效时

错误示例:

// ESLint 报错Expected an assignment or function call and instead saw an expression
element.offsetHeight;

正确示例:

// 使用 void 运算符明确表示故意丢弃返回值
void element.offsetHeight;

原因: 读取 offsetHeight 属性会触发浏览器回流reflow这是一种常见的强制重绘技巧。但 ESLint 会报错因为这是一个"无用"的表达式。添加 void 运算符可以:

  1. 消除 ESLint 警告
  2. 明确表示我们的意图是触发副作用而非使用返回值

常见使用场景:

  • CSS 动画开始前重置状态
  • 确保 transition 属性变更后立即生效
  • 在设置初始样式和应用动画样式之间强制重绘

项目概念文档维护

场景: 添加新功能、新类型或新概念时

规则: 项目使用 /docs/CONCEPTS.md 作为核心术语和概念的权威定义。添加新功能时应检查并更新概念文档。

需要更新概念文档的情况:

  • 引入新的类型定义(如新的 TaskType、AssetSource 等)
  • 添加新的 React Context
  • 创建新的 Service 或核心服务
  • 添加新的 MCP 工具
  • 引入新的数据流模式
  • 添加新的虚拟路径前缀

概念文档结构:

  • 术语表:中英文对照、定义、关键文件
  • 架构分层:应用层、核心库、适配层
  • 数据流AI 生成流程、素材库数据流
  • 状态管理Context 和持久化存储
  • 命名规范:文件、变量、事件

参考: 查看 /docs/CONCEPTS.md 获取完整的术语定义和概念说明

navigator.storage.estimate() 返回浏览器配额而非磁盘空间

场景: 需要获取用户设备存储空间信息时

错误示例:

// 错误:误以为 quota 是实际磁盘剩余空间
const estimate = await navigator.storage.estimate();
const diskFreeSpace = estimate.quota; // ❌ 这不是磁盘剩余空间!
console.log(`磁盘剩余: ${diskFreeSpace / 1024 / 1024 / 1024} GB`); 
// 可能显示 500+ GB但实际磁盘只剩 10GB

正确示例:

// 正确理解quota 是浏览器分配给该站点的配额上限
const estimate = await navigator.storage.estimate();
const usage = estimate.usage || 0;   // 该站点已使用的存储
const quota = estimate.quota || 0;   // 浏览器分配的配额(通常是磁盘空间的某个比例)
const usagePercent = quota > 0 ? (usage / quota) * 100 : 0;

// 只用于判断站点存储使用率,不用于显示磁盘空间
if (usagePercent > 80) {
  console.warn('站点存储使用率较高');
}

原因: navigator.storage.estimate() 返回的 quota 是浏览器为该源origin分配的存储配额通常是磁盘可用空间的某个比例如 50%而非实际磁盘剩余空间。向用户展示这个值会造成误解。Web API 无法直接获取真实的磁盘剩余空间。

异步初始化模式

场景: 使用 settingsManager 或其他需要异步初始化的服务时

错误示例:

async initialize(): Promise<boolean> {
  const settings = geminiSettings.get(); // 可能返回加密的 JSON
  await swTaskQueueClient.initialize({ apiKey: settings.apiKey });
}

正确示例:

async initialize(): Promise<boolean> {
  await settingsManager.waitForInitialization(); // 等待解密完成
  const settings = geminiSettings.get(); // 现在返回解密后的值
  await swTaskQueueClient.initialize({ apiKey: settings.apiKey });
}

原因: settingsManager 使用异步方法 decryptSensitiveDataForLoading() 解密敏感数据(如 API Key。如果在解密完成前调用 geminiSettings.get(),会返回加密的 JSON 对象而不是解密后的字符串,导致 API 请求失败。

跨层数据转换必须传递所有字段

场景: 在主线程和 Service Worker 之间传递数据对象时,需要将内部类型转换为传输格式

错误示例:

// 错误:转换时漏掉了 options 字段
const swWorkflow = {
  id: legacyWorkflow.id,
  name: legacyWorkflow.name,
  steps: legacyWorkflow.steps.map(step => ({
    id: step.id,
    mcp: step.mcp,
    args: step.args,
    description: step.description,
    status: step.status,
    // 漏掉了 step.options导致批量信息batchId 等)丢失
  })),
};

正确示例:

// 正确:显式传递所有字段,包括可选字段
const swWorkflow = {
  id: legacyWorkflow.id,
  name: legacyWorkflow.name,
  steps: legacyWorkflow.steps.map(step => ({
    id: step.id,
    mcp: step.mcp,
    args: step.args,
    description: step.description,
    status: step.status,
    options: step.options,  // 包含 batchId, batchIndex, batchTotal 等
  })),
};

常见遗漏的字段:

  • options - 批量参数、执行模式等
  • metadata - 元数据信息
  • context - 上下文信息
  • 任何 ?: 可选字段

原因: 跨层通信时,如果源类型有可选字段,在转换时很容易遗漏。这会导致功能静默失败(如批量生成只执行第一个),且很难排查。建议在转换函数中显式列出所有字段,或使用 TypeScript 的类型检查确保字段完整。

Service Worker 初始化时序

场景: 提交工作流到 Service Worker 执行前

错误示例:

// 错误直接提交工作流SW 可能还未初始化
const submitToSW = async (workflow) => {
  await workflowSubmissionService.submit(swWorkflow);
  // 如果 SW 的 workflowHandler 未初始化,工作流会被暂存
  // 步骤状态永远停留在 pending"待开始"
};

正确示例:

// 正确:先确保 SW 已初始化
const submitToSW = async (workflow) => {
  // 确保 SW 任务队列已初始化(发送 TASK_QUEUE_INIT 消息)
  const { swTaskQueueService } = await import('../services/sw-task-queue-service');
  await swTaskQueueService.initialize();
  
  await workflowSubmissionService.submit(swWorkflow);
};

原因: Service Worker 的 workflowHandler 需要收到 TASK_QUEUE_INIT 消息后才会初始化。如果在 SW 初始化前提交工作流,消息会被暂存到 pendingWorkflowMessages,等待配置到达。若配置永远不到达(如 swTaskQueueService.initialize() 未被调用),工作流就永远不会开始执行,步骤状态保持 pending

Service Worker 初始化统一入口

场景: 需要确保 SW 客户端已初始化并配置好后再进行 API 调用

错误示例:

// 错误:在各处重复 SW 初始化逻辑
async function callSWApi() {
  if (shouldUseSWTaskQueue()) {
    if (!swChannelClient.isInitialized()) {
      const settings = geminiSettings.get();
      if (settings.apiKey && settings.baseUrl) {
        await settingsManager.waitForInitialization();
        await swChannelClient.initialize();
        await swChannelClient.init({
          geminiConfig: { apiKey: settings.apiKey, baseUrl: settings.baseUrl },
          videoConfig: { baseUrl: settings.baseUrl },
        });
      }
    }
    // 使用 SW...
  }
}

正确示例:

// 正确:使用统一入口
async function callSWApi() {
  const useSW = await swChannelClient.ensureReady();
  
  if (useSW) {
    // 使用 SW...
  } else {
    // 降级到主线程...
  }
}

统一入口说明:

  • swChannelClient.ensureReady(): 统一的异步初始化入口,内部处理 URL 参数检查、设置验证、初始化和配置
  • swTaskQueueService.initialize(): 任务队列服务初始化,内部调用 ensureReady() + 设置 visibility 监听器
  • shouldUseSWTaskQueue(): 同步检查函数,仅用于判断模式,不进行初始化

原因: 分散的 SW 初始化逻辑会导致代码重复、维护困难,且容易遗漏某些检查步骤。使用统一入口可以确保所有初始化逻辑集中管理,便于维护和调试。

Service Worker 更新提示在开发模式下被跳过

场景: 在 localhost 本地测试 Service Worker 更新提示功能

现象: 修改代码并构建后,在 localhost 环境下看不到版本更新提示

原因: 项目在开发模式下(localhost127.0.0.1)会自动跳过更新提示,直接激活新的 Service Worker。

// apps/web/src/main.tsx 中的逻辑
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';

if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
  if (isDevelopment) {
    // 开发模式:直接跳过 waiting不显示提示
    newWorker.postMessage({ type: 'SKIP_WAITING' });
  } else {
    // 生产模式:显示更新提示
    window.dispatchEvent(new CustomEvent('sw-update-available', { ... }));
  }
}

测试方法:

  1. 在控制台手动触发更新提示(仅测试 UI:
window.__debugTriggerUpdate('0.5.10')
  1. 部署到生产环境测试: 只有在非 localhost 环境下才会显示更新提示

  2. 正确的版本升级流程:

pnpm run version:patch   # 升级版本号
pnpm run build:web       # 重新构建
# 部署到生产环境后会触发更新提示

注意:

  • Service Worker 更新检测是基于 sw.js 文件内容的字节级比较
  • 只修改 version.json 不会触发 SW 更新,必须修改 sw.js 内容
  • 版本号通过 __APP_VERSION__ 变量注入到 sw.js

SW RPC Handler 必须等待数据恢复完成

场景: Service Worker 中的 RPC handler 需要访问从 IndexedDB 恢复的数据(如任务列表)

错误示例:

// 错误RPC handler 直接访问数据,可能在 IndexedDB 恢复完成前被调用
private handleTaskListPaginated(data: { page: number; pageSize: number }) {
  const allTasks = this.taskQueue?.getAllTasks() || [];
  // 如果 restoreFromStorage() 还在执行allTasks 可能是空的或不完整的
  return { tasks: allTasks.slice(start, end), total: allTasks.length };
}

正确示例:

// 正确:先等待 IndexedDB 数据恢复完成
private async handleTaskListPaginated(data: { page: number; pageSize: number }) {
  // 确保 IndexedDB 数据已恢复
  await this.taskQueue?.waitForStorageRestore();
  
  const allTasks = this.taskQueue?.getAllTasks() || [];
  return { tasks: allTasks.slice(start, end), total: allTasks.length };
}

// SWTaskQueue 中提供等待方法
class SWTaskQueue {
  private storageRestorePromise: Promise<void> | null = null;
  
  async waitForStorageRestore(): Promise<void> {
    if (this.storageRestorePromise) {
      await this.storageRestorePromise;
    }
  }
}

现象: 页面刷新后,任务列表显示不完整(如 67 条任务只显示 2 条),因为 RPC 响应在数据恢复完成前就返回了。

配置同步到 IndexedDB 供 SW 读取

场景: Service Worker 需要读取用户配置(如 API Key、baseUrl但 SW 无法访问 localStorage

问题: 之前的架构依赖 postMessage 传递配置给 SW当通信不畅时 SW 可能拿不到配置导致任务失败。

错误示例:

// 错误SW 依赖 postMessage 获取配置
// 主线程
await swChannelClient.init({
  geminiConfig: { apiKey, baseUrl },
  videoConfig: { baseUrl },
});

// SW 中
private async handleInit(data: { geminiConfig; videoConfig }) {
  this.geminiConfig = data.geminiConfig;
  // 如果 postMessage 失败SW 就没有配置
}

正确示例:

// 正确:配置同步到 IndexedDBSW 直接读取

// 1. 主线程 SettingsManager 同步配置到 IndexedDB
private async saveToStorage(): Promise<void> {
  localStorage.setItem(DRAWNIX_SETTINGS_KEY, settingsJson);
  
  // 必须 await首次输入 API Key 后若立即提交工作流SW 需能读到最新配置
  await this.syncToIndexedDB();
}

private async syncToIndexedDB(): Promise<void> {
  const geminiConfig = {
    apiKey: this.settings.gemini.apiKey,
    baseUrl: this.settings.gemini.baseUrl,
    // ...
  };
  await configIndexedDBWriter.saveConfig(geminiConfig, videoConfig);
}

// 2. SW 从 IndexedDB 读取配置
const geminiConfig = await taskQueueStorage.getConfig<GeminiConfig>('gemini');
const videoConfig = await taskQueueStorage.getConfig<VideoAPIConfig>('video');

数据流:

用户修改设置 → localStorage + IndexedDB同时写入
SW 需要配置 → 直接读取 IndexedDB → 始终可用

关键点:

  • configIndexedDBWriter 使用与 SW 相同的数据库名 (sw-task-queue) 和存储名 (config)
  • 初始化时自动同步,老用户的 localStorage 配置会自动迁移到 IndexedDB
  • RPC init() 不再需要传递配置参数(可选,用于兼容旧版本)
  • 任务/工作流仍保留配置快照,用于恢复场景

原因: Service Worker 启动后会异步从 IndexedDB 恢复数据(restoreFromStorage()),这个过程可能需要一定时间。如果 RPC handler 在恢复完成前被调用,访问的数据是不完整的。需要通过 Promise 机制确保数据恢复完成后再返回响应。

SW 可用性检测需统一channel + ping

场景: 决定是否使用 SW 执行工作流时,需与 WorkflowSubmissionService 使用相同的检测逻辑

问题: 仅检查 navigator.serviceWorker.controller 会误判。controller 存在不代表 duplex channel 已就绪提交时会超时后降级WorkZone 卡在「待开始」。

错误示例:

// 错误:仅检查 controller
function checkSWAvailable(): boolean {
  return !!(navigator.serviceWorker?.controller);
}
// 若 channel 未初始化submitWorkflow 会超时,降级路径可能仍用 SW 执行器导致二次超时

正确示例:

// 正确:检查 swChannelClient.isInitialized() 且 ping 成功
async function checkSWChannelAvailable(): Promise<boolean> {
  if (!swChannelClient.isInitialized()) return false;
  return await Promise.race([
    swChannelClient.ping(),
    new Promise<boolean>((r) => setTimeout(() => r(false), 1500)),
  ]);
}

原因: controller 存在时 duplex channel 可能仍在初始化或 SW 正在修复 IndexedDB。需统一使用 channel + ping 检测,避免走 SW 路径后超时降级。

降级路径强制使用主线程执行器

场景: workflow 提交到 SW 超时后,使用 MainThreadWorkflowEngine 降级执行时

问题: WorkflowEngine 内部用 executorFactory.getExecutor(),当 ping 仍成功时会返回 SW 执行器,导致 generate_image/generate_video 再次走 SW 并超时WorkZone 一直「待开始」。

错误示例:

// 错误:降级时仍用 executorFactory可能返回 SW 执行器)
this.fallbackEngine = new MainThreadWorkflowEngine({
  onEvent: (e) => this.handleFallbackEngineEvent(e),
  executeMainThreadTool: async (name, args) => { /* ... */ },
});
// WorkflowEngine 内部: const executor = await executorFactory.getExecutor();
// → 返回 SW 执行器 → 再次超时

正确示例:

// 正确:强制使用主线程执行器
this.fallbackEngine = new MainThreadWorkflowEngine({
  onEvent: (e) => this.handleFallbackEngineEvent(e),
  forceFallbackExecutor: true,
  executeMainThreadTool: async (name, args) => { /* ... */ },
});
// WorkflowEngine 内部: const executor = this.options.forceFallbackExecutor
//   ? executorFactory.getFallbackExecutor()
//   : await executorFactory.getExecutor();

原因: 降级路径应完全在主线程执行,直接调用 API不依赖 SW 或 IndexedDB 就绪。

降级路径工作流工具集一致性

场景: 工作流降级到主线程执行时WorkflowEngine 的 executeToolStep 用 switch 按 step.mcp 分发。SW 侧(如 workflow-executor、swCapabilitiesHandler支持的工具主线程分支也必须能路由否则会落入 default 抛出 Unknown tool: xxx

错误示例:

// 错误SW 的 handler 支持 generate_grid_image但 engine 的 switch 没有对应 case
// sw-capabilities/handler.ts 有 case 'generate_grid_image'
// workflow-engine/engine.ts 只有 generate_image、generate_video、ai_analyze、insert_*...
// → generate_grid_image 落入 default抛 Unknown tool: generate_grid_image
switch (step.mcp) {
  case 'generate_image': // ...
  case 'generate_video': // ...
  case 'ai_analyze': // ...
  case 'insert_mermaid':
  case 'canvas_insert': // ...
  default:
    throw new Error(`Unknown tool: ${step.mcp}`);
}

正确示例:

// 正确:与 DelegatedOperationType / swCapabilitiesHandler.execute 支持的工具集一致
// 凡由主线程 handler 执行的工具,都走 executeMainThreadTool 分支
case 'generate_grid_image':
case 'generate_inspiration_board':
case 'split_image':
case 'generate_long_video':
case 'insert_mermaid':
case 'insert_mindmap':
case 'insert_svg':
case 'canvas_insert':
case 'insert_to_canvas': {
  if (!this.options.executeMainThreadTool) {
    throw new Error(`No main thread tool executor configured for: ${step.mcp}`);
  }
  const toolResult = await this.options.executeMainThreadTool(step.mcp, step.args);
  // ...
}

原因: 降级路径功能一致性要求主线程与 SW 支持相同工具集。新增 DelegatedOperation 类型或在 handler 增加 case 时,需同步在 WorkflowEngine.executeToolStep 中增加对应 case 并路由到 executeMainThreadTool。

相关文件:

  • packages/drawnix/src/services/sw-capabilities/types.ts - DelegatedOperationType
  • packages/drawnix/src/services/sw-capabilities/handler.ts - execute switch
  • packages/drawnix/src/services/workflow-engine/engine.ts - executeToolStep switch

同页面禁止创建多个 ServiceWorkerChannel

场景: 主线程需要与 SW 通信的多个模块(如 swChannelClientFetchRelayClient)各自调用 ServiceWorkerChannel.createFromPage()

问题: SW 端 enableGlobalRoutingevent.source.idSW client ID路由消息。同一页面的所有 createFromPage() 共享同一个 clientId。SW 端 ensureChannel(clientId) 只为第一个连接创建 channel 和 subscribeMap后续连接复用同一个 server-side channel。这导致 RPC 响应可能路由到错误的 client-side channelchannel.call() 永远不 resolve。

错误示例:

// FetchRelayClient 创建独立 channel — 与 swChannelClient 冲突!
class FetchRelayClient {
  private channel: ServiceWorkerChannel | null = null;

  async doInitialize(): Promise<boolean> {
    // 创建第二个 channel但 SW 只有一个 server-side channel
    this.channel = await ServiceWorkerChannel.createFromPage({ timeout: 120000 });
    return true;
  }

  async fetch(url: string, init: RequestInit): Promise<Response> {
    // channel.call() 永远不 resolve — 响应去了 swChannelClient 的 channel
    const response = await this.channel!.call('fetchRelay:start', { url, ...init });
    // ...
  }
}

正确示例:

// FetchRelayClient 复用 swChannelClient 的 channel
class FetchRelayClient {
  private swChannelClientRef: { getChannel: () => ServiceWorkerChannel | null } | null = null;

  private getChannel(): ServiceWorkerChannel | null {
    return this.swChannelClientRef?.getChannel() ?? null;
  }

  async initialize(): Promise<boolean> {
    const { swChannelClient } = await import('../sw-channel');
    this.swChannelClientRef = swChannelClient;
    if (!swChannelClient.isInitialized()) {
      await swChannelClient.initializeChannel();
    }
    return !!swChannelClient.getChannel();
  }

  async fetch(url: string, init: RequestInit): Promise<Response> {
    const channel = this.getChannel();
    if (!channel) return this.directFetch(url, init); // 降级
    const response = await channel.call('fetchRelay:start', { url, ...init });
    // ...
  }
}

原因: enableGlobalRoutingclientId 路由。同一浏览器 tab 的所有 createFromPage() 共享同一个 clientId来自 event.source.id。SW 端只为每个 clientId 维护一个 server-side channel 和一套 subscribeMap。第二个 createFromPage() 的握手消息被已有 channel 处理,导致路由混乱。

相关文件

  • packages/drawnix/src/services/sw-channel/client.ts - swChannelClient唯一的 createFromPage 调用点)
  • packages/drawnix/src/services/fetch-relay/client.ts - FetchRelayClient复用 swChannelClient.getChannel()
  • apps/web/src/sw/task-queue/channel-manager.ts - enableGlobalRouting + ensureChannel

Fetch Relay 初始化超时保护

场景: fetchRelayClient.initialize() 在热路径中被调用(如 FallbackMediaExecutor.generateImage()swTaskQueueService.doInitialize())。内部 ServiceWorkerChannel.createFromPage() 默认超时 120 秒,如果 SW 存在但响应慢(如正在 activate整个热路径会被阻塞 2 分钟。

错误示例:

// 错误await 可能阻塞 120 秒
await fetchRelayClient.initialize();

const relayResponse = await fetchRelayClient.fetch(url, { ... });

正确示例:

// 正确3 秒超时保护,超时后 isInitialized() 返回 false
// fetchRelayClient.fetch() 内部自动降级到 directFetch
const relayReady = await Promise.race([
  fetchRelayClient.initialize(),
  new Promise<boolean>((r) => setTimeout(() => r(false), 3000)),
]);
console.debug('[XXX] Fetch Relay ready:', relayReady);

// fetch() 内部会检查 isInitialized(),不可用时自动降级
const relayResponse = await fetchRelayClient.fetch(url, { ... });

原因: fetchRelayClient.initialize() 创建的 ServiceWorkerChannel 需要与 SW 完成握手。SW 在 activating、更新或忙碌时可能延迟响应。在图片生成、任务初始化等热路径中2 分钟阻塞会导致用户感知请求未发出。

补充:同理,recoverFetchRelayResults()doInitialize() 中也需加超时,否则会阻塞任务恢复流程(handleInterruptedTasksstartPolling 无法执行)。

相关文件

  • packages/drawnix/src/services/fetch-relay/client.ts - FetchRelayClient
  • packages/drawnix/src/services/media-executor/fallback-executor.ts - generateImage
  • packages/drawnix/src/services/sw-task-queue-service.ts - doInitialize / recoverFetchRelayResults

模块迁移时接口定义必须逐字段对比

场景: 将模块从 SW 迁移到主线程(或反向迁移)时,在新模块中重新定义了 interface,但遗漏了某些字段。

错误示例:

// SW 版本的 llm-api-logger.ts 有完整定义
interface LLMApiLog {
  id: string;
  // ... 基础字段 ...
  hasReferenceImages?: boolean;
  referenceImageCount?: number;
  referenceImages?: LLMReferenceImage[];  // ← SW 版本有这个字段
}

// 主线程版本迁移时遗漏了 referenceImages
interface LLMApiLog {
  id: string;
  // ... 基础字段 ...
  hasReferenceImages?: boolean;
  referenceImageCount?: number;
  // ❌ referenceImages 字段丢失!类型定义缺失导致调用方无法传入数据
}

正确示例:

// 主线程版本必须与 SW 版本逐字段对比
interface LLMApiLog {
  id: string;
  // ... 基础字段 ...
  hasReferenceImages?: boolean;
  referenceImageCount?: number;
  referenceImages?: LLMReferenceImage[];  // ✅ 保留所有字段
}

原因: 模块迁移时,不仅调用方传参需要完整,类型定义本身也必须包含所有字段。如果 interface 缺少字段即使调用方想传数据也无法传入TypeScript 严格对象字面量检查会报错)。迁移后应逐字段对比新旧 interface,确保功能完整性。本次案例中,主线程 LLMApiLog 遗漏 referenceImages 导致 debug 面板参考图始终显示"加载中..."。

相关文件

  • packages/drawnix/src/services/media-executor/llm-api-logger.ts - 主线程版本
  • apps/web/src/sw/task-queue/llm-api-logger.ts - SW 版本

模型参数偏好必须按模型作用域存储

场景: 图片、视频、音频生成表单需要记忆不同模型的用户参数偏好。

核心原则:

  • 优先使用 selectionKey 作为偏好作用域键,缺失时再回退 modelId
  • 不同供应商来源的同名模型不能共享参数
  • 回填优先级必须是:任务参数 / 外部显式初始化 > 模型偏好 > 模型默认值
  • 回填后必须重新做兼容性过滤,丢弃当前模型不支持的参数

错误示例:

const key = `prefs:${model.id}`;
localStorage.setItem(key, JSON.stringify(params));

// provider-a::gemini-2.5-flash
// provider-b::gemini-2.5-flash
// 最终会读写同一个 key导致参数串用

正确示例:

const scopeKey = model.selectionKey || model.id;
scopedPreferences[scopeKey] = sanitizeSelectedParams(model.id, params);

const restored =
  explicitTaskParams ??
  scopedPreferences[scopeKey] ??
  getModelDefaultParams(model.id);

原因: 运行时模型发现后,同名模型可能来自不同供应商,参数能力并不完全相同。只按 modelId 记忆会让用户在切换供应商后看到错误回填,最终演变成隐蔽状态 bug。

模型默认值与选择器排序的三层配置

场景: 修改默认模型或调整模型选择器中的厂商/模型排序。

三层配置:

  1. DEFAULT_*_MODEL_IDmodel-config.ts):代码级默认模型 ID影响无用户设置时的兜底值
  2. DEFAULT_SETTINGSsettings-manager.ts):设置面板"默认方案"的预设值,新用户首次看到的默认模型,文本模型优先复用 getDefaultTextModel(),避免默认值双写漂移
  3. BUILT_IN_MODEL_RECOMMENDATION_SCORESmodel-config.ts):模型在选择器内的排序权重,分数越高排越前

厂商 Tab 排序: 由 DISCOVERY_VENDOR_ORDERModelVendorBrand.tsx)控制,与模型数组顺序无关。

下线内置模型: 注释或移除 TEXT_MODELS / CHAT_MODELS 中的模型时,同步处理 BUILT_IN_MODEL_RECOMMENDATION_SCORES 和默认模型常量;否则模型可能仍通过排序权重、旧聊天清单或默认设置入口残留。

隐藏模型: 不在选择器默认展示但保留参数定义的模型放入 HIDDEN_VIDEO_MODELSALL_MODELS 不包含它们,getStaticModelConfig 单独兜底查找。

错误示例:

// 只改了常量,忘了改 settings-manager 的默认方案
export const DEFAULT_VIDEO_MODEL_ID = 'seedance-1.5-pro';
// settings-manager.ts 里仍然是 videoModelName: 'veo3.1'
// → 设置面板"默认方案"显示旧模型

// 只改了数组顺序,以为能影响选择器排序
const BUILT_IN_VIDEO_MODELS = [kling, seedance, veo, ...];
// → 实际排序由 recommendedScore 和 DISCOVERY_VENDOR_ORDER 决定

正确示例:

// 1. 改常量
export const DEFAULT_VIDEO_MODEL_ID = 'seedance-1.5-pro';
// 2. 同步改 settings-manager 默认方案
const DEFAULT_SETTINGS = {
  gemini: {
    videoModelName: 'seedance-1.5-pro',
    textModelName: getDefaultTextModel(),
  },
};
// 3. 给模型加 recommendedScore 控制选择器内排序
'seedance-1.5-pro': 97,
// 4. 调整 DISCOVERY_VENDOR_ORDER 控制厂商 Tab 顺序
// OpenAI 要在 DeepSeek 前面时,把 ModelVendor.GPT 放到 ModelVendor.DEEPSEEK 前
export const DISCOVERY_VENDOR_ORDER = [ModelVendor.GPT, ModelVendor.DEEPSEEK];

// 5. 更新默认文本模型时,让默认设置引用统一默认函数
export const DEFAULT_TEXT_MODEL_ID = 'gpt-5.5';

中断任务延迟判定

场景: 页面刷新后,handleInterruptedTasks() 处理仍处于 processing 状态的任务。如果图片生成通过 Fetch Relay 代理SW 可能仍在执行 fetch尚未完成此时 recoverFetchRelayResults() 找不到结果。

错误示例:

// 错误:立即标记失败,但 SW 可能几秒后就完成了
private async handleInterruptedTasks(): Promise<void> {
  for (const task of processingTasks) {
    if (!task.remoteId) {
      await taskStorageWriter.failTask(task.id, { code: 'INTERRUPTED', message: '...' });
    }
  }
}

正确示例:

// 正确:延迟 15 秒后再次尝试 Fetch Relay 恢复,仍无法恢复才标记失败
private async handleInterruptedTasks(): Promise<void> {
  for (const task of processingTasks) {
    if (!task.remoteId) {
      this.delayedInterruptCheck(task); // 15 秒后再检查
    }
  }
}

private delayedInterruptCheck(task: Task): void {
  setTimeout(async () => {
    const current = this.tasks.get(task.id);
    if (!current || current.status !== TaskStatus.PROCESSING) return;
    await this.recoverFetchRelayResults(); // 再试一次
    // 仍然 processing → 标记失败
  }, 15000);
}

原因: Fetch Relay 的 SW 端是异步执行的,图片生成 API 通常需要 5-30 秒。如果立即标记失败SW 完成的结果会被丢弃,用户看到失败但实际上请求成功了。

IndexedDB 主线程读取前检查 store 是否存在

场景: 主线程 StorageReader如 WorkflowStorageReader从 IndexedDB 读取数据时

问题: object store 由 SW 在 onupgradeneeded 中创建。首次加载或 SW 尚未初始化时 store 可能不存在,直接 transaction(storeName) 会抛 NotFoundError

错误示例:

async getWorkflow(id: string): Promise<Workflow | null> {
  const db = await this.getDB();
  const swWorkflow = await this.getById(WORKFLOWS_STORE, id);  // store 不存在时抛 NotFoundError
  return swWorkflow ? convert(swWorkflow) : null;
}

正确示例:

async getWorkflow(id: string): Promise<Workflow | null> {
  const db = await this.getDB();
  if (!db.objectStoreNames.contains(WORKFLOWS_STORE)) return null;
  const swWorkflow = await this.getById(WORKFLOWS_STORE, id);
  return swWorkflow ? convert(swWorkflow) : null;
}

原因: 主线程用 indexedDB.open(dbName) 不加版本,不会触发 onupgradeneeded,无法创建 store。需在读取前检查 db.objectStoreNames.contains(storeName)

降级模式持久化失败不阻塞执行

场景: MainThreadWorkflowEngine 或 workflowStorageWriter 在降级模式下写入 IndexedDB 时

问题: IndexedDB store 可能不存在,saveWorkflow 失败会 reject导致工作流执行中断用户看不到请求发出。

错误示例:

async saveWorkflow(workflow: Workflow): Promise<void> {
  const db = await this.getDB();
  if (!db.objectStoreNames.contains(WORKFLOWS_STORE)) {
    reject(new Error('Store not found'));  // 导致 WorkflowEngine 捕获异常并标记失败
  }
  // ...
}

正确示例:

async saveWorkflow(workflow: Workflow): Promise<void> {
  try {
    const db = await this.getDB();
    if (!db.objectStoreNames.contains(WORKFLOWS_STORE)) return;
    // ... 写入
  } catch {
    // 静默跳过:降级模式不依赖持久化,优先保证 API 调用执行
  }
}

原因: 降级模式下用户最关心的是请求能发出,持久化是次要的。失败时 resolve 而非 reject不阻塞工作流执行。

主线程任务数据必须通过 RPC 持久化到 SW

场景: 从云端同步或恢复任务数据到本地时

错误示例:

// 错误:只添加到本地内存,页面刷新后数据丢失
async restoreTasks(tasks: Task[]): Promise<void> {
  for (const task of tasks) {
    this.tasks.set(task.id, task);  // 只存到内存
    this.emitEvent('taskCreated', task);
  }
}

正确示例:

// 正确:通过 RPC 调用 SW 的 importTasks 方法持久化到 IndexedDB
async restoreTasks(tasks: Task[]): Promise<void> {
  const tasksToRestore = tasks.filter(task => !this.tasks.has(task.id));
  if (tasksToRestore.length === 0) return;
  
  // 转换为 SWTask 格式
  const swTasks = tasksToRestore.map(task => this.convertTaskToSWTask(task));
  
  // 调用 SW 的 importTasks RPC 持久化到 IndexedDB
  const result = await swChannelClient.importTasks(swTasks);
  
  if (result.success) {
    // 持久化成功后才添加到本地内存
    for (const task of tasksToRestore) {
      this.tasks.set(task.id, task);
      this.emitEvent('taskCreated', task);
    }
  }
}

现象: 云端同步任务后显示成功,但页面刷新后任务列表为空。

原因: swTaskQueueService 的本地 tasks Map 只是内存状态不会自动持久化。SW 的 IndexedDB 才是任务数据的持久化存储。必须通过 RPC 调用 SW 端的方法才能将数据保存到 IndexedDB。

相关文件:

  • 主线程:packages/drawnix/src/services/sw-task-queue-service.ts
  • SW 端:apps/web/src/sw/task-queue/channel-manager.ts (TASK_IMPORT RPC)
  • SW 存储:apps/web/src/sw/task-queue/storage.ts

远程同步的任务不应恢复执行

场景: 多设备同步场景下,避免远程同步过来的任务被错误地恢复执行

错误示例:

// 错误SW 重启后会恢复所有 PROCESSING/PENDING 状态的任务,包括远程同步的
private shouldResumeTask(task: SWTask): boolean {
  if (task.status === TaskStatus.PROCESSING) {
    return true; // 会恢复远程同步的任务,导致重复调用大模型接口
  }
  return false;
}

正确示例:

// 正确:检查 syncedFromRemote 标记,跳过远程同步的任务
private shouldResumeTask(task: SWTask): boolean {
  // 远程同步的任务不应恢复执行(避免多设备重复执行)
  if (task.syncedFromRemote) {
    return false;
  }
  
  if (task.status === TaskStatus.PROCESSING) {
    return true;
  }
  return false;
}

现象: 用户在设备 A 创建任务并同步到云端,设备 B 同步后页面刷新,任务被重新执行,导致重复调用大模型接口(产生额外费用)。

原因: SW 重启后会调用 restoreFromStorage() 恢复任务,并对 PROCESSING/PENDING 状态的任务调用 resumeTaskExecution()。远程同步的任务虽然通常是 COMPLETED 状态,但为了防止边界情况,需要通过 syncedFromRemote 标记明确区分本地创建和远程同步的任务。

实现要点:

  1. TaskSWTask 接口添加 syncedFromRemote?: boolean 字段
  2. task-sync-service.tscompactToTask() 设置 syncedFromRemote: true
  3. SW 的 shouldResumeTask() 检查此标记并返回 false
  4. 数据转换函数(convertTaskToStorageFormatconvertTaskToSWTask)传递此字段

相关文件:

  • 主线程类型:packages/drawnix/src/types/shared/core.types.ts
  • SW 类型:apps/web/src/sw/task-queue/types.ts
  • 任务恢复:apps/web/src/sw/task-queue/queue.ts (shouldResumeTask)
  • 同步服务:packages/drawnix/src/services/github-sync/task-sync-service.ts

PostMessage 日志由调试模式完全控制

场景: Service Worker 与主线程之间的通讯日志记录

关键原则: PostMessage 日志记录必须完全由调试模式控制,避免影响未开启调试模式的应用性能。

正确实现:

// 1. postmessage-logger.ts 中的日志记录检查
function shouldLogMessage(messageType: string): boolean {
  // 调试模式未启用时,立即返回 false不进行任何记录操作
  if (!isDebugModeActive()) {
    return false;
  }
  return !EXCLUDED_MESSAGE_TYPES.includes(messageType);
}

// 2. message-bus.ts 中的日志记录
export function sendToClient(client: Client, message: unknown): void {
  // Only attempt to log if debug mode is enabled
  let logId = '';
  if (isPostMessageLoggerDebugMode()) {
    const messageType = (message as { type?: string })?.type || 'unknown';
    logId = logSentMessage(messageType, message, client.id);
  }
  
  client.postMessage(message);
  // ... 仅在调试模式启用时广播日志
}

// 3. Service Worker 中的日志记录
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
  // Log received message only if debug mode is enabled
  let logId = '';
  if (isPostMessageLoggerDebugMode()) {
    logId = logReceivedMessage(messageType, event.data, clientId);
  }
  
  // ... 处理消息
});

// 4. 调试模式切换时的内存清理
export function setPostMessageLoggerDebugMode(enabled: boolean): void {
  const wasEnabled = debugModeEnabled;
  debugModeEnabled = enabled;
  
  if (!enabled && wasEnabled) {
    // 从启用变为禁用时,立即清空日志,释放内存
    logs.length = 0;
    pendingRequests.clear();
  }
}

性能影响:

  • 调试关闭: 零日志记录开销,零内存占用,应用运行不受影响
  • 调试启用: 完整的日志记录,实时显示在调试面板,可接受的性能开销仅在调试时产生

相关文件:

  • docs/SW_DEBUG_POSTMESSAGE_LOGGING.md - 完整的实现文档
  • apps/web/src/sw/task-queue/postmessage-logger.ts - 日志记录模块
  • apps/web/src/sw/task-queue/utils/message-bus.ts - 消息总线模块
  • apps/web/public/sw-debug.html - 调试面板界面

重复提交检测应由 UI 层处理

场景: 实现防重复提交功能时

错误示例:

// 错误:在服务层基于参数哈希进行去重
class TaskQueueService {
  private recentSubmissions: Map<string, number>;

  createTask(params: GenerationParams, type: TaskType): Task {
    const paramsHash = generateParamsHash(params, type);
    
    // 服务层拦截"相同参数"的任务
    if (this.isDuplicateSubmission(paramsHash)) {
      throw new Error('Duplicate submission detected');
    }
    
    this.recentSubmissions.set(paramsHash, Date.now());
    // ... 创建任务
  }

  private isDuplicateSubmission(hash: string): boolean {
    const lastSubmission = this.recentSubmissions.get(hash);
    return lastSubmission && Date.now() - lastSubmission < 5000;
  }
}

正确示例:

// 正确:服务层只检查 taskId 重复(防止同一任务被提交两次)
class TaskQueueService {
  createTask(params: GenerationParams, type: TaskType): Task {
    const taskId = generateTaskId(); // UUID v4每次不同
    
    if (this.tasks.has(taskId)) {
      console.warn(`Task ${taskId} already exists`);
      return;
    }
    
    // ... 创建任务,不做参数去重
  }
}

// UI 层通过按钮防抖和状态管理处理重复提交
const AIInputBar = () => {
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleSubmit = async () => {
    if (isSubmitting) return; // 防止重复点击
    
    setIsSubmitting(true);
    try {
      await taskQueueService.createTask(params, type);
    } finally {
      // 使用冷却时间防止快速连续提交
      setTimeout(() => setIsSubmitting(false), 1000);
    }
  };
};

原因:

  1. 用户意图不同: 用户连续提交相同参数可能是故意的(想生成多张相同提示词的图片)
  2. 去重规则复杂: "相同参数"的定义不清晰(图片 base64 是否算相同?时间戳呢?)
  3. 职责分离: 防重复点击是 UI 交互问题,应由 UI 层解决
  4. 调试困难: 服务层拦截导致的错误不易排查,用户不知道为什么提交失败

API 请求禁止重试

场景: 实现 API 调用(图片生成、视频生成、聊天等)时

错误示例:

// 错误:添加重试逻辑
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
  try {
    const response = await fetch(apiUrl, options);
    if (response.ok) return response.json();
  } catch (error) {
    if (attempt < maxRetries - 1) {
      await sleep(retryDelay);
      continue;
    }
    throw error;
  }
}

正确示例:

// 正确:直接请求,失败则抛出错误
const response = await fetch(apiUrl, options);
if (!response.ok) {
  const error = new Error(`HTTP ${response.status}`);
  throw error;
}
return response.json();

禁止重试的请求类型:

  • AI 生成 API图片、视频、角色
  • 聊天 API
  • 任务队列中的任务执行
  • Service Worker 中的 fetch 请求

原因:

  1. AI 生成请求成本高(时间和费用),重试会导致重复消耗
  2. 失败通常是由于内容策略、配额限制或 API 问题,重试无法解决
  3. 用户可以手动重试失败的任务
  4. 重试会延长错误反馈时间,影响用户体验

新画布功能必须作为 Plait 插件实现

场景: 需要在画布上添加新的交互功能(如激光笔、新画笔类型、标注工具等)

错误做法:使用独立 React 组件 + SVG overlay

// 错误:独立 React 组件,坐标系与画布不一致
const LaserPointer: React.FC = ({ active, board }) => {
  const svgRef = useRef<SVGSVGElement>(null);
  // 手动监听 pointer 事件,自己计算坐标
  const handlePointerMove = (e: React.PointerEvent) => {
    const rect = svgRef.current!.getBoundingClientRect();
    // 屏幕坐标,未经过画布缩放/平移变换 → 笔迹位置错误
    trailRef.current.push({ x: e.clientX - rect.left, y: e.clientY - rect.top });
  };
  return <svg className="overlay" onPointerMove={handlePointerMove}>...</svg>;
};

正确做法:实现为 withXxx 插件,复用框架基础设施

// 正确:作为 Plait 插件,复用 Generator/Smoother/坐标变换
export const withLaserPointer = (board: PlaitBoard) => {
  const { pointerDown, pointerMove, pointerUp } = board;
  const generator = new FreehandGenerator(board);  // 复用已有渲染器
  const smoother = new FreehandSmoother();          // 复用已有平滑算法

  board.pointerMove = (event: PointerEvent) => {
    if (isDrawing) {
      const smoothed = smoother.process([event.x, event.y]);
      // 使用框架标准坐标变换,与画布缩放/平移一致
      const point = toViewBoxPoint(board, toHostPoint(board, smoothed[0], smoothed[1]));
      points.push(point);
      generator.processDrawing(element, PlaitBoard.getElementTopHost(board));
      return;
    }
    pointerMove(event);
  };
  return board;
};

原因:

  1. 独立 SVG overlay 有自己的坐标系,不跟随画布缩放/平移,导致笔迹位置错误
  2. overlay 需要 stopPropagation 拦截事件,与画布交互冲突
  3. 无法复用 FreehandGeneratorSVG 渲染、FreehandSmoother点平滑等已有能力
  4. 插件方式通过 board.pointerDown/Move/Up 链自然融入事件流

工具互斥通过 board.pointer 管理

场景: 添加新的工具类型(如激光笔),需要与其他工具(选择、手型、画笔)互斥

错误做法:使用独立布尔状态

// 错误:独立状态,需要手动维护互斥
interface DrawnixState {
  laserPointerActive?: boolean;  // 独立状态
}

// 激活激光笔时,不会自动停用其他工具
updateAppState({ laserPointerActive: true });

// 需要额外逻辑处理互斥
const [laserActive, setLaserActive] = useState(false);
useEffect(() => {
  if (appState.laserPointerActive) {
    setLaserActive(true);
    updateState(prev => ({ ...prev, laserPointerActive: false }));
  }
}, [appState.laserPointerActive]);

正确做法:加入枚举,使用 board.pointer 系统

// 正确:加入 FreehandShape 枚举
export enum FreehandShape {
  feltTipPen = 'feltTipPen',
  eraser = 'eraser',
  laserPointer = 'laserPointer',  // 新增
}

// 切换工具时,通过框架统一管理
BoardTransforms.updatePointerType(board, FreehandShape.laserPointer);
updateAppState({ pointer: FreehandShape.laserPointer });

// board.pointer 是单值,自动与其他工具互斥
// 判断激活状态
const isActive = board.pointer === FreehandShape.laserPointer;

原因:

  1. board.pointer 是单值,设置新工具自动停用旧工具,无需手动管理互斥
  2. 工具栏 UI 通过 board.pointer === xxx 自动反映选中状态
  3. 快捷键切换V/H/L/E 等)只需更新 pointer 一处
  4. 插件中通过 PlaitBoard.isPointer(board, xxx) 判断是否激活,逻辑清晰

Pointer 双更新board 和 React context 必须同步

场景: 切换或恢复工具 pointer 时,只更新了 Plait board 的 pointer 而忘记更新 React contextappState.pointer),导致 UI 状态残留(如橡皮擦工具栏在退出幻灯片后仍然显示)

错误示例:

// 退出幻灯片时只恢复了 board pointer
const handleClose = () => {
  BoardTransforms.updatePointerType(board, savedPointer);
  // 遗漏React context 中的 appState.pointer 仍是 FreehandShape.eraser
  // 橡皮擦工具栏判断 appState.pointer === FreehandShape.eraser 仍为 true
};

正确示例:

// 同时更新 board 和 React context
const handleClose = () => {
  BoardTransforms.updatePointerType(board, savedPointer);
  setPointer(savedPointer);  // 同步更新 React context
};

原因: Pointer 状态存在于两层Plait boardboard.pointer)和 React contextappState.pointer。UI 组件(如橡皮擦工具栏)依赖 React context 判断显隐,只更新 board 不会触发 React 重渲染,导致 UI 与实际状态不一致。任何修改 pointer 的地方都必须同时调用 BoardTransforms.updatePointerTypesetPointer

Plait 选中状态渲染触发

场景: 在异步回调(如 setTimeout)中使用 addSelectedElement 选中元素时

错误示例:

// 错误addSelectedElement 只更新 WeakMap 缓存,不触发渲染
setTimeout(() => {
  const element = board.children.find(el => el.id === elementId);
  clearSelectedElement(board);
  addSelectedElement(board, element);  // 选中状态已更新,但 UI 不会刷新
  BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
}, 50);

正确示例:

// 正确:使用 Transforms.setNode 触发 board.apply() 从而触发渲染
setTimeout(() => {
  const elementIndex = board.children.findIndex(el => el.id === elementId);
  const element = elementIndex >= 0 ? board.children[elementIndex] : null;
  if (element) {
    clearSelectedElement(board);
    addSelectedElement(board, element);
    BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
    // 设置临时属性触发渲染,然后立即删除
    Transforms.setNode(board, { _forceRender: Date.now() } as any, [elementIndex]);
    Transforms.setNode(board, { _forceRender: undefined } as any, [elementIndex]);
  }
}, 50);

原因: Plait 的 addSelectedElement 只是将元素存入 BOARD_TO_SELECTED_ELEMENT WeakMap 缓存,不会触发任何渲染。在同步流程中(如 insertElement 内部),Transforms.insertNode 已经触发了 board.apply() 和渲染链,所以选中状态能正常显示。但在异步回调中单独调用时,需要手动触发一次 board.apply() 来刷新渲染。Transforms.setNode 会调用 board.apply(),从而触发完整的渲染链。

插入元素后选中需通过 ID 查找实际引用

场景: 使用 Transforms.insertNode 插入元素后,需要选中该元素

错误示例:

// 错误:直接使用传入的对象调用 addSelectedElement
const newElement = { id: idCreator(), type: 'pen', ... };
Transforms.insertNode(board, newElement, [board.children.length]);

clearSelectedElement(board);
addSelectedElement(board, newElement);  // 可能报错Unable to find the path for Plait node

正确示例:

// 正确:通过 ID 从 board.children 中查找实际插入的元素
const newElement = { id: idCreator(), type: 'pen', ... };
Transforms.insertNode(board, newElement, [board.children.length]);

// 查找实际插入到 board 中的元素引用
const insertedElement = board.children.find(child => child.id === newElement.id);
if (insertedElement) {
  clearSelectedElement(board);
  addSelectedElement(board, insertedElement);
}

原因: Transforms.insertNode 插入元素时Plait 可能会对元素进行处理或创建新的引用。addSelectedElement 内部会调用 findPath 查找元素路径,如果传入的对象引用与 board.children 中的不一致,会导致 "Unable to find the path for Plait node" 错误。

异步任务幂等性检查应检查存在性而非完成状态

场景: 实现防止任务重复执行的检查逻辑时(如页面刷新后恢复任务)

错误示例:

// 错误:只检查 completed 状态,会导致 in_progress 的任务被重复执行
async checkProcessedRequest(requestId: string): Promise<boolean> {
  const result = await db.get('requests', requestId);
  // 用户刷新页面时in_progress 的任务会被再次执行!
  if (result && result.status === 'completed' && result.response) {
    return true;
  }
  return false;
}
### Plait API 函数签名注意事项

**场景**: 调用 Plait 的工具函数(如 `getRectangleByElements`)时

 **错误示例**:
```typescript
// 错误:漏掉 board 参数,导致 elements.forEach is not a function 错误
const elementRect = getRectangleByElements([element], false);
// getRectangleByElements 的第一个参数是 board不是 elements

正确示例:

// 正确:检查任务是否存在,无论状态如何
async checkProcessedRequest(requestId: string): Promise<boolean> {
  const result = await db.get('requests', requestId);
  // 存在即返回 true防止重复执行
  if (result) {
    return true;
  }
  return false;
}

原因:

  • 当任务状态为 in_progress 时,说明任务已经开始执行
  • 如果只检查 completed 状态,用户刷新页面后会导致同一任务被重复执行
  • 正确的做法是检查任务记录是否存在,存在即视为"已处理"
  • 这符合幂等性原则:同一请求多次执行应该得到相同结果

适用场景:

  • Service Worker 恢复任务
  • 页面刷新后的任务续接
  • 分布式系统中的请求去重 // 正确board 作为第一个参数 const elementRect = getRectangleByElements(board, [element], false);

**常见的需要 board 参数的 Plait 函数**:
- `getRectangleByElements(board, elements, includePadding)`
- `getSelectedElements(board)`
- `PlaitElement.getElementG(element)` - 注意这个不需要 board

**原因**: Plait 的大多数工具函数需要 board 作为上下文,用于访问视口、缩放比例等信息。漏掉 board 参数会导致运行时错误,且错误信息可能难以理解(如将 elements 数组误认为 board 对象导致的方法调用错误)。

### 交互插件必须覆盖所有元素类型

**场景**: 橡皮擦、选择、拖拽等交互插件只处理了部分元素类型,新增元素类型后遗漏更新

❌ **错误示例**:
```typescript
// 橡皮擦只处理 Freehand 元素Frame/Image/Video 完全无法擦除
const checkAndMarkFreehandElementsForDeletion = (point: Point) => {
    const freehandElements = board.children.filter((element) =>
        Freehand.isFreehand(element)
    ) as Freehand[];
    // 只遍历 freehand其他元素类型被忽略
};

正确示例:

// 遍历所有 children按类型分别做命中检测
const checkAndMarkElementsForDeletion = (point: Point) => {
    board.children.forEach((element) => {
        if (elementsToDelete.has(element.id)) return;
        let hit = false;
        if (Freehand.isFreehand(element)) {
            hit = isHitFreehandWithRadius(board, element, viewBoxPoint, hitRadius);
        } else if (isErasableRectElement(element)) {
            hit = isHitRectElement(element, viewBoxPoint, hitRadius);
        }
        if (hit) {
            PlaitElement.getElementG(element).style.opacity = '0.2';
            elementsToDelete.add(element.id);
        }
    });
};

原因: 新增元素类型(如 Frame、Video所有交互插件橡皮擦、选择、碰撞检测等都需要更新以支持新类型。仅过滤特定类型会导致其他类型的元素无法被交互。

createPortal 到 document.body 会脱离 Plait React Context

场景: 在 createPortal(... document.body) 渲染的组件中使用 useBoard() 等 Plait hooks

错误示例:

// FrameSlideshow 通过 createPortal 渲染到 document.body
// 内部使用 PencilSettingsToolbar它调用 useBoard()
// 报错The `useBoard` hook must be used inside the <Plait> component's context
return createPortal(
    <PencilSettingsToolbar />,  // 内部调用 useBoard() → 报错
    document.body
);

正确示例:

// 通过 props 传递 board直接调用 Plait API
const FrameSlideshow: React.FC<{ board: PlaitBoard }> = ({ board }) => {
    const settings = getFreehandSettings(board);
    const handleColorChange = (color: string) => {
        setFreehandStrokeColor(board, color);
    };
    // 内联实现设置面板,不依赖 useBoard()
};

原因: createPortal 渲染到 document.body 会脱离 <Plait> 组件树React Context 无法穿透。必须通过 props 传递 board 并直接调用 Plait API。

蒙层挖洞的 pointer-events 设置

场景: 全屏蒙层遮住画布,中间留出"窗口"区域允许交互(如幻灯片播放)

错误示例:

// 蒙层容器设置 pointer-events: auto阻挡了窗口区域的画布交互
.frame-slideshow__mask {
    pointer-events: auto;  // 整个容器拦截事件,画布无法交互
}

正确示例:

// 容器透传事件,只有实际遮挡区域拦截
.frame-slideshow__mask {
    pointer-events: none;  // 容器不拦截
}
.frame-slideshow__mask-block {
    pointer-events: auto;  // 只有四块遮挡区域拦截事件
}

原因: 蒙层容器覆盖整个视口,如果设置 pointer-events: auto 会阻挡中间"窗口"区域的画布事件。正确做法是容器 pointer-events: none,仅四块实际遮挡的 div 设置 pointer-events: auto

禁止自动删除用户数据

场景: 添加定时清理、自动裁剪、过期删除等"优化"逻辑时

错误示例:

// 错误:自动删除超过 24 小时的已完成任务
async restoreFromStorage() {
  // ... 恢复任务
  taskQueueStorage.cleanupOldTasks(); // 会删除素材库依赖的任务数据!
}

// 错误:创建新会话时自动删除旧会话
const createSession = async () => {
  if (sessions.length >= MAX_SESSIONS) {
    await pruneOldSessions(MAX_SESSIONS); // 会删除用户的聊天历史!
  }
};

// 错误:定期清理"过期"的工作流数据
setInterval(() => cleanupOldWorkflows(), 24 * 60 * 60 * 1000);

正确示例:

// 正确:不自动删除任务数据
async restoreFromStorage() {
  // ... 恢复任务
  // NOTE: 不调用 cleanupOldTasks(),任务数据是素材库的数据来源
}

// 正确:不限制会话数量,让用户手动管理
const createSession = async () => {
  const newSession = await chatStorageService.createSession();
  // 不自动删除旧会话,用户可以手动删除
};

// 正确:只清理临时数据,不清理用户数据
setInterval(() => {
  cleanupRecentSubmissions(); // ✅ 清理内存中的去重缓存(临时数据)
  cleanupStaleRequests();     // ✅ 清理过期的请求状态(临时数据)
}, 60000);

可以自动清理的数据:

  • 内存中的临时状态(去重缓存、请求状态、锁)
  • 追踪事件缓存(临时数据)
  • 存储空间不足时的 LRU 缓存淘汰(用户会收到提示)

禁止自动清理的数据:

  • 任务数据(素材库依赖)
  • 聊天会话和消息
  • 工作流数据
  • 用户上传的素材
  • 项目和画板数据

原因: 本项目的素材库通过 taskQueueService.getTasksByStatus(COMPLETED) 获取 AI 生成的素材。如果自动删除已完成的任务,素材库就无法展示这些 AI 生成的图片/视频。类似地,聊天历史、工作流数据都是用户的重要数据,不应被自动删除。

类服务的 setInterval 必须保存 ID 并提供 destroy 方法

场景: 在类Service、Manager、Client中使用 setInterval 进行定期任务(如清理、监控、心跳)

错误示例:

class RequestManager {
  constructor() {
    // 错误:没有保存 interval ID无法清理
    setInterval(() => {
      this.cleanupExpiredRequests();
    }, 60000);
  }
  // 没有 destroy 方法!
}

class DuplexClient {
  private startPerformanceMonitoring(): void {
    // 错误interval 一旦创建就永远运行
    setInterval(() => {
      this.updatePerformanceMetrics();
    }, 5000);
  }
}

正确示例:

class RequestManager {
  private cleanupTimerId: ReturnType<typeof setInterval> | null = null;

  constructor() {
    this.startCleanupTimer();
  }

  private startCleanupTimer(): void {
    if (this.cleanupTimerId) {
      clearInterval(this.cleanupTimerId);
    }
    this.cleanupTimerId = setInterval(() => {
      this.cleanupExpiredRequests();
    }, 60000);
  }

  destroy(): void {
    if (this.cleanupTimerId) {
      clearInterval(this.cleanupTimerId);
      this.cleanupTimerId = null;
    }
    // 清理其他资源...
  }
}

检查清单:

  • 每个 setInterval 调用都保存返回的 ID 到类成员变量
  • 类必须提供 destroy() 方法用于清理定时器
  • 重复调用启动方法时先清理旧定时器
  • 单例模式的类在重新获取实例前也需要清理

原因: 类服务通常是单例或长期存在的,但在某些场景下(如热更新、测试、页面切换)需要销毁重建。未清理的 setInterval 会导致:

  1. 内存泄漏(闭包持有整个类实例)
  2. 定时器累积(每次创建新实例都增加一个定时器)
  3. 回调执行在已销毁的实例上

Map/Set 需要清理机制防止无限增长

场景: 使用 Map 或 Set 缓存数据(如工作流、请求、会话)

错误示例:

class WorkflowService {
  private workflows: Map<string, Workflow> = new Map();

  submit(workflow: Workflow): void {
    this.workflows.set(workflow.id, workflow);
    // 只有 set没有 delete
  }

  handleCompleted(workflowId: string): void {
    const workflow = this.workflows.get(workflowId);
    workflow.status = 'completed';
    // 完成后没有从 Map 中移除,导致无限增长
  }
}

正确示例:

// 清理延迟:完成后保留 5 分钟供查询
const CLEANUP_DELAY = 5 * 60 * 1000;

class WorkflowService {
  private workflows: Map<string, Workflow> = new Map();
  private cleanupTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();

  handleCompleted(workflowId: string): void {
    const workflow = this.workflows.get(workflowId);
    if (workflow) {
      workflow.status = 'completed';
    }
    // 调度延迟清理
    this.scheduleCleanup(workflowId);
  }

  handleFailed(workflowId: string, error: string): void {
    const workflow = this.workflows.get(workflowId);
    if (workflow) {
      workflow.status = 'failed';
      workflow.error = error;
    }
    this.scheduleCleanup(workflowId);
  }

  private scheduleCleanup(workflowId: string): void {
    // 清除已有的清理定时器
    const existingTimer = this.cleanupTimers.get(workflowId);
    if (existingTimer) {
      clearTimeout(existingTimer);
    }

    const timer = setTimeout(() => {
      this.workflows.delete(workflowId);
      this.cleanupTimers.delete(workflowId);
    }, CLEANUP_DELAY);

    this.cleanupTimers.set(workflowId, timer);
  }
}

常见需要清理的 Map/Set:

  • workflows - 工作流完成/失败后
  • pendingRequests - 请求超时或完成后
  • sessions - 会话过期后
  • subscriptions - 取消订阅后
  • batches - 批处理完成后

原因: 没有清理机制的 Map/Set 会随着使用不断增长,最终导致内存溢出。即使单个条目很小,长期积累也会消耗大量内存。应该在数据不再需要时(完成、失败、超时、取消)及时清理。

不要绕过封装函数直接调用底层 API

场景: 项目中有封装好的函数处理额外逻辑(如日志记录、状态追踪、错误处理)

错误示例:

// 错误:直接调用 postMessage绕过了日志记录系统
async sendToFocused(message: Message): Promise<boolean> {
  const focusedClient = await this.findFocusedClient();
  if (focusedClient) {
    focusedClient.postMessage(message); // 绕过了 sendToClient 的日志记录
    return true;
  }
  return false;
}

正确示例:

// 正确:使用封装函数,确保日志被记录
async sendToFocused(message: Message): Promise<boolean> {
  const focusedClient = await this.findFocusedClient();
  if (focusedClient) {
    sendToClient(focusedClient, message); // 通过封装函数发送,会记录日志
    return true;
  }
  return false;
}

常见场景:

  • sendToClient() vs 直接 client.postMessage()
  • fetchWithRetry() vs 直接 fetch()
  • logError() vs 直接 console.error()
  • cacheService.set() vs 直接 localStorage.setItem()

原因: 封装函数通常包含重要的附加逻辑(日志记录、错误处理、监控上报等)。直接调用底层 API 会绕过这些逻辑,导致功能不完整或难以调试。在添加新代码时,应检查是否有现成的封装函数可用。

页面卸载时必须清理所有定时器资源

场景: 页面使用多个 setInterval 进行定时任务(如心跳、监控、轮询)

错误示例:

// 启动多个定时器
const heartbeatTimer = setInterval(sendHeartbeat, 5000);
startMemoryMonitoring(); // 内部也创建了 memoryMonitorInterval

// 卸载时只清理了部分定时器
window.addEventListener('beforeunload', () => {
  clearInterval(heartbeatTimer);
  // 遗漏了 memoryMonitorInterval
});

正确示例:

// 启动多个定时器
const heartbeatTimer = setInterval(sendHeartbeat, 5000);
startMemoryMonitoring();

// 卸载时清理所有定时器
window.addEventListener('beforeunload', () => {
  clearInterval(heartbeatTimer);
  stopMemoryMonitoring(); // 确保清理所有定时器
});

检查清单:

  • 列出页面中所有的 setInterval 调用
  • 确保 beforeunloadunload 事件中清理每一个定时器
  • 封装在函数中的定时器需要提供对应的 stop 函数
  • 考虑使用统一的资源管理器来追踪所有需要清理的资源

原因: 遗漏的定时器会在页面卸载后继续运行(特别是在 SPA 或 iframe 场景),导致:

  1. 资源泄漏(回调函数持有的闭包无法释放)
  2. 不必要的 CPU 占用
  3. 可能访问已销毁的 DOM 或状态

调试日志清理规范

场景: 开发功能时添加 console.log 调试日志

错误示例:

// 开发时添加了大量调试日志,提交时忘记删除
function handleClick(event: PointerEvent) {
  console.log('[MyComponent] handleClick:', event);
  console.log('[MyComponent] current state:', state);
  // 业务逻辑...
  console.log('[MyComponent] result:', result);
}

正确示例:

// 1. 提交前删除所有 console.log 或将其注释掉
function handleClick(event: PointerEvent) {
  // 业务逻辑...
}

// 2. 使用分级日志记录高价值调试信息
function complexFunction() {
  // console.info('[System] Initializing component'); // 高级生命周期事件
  // console.debug('[Debug] Trace data:', data);      // 详细数据追踪
  // 业务逻辑...
}

原因: 调试日志会污染控制台输出,影响生产环境的日志分析,也会增加代码体积。开发时可以自由添加日志,但提交前必须清理。如果某些日志对生产调试有价值,应使用注释形式保留或使用分级的 console.debug/info (但需确保不会导致性能问题)。

Exceptions:

  • console.error / console.warn 用于记录真正的错误/警告是允许的
  • 带有 [DEBUG] 前缀且通过环境变量控制的日志可以保留
  • 关键系统启动或成功标志日志 (如 Initialized successfully) 推荐保留一份但需保持简洁。

同步数据格式一致性

场景: 解析远程 Gist 中的同步数据文件时

错误示例:

// 错误:直接把 tasks.json 当数组解析
if (gist.files['tasks.json']) {
  const tasksData = await parseGistFile(gist.files['tasks.json']);
  if (Array.isArray(tasksData)) {
    remoteTasks = tasksData;  // 永远不会执行!
  }
}

// 错误:查找 .drawnix 文件
for (const [fileName] of Object.entries(gist.files)) {
  if (fileName.endsWith('.drawnix')) {  // 实际格式是 board_*.json
    remoteBoards.push(fileName);
  }
}

正确示例:

// 正确tasks.json 结构是 { completedTasks: Task[] }
if (gist.files['tasks.json']) {
  const tasksData = await parseGistFile(gist.files['tasks.json']);
  if (tasksData && Array.isArray(tasksData.completedTasks)) {
    remoteTasks = tasksData.completedTasks;
  }
}

// 正确:画板文件格式是 board_{id}.json
for (const [fileName] of Object.entries(gist.files)) {
  if (fileName.startsWith('board_') && fileName.endsWith('.json')) {
    const boardId = fileName.replace('board_', '').replace('.json', '');
    remoteBoards.push({ id: boardId, fileName });
  }
}

同步数据格式参考:

文件名 结构
tasks.json { completedTasks: Task[] }
board_{id}.json Board 对象
workspace.json { folders, boardMetadata, currentBoardId }
master-index.json { boards: Record<id, BoardSyncInfo>, devices, ... }
media_{hash}.json { url, mimeType, data, size }

原因: 同步数据有特定的结构,直接解析会导致数据无法正确读取。调试面板和应用代码必须使用相同的格式。


同步模块日志规范

场景: 在 github-sync/* 目录下的同步相关模块中记录日志

错误示例:

// sync-engine.ts
async pullFromRemote(): Promise<SyncResult> {
  console.log('[SyncEngine] pullFromRemote START');
  console.log('[SyncEngine] Downloading media 10/12:', url);
  console.error('[SyncEngine] Failed to download:', error);
}

正确示例:

// sync-engine.ts
import { logInfo, logDebug, logSuccess, logWarning, logError } from './sync-log-service';

async pullFromRemote(): Promise<SyncResult> {
  logInfo('pullFromRemote START');
  logDebug('Downloading media', { current: 10, total: 12, url });
  logError('Failed to download', error instanceof Error ? error : new Error(String(error)));
}

统一日志 API:

函数 用途 参数
logDebug(message, data?) 详细调试信息 message: string, data?: object
logInfo(message, data?) 一般流程信息 message: string, data?: object
logSuccess(message, data?) 操作成功 message: string, data?: object
logWarning(message, data?) 警告信息 message: string, data?: object
logError(message, error) 错误信息 message: string, error: Error

原因:

  1. 同步模块的日志需要在 /sw-debug.html?tab=gist 调试面板中查看,便于问题定位
  2. console.log 会污染浏览器控制台,且无法持久化和结构化查询
  3. 统一日志服务支持:内存缓存 + IndexedDB 持久化 + 结构化查询 + 自动清理

注意事项:

  • logError 的第二个参数必须是 Error 对象,不能是字符串
  • 使用结构化 data 对象而非字符串拼接,便于后续查询和分析
  • sync 类别的日志始终记录(不受 debugMode 影响),便于问题诊断

Z-Index 管理规范

规范文档: 参考 docs/Z_INDEX_GUIDE.md 获取完整规范

核心原则:

  • 使用预定义的层级常量,禁止硬编码魔术数字
  • TypeScript: 从 constants/z-index.ts 导入 Z_INDEX
  • SCSS: 从 styles/z-index.scss 导入并使用 $z-* 变量或 z() 函数

层级结构 (每层预留100单位):

Layer 0 (0-999):     Base & Canvas Internal
Layer 1 (1000-1999): Canvas Elements & Decorations
Layer 2 (2000-2999): Toolbars (unified-toolbar: 2000, popovers: 3000)
Layer 3 (3000-3999): Popovers & Tooltips
Layer 4 (4000-4999): Drawers & Panels (task-queue, chat-drawer)
Layer 5 (5000-5999): Modals & Dialogs (AI dialogs: 5100+)
Layer 6 (6000-6999): Notifications (active-task-warning: 6000)
Layer 7 (7000-7999): Auth Dialogs
Layer 8 (8000-8999): Image Viewer
Layer 9 (9000+):     Critical Overlays (loading, system-error)

使用示例:

// TypeScript/TSX
import { Z_INDEX } from '@/constants/z-index';
<Rnd style={{ zIndex: Z_INDEX.DIALOG_AI_IMAGE }}>
// SCSS
@import 'styles/z-index';
.my-toolbar {
  z-index: $z-unified-toolbar;  // 或 z-index: z('unified-toolbar');
}

禁止事项:

  • 禁止使用随意的数字 (如 9999, 10000, 10001)
  • 禁止在同一层级随意 +1/-1
  • 临时修复必须在完成后转换为规范用法

媒体 URL 处理规范(避免 CSP 和生命周期问题)

场景: 需要在画布中引用动态生成的图片/视频如合并图片、AI 生成结果)

错误示例 1: 使用 data: URL

// 错误data: URL 会被 CSP 的 connect-src 阻止 fetch
const dataUrl = canvas.toDataURL('image/png');
DrawTransforms.insertImage(board, { url: dataUrl, ... });
// @plait/core 的 convertImageToBase64 会对所有 URL 发起 fetch
// 生产环境 CSP connect-src 不包含 data: 会报错!

错误示例 2: 使用 blob: URL

// 错误blob: URL 在页面刷新后失效
const blob = await fetch(imageUrl).then(r => r.blob());
const blobUrl = URL.createObjectURL(blob);
DrawTransforms.insertImage(board, { url: blobUrl, ... });
// 页面刷新后blob: URL 失效,图片无法显示!

正确示例: 使用虚拟路径 + Service Worker 拦截

// 1. 生成 Blob 并缓存到 Cache API
const blob = await new Promise<Blob>((resolve, reject) => {
  canvas.toBlob((b) => b ? resolve(b) : reject(new Error('Failed')), 'image/png');
});

// 2. 使用虚拟路径 URL由 Service Worker 拦截返回缓存内容)
const taskId = `merged-image-${Date.now()}`;
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
const cacheKey = `${location.origin}${stableUrl}`;

// 3. 缓存到 Cache API
await unifiedCacheService.cacheMediaFromBlob(cacheKey, blob, 'image', { taskId });

// 4. 使用虚拟路径插入图片
DrawTransforms.insertImage(board, { url: stableUrl, ... });

虚拟路径规范:

  • 统一前缀: /__aitu_cache__/
  • 图片路径: /__aitu_cache__/image/{taskId}.{ext}
  • 视频路径: /__aitu_cache__/video/{taskId}.{ext}
  • Service Worker 通过路径或扩展名区分类型

原因:

  1. data: URL 被 CSP 的 connect-src 阻止(生产环境)
  2. blob: URL 生命周期与页面绑定,刷新后失效
  3. 虚拟路径 + Cache API 持久化,刷新后仍可访问

虚拟路径 URL 匹配规范

场景: 需要根据素材 URL 查找或删除画布中的元素时(如删除素材时同步删除画布元素)

错误示例: 使用精确匹配或 startsWith

// 错误:素材 URL 可能是完整 URL画布元素可能是相对路径
function isCacheUrl(url: string): boolean {
  return url.startsWith('/__aitu_cache__/');  // 无法匹配 http://localhost/__aitu_cache__/...
}

function findElement(assetUrl: string) {
  return board.children.find(el => el.url === assetUrl);  // 精确匹配会失败
}
// 素材 URL: http://localhost:7200/__aitu_cache__/image/xxx.png
// 元素 URL: /__aitu_cache__/image/xxx.png
// 结果:无法匹配!

正确示例: 提取路径部分进行匹配

const CACHE_URL_PREFIX = '/__aitu_cache__/';

// 检查是否为缓存 URL支持完整 URL 和相对路径)
function isCacheUrl(url: string): boolean {
  return url.includes(CACHE_URL_PREFIX);  // ✅ 使用 includes
}

// 提取缓存路径部分用于匹配
function extractCachePath(url: string): string | null {
  const cacheIndex = url.indexOf(CACHE_URL_PREFIX);
  if (cacheIndex === -1) return null;
  return url.slice(cacheIndex);  // 返回 /__aitu_cache__/... 部分
}

// 匹配时使用路径部分比较
function findElements(assetUrl: string) {
  const targetPath = extractCachePath(assetUrl);
  return board.children.filter(el => {
    const elPath = extractCachePath(el.url);
    return el.url === assetUrl || (targetPath && elPath && targetPath === elPath);
  });
}

原因:

  • 素材存储时可能使用完整 URL含 origin
  • 画布元素可能使用相对路径(由 Service Worker 拦截)
  • 同一资源的两种 URL 形式必须能相互匹配

Cache API 缓存 key 一致性规范

场景: 主线程缓存媒体到 Cache APIService Worker 需要读取该缓存

错误示例: 使用 location.origin 拼接完整 URL

// 主线程缓存时
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
const cacheKey = `${location.origin}${stableUrl}`;  // http://localhost:7200/...
await cache.put(cacheKey, response);

// SW 读取时(代理场景下 origin 不同)
const cacheKey = request.url;  // https://ai-tu.netlify.app/...
const cached = await cache.match(cacheKey);  // ❌ 找不到!

正确示例: 使用相对路径作为缓存 key + 多 key 回退查找

// 主线程缓存时 - 使用相对路径
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
const cacheKey = stableUrl;  // /__aitu_cache__/image/xxx.png
await cache.put(cacheKey, response);

// SW 读取时 - 优先完整 URL回退到相对路径
let cachedResponse = await cache.match(request.url);  // 完整 URL
if (!cachedResponse) {
  cachedResponse = await cache.match(url.pathname);   // 相对路径回退
}

原因:

  • 使用 location.origin 会在代理场景下导致 key 不一致(本地 vs 线上域名)
  • 推荐使用相对路径作为缓存 key确保一致性
  • SW 端采用多 key 回退策略,兼容历史数据和不同场景

相对路径 URL 解析规范

场景: 需要从 URL 中提取文件扩展名、路径等信息时(如下载文件时确定文件名)

错误示例: 直接使用 new URL() 解析

// 错误:相对路径无法被 new URL() 解析,会抛异常
function getFileExtension(url: string): string {
  try {
    const urlPath = new URL(url).pathname;  // ❌ 相对路径会抛 TypeError
    const ext = urlPath.substring(urlPath.lastIndexOf('.') + 1);
    return ext;
  } catch {
    return 'bin';  // 回退到错误的扩展名
  }
}

// 下载合并图片时:
// url = '/__aitu_cache__/image/merged-image-xxx.png'
// 结果:下载文件扩展名变成 .bin

正确示例: 先判断是否为相对路径

function getFileExtension(url: string): string {
  try {
    let urlPath: string;
    
    // 相对路径直接使用,不需要 URL 解析
    if (url.startsWith('/') || !url.includes('://')) {
      urlPath = url;
    } else {
      urlPath = new URL(url).pathname;
    }
    
    const lastDotIndex = urlPath.lastIndexOf('.');
    if (lastDotIndex > 0 && lastDotIndex < urlPath.length - 1) {
      return urlPath.substring(lastDotIndex + 1).toLowerCase();
    }
  } catch {
    // URL 解析失败
  }
  return 'bin';
}

原因:

  • new URL(path) 要求完整 URL 或提供 base URL相对路径会抛 TypeError: Invalid URL
  • 虚拟路径如 /__aitu_cache__/xxx 是相对路径,需要特殊处理
  • 判断 startsWith('/') 或不包含 :// 可以识别相对路径

Service Worker 架构设计:避免复杂的往返通信

场景: 设计需要 Service Worker 执行的工具或任务时

错误示例: 复杂的往返通信架构

// 错误ai_analyze 被设计为需要主线程执行,但内部又通过 SW 发起 chat 请求
// 形成了复杂的往返通信链,页面刷新时容易断链

// 流程:
// 1. 主线程发起工作流 → SW
// 2. SW 发现 ai_analyze 需要主线程执行
// 3. SW → 主线程 (MAIN_THREAD_TOOL_REQUEST)
// 4. 主线程执行 ai_analyze调用 agentExecutor
// 5. agentExecutor 调用 callApiStreamViaSW
// 6. 主线程 → SW (CHAT_START)  ← 又回到 SW
// 7. SW 执行 chat通过 MessageChannel 返回结果
// 8. 主线程收到结果,发送 MAIN_THREAD_TOOL_RESPONSE
// 9. SW 继续工作流

// 问题:刷新页面时,步骤 6-8 的通信链会断裂,导致工作流卡住

export function requiresMainThread(toolName: string): boolean {
  const delegatedTools = [
    'ai_analyze',  // ❌ 内部又调用 SW不应该委托给主线程
    // ...
  ];
  return delegatedTools.includes(toolName);
}

正确示例: 简化架构,避免往返通信

// 正确:如果操作最终在 SW 中执行,就应该直接在 SW 中实现

// 简化后的流程:
// 1. 主线程发起工作流 → SW
// 2. SW 直接执行 ai_analyze不委托给主线程
// 3. SW 内部调用 chat API
// 4. SW 解析结果,添加后续步骤
// 5. SW 继续执行后续步骤

// 在 SW 中注册工具,直接执行
export const swMCPTools: Map<string, SWMCPTool> = new Map([
  ['generate_image', generateImageTool],
  ['generate_video', generateVideoTool],
  ['ai_analyze', aiAnalyzeTool],  // ✅ 直接在 SW 执行
]);

// 从委托列表中移除
export function requiresMainThread(toolName: string): boolean {
  const delegatedTools = [
    'canvas_insert',  // 需要 DOM 操作,必须在主线程
    'insert_mermaid', // 需要渲染,必须在主线程
    // 'ai_analyze' - 不再委托,直接在 SW 执行
  ];
  return delegatedTools.includes(toolName);
}

原因:

  1. 复杂的往返通信增加了故障点,页面刷新时容易断链
  2. Service Worker 是独立于页面的后台进程,刷新不影响 SW 执行
  3. 如果工具最终依赖 SW 执行(如 chat API就应该直接在 SW 中实现
  4. 只有真正需要 DOM/Canvas 操作的工具才应该委托给主线程

判断标准: 工具是否真正需要主线程

  • 需要委托DOM 操作、Canvas 渲染、获取用户输入
  • 不需要委托:纯 API 调用、数据处理、文件操作

Service Worker 更新注意: 修改 SW 代码后需要重新加载才能生效:

  1. Chrome DevTools → Application → Service Workers → 点击 "Update"
  2. 或关闭所有使用该 SW 的标签页,重新打开

Service Worker 更新后禁止自动刷新页面

场景: Service Worker 更新检测和页面刷新逻辑

错误示例:

// 错误:收到 SW 更新消息后自动刷新页面
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type === 'SW_UPDATED') {
    window.location.reload();  // 自动刷新会打断用户操作!
  }
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  window.location.reload();  // 自动刷新会打断用户操作!
});

正确示例:

// 正确:使用标志位,只有用户确认后才刷新
let userConfirmedUpgrade = false;

// 监听 SW_UPDATED 消息
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type === 'SW_UPDATED') {
    // 只有用户主动确认升级后才刷新页面
    if (!userConfirmedUpgrade) {
      return;  // 跳过自动刷新
    }
    setTimeout(() => window.location.reload(), 1000);
  }
});

// 监听 controller 变化
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (!userConfirmedUpgrade) {
    return;  // 跳过自动刷新
  }
  setTimeout(() => window.location.reload(), 1000);
});

// 监听用户确认升级事件
window.addEventListener('user-confirmed-upgrade', () => {
  userConfirmedUpgrade = true;
  // 触发 SW 跳过等待
  pendingWorker?.postMessage({ type: 'SKIP_WAITING' });
});

原因:

  • 自动刷新会打断用户正在进行的操作(编辑、生成任务等)
  • 用户可能有未保存的工作,强制刷新会导致数据丢失
  • 应该显示更新提示,让用户选择合适的时机刷新

相关文件:

  • apps/web/src/main.tsx - Service Worker 注册和更新逻辑
  • components/version-update/version-update-prompt.tsx - 版本更新提示组件

postmessage-duplex 库使用规范 (v1.1.0)

场景: 使用 postmessage-duplex 库实现主线程与 Service Worker 的双工通信时

通信模式选择

模式 方法 用途 特点
RPC call() 请求-响应 需要等待响应,有超时
广播 broadcast() + onBroadcast() 单向通知 fire-and-forget无响应

错误示例 1 - 单向通知使用 publish:

// 错误publish 需要响应,用于单向通知会导致超时警告
channel.publish('task:status', { taskId, status });  // 等待响应超时

正确示例 1 - 使用 broadcast:

// 正确:单向通知使用 broadcastfire-and-forget
channel.broadcast('task:status', { taskId, status });  // 不等待响应

错误示例 2 - 接收广播使用 subscribe:

// 错误subscribe 用于 RPC接收广播应使用 onBroadcast
channel.subscribe('task:status', (response) => {
  handleStatus(response.data);  // 广播消息不会触发
  return { ack: true };
});

正确示例 2 - 使用 onBroadcast:

// 正确:使用 onBroadcast 接收广播消息
channel.onBroadcast('task:status', ({ data }) => {
  if (data) {
    handleStatus(data as TaskStatusEvent);  // data 直接是广播数据
  }
  // 不需要 return这是单向的
});

客户端初始化

错误示例 3 - 未启用自动重连:

// 错误SW 更新后连接断开,需要手动处理
const channel = await ServiceWorkerChannel.createFromPage({
  timeout: 30000,
});

正确示例 3 - 启用 autoReconnect:

// 正确启用自动重连SW 更新时自动恢复连接
const channel = await ServiceWorkerChannel.createFromPage({
  timeout: 30000,
  autoReconnect: true,
  log: { log: () => {}, warn: () => {}, error: () => {} },  // 禁用内部日志
} as any);  // log 不在 PageChannelOptions 类型中,需要 as any

SW 端配置

错误示例 4 - 手动处理连接:

// 错误v1.0.0 模式):手动监听 SW_CHANNEL_CONNECT
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SW_CHANNEL_CONNECT') {
    // 手动创建 channel...
  }
});

正确示例 4 - 使用 enableGlobalRouting:

// 正确v1.1.0):使用 enableGlobalRouting 自动管理 channel
ServiceWorkerChannel.enableGlobalRouting((clientId, event) => {
  const channel = ensureChannel(clientId);  // 按需创建 channel
  channel.handleMessage(event);  // 处理消息
});

function ensureChannel(clientId: string) {
  if (!channels.has(clientId)) {
    const channel = ServiceWorkerChannel.createFromWorker(clientId, {
      timeout: 30000,
      subscribeMap: createRpcHandlers(),
      log: { log: () => {}, warn: () => {}, error: () => {} },
    });
    channels.set(clientId, channel);
  }
  return channels.get(clientId);
}

isReady 检查

错误示例 5 - isReady 布尔值检查:

// 错误isReady 返回数字 0/1不是布尔值
if (this.channel?.isReady === true) {  // 永远 falseisReady 是 1
  return true;
}

正确示例 5:

// 正确:使用 truthy 检查
if (!!this.channel?.isReady) {  // 0 → false, 1 → true
  return true;
}

原因:

  1. v1.1.0 引入 broadcast 模式broadcast() + onBroadcast() 用于单向通知,不需要响应,避免超时
  2. enableGlobalRouting 自动管理:不再需要手动处理 SW_CHANNEL_CONNECT,库自动路由消息到正确的 channel
  3. autoReconnect 处理 SW 更新SW 更新时自动重新建立连接,无需页面刷新
  4. log 选项禁用日志PageChannelOptions 类型不含 log,需要 as any 绕过类型检查
  5. isReady 返回数字0/1 而非布尔值,使用 !! 转换

任务队列双服务同步

场景: 项目中有两个任务队列服务,需要正确同步数据

服务 位置 用途
taskQueueService 本地内存 UI 组件状态useTaskQueue hook
swTaskQueueService SW 通信 持久化任务数据

错误示例:

// 错误:只从本地服务读取,首次渲染时可能为空
export function useTaskQueue() {
  const [tasks, setTasks] = useState<Task[]>([]);
  
  useEffect(() => {
    setTasks(taskQueueService.getAllTasks());  // 可能为空!
  }, []);
}

正确示例:

// 正确:渲染时直接从 IndexedDB 加载数据
export function useTaskQueue() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const syncAttempted = useRef(false);
  
  useEffect(() => {
    setTasks(taskQueueService.getAllTasks());
  }, []);

  // 渲染时从 IndexedDB 加载
  useEffect(() => {
    if (syncAttempted.current) return;
    syncAttempted.current = true;

    const loadTasks = async () => {
      // 直接从 IndexedDB 读取任务
      const storedTasks = await taskStorageReader.getAllTasks();
      if (storedTasks.length > 0) {
        taskQueueService.restoreTasks(storedTasks);
      }
    };
    loadTasks();
  }, []);
}

原因:

  • taskQueueService 是纯内存服务,页面刷新后数据丢失
  • 任务数据持久化在 IndexedDB 中,直接通过 taskStorageReader 读取
  • 避免 postMessage RPC 的 1MB 大小限制和通信不稳定性
  1. 响应数据格式可能有多种嵌套结构,需要灵活解析而非假设单一格式

相关文件:

  • packages/drawnix/src/services/sw-channel/client.ts - 主应用的 SW 通道客户端
  • apps/web/src/sw/task-queue/channel-manager.ts - SW 端的通道管理器
  • apps/web/public/sw-debug/duplex-client.js - 调试页面的 duplex 客户端

任务队列入口红点需区分状态与未读

场景: 工具栏任务队列图标需要提示失败任务或生成中任务时

错误示例:

// 错误:把历史失败任务当成红点条件,失败任务不清理就会永久亮
const showFailedDot =
  failedTasks.length > 0 && activeTasks.length === 0;

正确示例:

// 正确:生成中是实时状态;失败红点是未查看通知
const latestFailedAt = failedTasks.reduce(
  (latest, task) =>
    Math.max(latest, task.completedAt || task.updatedAt || task.createdAt || 0),
  0
);
const showFailedDot =
  latestFailedAt > acknowledgedFailedAt && activeTasks.length === 0;

useEffect(() => {
  if (taskPanelExpanded && latestFailedAt > acknowledgedFailedAt) {
    setAcknowledgedFailedAt(latestFailedAt);
  }
}, [acknowledgedFailedAt, latestFailedAt, taskPanelExpanded]);

原因:

  • failed 是任务终态,可能长期保留在 IndexedDB/队列历史中,不能直接等价于“未读”
  • 用户打开任务队列已经完成查看动作,应清除红点;之后只有新失败或失败任务更新时间晚于确认点才重新提示
  • 数字 Badge/绿色点可表达“正在生成”这种持续状态;红点更适合表达“有新情况待查看”

相关文件:

  • packages/drawnix/src/components/toolbar/bottom-actions-section.tsx - 任务队列入口图标和红点逻辑

降级模式(?sw=0下任务必须自动执行

场景: 用户通过 ?sw=0 URL 参数禁用 Service Worker使用降级模式

错误示例:

// 错误:降级模式下只创建任务,没有执行
class TaskQueueService {
  createTask(params: GenerationParams, type: TaskType): Task {
    const task = { id: generateTaskId(), type, status: TaskStatus.PROCESSING, ... };
    this.tasks.set(task.id, task);
    this.persistTask(task);  // 保存到 IndexedDB
    this.emitEvent('taskCreated', task);
    return task;  // 任务被创建但永远不会执行!
  }
}

正确示例:

// 正确:创建任务后自动触发执行
class TaskQueueService {
  createTask(params: GenerationParams, type: TaskType): Task {
    const task = { id: generateTaskId(), type, status: TaskStatus.PROCESSING, ... };
    this.tasks.set(task.id, task);
    this.persistTask(task);
    this.emitEvent('taskCreated', task);
    
    // 自动执行任务fire-and-forget
    this.executeTask(task).catch((error) => {
      console.error('[TaskQueueService] Task execution error:', error);
    });
    
    return task;
  }
  
  private async executeTask(task: Task): Promise<void> {
    const executor = await executorFactory.getExecutor();
    // 根据任务类型执行
    if (task.type === TaskType.IMAGE) {
      await executor.generateImage({ taskId: task.id, prompt: task.params.prompt, ... });
    } else if (task.type === TaskType.VIDEO) {
      await executor.generateVideo({ taskId: task.id, prompt: task.params.prompt, ... });
    }
    // 轮询等待完成
    const result = await waitForTaskCompletion(task.id, { timeout: 10 * 60 * 1000 });
    // 更新状态
    ...
  }
}

原因:

  • SW 模式下,任务由 SW 自动执行(在 submitToSW 中)
  • 降级模式下,任务只保存到 IndexedDB没有后台进程执行它们
  • legacyTaskQueueService 必须在 createTask 后自动调用 executeTask
  • 同样,retryTask 也需要在重置状态后触发执行

相关文件:

  • packages/drawnix/src/services/task-queue-service.ts - 降级模式任务服务
  • packages/drawnix/src/services/media-executor/factory.ts - 执行器工厂
  • packages/drawnix/src/services/media-executor/fallback-executor.ts - 降级执行器

画布生成锚点重试必须区分生成失败和后处理失败

场景: 画布上的图片生成锚点显示“生成失败 / Failed to fetch”用户第一次点击“重试”。

错误示例:

// 错误:任务已 completed但后处理失败只重新插入旧结果不会重新发生成请求
if (task.status === TaskStatus.COMPLETED) {
  workflowCompletionService.clearTask(task.id);
  handleTaskCompleted(task);
}

正确示例:

// 正确:后处理失败但按钮语义是“重试生成”时,显式允许 completed 任务重新生成
const shouldRegenerateCompletedTask =
  task.status === TaskStatus.COMPLETED &&
  postProcessingStatus === 'failed';

if (task.status === TaskStatus.FAILED || shouldRegenerateCompletedTask) {
  workflowCompletionService.clearTask(task.id);
  taskQueueService.retryTask(task.id, { allowCompleted: shouldRegenerateCompletedTask });
}

原因:

  • 生成锚点的 failed 可能来自任务失败,也可能来自取图、显影、插入画布等后处理失败
  • “重试”按钮承诺重新触发生成时,不能只清理后处理状态或复用旧结果
  • COMPLETED 任务开放重试必须显式传参,并清空旧 result / insertedToCanvas / completedAt,避免 UI 继续读取旧失败态

相关文件:

  • packages/drawnix/src/hooks/useAutoInsertToCanvas.ts - 生成锚点重试事件处理
  • packages/drawnix/src/services/task-queue-service.ts - 任务重试状态重置与执行
  • packages/drawnix/src/components/image-generation-anchor/ImageGenerationAnchorContent.tsx - 生成锚点重试入口

创建图片/视频任务及执行时须传递 referenceImages

场景: MCP 工具(如 image-generation.tsvideo-generation.ts)通过 taskQueueService.createTask 创建任务,降级模式下由 executeTask 调用 executor.generateImage / generateVideo 发起请求

错误示例:

// 错误createTask 只传 uploadedImages不传 referenceImages
const task = taskQueueService.createTask(
  {
    prompt,
    size: size || '1x1',
    uploadedImages: uploadedImages?.length ? uploadedImages : undefined,
    model,
    // 缺少 referenceImages → task.params.referenceImages 为空
  },
  TaskType.IMAGE
);

// 错误executeTask 调用 executor 时不传 referenceImages
await executor.generateImage({
  taskId: task.id,
  prompt: task.params.prompt,
  model: task.params.model,
  size: task.params.size,
  // 缺少 referenceImages → 请求体不带参考图
});

正确示例:

// 正确createTask 同时传入 referenceImages 与 uploadedImages
const task = taskQueueService.createTask(
  {
    prompt,
    size: size || '1x1',
    uploadedImages: uploadedImages?.length ? uploadedImages : undefined,
    referenceImages: referenceImages?.length ? referenceImages : undefined,
    model,
  },
  TaskType.IMAGE
);

// 正确executeTask 从 task.params 取出 referenceImages 传给 executor
await executor.generateImage({
  taskId: task.id,
  prompt: task.params.prompt,
  model: task.params.model,
  size: task.params.size,
  referenceImages: task.params.referenceImages as string[] | undefined,
  uploadedImages: task.params.uploadedImages as Array<{ url?: string }> | undefined,
});

原因:

  • 执行器(含 fallback依赖 params.referenceImagesparams.inputReference 构建请求体;仅传 uploadedImagesexecutor 可从 uploadedImages 提取 URLtask.params 中必须至少有一种来源referenceImages 或 uploadedImages且 createTask 与 executeTask 两处都要一致传递
  • 视频任务同理createTask 传 referenceImagesexecuteTask 传 referenceImagesinputReference,否则 sw=0 下 Form Data 不会包含 input_reference

相关文件:

  • packages/drawnix/src/mcp/tools/image-generation.ts - 图片任务 createTask
  • packages/drawnix/src/mcp/tools/video-generation.ts - 视频任务 createTask
  • packages/drawnix/src/services/task-queue-service.ts - 降级 executeTask 调用 executor
  • packages/drawnix/src/services/sw-task-queue-service.ts - SW 降级 executeWithFallback

设置保存后需要主动更新 Service Worker 配置

场景: 用户在设置面板修改配置(如 API Key、流式请求开关并保存后

错误示例:

// 错误:只保存到本地存储,不更新运行中的 SW 配置
const handleSave = async () => {
  geminiSettings.set({
    apiKey,
    baseUrl,
    imageStreamEnabled,  // 新增的配置
  });
  // SW 使用的仍是初始化时的旧配置!
};

正确示例:

// 正确:保存后同时更新 SW 配置
const handleSave = async () => {
  // 1. 保存到本地存储
  geminiSettings.set({
    apiKey,
    baseUrl,
    imageStreamEnabled,
  });

  // 2. 主动推送配置到运行中的 SW
  swTaskQueueClient.updateConfig({
    geminiConfig: {
      apiKey,
      baseUrl,
      imageStreamEnabled,
    },
  });
};

原因:

  • Service Worker 在初始化时接收配置(通过 TASK_QUEUE_INIT 消息)
  • 之后 SW 使用内存中的配置,不会重新读取本地存储
  • 如果用户修改设置后不调用 updateConfig()SW 继续使用旧配置
  • 这会导致用户开启的功能(如流式请求)看似保存成功但实际未生效

通信协议:

// 主线程 → Service Worker
swTaskQueueClient.updateConfig({
  geminiConfig: { ... },  // 可选
  videoConfig: { ... },   // 可选
});

// SW 内部处理
case 'TASK_QUEUE_UPDATE_CONFIG':
  Object.assign(this.geminiConfig, data.geminiConfig);
  Object.assign(this.videoConfig, data.videoConfig);
  break;

Service Worker 内部处理虚拟路径 URL

场景: 在 Service Worker 内部需要获取 /__aitu_cache__//asset-library/ 等虚拟路径的资源时

错误示例: 使用 fetch 获取虚拟路径

// 错误SW 内部的 fetch 不会触发 SW 的 fetch 事件拦截
async function processReferenceImage(url: string) {
  if (url.startsWith('/__aitu_cache__/')) {
    const response = await fetch(url);  // ❌ 这个请求不会被 SW 拦截!
    const blob = await response.blob();  // 会失败或返回 404
    return blobToBase64(blob);
  }
}

正确示例: 直接从 Cache API 获取

// 正确:直接从 Cache API 获取,绕过 fetch
async function processReferenceImage(url: string) {
  if (url.startsWith('/__aitu_cache__/')) {
    const cache = await caches.open(IMAGE_CACHE_NAME);
    // 缓存 key 是完整 URL包含 origin
    const cacheKey = `${self.location.origin}${url}`;
    const cachedResponse = await cache.match(cacheKey);
    if (cachedResponse) {
      const blob = await cachedResponse.blob();
      return blobToBase64(blob);
    }
  }
}

原因:

  • Service Worker 的 fetch 事件只拦截来自页面(客户端)的请求
  • SW 内部发起的 fetch 请求不会触发自身的 fetch 事件(避免无限循环)
  • 因此必须直接从 Cache API 获取,而不是通过 fetch
  • 注意缓存 key 是完整 URL需要用 self.location.origin 构造

Service Worker 中 opaque 响应的处理

场景: 使用 no-cors 模式获取外部图片时,会返回 opaque 响应

错误示例:

// 错误:只检查 status !== 0会把 opaque 响应当作失败
for (let options of fetchOptions) {
  response = await fetch(currentUrl, options);
  if (response && response.status !== 0) {
    break; // opaque 响应 status === 0会被跳过
  }
}

// 错误:尝试读取 opaque 响应的 body
if (response.type === 'opaque') {
  const blob = await response.blob(); // blob 是空的!
  const corsResponse = new Response(blob, { ... }); // 创建的是空响应
  await cache.put(request, corsResponse); // 缓存了空响应
}

正确示例:

// 正确:同时检查 status 和 type
for (let options of fetchOptions) {
  response = await fetch(currentUrl, options);
  // opaque 响应 status === 0 但 type === 'opaque',应该接受
  if (response && (response.status !== 0 || response.type === 'opaque')) {
    break;
  }
}

// 正确opaque 响应无法缓存,直接返回给浏览器
if (response.type === 'opaque') {
  // 标记域名,后续请求跳过 SW
  markCorsFailedDomain(hostname);
  // 直接返回,依赖浏览器 disk cache
  return response;
}

原因:

  • no-cors 模式返回的 opaque 响应,status 始终是 0type'opaque'
  • opaque 响应的 body 是安全锁定的,无法读取(返回空 Blob
  • 浏览器可以用 opaque 响应显示图片,但 SW 无法读取或有效缓存
  • 对于 CORS 配置错误的服务器,应该依赖浏览器的 disk cache

Cache API 返回前必须验证响应有效性

场景: 从 Cache API 返回缓存的响应时

错误示例:

// 错误:直接返回缓存,没有验证内容是否有效
const cachedResponse = await cache.match(request);
if (cachedResponse) {
  return cachedResponse; // 可能是之前错误缓存的空响应!
}

正确示例:

const cachedResponse = await cache.match(request);
if (cachedResponse) {
  const responseClone = cachedResponse.clone();
  const blob = await responseClone.blob();
  
  // 检查 blob 是否为空
  if (blob.size === 0) {
    console.warn('检测到空缓存,删除并重新获取');
    await cache.delete(request);
    // 继续执行网络请求逻辑...
  } else {
    // 缓存有效,返回响应
    return cachedResponse;
  }
}

原因:

  • 之前的代码 bug如尝试缓存 opaque 响应的空 body可能导致空响应被缓存
  • 返回空响应会导致图片无法显示,用户体验差
  • 在返回前验证 blob.size > 0 可以自动修复历史问题
  • 删除无效缓存后重新获取,确保用户看到正确的内容

Cache.put() 会消费 Response body无法重复使用

场景: 需要将同一个 Response 对象缓存到多个不同的 key 时

错误示例:

// 错误Cache.put() 会消费 Response 的 body后续无法 clone
const response = new Response(blob, {
  headers: { 'Content-Type': 'image/jpeg' },
});

// 第一次 put 消费了 body
await thumbCache.put(cacheKey1, response);

// 第二次 clone 会失败Response body is already used
await thumbCache.put(cacheKey2, response.clone()); // ❌ TypeError

正确示例:

// 正确:为每个缓存 key 创建独立的 Response 对象
const createResponse = () => new Response(blob, {
  headers: { 'Content-Type': 'image/jpeg' },
});

// 每个 put 使用独立的 Response 对象
await thumbCache.put(cacheKey1, createResponse());
await thumbCache.put(cacheKey2, createResponse());
await thumbCache.put(cacheKey3, createResponse());

原因:

  • Cache.put() 方法会读取并消费 Response 的 body stream
  • 一旦 body 被消费,就无法再次读取或 clone
  • Response 对象本身很轻量(只是包装 Blob为每个 key 创建新对象是安全的
  • 使用工厂函数 createResponse() 可以方便地创建多个独立实例

fetchOptions 优先级:优先尝试可缓存的模式

场景: 在 Service Worker 中获取外部图片时,需要尝试多种 fetch 模式

错误示例:

// 错误no-cors 模式优先,会导致 opaque 响应无法缓存
let fetchOptions = [
  { mode: 'no-cors' },  // ❌ 优先尝试,但无法缓存
  { mode: 'cors' },     // 可缓存,但优先级低
];

正确示例:

// 正确:优先尝试 cors 模式(可缓存),最后才尝试 no-cors
let fetchOptions = [
  { mode: 'cors' },     // ✅ 优先尝试,可以缓存
  { mode: 'no-cors' },  // 最后备选,无法缓存但可以绕过 CORS
];

原因:

  • cors 模式返回的响应可以被 Service Worker 读取和缓存
  • no-cors 模式返回的 opaque 响应无法读取 body无法有效缓存
  • 优先尝试可缓存的模式可以提高后续请求的命中率
  • 只有在 cors 模式失败时才降级到 no-cors 模式

CDN 响应必须多重验证后才能缓存

场景: Service Worker 从 CDN 获取静态资源并缓存时

错误示例:

// 错误:只检查 response.ok可能缓存 CDN 返回的 HTML 错误页面
const response = await fetch(cdnUrl);
if (response.ok) {
  cache.put(request, response.clone());
  return response; // 可能是 404 页面被当作 JS 执行!
}

正确示例:

const response = await fetch(cdnUrl);
if (response.ok) {
  // 1. Content-Type 验证
  const contentType = response.headers.get('Content-Type') || '';
  const isValidType = contentType.includes('javascript') || 
                      contentType.includes('css') || 
                      contentType.includes('json');
  if (!isValidType) continue; // 尝试下一个源
  
  // 2. Content-Length 验证(排除空响应)
  const length = parseInt(response.headers.get('Content-Length') || '0', 10);
  if (length > 0 && length < 50) continue;
  
  // 3. 内容采样验证(检测 HTML 错误页面)
  const sample = await response.clone().text().then(t => t.slice(0, 200));
  if (sample.includes('<!DOCTYPE') || sample.includes('Not Found')) {
    continue; // CDN 返回了 HTML 错误页面
  }
  
  cache.put(request, response.clone());
  return response;
}

原因:

  • CDN 可能返回 404 但 HTTP 状态码仍是 200某些 CDN 的行为)
  • npm 包不存在时CDN 返回 HTML 错误页面
  • 错误页面被当作 JS 执行会导致 React 多实例冲突,应用崩溃
  • 多重验证确保只缓存真正有效的资源

CDN 请求应设置短超时快速回退

场景: Service Worker 实现 CDN 优先加载策略时

错误示例:

// 错误超时太长CDN 回源慢时用户等待时间过长
const CDN_CONFIG = {
  fetchTimeout: 10000, // 10 秒超时
};

正确示例:

// 正确短超时CDN 缓存命中很快(<200ms超时说明在回源
const CDN_CONFIG = {
  fetchTimeout: 1500, // 1.5 秒超时,快速回退到服务器
};

原因:

  • CDN 缓存命中通常 < 200ms1.5s 足够
  • CDN 回源(首次请求)可能需要 3-5 秒,等待太久影响用户体验
  • 短超时后快速回退到服务器,保证首次加载速度
  • 用户请求会触发 CDN 缓存,后续访问自动加速

工作流恢复时 UI 与 SW 状态不一致的处理

场景: 页面刷新后UI 从 IndexedDB/本地存储恢复的状态可能与 SW 端的真实状态不一致

错误示例:

// 错误:检测到终态但有运行中步骤时,直接标记为失败
if (isTerminalStatus && hasRunningSteps) {
  hasClaimedRef.current = true;  // 标记为已 claim
  onWorkflowStateChange?.(workflowId, 'failed', '工作流已结束');
  return;  // 不再尝试从 SW 获取真实状态
}

// 后续即使 SW 更新了状态,也因为 hasClaimedRef.current = true 而跳过 claim

正确示例:

// 正确:不一致状态也触发 claim从 SW 获取真实状态
const isInconsistentState = isTerminalStatus && hasRunningSteps;
const needsClaim = isActiveByStatus || isActiveBySteps || isInconsistentState;

if (isInconsistentState) {
  console.log('Inconsistent state detected, will claim from SW');
  // 不要在这里标记为 claimed让 claim 逻辑去处理
}

// claim 逻辑会等待 swChannelClient 初始化,然后从 SW 获取真实状态
if (needsClaim && !hasClaimedRef.current) {
  hasClaimedRef.current = true;
  const result = await swChannelClient.claimWorkflow(workflowId);
  // 根据 SW 返回的真实状态更新 UI
}

原因:

  • 页面刷新时UI 状态可能来自旧的 IndexedDB 数据
  • SW 端可能已经更新了工作流状态(如从 failed 变为 running
  • 如果直接根据本地状态判断,会导致用户看到错误的"失败"状态
  • 正确做法是检测不一致状态后,从 SW 获取真实状态再更新 UI

SW 重发 Tool Request 需延迟等待主线程 Handler 准备

场景: 页面刷新后SW 在工作流 claim 时重发 pending tool request

错误示例:

// channel-manager.ts - handleWorkflowClaim 中
if ((workflow.status === 'running') && hasPendingToolRequest) {
  // 错误:立即重发,但主线程的 handler 可能还没注册
  this.resendPendingToolRequestsForWorkflow(workflowId);
}

// 结果:
// 1. SW 发送 publish('workflow:toolRequest', {...})
// 2. 主线程还没完成 registerToolRequestHandler()
// 3. publish 返回 undefined/null
// 4. SW 误判为"超时",工作流失败

正确示例:

// 正确:延迟重发,等待主线程 handler 准备好
if ((workflow.status === 'running') && hasPendingToolRequest) {
  console.log(`Will resend pending tool requests after delay`);
  // 延迟 500ms给主线程时间完成 registerToolRequestHandler
  setTimeout(() => {
    console.log(`Resending pending tool requests (delayed)`);
    this.resendPendingToolRequestsForWorkflow(workflowId);
  }, 500);
}

原因:

  • 页面刷新后,组件挂载顺序不确定
  • WorkZoneContent 的 claim 可能在 WorkflowSubmissionService.registerToolRequestHandler() 完成前执行
  • SW 的 publish() 方法需要主线程已经 subscribe() 才能收到响应
  • 500ms 延迟给主线程足够时间完成初始化(通常 200ms 内完成)

初始化时序:

页面刷新
    ↓
WorkflowSubmissionService.init()        WorkZoneContent.useEffect()
    ↓                                        ↓
registerToolRequestHandler()            swChannelClient.claimWorkflow()
(等待 swChannelClient 初始化)                ↓
    ↓                                   SW 收到 claim
swChannelClient.isInitialized()             ↓
    ↓                                   SW 重发 tool request ← 可能此时 handler 还没准备好!
subscribe('workflow:toolRequest')

SW 错误处理链必须保持完整

场景: 在 SW 执行链中需要传递特殊错误属性(如 isAwaitingClient)时

错误示例:

// workflow-executor.ts - executeStep 中
try {
  await this.requestMainThreadTool(workflowId, stepId, toolName, args);
} catch (error) {
  // 错误:所有错误都标记为 failed
  step.status = 'failed';
  step.error = error.message;
}

// 然后在 executeWorkflow 循环中
if (step.status === 'failed') {
  // 错误:创建新的 Error 对象,丢失了 isAwaitingClient 属性
  throw new Error(`Step ${step.id} failed: ${step.error}`);
}

// 最后在 catch 块中
catch (error) {
  // error.message = "Step xxx failed: AWAITING_CLIENT:insert_mermaid"
  // 但 error.isAwaitingClient 是 undefined
  if (error?.isAwaitingClient) {  // 永远为 false
    // 不会进入这个分支
  }
  workflow.status = 'failed';  // 错误地标记为失败
}

正确示例:

// workflow-executor.ts - executeStep 中
try {
  await this.requestMainThreadTool(workflowId, stepId, toolName, args);
} catch (error) {
  // 正确:检测特殊错误类型,保持原始属性
  if (error?.isAwaitingClient || error?.message?.startsWith('AWAITING_CLIENT:')) {
    // 保持 step 为 running 状态,重新抛出原始错误
    step.status = 'running';
    throw error;  // 保留 isAwaitingClient 属性
  }
  
  // 其他错误才标记为 failed
  step.status = 'failed';
  step.error = error.message;
}

// 在 catch 块中
catch (error) {
  // error.isAwaitingClient 现在是 true
  if (error?.isAwaitingClient) {
    console.log('Workflow waiting for client to reconnect');
    workflow.status = 'running';  // 保持 running 状态
    return;
  }
  workflow.status = 'failed';
}

原因:

  • JavaScript 错误对象可以有自定义属性(如 error.isAwaitingClient = true
  • 在中间层创建新的 Error 对象会丢失这些属性
  • 如果需要在上层检查特殊错误类型,必须重新抛出原始错误
  • 或者在检查时同时检查 error.message 内容作为备选

LLM API 日志必须记录异步任务的 remoteId

场景: 异步任务(视频生成、异步图片生成如 nbp4k-async)需要记录远程任务 ID 便于调试和任务恢复追踪

错误示例:

// failLLMApiLog 参数类型不支持 remoteId
export function failLLMApiLog(
  logId: string,
  params: {
    httpStatus?: number;
    duration: number;
    errorMessage: string;
    responseBody?: string;
    // 缺少 remoteId 字段
  }
): void { ... }

// 调用时 remoteId 被忽略或导致类型错误
failLLMApiLog(logId, {
  duration,
  errorMessage: msg,
  remoteId: taskRemoteId,  // ❌ 类型错误
});

正确示例:

// 参数类型支持 remoteId
export function failLLMApiLog(
  logId: string,
  params: {
    httpStatus?: number;
    duration: number;
    errorMessage: string;
    responseBody?: string;
    remoteId?: string;  // ✅ 支持异步任务 ID
  }
): void {
  const log = memoryLogs.find(l => l.id === logId);
  if (log) {
    // ... 其他字段
    if (params.remoteId) log.remoteId = params.remoteId;  // ✅ 记录 remoteId
    updateLogInDB(log);
  }
}

// completeLLMApiLog 同样需要支持
export function completeLLMApiLog(
  logId: string,
  params: {
    // ... 其他字段
    remoteId?: string;  // ✅ 成功时也记录 remoteId
  }
): void { ... }

原因:

  • 异步任务通过 remoteId 标识后端任务,是恢复轮询的关键信息
  • 调试时需要通过 remoteId 追踪任务在后端的状态
  • LLMApiLog 接口已定义 remoteId 字段,日志函数参数也需要支持

Service Worker 静态资源回退应尝试所有版本缓存

场景: 用户使用旧版本 HTML但服务器已部署新版本删除了旧静态资源

错误示例:

// 错误:只尝试当前版本缓存,服务器 404 时直接返回错误
const cachedResponse = await cache.match(request);
if (cachedResponse) {
  return cachedResponse;
}

const response = await fetch(request);
if (!response.ok) {
  return new Response('Not found', { status: 404 });
}

正确示例:

// 正确:服务器返回 4xx/5xx 或 HTML 回退时,尝试所有版本缓存
const response = await fetch(request);

// 检测服务器返回 HTML 错误页面SPA 404 回退)
const contentType = response.headers.get('Content-Type');
const isHtmlFallback = response.ok && contentType?.includes('text/html') && 
  request.destination === 'script';

// 服务器错误或 HTML 回退时,尝试旧版本缓存
if (response.status >= 400 || isHtmlFallback) {
  const allCacheNames = await caches.keys();
  for (const cacheName of allCacheNames) {
    if (cacheName.startsWith('drawnix-static-v')) {
      const oldCache = await caches.open(cacheName);
      const oldResponse = await oldCache.match(request);
      if (oldResponse) {
        console.log(`Found resource in ${cacheName}`);
        return oldResponse;
      }
    }
  }
}

原因:

  • 用户可能缓存了旧版本 HTML但新部署删除了旧静态资源
  • 旧 HTML 请求旧资源,服务器返回 404 或 HTML 错误页面
  • 尝试旧版本缓存可以找到用户需要的资源,避免白屏
  • 这是 PWA 的重要容错机制,确保版本升级平滑过渡

图像处理工具复用规范

场景: 需要对图片进行边框检测、去白边、裁剪等处理时

核心工具文件: utils/image-border-utils.ts

可用的公共方法:

方法 用途 返回值
trimCanvasWhiteAndTransparentBorder 去除 Canvas 白边和透明边 HTMLCanvasElement
trimCanvasWhiteAndTransparentBorderWithInfo 去除边框并返回偏移信息 { canvas, left, top, trimmedWidth, trimmedHeight, wasTrimmed }
trimImageWhiteAndTransparentBorder 去除图片 URL 的白边 Promise<string> (data URL)
trimCanvasBorders 去除 Canvas 边框(灰色+白色) HTMLCanvasElement | null
removeWhiteBorder 去除图片白边(激进模式) Promise<string>

错误示例:

// 错误:在组件中重复实现去白边逻辑
const trimWhiteBorder = (canvas: HTMLCanvasElement) => {
  const ctx = canvas.getContext('2d');
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  // ... 50+ 行重复代码
};

正确示例:

// 正确:使用公共工具方法
import { 
  trimCanvasWhiteAndTransparentBorder,
  trimCanvasWhiteAndTransparentBorderWithInfo,
  trimImageWhiteAndTransparentBorder 
} from '../utils/image-border-utils';

// 只需要裁剪后的 Canvas
const trimmedCanvas = trimCanvasWhiteAndTransparentBorder(canvas);

// 需要知道裁剪偏移量(如计算插入位置)
const { canvas: trimmedCanvas, left, top } = trimCanvasWhiteAndTransparentBorderWithInfo(canvas);

// 处理图片 URL
const trimmedUrl = await trimImageWhiteAndTransparentBorder(imageDataUrl);

使用场景:

  • 合并图片后去白边 → trimCanvasWhiteAndTransparentBorderWithInfo(需要偏移量)
  • 生成预览图去白边 → trimImageWhiteAndTransparentBorder(异步处理 URL
  • 图片分割时去边框 → trimCanvasBorders(检测灰色+白色)

原因: 图像处理逻辑(像素遍历、边界检测)容易出错且代码量大。使用统一的公共方法可以:

  1. 避免重复代码
  2. 确保一致的处理行为
  3. 便于统一优化和修复 bug

SSH 远程执行复杂 Shell 命令应使用 base64 编码

场景: 通过 SSH 在远程服务器执行包含引号、变量替换等复杂 shell 脚本时

错误示例:

// 错误:多层引号转义导致 shell 语法错误
const remoteCommand = `bash -c '
  VERSION=$(tar -xzf ${uploadsDir}/${tarName} -O web/version.json 2>/dev/null | grep '"'"'"version"'"'"' | sed '"'"'s/.*"version": "\\([^"]*\\)".*/\1/'"'"')
  if [ -z "$VERSION" ]; then
    echo "无法读取版本号"
    exit 1
  fi
  // ... 更多命令
'`;
// 错误:/bin/sh: -c: line 1: unexpected EOF while looking for matching `)'

正确示例:

// 正确:使用 base64 编码避免引号转义问题
const extractScript = `VERSION=$(tar -xzf ${uploadsDir}/${tarName} -O web/version.json 2>/dev/null | grep '"version"' | sed 's/.*"version": "\\([^"]*\\)".*/\\1/')
if [ -z "$VERSION" ]; then
  echo "无法读取版本号"
  exit 1
fi
// ... 更多命令`;

// 将脚本编码为 base64避免引号转义问题
const encodedScript = Buffer.from(extractScript).toString('base64');
const remoteCommand = `echo ${encodedScript} | base64 -d | bash`;

sshCommand += ` ${config.DEPLOY_USER}@${config.DEPLOY_HOST} "${remoteCommand}"`;

原因:

  • SSH 命令需要经过多层引号转义Node.js 字符串 → SSH 命令 → shell 执行),复杂的引号嵌套容易导致语法错误
  • base64 编码将脚本转换为纯 ASCII 字符串,避免了所有引号转义问题
  • 远程服务器通过 base64 -d 解码后执行,保持脚本原始格式

适用场景:

  • 通过 SSH 执行多行 shell 脚本
  • 脚本中包含引号、变量替换、管道等复杂语法
  • 需要避免引号转义导致的语法错误

验证命令

修改代码后必须执行以下验证命令:

# 类型检查 (以 drawnix 为例)
cd packages/drawnix && npx tsc --noEmit
# 代码规范
pnpm nx lint drawnix
# 单元测试
pnpm nx test drawnix
# 构建验证
pnpm run build

CSS !important 覆盖 JavaScript 动态样式

场景: 需要通过 JavaScript 动态设置元素样式(如光标、颜色、尺寸),但 CSS 中存在 !important 规则

错误示例:

// SCSS 中使用 !important 固定光标样式
.plait-board-container {
  &.pointer-eraser {
    .board-host-svg {
      cursor: url('data:image/svg+xml;base64,...') 10 10, crosshair !important;
    }
  }
}
// JavaScript 动态设置光标被 CSS !important 覆盖,无效
function applyCursorStyle(board: PlaitBoard, size: number) {
  const hostSvg = document.querySelector('.board-host-svg');
  hostSvg.style.cursor = generateCursorSvg(size); // 被 !important 覆盖!
}

正确示例:

// SCSS 中不使用 !important或完全移除静态规则
.plait-board-container {
  // 光标由 JavaScript 动态设置usePencilCursor hook
  // 不再使用固定大小的 CSS 光标
}
// JavaScript 动态设置光标正常生效
function applyCursorStyle(board: PlaitBoard, size: number) {
  const hostSvg = document.querySelector('.board-host-svg');
  hostSvg.style.cursor = generateCursorSvg(size); // 正常生效
}

原因: CSS 的 !important 规则优先级高于 JavaScript 设置的内联样式。当需要动态控制样式时(如根据用户设置调整光标大小),必须移除 CSS 中的 !important 规则,否则 JavaScript 的样式设置会被完全覆盖。

检查方法: 如果 JavaScript 设置的样式不生效,在浏览器开发者工具中检查元素样式,查看是否有 !important 规则覆盖。

Freehand 元素属性设置需要自定义 callback

场景: 修改 Freehand手绘线条元素的属性如 strokeStyle、strokeColor

错误示例:

// 错误:直接使用 PropertyTransformsFreehand 元素可能不被正确处理
const setStrokeStyle = (style: StrokeStyle) => {
  PropertyTransforms.setStrokeStyle(board, style, { getMemorizeKey });
};

正确示例:

// 正确:使用 callback 确保所有选中元素都被处理
export const setStrokeStyle = (board: PlaitBoard, strokeStyle: StrokeStyle) => {
  PropertyTransforms.setStrokeStyle(board, strokeStyle, {
    getMemorizeKey,
    callback: (element: PlaitElement, path: Path) => {
      Transforms.setNode(board, { strokeStyle }, path);
    },
  });
};

原因: PropertyTransforms 的默认行为可能不会处理所有类型的元素(如自定义的 Freehand 元素)。通过提供 callback 函数,可以确保对所有选中的元素执行属性设置操作。颜色设置(setStrokeColorsetFillColor)也使用了相同的模式。

错误 3: 第三方窗口/弹窗组件破坏 React 事件委托

场景: 使用 WinBox.js 或其他直接操作 DOM 的第三方窗口库包装 React 组件时

错误示例:

// 错误:使用 mount 选项将 React 渲染的 DOM 移动到外部,会破坏 React 的事件冒泡链
new WinBox({
  mount: containerRef.current, // 导致 onClick/onDoubleClick 无响应
  // ...
});

正确示例:

// 正确:使用 React.createPortal 将内容渲染到第三方组件提供的 DOM 容器中
const WinBoxWindow = ({ children }) => {
  const [contentRef, setContentRef] = useState<HTMLElement | null>(null);
  
  useEffect(() => {
    const winbox = new WinBox({
      oncreate: () => {
        setContentRef(winbox.body); // 获取 WinBox 提供的容器
      }
    });
  }, []);

  return contentRef ? createPortal(children, contentRef) : null;
};

原因: React 使用事件委托机制在 root 节点监听事件。如果第三方库通过 appendChild 等原生 API 将 DOM 节点移出 React 的 root 树,事件将无法正常冒泡到 React 的监听器。createPortal 允许在物理上移动 DOM 的同时,在逻辑上保持 React 的组件树和事件流完整。

错误 4: 筛选逻辑中“全部”选项处理不当

场景: 实现带有“全部ALL”选项的多重过滤逻辑时

错误示例:

// 错误:未处理 undefined 情况,导致多条件组合时结果意外为空
const matchesType = filters.activeType === 'ALL' || asset.type === filters.activeType;
// 如果 activeType 是 undefined (初始状态)(undefined === 'ALL') 为 false逻辑失效

正确示例:

// 正确:显式处理 undefined 和 'ALL',确保逻辑鲁棒
const matchesType = 
  !filters.activeType || 
  filters.activeType === 'ALL' || 
  asset.type === filters.activeType;

原因: 初始状态或重置状态下,筛选变量可能是 undefinednull。在进行比较前必须先进行存在性检查,否则会导致筛选结果不符合预期(通常表现为只有单独筛选有效,组合筛选失效)。

错误 5: 动态缩放网格布局出现间隙或重叠

场景: 实现支持用户调整元素显示尺寸(放大/缩小)的网格列表时

错误示例:

// 错误:使用 Flex 布局配合动态计算的百分比宽度,容易产生像素计算偏差
.grid-row {
  display: flex;
  .item {
    width: 18.523%; // 计算出的宽度,容易在右侧留下缝隙
  }
}

正确示例:

// 正确:使用 CSS Grid 布局配合 1fr确保完美平铺和对齐
.grid-row {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); // 或动态设置列数
  gap: 16px;
  .item {
    width: 100%;
    height: 100%;
  }
}

原因: Flex 布局在处理非整数像素的列宽时浏览器舍入误差会导致右侧出现白边或元素重叠。CSS Grid 的 1fr 单元由浏览器底层引擎处理自动分配,能确保每一列都精准对齐容器边界,尤其适合需要频繁变动尺寸的素材预览场景。

错误 6: UI 元素高度不统一导致视觉错位

场景: 搜索框、按钮、已选计数条等多个组件并排排列时

错误示例:

.search-input { height: 36px; }
.action-button { height: 32px; }
// 导致并排排列时基准线不齐,视觉凌乱

正确示例:

// 正确:统一锁定核心高度(如 32px并在组件库样式上使用 !important 覆盖
.t-input { height: 32px !important; }
.t-button { height: 32px !important; }
.counter-tag { height: 32px; display: flex; align-items: center; }

原因: “素雅”和“专业”感来自于严格的视觉对齐。在紧凑的工具栏布局中,即便只有 2-4px 的高度差也会被用户感知。应选定一个标准高度并强制执行,消除视觉噪音。

错误 7: 后台清理任务过度记录日志

场景: Service Worker 或后台定时器定期清理过期日志、缓存或任务时

错误示例:

// 错误:逐条记录清理项,导致控制台瞬间被淹没
expiredLogs.forEach(log => console.log(`Deleted expired log: ${log.id}`));

正确示例:

// 正确:仅记录清理结果摘要
if (deletedCount > 0) {
  // console.log(`Service Worker: 清理了 ${deletedCount} 条过期控制台日志`);
}

原因: 后台任务通常是用户无感知的,过度记录调试信息会干扰正常开发。应汇总结果并优先使用分级日志(推荐注释掉或仅在调试模式显示)。

错误 8: 点击外部关闭下拉菜单使用透明遮罩层

场景: 实现自定义下拉菜单、弹出面板等需要"点击外部关闭"功能时

错误示例:

// 错误:使用透明遮罩层检测点击,在复杂 z-index 场景下会失效
{isOpen && (
  <>
    <div 
      className="dropdown-overlay"  // position: fixed; z-index: 999
      onClick={() => setIsOpen(false)}
    />
    <div className="dropdown-menu" style={{ zIndex: 1000 }}>
      {/* 菜单内容 */}
    </div>
  </>
)}
// 问题:页面上其他高 z-index 元素(工具栏、弹窗等)会遮挡遮罩层,
// 导致点击这些区域无法触发关闭

正确示例:

// 正确:使用全局 document 事件监听,不受 z-index 影响
useEffect(() => {
  if (!isOpen) return;

  const handleClickOutside = (e: MouseEvent) => {
    const target = e.target as HTMLElement;
    // 检查点击是否在下拉组件内部
    if (target.closest('.dropdown-menu')) return;
    // 点击在外部,关闭下拉
    setIsOpen(false);
  };

  // 使用 mousedown 响应更快
  document.addEventListener('mousedown', handleClickOutside);
  return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);

// 组件只渲染下拉菜单,无需遮罩层
{isOpen && (
  <div className="dropdown-menu">
    {/* 菜单内容 */}
  </div>
)}

原因: 透明遮罩层方案依赖正确的 z-index 层级,在有多个浮层组件的复杂页面中容易失效。全局 document 事件监听在事件捕获阶段工作,不受 DOM 层级和 z-index 影响,是更可靠的方案。同时代码也更简洁,无需维护额外的遮罩层元素和样式。

错误 9: 传递给第三方库的回调无法获取最新 React state

场景: 将 useCallback 创建的回调函数传递给第三方库(如 WinBox 的 addControl、图表库的事件处理器等)时

错误示例:

// 错误:回调中直接使用 state第三方库保存的是旧回调引用
const [splitSide, setSplitSide] = useState<'left' | 'right' | null>(null);

const handleSplit = useCallback(() => {
  // splitSide 永远是创建回调时的值(通常是初始值 null
  if (splitSide === 'right') {
    doSomething(); // 永远不会执行!
  }
}, [splitSide]); // 即使加了依赖,第三方库保存的仍是旧回调

useEffect(() => {
  winbox.addControl({ click: handleSplit }); // WinBox 保存了这个引用
}, []);

正确示例:

// 正确:使用 ref 保存状态,回调中读取 ref.current 获取最新值
const [splitSide, _setSplitSide] = useState<'left' | 'right' | null>(null);
const splitSideRef = useRef<'left' | 'right' | null>(null);

// 同步更新 state 和 ref
const setSplitSide = useCallback((side: 'left' | 'right' | null) => {
  _setSplitSide(side);
  splitSideRef.current = side;
}, []);

const handleSplit = useCallback(() => {
  // 使用 ref 获取最新值
  const currentSplitSide = splitSideRef.current;
  if (currentSplitSide === 'right') {
    doSomething(); // 正确执行
  }
}, []); // 依赖数组可以为空,因为读取的是 ref

  useEffect(() => {
    winbox.addControl({ click: handleSplit });
  }, []);

原因: 第三方库(如 WinBox、ECharts、D3 等)在初始化时保存回调函数的引用,之后不会自动更新。当 React 重新渲染创建新的 useCallback 实例时,第三方库内部保存的仍然是旧引用。旧回调中的闭包捕获的是创建时的 state 值,导致永远获取不到最新状态。使用 useRef 保存状态可以绕过闭包问题,因为 ref 对象本身不变,只是 .current 属性的值在变化。

错误 10: 独立的 React 树缺少上下文环境

场景: 在使用 createRootrender 手动挂载组件(如画布元素 ToolGeneratorWorkZone 或第三方窗口内部)时

错误示例:

// 错误:直接渲染组件,导致新 React 树与主应用树脱节,无法访问全局 Context
const root = createRoot(container);
root.render(<MyComponent />);
// 报错Uncaught Error: useI18n must be used within I18nProvider

正确示例:

// 正确:使用项目提供的提供者包装器,重新注入必要的上下文
import { ToolProviderWrapper } from '../toolbox-drawer/ToolProviderWrapper';

const root = createRoot(container);
root.render(
  <ToolProviderWrapper board={board}>
    <MyComponent />
  </ToolProviderWrapper>
);

原因: 独立的 React 树不会继承父级树的 Context。在 Opentu 中,画布元素是通过 SVG foreignObject 独立挂载的,必须通过 ToolProviderWrapper 显式重新提供 I18nProviderAssetProviderWorkflowProviderDrawnixContext 等核心上下文,才能保证内部组件功能正常。

错误 11: 获取第三方组件位置使用其内部属性而非 DOM API

场景: 需要获取第三方弹窗/组件的屏幕位置进行坐标转换时(如 WinBox、Modal 等)

错误示例:

// 错误:使用 WinBox 的内部属性,可能与实际视口坐标不一致
const wb = winboxRef.current;
const rect = {
  x: wb.x,      // 可能是相对于 root 容器的坐标
  y: wb.y,      // 不一定等于视口坐标
  width: wb.width,
  height: wb.height,
};
// 与 getBoundingClientRect() 的坐标系不匹配,导致位置计算偏差

正确示例:

// 正确:使用 DOM 的 getBoundingClientRect() 获取准确的视口坐标
const wbWindow = wb.window as HTMLElement;
const domRect = wbWindow.getBoundingClientRect();
const rect = {
  x: domRect.left,   // 相对于视口的 X 坐标
  y: domRect.top,    // 相对于视口的 Y 坐标
  width: domRect.width,
  height: domRect.height,
};
// 与其他元素的 getBoundingClientRect() 使用相同坐标系,计算准确

原因: 第三方组件库(如 WinBox、Dialog 等)的内部位置属性可能使用不同的坐标系统(相对于 root 容器、相对于父元素等),与浏览器的视口坐标不一致。而 getBoundingClientRect() 始终返回元素相对于视口的准确位置,是进行坐标转换的可靠来源。当需要将一个元素的位置映射到另一个坐标系(如画布坐标)时,应统一使用 getBoundingClientRect() 获取两者的视口坐标,再进行转换。

错误 12: CustomEvent 传递硬编码占位符而非实际值

场景: 使用 CustomEvent 在组件/模块间传递数据时

错误示例:

// 错误使用硬编码占位符UI 会显示 "vnew" 而非实际版本号
window.dispatchEvent(new CustomEvent('sw-update-available', { 
  detail: { version: 'new' }  // ❌ 硬编码的占位符
}));

// 结果UI 显示 "新版本 vnew 已就绪"

正确示例:

// 正确:先获取实际值再传递
fetch(`/version.json?t=${Date.now()}`)
  .then(res => res.ok ? res.json() : null)
  .then(data => {
    window.dispatchEvent(new CustomEvent('sw-update-available', { 
      detail: { version: data?.version || 'unknown' }  // ✅ 实际版本号
    }));
  })
  .catch(() => {
    window.dispatchEvent(new CustomEvent('sw-update-available', { 
      detail: { version: 'unknown' }  // ✅ 明确的回退值
    }));
  });

// 结果UI 显示 "新版本 v0.5.35 已就绪"

原因: CustomEvent 的 detail 数据会直接被消费者使用。如果传递硬编码的占位符(如 'new''loading'),接收方无法区分这是占位符还是真实数据,导致 UI 显示错误。应该先获取实际数据再发送事件,或使用明确的回退值(如 'unknown')并在 UI 中特殊处理。

错误 13: 嵌套滚动容器中 scroll 事件绑定错误的元素

场景: 在有多层可滚动容器(如 SideDrawer > VirtualTaskList的嵌套布局中实现滚动相关功能如回到顶部按钮

错误示例:

// 错误:直接在组件自身的容器上监听 scroll但实际滚动可能发生在外层容器
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const container = containerRef.current;
  container?.addEventListener('scroll', handleScroll); // 可能永远不触发!
}, []);

// 问题:
// - 外层容器 (.side-drawer__content) 设置了 overflow-y: auto
// - 内层容器 (.virtual-task-list-scrollarea) 也设置了 overflow: auto
// - 实际滚动可能发生在外层,内层的 scroll 事件永远不触发

正确示例:

// 正确:向上查找实际的滚动容器
const findScrollContainer = (element: HTMLElement | null): HTMLElement | null => {
  let current = element;
  while (current) {
    const style = getComputedStyle(current);
    const overflowY = style.overflowY;
    if ((overflowY === 'auto' || overflowY === 'scroll') && 
        current.scrollHeight > current.clientHeight) {
      return current;
    }
    current = current.parentElement;
  }
  return null;
};

useEffect(() => {
  const container = containerRef.current;
  const actualScrollContainer = findScrollContainer(container);
  actualScrollContainer?.addEventListener('scroll', handleScroll); // ✅ 正确的容器
}, []);

原因: 当存在嵌套的 overflow: auto/scroll 容器时,滚动行为取决于哪个容器的内容先溢出。如果外层容器先溢出,滚动事件会在外层触发,内层容器上的监听器永远不会被调用。必须动态查找实际发生滚动的容器。

错误 14: 在动态高度容器中使用 absolute 定位固定元素

场景: 在可滚动列表底部放置固定按钮(如回到顶部、加载更多)

错误示例:

// 错误:使用 position: absolute按钮会被推到内容底部产生空白
<div style={{ height: '100%', position: 'relative' }}>
  <div style={{ overflow: 'auto' }}>
    {/* 长列表内容 */}
  </div>
  <Button 
    style={{ position: 'absolute', bottom: 16 }} // ❌ 会被推到内容底部!
  />
</div>
// 问题:如果父容器的 height: 100% 没有生效(嵌套 flex 布局常见),
// 容器高度会被内容撑开,按钮定位在这个很高的容器底部,产生大量空白

正确示例:

// 正确:使用 position: fixed + 动态计算位置
const [buttonPosition, setButtonPosition] = useState<{ left: number; bottom: number } | null>(null);

const updateButtonPosition = (scrollContainer: HTMLElement) => {
  const rect = scrollContainer.getBoundingClientRect();
  setButtonPosition({
    left: rect.left + scrollContainer.clientWidth / 2,
    bottom: window.innerHeight - rect.bottom + 16,
  });
};

<Button 
  style={{
    position: 'fixed',
    left: buttonPosition.left,
    bottom: buttonPosition.bottom,
    transform: 'translateX(-50%)',
  }}
/>

原因: position: absolute; bottom: X 是相对于最近的定位父元素的底部。如果该父元素的高度被内容撑开(而非受限于视口),按钮会出现在内容底部而非视口底部。使用 position: fixed 相对于视口定位,配合动态计算可以正确放置按钮。

错误 15: ResizeObserver 监听错误的容器宽度

场景: 在嵌套组件(如弹窗内的任务列表)中使用 ResizeObserver 检测宽度以切换响应式布局

错误示例:

// 错误:只监听组件自身容器,但实际宽度由外层容器决定
useEffect(() => {
  const container = containerRef.current;
  const resizeObserver = new ResizeObserver((entries) => {
    const width = entries[0].contentRect.width;
    setIsCompact(width < 500); // 可能检测到错误的宽度!
  });
  resizeObserver.observe(container);
}, []);
// 问题:组件容器可能设置了 flex: 1 或 100%
// 实际宽度由外层的抽屉/弹窗决定,应该监听外层容器

正确示例:

// 正确:查找并监听正确的父容器
useEffect(() => {
  const container = containerRef.current;
  if (!container) return;

  // 查找合适的父容器(优先级:抽屉 > 弹窗 > 自身)
  const drawerContent = container.closest('.side-drawer__content') as HTMLElement;
  const dialogTaskList = container.closest('.dialog-task-list') as HTMLElement;
  const dialogBody = container.closest('.t-dialog__body') as HTMLElement;
  const targetElement = drawerContent || dialogTaskList || dialogBody || container;

  const resizeObserver = new ResizeObserver((entries) => {
    const width = entries[0].contentRect.width;
    setIsCompact(width < 500); // ✅ 监听正确的容器
  });
  resizeObserver.observe(targetElement);
}, []);

原因: 在嵌套布局中,组件自身的容器宽度可能由 CSSflex: 1width: 100%动态计算。ResizeObserver 应该监听决定实际可视宽度的外层容器(如抽屉内容区域、弹窗主体),才能正确触发响应式布局切换。


错误 16: 垂直按钮列表中的 Tooltip 遮挡相邻元素

场景: 当多个按钮垂直紧密排列时默认的上方气泡Tooltip会遮挡上方的按钮。

错误示例:

<div className="actions-list">
  <Tooltip content="操作1">
    <Button icon={<Icon1 />} />
  </Tooltip>
  <Tooltip content="操作2">
    <Button icon={<Icon2 />} />
  </Tooltip>
</div>

正确示例:

<div className="actions-list">
  <Tooltip content="操作1" placement="left" theme="light">
    <Button icon={<Icon1 />} />
  </Tooltip>
  <Tooltip content="操作2" placement="left" theme="light">
    <Button icon={<Icon2 />} />
  </Tooltip>
</div>

原因: 默认的 top 弹出位置会覆盖紧邻上方的交互元素。改用 leftright 弹出可以避开按钮排列轴向,确保所有按钮都可被顺畅点击。

错误 17: 基于过滤结果动态生成筛选按钮

场景: 实现分类筛选功能时,分类列表不应随当前选择而缩小。

错误示例:

// 错误:分类按钮列表随 filteredTools 变化
const categories = useMemo(() => {
  return Array.from(new Set(filteredTools.map(t => t.category)));
}, [filteredTools]);

正确示例:

// 正确:分类按钮始终包含所有可用选项
const allCategories = useMemo(() => {
  return Array.from(new Set(allAvailableTools.map(t => t.category)));
}, [allAvailableTools]);

原因: 如果筛选按钮是根据当前显示的结果动态生成的,一旦用户选定了一个分类,其它分类按钮就会因为结果中不存在而消失,导致用户无法直接切换到其它分类。

错误 18: 抽屉组件 z-index 硬编码过低导致被工具栏遮挡

场景: 侧边抽屉开启后,左边缘被工具栏覆盖,导致内边距看起来不对称或部分内容不可见。

错误示例:

<BaseDrawer zIndex={12} ... /> // z-index 太低,会被 2000 级的工具栏挡住

正确示例:

<BaseDrawer ... /> // 使用默认规范定义的 z-index (4030)

原因: 项目中的 z-index 有严格的分层规范(参见 docs/Z_INDEX_GUIDE.md)。工具栏位于 2000 层,而抽屉应该位于 4000 层及以上。硬编码低层级会破坏预留宽度的视觉预期。

错误 19: 初始化时重要元素不可见未自动滚动

场景: 在高度受限的可滚动容器中,重要的操作按钮(如 AI 生成按钮)可能位于可视区域外,用户不知道功能存在。

错误示例:

// 错误:不检查重要元素是否可见
const ToolbarContainer = () => {
  return (
    <div className="scrollable-toolbar">
      <HandButton />
      <SelectButton />
      {/* ... 更多按钮 */}
      <AIImageButton /> {/* 屏幕小时可能不可见 */}
      <AIVideoButton />
    </div>
  );
};

正确示例:

const ToolbarContainer = () => {
  const scrollableRef = useRef<HTMLDivElement>(null);
  const hasScrolledToAI = useRef(false); // 防止重复执行

  useEffect(() => {
    // 只执行一次,避免死循环
    if (hasScrolledToAI.current) return;
    
    const scrollable = scrollableRef.current;
    if (!scrollable) return;

    const checkAndScroll = () => {
      hasScrolledToAI.current = true; // 标记为已执行
      
      // 查找目标按钮
      const targetButton = scrollable.querySelector<HTMLElement>('[data-button-id="ai-image"]');
      if (!targetButton) return;

      // 检测是否可见
      const scrollableRect = scrollable.getBoundingClientRect();
      const buttonRect = targetButton.getBoundingClientRect();
      const isVisible = buttonRect.bottom <= scrollableRect.bottom && 
                        buttonRect.top >= scrollableRect.top;

      // 不可见时滚动(检查高度 > 0 避免极端情况)
      if (!isVisible && scrollableRect.height > 0) {
        scrollable.scrollTop += buttonRect.top - scrollableRect.top;
      }
    };

    // 延迟执行,确保 DOM 渲染完成
    const timeoutId = setTimeout(() => {
      requestAnimationFrame(checkAndScroll);
    }, 100);

    return () => clearTimeout(timeoutId);
  }, []);

  return (
    <div ref={scrollableRef} className="scrollable-toolbar">
      {/* 按钮需要添加 data-button-id 属性以便定位 */}
      <HandButton />
      <SelectButton />
      <AIImageButton data-button-id="ai-image" />
      <AIVideoButton data-button-id="ai-video" />
    </div>
  );
};

防止死循环的关键点:

  1. 使用 ref 标志确保只执行一次
  2. 在执行前立即设置标志,而非执行后
  3. 检查容器高度 > 0避免容器未渲染时的极端情况
  4. 不在滚动失败时重试

原因: 屏幕尺寸多样化,重要功能按钮可能因容器高度不足而不可见。初始化时自动滚动到这些元素可以提升功能发现率,但必须确保只执行一次以避免死循环。

交互规范: 三段式循环排序模式

场景: 实现包含正序、逆序且需要支持恢复默认状态的排序按钮时。

错误示例: 使用多个独立按钮分别代表正序和逆序,或者简单的二段式切换(无法方便地回到默认排序)。

正确示例: 使用单个按钮循环切换:正序 -> 逆序 -> 恢复默认排序(如日期降序)

const handleSortClick = () => {
  if (currentSort === group.options.asc) {
    setFilters({ sortBy: group.options.desc }); // 切换到逆序
  } else if (currentSort === group.options.desc) {
    setFilters({ sortBy: 'DATE_DESC' }); // 恢复默认
  } else {
    setFilters({ sortBy: group.options.asc }); // 切换到正序
  }
};

原因: 这种模式在节省 UI 空间的同时,能让用户在有限的点击次数内触达所有排序状态,且逻辑闭环。

样式规范: 筛选岛Island组件的间距与对齐

场景: 在紧凑的水平排列筛选组中显示图标和计数标签时。

错误示例:

.filter-option {
  width: 32px; // 固定宽度在数字变长时会溢出
  .count {
    position: absolute; // 绝对定位容易导致与图标重叠
    right: 2px;
  }
}

正确示例:

.filter-option {
  padding: 0 8px; // 使用 padding 适应不同宽度的数字
  display: flex;
  align-items: center;
  gap: 4px; // 为图标和计数预留固定间距
  .count {
    font-size: 11px; // 保持精致感
  }
}

原因: 筛选组通常包含数量反馈个位数和多位数占用的空间不同。使用弹性布局Flex + gap能确保在任何数据状态下 UI 都是对齐且易读的。

UI 设计原则: 以影代框Shadows Over Borders

场景: 为面板、卡片或容器定义边界时。

错误示例:

.container {
  border: 1px solid var(--td-component-border);
}

正确示例:

.container {
  border: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}

原因: 过多的物理线条Border会增加页面的视觉复杂度和“噪音”产生生硬的切割感。改用弥散的弱阴影Shadow可以自然地体现层级关系使界面显得更加通透、轻量且具有现代感。

样式规范: 消除容器与首个子元素边距叠加

场景: 容器有内边距Padding且首个子元素有上外边距Margin-top会导致顶部间距过大。

错误示例:

.container { padding: 16px; }
.title { margin-top: 16px; } // 结果顶部间距变成了 32px

正确示例:

.container { padding: 16px; }
.title { margin-top: 16px; }
.title:first-child { margin-top: 0; } // 或在容器内指定首个标题 margin-top: 4px

原因: 避免 Padding 与 Margin 的视觉累加,确保界面排版符合设计的网格预期。

UI 设计原则: 批量操作按钮视觉层级

场景: 设计包含多个操作按钮的批量操作栏(如任务队列、文件管理器)时。

错误示例:

// 所有按钮都使用主题色,视觉噪音过大
<div className="batch-actions">
  <Button theme="primary">重试 (3)</Button>
  <Button theme="primary">删除 (6)</Button>
  <Button theme="primary">导出 (6)</Button>
</div>

正确示例:

// 主要正向操作使用主题色,危险/次要操作弱化
<div className="batch-actions">
  <Button theme="primary">重试 (3)</Button>           {/* 主要正向操作 - 主题色实心 */}
  <Button variant="text" theme="default">删除 (6)</Button>  {/* 危险操作 - 弱化为文字按钮 */}
</div>

原因:

  • 过多的主题色按钮会造成视觉疲劳,用户难以快速识别主要操作
  • 危险操作(如删除)应该弱化,避免用户误操作
  • 按钮层级建议:主要正向操作 > 次要操作 > 危险操作
  • 视觉层级:theme="primary" 实心 > variant="outline" 描边 > variant="text" 文字

性能指南

  • 使用 React.lazy 对大型组件进行代码分割
  • 对图片实现懒加载和预加载
  • 避免在 render 中创建新对象/函数
  • 对长列表考虑使用虚拟化

CPU 密集型循环需要 yield 让出主线程

场景: 在异步函数中执行大量同步操作JSON.stringify、加密、checksum 计算等)时,即使函数是 async 的,循环内部如果没有 await 点,仍然会连续占用主线程,导致页面卡顿。

错误示例:

// 错误:虽然是 async 函数,但循环内部没有 yield 点
async function pushToRemote() {
  // 收集数据
  const localData = await collectSyncData();
  
  // 这个循环会连续执行,阻塞主线程
  for (const [boardId, board] of localData.boards) {
    const boardJson = JSON.stringify(board); // 同步操作
    const encrypted = await cryptoService.encrypt(boardJson); // 虽然有 await但每次 await 之间的 JSON.stringify 会累积
    filesToUpdate[boardId] = encrypted;
  }
  // 用户可能看到页面卡顿、事件处理器超时警告
}

正确示例:

import { yieldToMain } from '@aitu/utils';

async function pushToRemote() {
  const localData = await collectSyncData();
  
  let processedCount = 0;
  for (const [boardId, board] of localData.boards) {
    const boardJson = JSON.stringify(board);
    const encrypted = await cryptoService.encrypt(boardJson);
    filesToUpdate[boardId] = encrypted;
    
    // 每处理 3 个画板让出主线程,避免 UI 阻塞
    processedCount++;
    if (processedCount % 3 === 0) {
      await yieldToMain();
    }
  }
}

// yieldToMain 的实现:
export const yieldToMain = (): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, 0));
};

原因:

  • JavaScript 是单线程的async/await 只是让代码看起来同步,但仍然在主线程执行
  • 大量 JSON.stringify()、加密计算等同步操作会连续占用主线程
  • 浏览器无法处理用户输入、渲染更新,导致 [Violation] 'mouseup' handler took 606ms 等警告
  • yieldToMain() 通过 setTimeout(0) 将后续执行推迟到下一个事件循环,让浏览器有机会处理待处理事件

最佳实践:

  • 每 3-5 个耗时操作后调用一次 await yieldToMain()
  • 可以使用 @aitu/utils 中的 processBatched() 函数自动处理分批
  • 适用场景:同步、备份、批量导入导出、大量数据处理

参考文件:

  • packages/utils/src/async/index.ts - yieldToMainprocessBatched 工具函数
  • packages/drawnix/src/services/github-sync/sync-engine.ts - 同步引擎中的使用示例

预缓存配置规范

场景: 使用 Service Worker 预缓存静态资源时,需要合理配置排除列表

错误示例:

// vite.config.ts - 错误:没有排除调试工具和大型资源
function precacheManifestPlugin(): Plugin {
  const EXCLUDE_PATTERNS = [
    /stats\.html$/,
    /\.map$/,
  ];
  // 扫描所有目录,包括 sw-debug/、product_showcase/ 等
  scanDir(outDir);  // ❌ 会将所有文件加入预缓存
}

正确示例:

// vite.config.ts - 正确:明确排除非核心资源
function precacheManifestPlugin(): Plugin {
  const EXCLUDE_PATTERNS = [
    /stats\.html$/,
    /\.map$/,
    /sw-debug\.html$/,  // ✅ 排除调试面板入口
  ];
  
  // 跳过不需要预缓存的目录
  const SKIP_DIRECTORIES = [
    'product_showcase',  // 大型展示资源
    'help_tooltips',     // 帮助提示图片
    'sw-debug',          // 调试面板(仅在访问时加载)
  ];
  
  if (entry.isDirectory()) {
    if (!SKIP_DIRECTORIES.includes(entry.name)) {
      scanDir(fullPath, relativePath);
    }
  }
}

应该排除的资源类型:

  1. 调试工具 - 如 sw-debug/,仅开发/排查时访问
  2. 大型展示资源 - 如 product_showcase/,非核心功能
  3. 帮助/文档资源 - 如 help_tooltips/,按需加载即可
  4. Source Maps - 生产环境不需要预缓存

原因: 预缓存会在主应用启动时由 Service Worker 下载所有列表中的文件。如果包含非核心资源,会:

  • 增加首次加载时间和带宽消耗
  • 占用用户设备存储空间
  • 影响主应用的启动性能和用户体验

统计与监控上报旁路化

场景: 使用 PostHog、Sentry、Web Vitals、Page Report 等统计/监控上报时若在首屏或用户操作路径上同步执行初始化、脱敏、capture会抢主线程与网络导致 LCP/INP 变差,甚至触发 413 等错误。

错误示例:

// 错误:首屏 1s 后同步初始化监控,与主流程抢 CPU
setTimeout(() => {
  initWebVitals();
  initPageReport();
}, 1000);

// 错误:在 track 内同步脱敏 + capture阻塞调用方
track(eventName: string, eventData?: Record<string, any>) {
  const sanitized = sanitizeObject(eventData);
  window.posthog.capture(eventName, sanitized);
}

// 错误:在 Web Vitals 回调栈里直接上报,占用性能回调时间
function reportWebVitals(metric: Metric) {
  analytics.track('$web_vitals', eventProperties); // 同步
}

正确示例:

// 正确:用 requestIdleCallback 在空闲时初始化(无则 setTimeout 兜底)
const scheduleMonitoring = () => {
  if (typeof window.requestIdleCallback === 'function') {
    window.requestIdleCallback(initMonitoring, { timeout: 3000 });
  } else {
    setTimeout(initMonitoring, 3000);
  }
};
scheduleMonitoring();

// 正确:脱敏与 capture 在 requestIdleCallback/setTimeout 中执行,调用方同步返回
track(eventName: string, eventData?: Record<string, any>) {
  const doTrack = () => {
    try {
      const sanitized = eventData ? sanitizeObject(eventData) : undefined;
      window.posthog!.capture(eventName, sanitized);
    } catch (e) {
      console.debug('[Analytics] track failed', e);
    }
  };
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback(doTrack, { timeout: 2000 });
  } else {
    setTimeout(doTrack, 0);
  }
}

// 正确:先延后再上报,不阻塞 CWV 回调栈
function reportWebVitals(metric: Metric) {
  setTimeout(() => analytics.track('$web_vitals', eventProperties), 0);
}

原因:

  • 统计上报是旁路逻辑,不得影响首屏与交互性能
  • 初始化延后到空闲可避免与主流程争抢track 内延后执行脱敏与网络请求,调用方不阻塞
  • 失败只 log如 console.debug不向上 throw避免影响主流程

参考: apps/web/src/main.tsx(统计 initpackages/drawnix/src/utils/posthog-analytics.tstrack 旁路)、packages/drawnix/src/services/web-vitals-service.tsCWV 延后上报)

安全指南

  • 验证和清理所有用户输入
  • 永远不要硬编码敏感信息API keys 等)
  • 对 API 调用使用适当的错误处理
  • 在日志中过滤敏感信息

上报工具敏感信息过滤

场景: 使用 PostHog、Sentry 等上报工具时,需要确保不泄露 API Key 等敏感信息

错误示例:

// Sentry: 启用自动 PII 收集
Sentry.init({
  sendDefaultPii: true, // 会收集 IP 地址等
  // 没有 beforeSend 过滤
});

// PostHog: 直接传递未过滤的数据
window.posthog.capture(eventName, eventData); // eventData 可能包含敏感信息

正确示例:

// Sentry: 禁用 PII添加 beforeSend 过滤
import { sanitizeObject, sanitizeUrl } from '@drawnix/drawnix';

Sentry.init({
  sendDefaultPii: false,
  beforeSend(event) {
    if (event.extra) event.extra = sanitizeObject(event.extra);
    if (event.request?.url) event.request.url = sanitizeUrl(event.request.url);
    return event;
  },
});

// PostHog: 使用 sanitizeObject 过滤敏感字段
const sanitizedData = sanitizeObject(eventData);
window.posthog.capture(eventName, sanitizedData);

敏感字段列表: apikey, api_key, password, token, secret, authorization, bearer, credential

相关工具模块:

  • 主线程: packages/drawnix/src/utils/sanitize-utils.ts
  • Service Worker: apps/web/src/sw/task-queue/utils/sanitize-utils.ts

Console 日志安全打印

场景: 使用 console.error/warn 记录错误时

错误示例:

// 错误:直接打印完整 error 对象,可能包含敏感信息
try {
  await loadConfig();
} catch (error) {
  console.error('Failed to load config:', error); // error 可能包含 API Key
}

正确示例:

import { getSafeErrorMessage } from '@drawnix/drawnix';

try {
  await loadConfig();
} catch (error) {
  // 只记录错误类型,不记录详细信息
  console.error('Failed to load config:', getSafeErrorMessage(error));
}

// getSafeErrorMessage 实现:
function getSafeErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.name || 'Error';
  }
  return 'Unknown error';
}

原因:

  • 错误对象可能包含敏感的请求/响应数据
  • API 错误可能在 message 中包含 API Key 或其他敏感信息
  • 生产环境的 console 日志可能被监控工具收集

敏感信息模板变量安全处理

场景: 工具 URL 或配置中包含敏感信息(如 apiKey

错误示例:

// 错误:在插入画布时就替换模板变量,导致实际 apiKey 被存储
const executeToolInsert = (tool: ToolDefinition) => {
  const { url } = processToolUrl(tool.url); // 替换 ${apiKey} 为实际值
  ToolTransforms.insertTool(board, tool.id, url, ...); // 存储了实际 apiKey
};
// 问题:导出/备份时会泄露敏感信息

正确示例:

// 正确:存储原始模板 URL渲染时才替换
const executeToolInsert = (tool: ToolDefinition) => {
  // 存储原始模板 URL如 https://api.com?key=${apiKey}
  ToolTransforms.insertTool(board, tool.id, tool.url, ...);
};

// 在渲染 iframe 时动态替换
private createIframe(element: PlaitTool): HTMLIFrameElement {
  const { url: processedUrl } = processToolUrl(element.url);
  iframe.src = processedUrl;
  // 保存原始模板 URL用于设置变化时重新替换
  (iframe as any).__templateUrl = element.url;
}

// 监听设置变化,动态刷新 iframe
window.addEventListener('gemini-settings-changed', () => {
  this.refreshTemplateIframes();
});

原因:

  • 敏感信息(如 apiKey应该使用模板变量形式${apiKey})存储在数据中
  • 只在渲染时动态替换为实际值
  • 这样可以确保导出/备份时不会泄露敏感信息
  • 用户更新设置后,已打开的工具可以自动刷新使用新的配置

配置对象返回深拷贝防止意外修改

场景: 设置管理器返回配置对象后,外部代码(如脱敏函数)修改返回值

错误示例:

// 错误getSetting 返回直接引用
class SettingsManager {
  private settings: Record<string, any> = {};
  
  public getSetting<T>(path: string): T {
    const keys = path.split('.');
    let value = this.settings;
    for (const key of keys) {
      value = value?.[key];
    }
    return value as T;  // 返回直接引用!
  }
}

// 使用时:外部代码修改了返回值
const config = settingsManager.getSetting('gemini');
sanitizeObject(config);  // sanitizeObject 修改了 config.apiKey 为 '[REDACTED]'
// 现在全局设置中的 apiKey 也变成了 '[REDACTED]'

正确示例:

// 正确:返回深拷贝
class SettingsManager {
  private settings: Record<string, any> = {};
  
  public getSetting<T>(path: string): T {
    const keys = path.split('.');
    let value = this.settings;
    for (const key of keys) {
      value = value?.[key];
    }
    
    // 返回深拷贝,防止外部代码修改返回值影响原始设置
    if (value && typeof value === 'object') {
      return JSON.parse(JSON.stringify(value)) as T;
    }
    return value as T;
  }
}

// 使用时:外部代码修改的是副本,不影响原始设置
const config = settingsManager.getSetting('gemini');
sanitizeObject(config);  // 修改的是副本
// 全局设置中的 apiKey 保持不变

原因:

  • sanitizeObject 等脱敏函数会修改传入的对象(如将 apiKey 替换为 [REDACTED]
  • 如果返回直接引用,脱敏函数会意外修改全局配置
  • 这会导致后续 API 调用使用被脱敏的值(如 "[REDACTED]""{key}")而失败
  • 返回深拷贝确保全局配置不会被外部代码意外修改

部署脚本安全实践

场景: 创建部署脚本(上传文件、执行远程命令等)时

错误示例:

// 错误:在代码中硬编码密码
const password = 'my-secret-password';
const sshCommand = `sshpass -p "${password}" ssh user@host`;

// 错误:.env 文件未在 .gitignore 中,可能被提交到 Git
// .env 文件包含敏感信息但被提交了

// 错误:使用密码认证,密码会出现在进程列表中
const scpCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" scp ...`;

正确示例:

// 正确:从 .env 文件读取配置(确保 .env 在 .gitignore 中)
const config = loadEnvConfig(); // 从 .env 读取

// 正确:优先使用 SSH 密钥认证
if (config.DEPLOY_SSH_KEY) {
  sshCommand = `ssh -i "${sshKeyPath}" ...`;
} else if (config.DEPLOY_SSH_PASSWORD) {
  // 如果必须使用密码,使用环境变量而不是命令行参数
  process.env.SSHPASS = config.DEPLOY_SSH_PASSWORD;
  sshCommand = 'sshpass -e ssh ...'; // -e 从环境变量读取
}

// 正确:配置免密 sudo而不是在脚本中传递 sudo 密码
// 在服务器上sudo visudo
// 添加username ALL=(ALL) NOPASSWD: /bin/cp, /usr/sbin/nginx

安全最佳实践:

  1. SSH 密钥认证(强烈推荐):

    • 生成密钥对:ssh-keygen -t ed25519
    • 将公钥添加到服务器:ssh-copy-id user@host
    • .env 中配置:DEPLOY_SSH_KEY=~/.ssh/id_ed25519
  2. .env 文件管理

    • 确保 .env.gitignore
    • 创建 .env.example 作为模板(不包含真实密码)
    • 永远不要将 .env 提交到版本控制
  3. Sudo 权限

    • 配置免密 sudo更安全sudo visudo 添加 NOPASSWD 规则
    • ⚠️ 如果必须使用密码,使用 sudo -S 从标准输入读取(但仍不安全)
  4. 密码传递

    • 避免在命令行中传递密码(sshpass -p "password"
    • 使用环境变量:sshpass -eSSHPASS 环境变量读取
    • 优先使用 SSH 密钥,完全避免密码

原因:

  • 密码在命令行参数中会出现在进程列表中(ps aux),容易被其他用户看到
  • .env 文件如果被提交到 Git所有敏感信息都会泄露
  • 使用 SSH 密钥认证更安全,且不需要每次输入密码
  • 免密 sudo 避免了在脚本中存储 sudo 密码的风险

检查清单:

  • .env 文件在 .gitignore
  • 创建了 .env.example 模板文件
  • 脚本中没有硬编码的密码或服务器地址
  • 优先使用 SSH 密钥认证
  • 配置了免密 sudo如果需要

API 轮询与任务恢复规则

场景: 视频生成等需要轮询的 API 调用,以及页面刷新后的任务恢复

错误 1: 轮询时不区分业务失败和网络错误

错误示例:

// 所有错误都重试 - 错误!业务失败不应重试
while (attempts < maxAttempts) {
  try {
    const response = await fetch(`${baseUrl}/videos/${videoId}`);
    const data = await response.json();
    
    if (data.status === 'failed') {
      throw new Error(data.error.message);  // 这个错误会被 catch 重试
    }
  } catch (err) {
    // 所有错误都重试 - 业务失败也会重试!
    consecutiveErrors++;
    await sleep(backoffInterval);
  }
}

正确示例:

// 区分业务失败和网络错误
class VideoGenerationFailedError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'VideoGenerationFailedError';
  }
}

while (attempts < maxAttempts) {
  try {
    const response = await fetch(`${baseUrl}/videos/${videoId}`);
    const data = await response.json();
    
    if (data.status === 'failed') {
      // 使用特殊错误类型,不应重试
      throw new VideoGenerationFailedError(data.error.message);
    }
  } catch (err) {
    // 业务失败直接抛出,不重试
    if (err instanceof VideoGenerationFailedError) {
      throw err;
    }
    // 只有网络错误才重试
    consecutiveErrors++;
    await sleep(backoffInterval);
  }
}

原因: 业务失败(如 generation_failedINVALID_ARGUMENT)是 API 明确返回的错误,重试也不会成功,只会浪费时间。网络错误是临时的,重试可能成功。


错误 2: 页面刷新后自动恢复所有失败任务

错误示例(黑名单 - 太宽松):

// 排除少数已知失败,其余全部恢复 - 错误!
const isDefinitiveFailure = (task: Task): boolean => {
  const msg = task.error?.message?.toLowerCase() || '';
  return msg.includes('prohibited') || msg.includes('content policy');
};
failedTasks.forEach(task => {
  if (!isDefinitiveFailure(task)) {
    taskService.updateStatus(task.id, 'processing'); // 服务端真正失败的也被恢复了!
  }
});

正确示例(白名单 - 只恢复客户端中断):

// 白名单:只恢复因页面刷新/客户端中断导致的失败
const RECOVERABLE_ERROR_CODES = new Set([
  'INTERRUPTED',                  // 页面刷新中断
  'INTERRUPTED_DURING_SUBMISSION', // 提交过程中中断
  'RESUME_FAILED',                // 上次恢复尝试失败
]);

failedTasks.forEach(task => {
  const errorCode = task.error?.code || '';
  if (!RECOVERABLE_ERROR_CODES.has(errorCode)) return; // 服务端失败不恢复
  taskService.updateStatus(task.id, 'processing');
});

原因:

  1. 黑名单遗漏:无法穷举所有服务端错误类型,新增的错误类型会被误恢复
  2. 白名单安全:只有我们自己设置的中断错误码才恢复,服务端返回的任何失败都保持终态
  3. failed 是终态:用户看到失败后刷新页面,不应该看到任务又变成进行中

错误 3: 计费任务重试时重复调用生成接口

场景: 视频生成、角色提取等长耗时且按次计费的异步任务。

错误示例:

// 错误:无论是否已有 remoteId重试都重新提交生成请求
async retryTask(task) {
  task.status = 'pending';
  // 重新进入流程,导致重新调用 POST /videos
  this.processQueue(); 
}

正确示例:

// 正确:如果已有 remoteId直接进入轮询阶段跳过提交
async executeTask(task) {
  if (task.remoteId && (task.type === 'video' || task.type === 'character')) {
    task.executionPhase = 'polling';
    return this.executeResume(task, task.remoteId);
  }
  // 正常提交逻辑...
}

原因: AI 厂商的生成接口通常较贵。一旦任务 ID (remoteId) 已成功返回,该任务就在云端排队生成。此时任何重试或恢复操作都应仅限于查询进度,严禁再次点击生成接口导致重复扣费和资源浪费。


错误 4: 异步任务 ID 找回逻辑不完整

场景: 任务提交成功但在 remoteId 保存到数据库前发生页面刷新或 Service Worker 重启。

错误示例:

// 错误:仅检查已完成的任务结果
async resumeTask(task) {
  if (!task.remoteId) {
    // 如果还没完成,就直接报错提示无法恢复
    const successLog = await findSuccessLog(task.id);
    if (!successLog) throw new Error('无法恢复');
  }
}

正确示例:

// 正确:通过 API 日志系统尝试找回丢失的任务 ID哪怕任务还没完成
async resumeTask(task) {
  if (!task.remoteId) {
    // 从日志中找回 remoteId 或解析响应体
    const latestLog = await findLatestLogByTaskId(task.id);
    const recoveredId = latestLog?.remoteId || parseIdFromBody(latestLog?.responseBody);
    if (recoveredId) {
      task.remoteId = recoveredId;
      return this.resumePolling(task); // 继续轮询进度
    }
  }
}

原因: 状态更新的持久化可能因崩溃而丢失。利用独立的日志系统记录每一次 API 响应,可以在主状态丢失时找回关键的任务 ID实现任务进度的无缝衔接。


任务恢复决策表

任务类型 错误码 是否自动恢复 原因
视频/异步图片 INTERRUPTED 页面刷新中断,查询状态不扣费
视频/异步图片 INTERRUPTED_DURING_SUBMISSION 提交中断,可能已在后台执行
视频/异步图片 RESUME_FAILED 上次恢复失败,值得重试
任何类型 服务端返回的错误 业务失败重试也不会成功
同步图片 任何错误 每次调用都扣费

错误 5: fire-and-forget 写入后立即异步读取导致竞态

场景: 内存状态更新后通过 fire-and-forget 持久化到 IndexedDB另一个模块随后从 IndexedDB 读取

错误示例:

// useTaskStorage: 恢复任务状态(内存同步更新 + IndexedDB 异步写入)
taskQueueService.updateTaskStatus(task.id, 'processing'); // 内存立即更新persistTask() fire-and-forget

// fallback-executor: 从 IndexedDB 读取(可能读到旧状态!)
const tasks = await taskStorageReader.getAllTasks({ status: 'processing' });
// tasks 可能为空 — IndexedDB 写入尚未完成

正确示例:

// 从内存读取,始终是最新状态
const allTasks = taskQueueService.getAllTasks();
fallbackExecutor.resumePendingTasks(onUpdate, allTasks);

// resumePendingTasks 优先使用传入的内存数据
async resumePendingTasks(onUpdate, tasksFromMemory?: Task[]) {
  const tasks = tasksFromMemory
    ? tasksFromMemory.filter(t => t.status === 'processing')
    : await taskStorageReader.getAllTasks({ status: 'processing' }); // fallback
}

原因: persistTask() 是 fire-and-forget不 awaitIndexedDB 写入有延迟。如果另一个模块紧接着从 IndexedDB 读取,可能读到写入前的旧数据。内存中的状态是同步更新的,应优先使用。


错误 6: useEffect 中初始对账遍历所有任务导致死循环

场景: useEffect 内遍历任务列表执行对账,对账触发状态更新,依赖项变化导致 useEffect 重跑

错误示例:

useEffect(() => {
  // 初始对账:处理所有终态任务
  const allTasks = taskQueueService.getAllTasks();
  for (const task of allTasks) {
    processTaskEvent(task); // 可能触发 workflowControl.updateStep → 状态更新 → 重渲染
  }

  const sub = taskQueueService.observe().subscribe(handleEvent);
  return () => sub.unsubscribe();
}, [workflowControl]); // workflowControl 每次渲染都是新引用 → 死循环!

正确示例:

const reconciliationDoneRef = useRef(false);

useEffect(() => {
  // 用 ref 确保初始对账只执行一次
  if (!reconciliationDoneRef.current) {
    reconciliationDoneRef.current = true;
    const allTasks = taskQueueService.getAllTasks();
    for (const task of allTasks) {
      processTaskEvent(task);
    }
  }

  const sub = taskQueueService.observe().subscribe(handleEvent);
  return () => sub.unsubscribe();
}, [workflowControl]);

原因: 初始对账中的 processTaskEvent 可能触发状态更新(如 workflowControl.updateStep),导致组件重渲染。如果 workflowControl 未被 useMemo 包裹每次渲染都是新引用useEffect 会重新执行,形成死循环。用 ref 守卫确保对账逻辑只跑一次。


生产代码禁止保留调试日志

场景: 开发调试时添加的 console.log 语句未在提交前清理

错误示例:

// 调试日志遗留在生产代码中
const handleZoomPercentClick = useCallback(() => {
  console.log('[ViewNavigation] Zoom percent clicked, current state:', zoomMenuOpen);
  setZoomMenuOpen((prev) => !prev);
}, [zoomMenuOpen]);

// Popover 中的调试日志
<Popover
  onOpenChange={(open) => {
    console.log('[Popover] onOpenChange:', open);
    setZoomMenuOpen(open);
  }}
>

正确示例:

// 清理调试日志,保持代码简洁
const handleZoomPercentClick = useCallback(() => {
  setZoomMenuOpen((prev) => !prev);
}, []);

// 直接传递 setter 函数
<Popover onOpenChange={setZoomMenuOpen}>

原因:

  1. 调试日志会污染用户控制台,影响体验
  2. 暴露内部实现细节,存在安全隐患
  3. 增加打包体积和运行时开销
  4. 代码 Review 时容易被忽略,形成技术债

例外情况:

  • console.error / console.warn 用于记录真正的错误/警告是允许的
  • 带有 [DEBUG] 前缀且通过环境变量控制的日志可以保留

组件空状态不应简单返回 null

场景: 组件在没有数据时需要决定是否渲染

错误示例:

// 错误:没有历史记录时直接隐藏整个组件,用户看不到预设提示词
const PromptHistoryPopover = () => {
  const { history } = usePromptHistory();
  
  // 没有历史记录就不显示按钮
  if (history.length === 0) {
    return null;
  }
  
  return (
    <button>提示词</button>
    // ...
  );
};

正确示例:

// 正确:即使没有历史记录也显示按钮,展示预设提示词
const PromptHistoryPopover = () => {
  const { history } = usePromptHistory();
  const presetPrompts = getPresetPrompts();
  
  // 合并历史记录和预设提示词
  const allPrompts = [...history, ...presetPrompts];
  
  // 按钮始终显示
  return (
    <button>提示词</button>
    // 面板中显示历史 + 预设
  );
};

原因:

  1. 组件的核心功能(如提示词选择)不应该依赖于是否有历史数据
  2. 预设内容为新用户提供了引导,提升首次使用体验
  3. 隐藏入口会让用户不知道功能存在

文案应考虑所有使用场景

场景: 为组件、按钮、标题等编写文案时

错误示例:

// 错误:标题"历史提示词"在没有历史记录时不贴切
<PromptListPanel
  title={language === 'zh' ? '历史提示词' : 'Prompt History'}
  items={promptItems}  // 可能包含历史记录 + 预设提示词
/>

正确示例:

// 正确:使用更通用的标题"提示词"
<PromptListPanel
  title={language === 'zh' ? '提示词' : 'Prompts'}
  items={promptItems}
/>

原因:

  1. 文案过于具体会在某些场景下显得不准确
  2. 通用的文案能适应更多使用场景(有/无历史记录)
  3. 避免后续因场景变化而频繁修改文案

UI 重构时必须保持信息完整性

场景: 重构 UI 样式(如简化布局、统一风格)时

错误示例:

// 重构前:显示完整的性能信息
entry.innerHTML = `
  <span class="log-perf">⚡ 任务时长: ${duration}ms | FPS: ${fps}</span>
  <span class="log-memory">📊 ${usedMB} MB / ${limitMB} MB (${percent}%)</span>
`;

// 重构后:为了"简化"只显示时长徽章,丢失了 FPS 和内存信息
let perfBadge = '';
if (log.performance?.longTaskDuration) {
  perfBadge = `<span class="log-duration">${duration}ms</span>`;
}
// ❌ FPS、内存信息没有了

正确示例:

// 重构后:样式简化但信息完整
let perfText = '';
if (log.performance) {
  const parts = [];
  if (log.performance.longTaskDuration) {
    parts.push(`任务时长: ${log.performance.longTaskDuration.toFixed(0)}ms`);
  }
  if (log.performance.fps !== undefined) {
    parts.push(`FPS: ${log.performance.fps}`);
  }
  perfText = parts.join(' | ');
}
// ✅ 所有原有信息都保留

检查清单:

  • 重构前列出所有显示的信息项
  • 重构后逐一核对是否都有展示
  • 用真实数据测试,确认信息完整

原因: 样式重构的目的是优化视觉呈现,而不是删减功能。用户依赖这些信息进行问题诊断,丢失信息会影响使用体验。


日志/数据保留应优先保留问题记录

场景: 实现日志、任务历史等有容量上限的列表时

错误示例:

// 简单 FIFO新日志进来就删除最旧的
state.logs.unshift(newLog);
if (state.logs.length > MAX_LOGS) {
  state.logs.pop();  // ❌ 可能删掉重要的错误日志
}

正确示例:

// 优先保留问题记录
function isProblemLog(log) {
  if (log.status >= 400 || log.error) return true;  // 错误请求
  if (log.duration >= 1000) return true;  // 慢请求
  return false;
}

function trimLogsWithPriority(maxLogs) {
  // 分类
  const bookmarked = logs.filter(l => isBookmarked(l.id));
  const problems = logs.filter(l => !isBookmarked(l.id) && isProblemLog(l));
  const normal = logs.filter(l => !isBookmarked(l.id) && !isProblemLog(l));
  
  // 优先保留:收藏 > 问题 > 正常
  const mustKeep = bookmarked.length + problems.length;
  if (mustKeep >= maxLogs) {
    state.logs = [...bookmarked, ...problems.slice(0, maxLogs - bookmarked.length)];
  } else {
    state.logs = [...bookmarked, ...problems, ...normal.slice(0, maxLogs - mustKeep)];
  }
}

保留优先级:

  1. 用户收藏/标记的记录
  2. 错误记录(状态码 >= 400、有 error 字段)
  3. 慢请求(耗时 >= 1s
  4. 正常记录

原因: 正常请求通常不需要回溯,而问题请求是排查问题的关键依据。如果问题请求被正常请求挤掉,会大大增加问题定位难度。


批量加载与单个加载方法必须保持逻辑一致

场景: 存在 loadAll*()load*() 两种加载方法时

错误示例:

// loadBoard 有迁移逻辑
async loadBoard(id: string): Promise<Board | null> {
  const board = await this.getBoardsStore().getItem(id);
  if (board?.elements) {
    await migrateElementsBase64Urls(board.elements);  // ✅ 有迁移
  }
  return board;
}

// loadAllBoards 缺少迁移逻辑
async loadAllBoards(): Promise<Board[]> {
  const boards: Board[] = [];
  await this.getBoardsStore().iterate((value) => {
    boards.push(value);  // ❌ 没有迁移!
  });
  return boards;
}
// 问题:应用初始化用 loadAllBoards(),迁移逻辑永远不会执行

正确示例:

async loadAllBoards(): Promise<Board[]> {
  const boards: Board[] = [];
  await this.getBoardsStore().iterate((value) => {
    if (value.elements) {
      value.elements = migrateElementsFillData(value.elements);
    }
    boards.push(value);
  });
  
  // 迁移 Base64 图片 URL与 loadBoard 保持一致)
  for (const board of boards) {
    if (board.elements) {
      const migrated = await migrateElementsBase64Urls(board.elements);
      if (migrated) await this.saveBoard(board);
    }
  }
  
  return boards;
}

原因: 应用初始化通常使用批量加载方法(loadAll*),而开发时可能只在单个加载方法中添加新逻辑。这会导致新逻辑在实际运行时永远不会执行。


IndexedDB 元数据必须验证 Cache Storage 实际数据

场景: IndexedDB 存储元数据Cache Storage 存储实际 Blob 数据

错误示例:

// 只从 IndexedDB 读取元数据,不验证 Cache Storage
async getAllAssets(): Promise<Asset[]> {
  const keys = await this.store.keys();
  return Promise.all(keys.map(async key => {
    const stored = await this.store.getItem(key);
    return storedAssetToAsset(stored);  // ❌ 不验证实际数据是否存在
  }));
}
// 问题IndexedDB 有记录但 Cache Storage 数据被清理,导致 404

正确示例:

async getAllAssets(): Promise<Asset[]> {
  // 先获取 Cache Storage 中的有效 URL
  const cache = await caches.open('drawnix-images');
  const validUrls = new Set(
    (await cache.keys()).map(req => new URL(req.url).pathname)
  );
  
  const keys = await this.store.keys();
  return Promise.all(keys.map(async key => {
    const stored = await this.store.getItem(key);
    
    // 验证 Cache Storage 中有实际数据
    if (stored.url.startsWith('/asset-library/')) {
      if (!validUrls.has(stored.url)) {
        console.warn('Asset not in Cache Storage, skipping:', stored.url);
        return null;  // ✅ 跳过无效资源
      }
    }
    
    return storedAssetToAsset(stored);
  }));
}

原因:

  • IndexedDB 和 Cache Storage 是独立的存储机制
  • Cache Storage 可能被浏览器清理(存储压力时)
  • 如果不验证,会显示实际无法加载的资源,导致 404 错误

本地缓存图片只存 Cache Storage不存 IndexedDB

场景: 缓存本地生成的图片如分割图片、Base64 迁移、合并图片)

错误示例:

// 本地图片也存入 IndexedDB 元数据
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
await unifiedCacheService.cacheMediaFromBlob(stableUrl, blob, 'image', { taskId });
// 问题IndexedDB 会堆积大量不需要的元数据

正确示例:

// 本地图片只存 Cache Storage
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
await unifiedCacheService.cacheToCacheStorageOnly(stableUrl, blob);
// ✅ 只存实际数据,不存元数据

适用场景:

  • 只存 Cache Storage分割图片、Base64 迁移图片、合并图片
  • 同时存 Cache Storage + IndexedDBAI 生成图片、本地上传素材

原因:

  • 本地图片不需要在素材库单独显示(它们只是画布元素的缓存)
  • 减少 IndexedDB 存储压力
  • 避免 IndexedDB 和 Cache Storage 数据不一致

错误: 将图标组件作为 React 子元素直接渲染

场景: 在 ToolButton 或类似组件的 icon 属性中传递图标时,或在 JSX 中使用三元表达式选择图标时。

错误示例:

// 报错Functions are not valid as a React child
<ToolButton icon={MediaLibraryIcon} />

// 三元表达式中也是错误的
<button>{locked ? LockIcon : UnlockIcon}</button>

正确示例:

// 正确:实例化组件为 React 元素
<ToolButton icon={<MediaLibraryIcon />} />

// 三元表达式中也要实例化
<button>{locked ? <LockIcon /> : <UnlockIcon />}</button>

原因: icon 属性通常被直接渲染(如 {props.icon})。在 React 中你可以渲染元素Element但不能直接渲染组件函数Component Function。将组件改为函数式组件React.FC)后,必须使用 JSX 语法 <Icon /> 来实例化。

常见出错位置: popup-toolbar.tsxsize-input.tsxlink-button.tsxapp-menu-items.tsx 等使用图标的组件。


图标组件规范: 使用 React.FC 支持 size 属性

场景: 定义或更新 icons.tsx 中的图标时。

错误示例:

export const MyIcon = createIcon(<svg>...</svg>);

正确示例:

export const MyIcon: React.FC<React.SVGProps<SVGSVGElement> & { size?: number }> = ({ size = 24, ...props }) => (
  <svg width={size} height={size} {...props}>...</svg>
);

原因: 统一使用 React.FC 定义图标组件,可以方便地通过 size 属性控制尺寸,同时通过解构 {...props} 支持透传 classNamestyle 等 SVG 标准属性,增强了图标的灵活性和一致性。


错误: CSS 全局规则覆盖 SVG 特定颜色

场景: 为图标设置特定品牌色(如 AI 工具的玫红/橙色)时。

错误示例:

// scss 文件中
.tool-icon svg {
  stroke: currentColor !important; // 覆盖了所有内联 stroke 属性
}

正确示例:

// icons.tsx 中
<path d="..." stroke="#E91E63" /> // 在路径级别设置颜色,避免被全局 CSS 轻易覆盖

原因: 全局的 stroke: currentColor 规则会强制图标跟随文字颜色,导致 AI 生成等需要强调色的图标变成灰色。应移除这类过于激进的全局样式,或在图标内部路径上显式指定颜色。


错误: 筛选逻辑中“全部 (ALL)”选项导致结果为空

场景: 实现带有“全部 (ALL)”选项的素材库筛选逻辑时。

错误示例:

const matchesSource = filters.activeSource === 'ALL' || asset.source === filters.activeSource;
// 如果 filters.activeSource 初始值为 undefined(undefined === 'ALL') 为 false结果为空

正确示例:

const matchesSource = !filters.activeSource || filters.activeSource === 'ALL' || asset.source === filters.activeSource;

原因: 初始状态下筛选变量可能为 undefined。进行逻辑判断时,必须同时考虑 undefinednull'ALL' 这几种代表”不筛选”的情况,否则会导致筛选结果意外为空。

API 错误字段类型安全

场景: 处理外部 API 返回的 error 字段时,error 可能是字符串也可能是对象

错误示例:

// API 返回:{ error: { code: “generation_failed”, message: “[403]...” } }
const data = await response.json();
throw new Error(data.error || data.message || 'Failed');
// data.error 是对象 → new Error({...}) → error.message = “[object Object]”

正确示例:

const data = await response.json();
const errMsg = typeof data.error === 'string'
  ? data.error
  : (data.error?.message || data.message || 'Failed');
const error = new Error(errMsg);
if (typeof data.error === 'object' && data.error?.code) {
  (error as any).code = data.error.code;
}
throw error;

原因: new Error(value) 内部调用 String(value),对象会变成 ”[object Object]”。API 的 error 字段格式不统一(有的返回字符串,有的返回 { code, message } 对象),必须用 typeof 区分处理。

数值范围转换规则

场景: 回调链中传递进度/百分比等数值时,必须明确每一层的值域范围并保持一致

错误示例:

// pollVideoStatus 回调返回 0-1 范围
onProgress(apiProgress / 100); // 100 → 1.0

// 调用方误以为是 0-100 范围,乘以 0.8
options?.onProgress?.({ progress: 10 + progress * 0.8 });
// 结果10 + 1.0 * 0.8 = 10.8,而非预期的 90

正确示例:

// 明确注释值域范围
onProgress(apiProgress / 100); // 输出 0-1

// 正确映射0-1 → 10-90 需乘以 80
// progress 是 0-1 范围,映射到 10-9010 + (0~1) * 80
options?.onProgress?.({ progress: 10 + progress * 80 });
// 结果10 + 1.0 * 80 = 90 ✓

原因: 回调链跨多层传递数值时0-1 和 0-100 两种范围容易混淆。每个回调的输入/输出值域必须用注释明确标注映射公式应能通过边界值0 和 max验证正确性。


Plait 插件规范

自定义组件 onContextChanged 必须处理 viewport 变化

场景: 创建自定义 Plait 组件(继承 PlaitPluginElementComponent)时,选择框(蓝色虚线 + 控制点)在缩放/平移画布后与元素偏移。

错误示例:

// 错误onContextChanged 没有处理 viewport 变化
onContextChanged(
  value: PlaitPluginElementContext<MyElement, PlaitBoard>,
  previous: PlaitPluginElementContext<MyElement, PlaitBoard>
): void {
  if (value.element !== previous.element || value.hasThemeChanged) {
    this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
  } else {
    const needUpdate = value.selected !== previous.selected;
    if (needUpdate || value.selected) {
      this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
    }
  }
  // 问题:缩放/平移画布后,选择框位置不更新,与元素产生偏移
}

正确示例:

// 正确:检测 viewport 变化并重绘选择框(参考 ToolComponent
onContextChanged(
  value: PlaitPluginElementContext<MyElement, PlaitBoard>,
  previous: PlaitPluginElementContext<MyElement, PlaitBoard>
): void {
  const viewportChanged =
    value.board.viewport.zoom !== previous.board.viewport.zoom ||
    value.board.viewport.offsetX !== previous.board.viewport.offsetX ||
    value.board.viewport.offsetY !== previous.board.viewport.offsetY;

  if (value.element !== previous.element || value.hasThemeChanged) {
    this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
  } else if (viewportChanged && value.selected) {
    // viewport 改变且元素被选中时,更新选择框位置
    this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
  } else {
    const needUpdate = value.selected !== previous.selected;
    if (needUpdate || value.selected) {
      this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
    }
  }
}

原因: 选择框渲染在 board-active-svg(独立 SVG无 viewBox坐标通过 toActiveRectangleFromViewBoxRectangle 实时计算。而元素渲染在 board-host-svg(有 viewBox 自动映射坐标)。当 viewport 变化时host-svg 中的元素位置自动调整,但 active-svg 上的选择框必须手动重新计算坐标才能对齐。ToolComponent 已正确实现了此模式。


UI 交互规范

媒体预览统一使用公共组件

场景: 需要实现图片/视频预览功能时(如任务列表、生成结果预览等)。

错误示例:

// 自定义 Dialog 实现预览
<Dialog visible={previewVisible} header="图片预览" width="90vw">
  <div className="preview-container">
    <Button icon={<ChevronLeftIcon />} onClick={handlePrevious} />
    <img src={previewUrl} />
    <Button icon={<ChevronRightIcon />} onClick={handleNext} />
  </div>
</Dialog>

正确示例:

import { UnifiedMediaViewer, type MediaItem } from '../shared/media-preview';

<UnifiedMediaViewer
  visible={previewVisible}
  items={mediaItems}
  initialIndex={previewIndex}
  onClose={handleClose}
  showThumbnails={true}
/>

原因: 项目已有功能完善的 UnifiedMediaViewer 公共组件,支持:

  • 单图预览、对比预览、编辑模式
  • 缩略图导航栏
  • 键盘快捷键左右箭头、Escape
  • 缩放、拖拽、全屏
  • 视频同步播放

自定义实现会导致功能不一致、代码重复,且缺失公共组件已有的增强功能。


媒体封面/占位统一使用公共组件

场景: 音频封面、音频缩略图、播放器封面等媒体入口需要处理无图、404 和加载失败。

错误示例:

{previewImageUrl && !loadFailed ? (
  <img src={previewImageUrl} onError={() => setLoadFailed(true)} />
) : (
  <div className="audio-fallback">
    <Music4 />
  </div>
)}

正确示例:

<AudioCover
  src={previewImageUrl}
  fallbackSrc={posterUrl}
  alt={title || 'Audio cover'}
  imageClassName="player__cover-image"
  fallbackClassName="player__cover-fallback"
/>

规则:

  • 媒体封面加载失败兜底逻辑要收敛到公共组件,禁止在播放器、画布节点、预览器里重复维护一套 onError + useState
  • 默认封面视觉必须统一,后续改默认图时只改一处
  • 若存在 thumbnailUrl -> 原图 URL 的回退链路,应由公共组件统一支持
  • 业务组件只负责尺寸、布局、圆角样式,不负责重复实现失败状态机

原因:

  • 音频相关展示入口分散,重复实现很容易出现“有的地方有占位,有的地方白屏/裂图”
  • 公共组件可以统一失败行为、减少重复代码,并降低后续视觉调整成本

生成结果缩略图使用 contain 完整展示

场景: 展示 AI 生成的图片/视频缩略图时(任务队列、生成历史、预览缩略图等)。

错误示例:

.thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: cover; // 裁切图片,可能丢失重要内容
}

正确示例:

.thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: contain; // 完整展示图片
}

原因: AI 生成的图片内容完整性很重要,用户需要看到完整的生成结果才能判断质量。使用 cover 会裁切图片边缘,可能导致:

  • 宫格图部分格子被裁切
  • 竖版/横版图片重要内容被裁切
  • 用户无法准确评估生成效果

例外情况: 以下场景可以使用 cover

  • 用户上传的参考图片(用户已知图片内容)
  • 角色头像(圆形需要填充)
  • 聊天消息中的图片

小图应提供 hover 大图预览

场景: 展示缩略图(尤其是 AI 生成结果的小图)时。

错误示例:

// 只有小图,没有预览
<div className="thumbnail">
  <img src={image.url} alt={image.name} />
</div>

正确示例:

const [hoveredImage, setHoveredImage] = useState<{ url: string; x: number; y: number } | null>(null);

<div
  className="thumbnail"
  onMouseEnter={(e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    setHoveredImage({ url: image.url, x: rect.left + rect.width / 2, y: rect.top - 10 });
  }}
  onMouseLeave={() => setHoveredImage(null)}
>
  <img src={image.url} alt={image.name} />
</div>

{/* Hover 预览通过 Portal 渲染到 body */}
{hoveredImage && ReactDOM.createPortal(
  <div
    className="hover-preview"
    style={{ left: hoveredImage.x, top: hoveredImage.y, transform: 'translate(-50%, -100%)' }}
  >
    <img src={hoveredImage.url} alt="Preview" />
  </div>,
  document.body
)}

原因: 缩略图尺寸较小,用户难以判断图片细节。提供 hover 大图预览可以:

  • 快速查看图片细节,无需点击打开预览弹窗
  • 提升用户体验,减少操作步骤
  • 方便用户快速对比多张图片

Tooltip 样式统一规范

场景: 在项目中使用 TDesign 的 Tooltip 组件时。

错误示例:

<Tooltip content="提示文字">
  <Button icon={<Icon />} />
</Tooltip>

正确示例:

<Tooltip content="提示文字" theme="light" showArrow={false}>
  <Button icon={<Icon />} />
</Tooltip>

原因: 为了保持项目视觉风格的高度统一,所有 Tooltip 必须使用 theme="light"(白底黑字)。同时,为了界面更简洁,推荐在图标按钮或紧凑列表项上使用 showArrow={false} 隐藏箭头。

高层级容器中的 Tooltip 遮挡问题

场景: 在使用 createPortal 渲染的弹窗、下拉菜单或设置了极高 zIndex 的容器内部使用 Tooltip 时。

错误示例:

// 在 zIndex: 10000 的下拉菜单中
<Tooltip content="状态提示">
  <div className="status-dot" />
</Tooltip>
// 结果Tooltip 被挡在下拉菜单下面,看不见

正确示例:

<Tooltip content="状态提示" theme="light" zIndex={20000}>
  <div className="status-dot" />
</Tooltip>

原因: 项目中部分浮层(如模型选择下拉)使用了 createPortalzIndex 达到 10000。默认层级的 Tooltip 会被遮挡。在这种情况下,必须显式将 TooltipzIndex 提升到更高(如 20000以确保可见。

信号/状态展示的量化表意

场景: 展示模型健康度、网络信号等需要量化感知的状态时。

错误示例: 使用单一圆点或方块,仅靠颜色区分。用户难以感知“程度”的差异。

正确示例: 使用“信号格”或“进度条”设计,配合颜色变化。

  • 3 格绿色:极佳
  • 2 格橙色:一般
  • 1 格红色:极差

原因: 相比单一的圆点信号格能更直观地传达“量”的概念符合用户的直觉认知如手机信号、WiFi 强度)。

可点击容器模式:扩大交互区域

场景: 当 checkbox、按钮等小型交互元素嵌套在容器中时用户期望点击整个容器都能触发操作。

错误示例:

// 只有点击 checkbox 本身才能触发
<div className="selection-info">
  <Checkbox checked={isAllSelected} onChange={toggleSelectAll} />
  <span>{selectedCount}</span>
</div>
.selection-info {
  // 没有任何点击相关样式
}

正确示例:

// 点击整个容器都能触发
<div
  className="selection-info"
  onClick={toggleSelectAll}
  role="button"
  tabIndex={0}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      toggleSelectAll();
    }
  }}
>
  <Checkbox checked={isAllSelected} />  {/* 移除 onChange由父容器处理 */}
  <span>{selectedCount}</span>
</div>
.selection-info {
  cursor: pointer;
  user-select: none;
  
  &:active {
    background: #cbd5e1;  // 按压反馈
  }
  
  .t-checkbox {
    pointer-events: none;  // 禁用子元素直接点击,让父容器统一处理
  }
}

关键点:

  • 父容器添加 onClickrole="button"tabIndex={0} 和键盘支持
  • 子元素使用 pointer-events: none 禁用直接点击
  • 添加 cursor: pointer:active 反馈

Shift 连选时防止文本被选中

场景: 实现列表/网格的多选功能时,用户使用 Shift 键连选会触发浏览器默认的文本选择行为。

错误示例:

.list-item {
  cursor: pointer;
  // 没有禁用文本选择Shift+Click 时文字会被选中高亮
}
// 用户 Shift+Click 连选时,列表项的文字被蓝色高亮选中
const handleClick = (id: string, event: React.MouseEvent) => {
  if (event.shiftKey) {
    // 执行连选逻辑
    selectRange(lastSelectedId, id);
  }
};

正确示例:

.list-item {
  cursor: pointer;
  user-select: none; // 防止 Shift 连选时文本被选中
}
// Shift+Click 时只执行连选逻辑,不会选中文字
const handleClick = (id: string, event: React.MouseEvent) => {
  if (event.shiftKey && lastSelectedId) {
    selectRange(lastSelectedId, id);
    return;
  }
  toggleSelection(id);
  lastSelectedIdRef.current = id;
};

原因: 浏览器默认行为是 Shift+Click 选中两次点击之间的所有文本。在实现自定义多选功能时,需要通过 user-select: none 禁用这一行为,否则用户会看到文本被选中的蓝色高亮,影响交互体验。


筛选与选中状态联动

场景: 实现带筛选功能的列表选择时,选中状态应与筛选结果联动。

错误示例:

// 选中数量始终显示总选中数,不考虑筛选
<span>{selectedAssetIds.size}</span>

// 删除按钮也基于总选中数
<Button disabled={selectedAssetIds.size === 0} />

// 删除操作删除所有选中项,包括不在当前筛选结果中的
const handleDelete = () => {
  deleteAssets(Array.from(selectedAssetIds));
};

正确示例:

// 计算当前筛选结果中被选中的数量
const filteredSelectedCount = useMemo(() => {
  return filteredResult.assets.filter(asset => selectedAssetIds.has(asset.id)).length;
}, [filteredResult.assets, selectedAssetIds]);

// 显示筛选后的选中数量
<span>{filteredSelectedCount}</span>

// 按钮状态基于筛选后的选中数量
<Button disabled={filteredSelectedCount === 0} />

// 操作只影响当前筛选结果中被选中的项
const handleDelete = () => {
  const filteredSelectedAssets = filteredResult.assets.filter(a => selectedAssetIds.has(a.id));
  deleteAssets(filteredSelectedAssets.map(a => a.id));
};

核心原则:

  • 显示:选中计数基于筛选后的结果
  • 全选:只选中/取消当前筛选结果
  • 操作:删除、下载等只影响筛选后被选中的项
  • 按钮状态disabled 基于筛选后的选中数量

全局组件配色统一

场景: 项目使用第三方 UI 库(如 TDesign需要统一覆盖组件样式以符合品牌规范。

错误示例:

// 在多个组件文件中分散覆盖
// AssetItem.scss
.asset-item .t-checkbox.t-is-checked .t-checkbox__input {
  background: $brand-orange;
}

// MediaLibraryGrid.scss
.media-library-grid .t-checkbox.t-is-checked .t-checkbox__input {
  background: $brand-orange;
}

// OtherComponent.scss
.other .t-checkbox.t-is-checked .t-checkbox__input {
  background: $brand-orange;  // 重复代码,且容易遗漏
}

正确示例:

// 在 tdesign-theme.scss 中集中覆盖
/* 全局 Checkbox 样式覆盖 - 橙色背景 + 白色勾选图标 */
.t-checkbox {
  &.t-is-checked,
  &.t-is-indeterminate {
    .t-checkbox__input {
      background-color: var(--td-brand-color) !important;
      border-color: var(--td-brand-color) !important;
    }
  }

  .t-checkbox__input {
    &::after {
      border-color: #fff !important;  // 确保勾选图标为白色
    }
  }
}

最佳实践:

  • styles/tdesign-theme.scss 中集中管理所有第三方组件的品牌色覆盖
  • 使用 CSS 变量(如 --td-brand-color)保持一致性
  • 组件级别的样式文件只处理布局和特殊场景,不重复颜色定义
  • 确保 checked、indeterminate、hover、active 等所有状态都被覆盖

React 加载状态规范

避免 Suspense 导致的布局抖动

场景: 使用 React.lazySuspense 加载组件时,如果 fallback 占位符的高度与加载后的真实内容差异巨大,会导致页面布局发生剧烈的跳动。

错误示例:

// 错误fallback 只有 16px 高,加载后内容有 500px 高
<Suspense fallback={<div className="spinner" />}>
  <ChatMessagesArea />
</Suspense>

正确示例:

// 正确:使用撑满容器或固定高度的 fallback
<Suspense fallback={
  <div className="loading-container--full">
    <div className="spinner" />
  </div>
}>
  <ChatMessagesArea />
</Suspense>

// SCSS
.loading-container--full {
  flex: 1;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

原因: 布局抖动Layout Shift严重影响用户体验和视觉稳定性。Fallback 应尽可能模拟加载后的布局尺寸。


API 与任务处理规范

优先使用结构化数据而非字符串解析

场景: 在 UI 组件中展示复杂的业务数据(如 AI 生成消息中的模型、参数、上下文)时。

错误示例:

// 错误:通过解析拼接后的字符串来提取参数
const metaTags = textContent.split('\n').filter(line => line.startsWith('模型:'));

正确示例:

// 正确:在数据模型中直接存储结构化对象
interface ChatMessage {
  id: string;
  content: string;
  aiContext?: {
    model: string;
    params: Record<string, any>;
  };
}

// UI 渲染时优先读取结构化数据
const model = message.aiContext?.model || parseFallback(message.content);

原因: 字符串解析极其脆弱,容易因文案微调、语言切换或历史数据格式不一而失效。结构化数据是唯一可靠的真相来源。

日志记录应反映实际发送的数据

场景: 在调用外部 API 前记录请求参数用于调试(如 /sw-debug.html 的 LLM API 日志),数据在发送前经过了处理(如图片裁剪、压缩)。

错误示例:

// 错误:在数据处理前收集日志信息
const referenceImageInfos = await Promise.all(
  refUrls.map(url => getImageInfo(url))  // 获取原始图片信息
);

// 后续处理会改变图片
for (const url of refUrls) {
  let blob = await fetchImage(url);
  blob = await cropImageToAspectRatio(blob, targetWidth, targetHeight);  // 裁剪
  formData.append('input_reference', blob);
}

// 日志记录的是裁剪前的尺寸,与实际发送的不符!
startLLMApiLog({ referenceImages: referenceImageInfos });

正确示例:

// 正确:在数据处理后收集日志信息
const referenceImageInfos: ImageInfo[] = [];

for (const url of refUrls) {
  let blob = await fetchImage(url);
  blob = await cropImageToAspectRatio(blob, targetWidth, targetHeight);  // 裁剪
  
  // 获取处理后的图片信息用于日志
  const info = await getImageInfo(blob);
  referenceImageInfos.push(info);
  
  formData.append('input_reference', blob);
}

// 日志记录的是实际发送的数据
startLLMApiLog({ referenceImages: referenceImageInfos });

原因: 调试日志的价值在于准确记录实际发送给 API 的数据。如果日志记录的是处理前的数据,当 API 返回错误(如"图片尺寸不匹配")时,日志显示的尺寸与实际不符,会严重误导排查方向。

外部 API 调用频率控制

场景: 调用外部服务的低频刷新接口(如每 5 分钟刷新一次的状态接口),多个组件可能同时触发请求。

错误示例:

// 错误:直接导出函数,每次调用都发起请求
export async function fetchHealthData(): Promise<Data[]> {
  const response = await fetch(API_URL);
  return response.json();
}

// 多个组件同时调用会产生重复请求
// ComponentA: fetchHealthData()
// ComponentB: fetchHealthData()  // 同时发起第二个请求

正确示例:

// 正确:使用单例控制调用频率和并发
class HealthDataFetcher {
  private static instance: HealthDataFetcher;
  private cachedData: Data[] = [];
  private lastFetchTime = 0;
  private pendingFetch: Promise<Data[]> | null = null;
  
  static getInstance() {
    if (!this.instance) this.instance = new HealthDataFetcher();
    return this.instance;
  }

  async fetch(force = false): Promise<Data[]> {
    // 1. 检查最小调用间隔(如 1 分钟)
    if (!force && Date.now() - this.lastFetchTime < 60_000) {
      return this.cachedData;
    }
    // 2. 复用进行中的请求(防并发)
    if (this.pendingFetch) return this.pendingFetch;
    // 3. 发起新请求
    this.pendingFetch = this.doFetch();
    try { return await this.pendingFetch; }
    finally { this.pendingFetch = null; }
  }
}

export const healthDataFetcher = HealthDataFetcher.getInstance();

原因: 外部接口数据通常有刷新周期(如 5 分钟在刷新周期内重复请求是浪费。单例模式可以1) 设置最小调用间隔避免频繁请求2) 复用进行中的 Promise 防止并发请求3) 统一管理缓存,所有调用方共享数据。

第三方统计与 Session Replay 对主线程与请求体的影响

场景: 接入第三方统计/监控(如 PostHog或开启 Session Replay如 rrweb时。

要点:

  • 开启 Session Replay 前需评估主线程开销wheel、mousemove、setInterval 等)、单次上报体体积(易触发 413 Content Too Large
  • 默认建议关闭或按采样开启;若开启,需配置限流、单批大小与 413 错误处理(如 on_xhr_error / on_request_error 中识别 413 后丢弃或有限重试并打 debug 日志),避免无限重试加重主线程与网络负担。
  • 统计上报须遵循「统计上报旁路化」:初始化与上报在 requestIdleCallback 或 setTimeout 中执行,不在主路径上做脱敏与网络请求;失败静默不向上抛。

错误示例:

// 错误:默认开启 Session Recording导致 wheel/setInterval 卡顿与 413
posthog.init('phc_xxx', {
  api_host: 'https://us.i.posthog.com',
  // 未设置 disable_session_recordingrrweb 默认录制
});

正确示例:

// 正确:默认关闭 Session Recording并处理 413 避免重试风暴
posthog.init('phc_xxx', {
  api_host: 'https://us.i.posthog.com',
  disable_session_recording: true,
  rate_limiting: { events_per_second: 10, events_burst_limit: 30 },
  on_xhr_error: function(err) {
    if (err && (err.statusCode ?? err.status) === 413) {
      console.debug('[PostHog] 413 Content Too Large, batch dropped');
    }
  },
});

原因: Session Replayrrweb在滚轮、鼠标移动和定时器中录制 DOM/输入,会直接占用主线程并产生大量数据,单次 flush 易超服务端限制导致 413413 响应常不带 CORS 头,浏览器会先报 CORS。关闭或按采样开启、并处理 413 可消除卡顿与报错。

无效配置下的数据不应被持久化或执行

场景: 用户在未配置 API Key 时创建了任务,后来配置了 API Key这些旧任务不应被执行。

错误示例:

// 错误initialize 时直接恢复所有 PENDING 任务
async initialize(config: Config): Promise<void> {
  this.config = config;
  this.initialized = true;
  
  // 恢复并执行所有 PENDING 任务(包括无效配置时创建的)
  for (const task of this.tasks.values()) {
    if (task.status === TaskStatus.PENDING) {
      this.executeTask(task);  // ❌ 执行了"孤儿任务"
    }
  }
}

正确示例:

// 正确:首次初始化时清除无效配置下创建的任务
private hadSavedConfig = false;

async restoreFromStorage(): Promise<void> {
  const { config } = await storage.loadConfig();
  if (config) {
    this.hadSavedConfig = true;  // 标记有保存的配置
  }
}

async initialize(config: Config): Promise<void> {
  // 首次初始化时清除"孤儿任务"
  if (!this.hadSavedConfig) {
    for (const task of this.tasks.values()) {
      if (task.status === TaskStatus.PENDING) {
        await storage.deleteTask(task.id);  // ✅ 清除无效任务
      }
    }
  }
  this.hadSavedConfig = true;
  // ... 继续正常初始化
}

原因: 无效配置(如缺少 API Key下创建的任务是"孤儿数据",不应在后续有效配置时被执行。通过 hadSavedConfig 标志区分"首次初始化"和"恢复已有配置",确保只有在有效配置下创建的任务才会被执行。


模块导入规范

同名模块的全局状态隔离问题

场景: 项目中存在多个同名模块(如 canvas-insertion.ts),各自维护独立的全局变量(如 boardRef)。

错误示例:

// MediaViewport.tsx - 错误:从 mcp/tools 导入
import { quickInsert } from '../../../mcp/tools/canvas-insertion';
// 但 boardRef 是在 services/canvas-operations 版本中被设置的
// 导致 "画布未初始化" 错误

正确示例:

// MediaViewport.tsx - 正确:从 services/canvas-operations 导入
import { quickInsert } from '../../../services/canvas-operations';
// 与 AIInputBar.tsx 中 setCanvasBoard 设置的是同一个 boardRef

原因: 项目中 mcp/tools/canvas-insertion.tsservices/canvas-operations/canvas-insertion.ts 是两个独立模块,各自有独立的 boardRef 变量。AIInputBar 只设置了 services 版本的 boardRef,所以必须从 services/canvas-operations 导入才能正确访问已初始化的 board。


坐标变换场景的一致性处理

翻转状态下的鼠标交互

场景: 当元素支持翻转flipH/flipV或旋转时基于鼠标位移计算的逻辑如拖拽裁剪框、调整大小需要根据变换状态调整方向。

错误示例:

// 错误:未考虑翻转状态,翻转后拖拽方向和鼠标移动方向相反
const handleMouseMove = (e: MouseEvent) => {
  const deltaX = (e.clientX - dragStart.x) / scale;
  const deltaY = (e.clientY - dragStart.y) / scale;
  
  // 直接使用 delta翻转后方向错误
  newCrop.x = initialCrop.x + deltaX;
  newCrop.y = initialCrop.y + deltaY;
};

正确示例:

// 正确:根据翻转状态调整 delta 方向
const handleMouseMove = (e: MouseEvent) => {
  let deltaX = (e.clientX - dragStart.x) / scale;
  let deltaY = (e.clientY - dragStart.y) / scale;
  
  // 翻转后需要反转 delta 方向
  if (flipH) deltaX = -deltaX;
  if (flipV) deltaY = -deltaY;
  
  newCrop.x = initialCrop.x + deltaX;
  newCrop.y = initialCrop.y + deltaY;
};

原因: 图片翻转后,视觉上的坐标系发生了变化。水平翻转后鼠标向右移动在视觉上是向左,垂直翻转后鼠标向下移动在视觉上是向上。

翻转状态下的 cursor 样式

场景: 调整大小的控制点需要显示正确的 cursor 方向指示。

错误示例:

// 错误cursor 样式写死在 CSS 中,翻转后方向不对
<div className="handle--nw" /> // cursor: nw-resize固定

正确示例:

// 正确:根据翻转状态动态计算 cursor
const getCursorForHandle = (handle: string): string => {
  let adjusted = handle;
  if (flipH) adjusted = adjusted.replace('w', 'e').replace('e', 'w');
  if (flipV) adjusted = adjusted.replace('n', 's').replace('s', 'n');
  return `${adjusted}-resize`;
};

<div 
  className="handle--nw" 
  style={{ cursor: getCursorForHandle('nw') }}
/>

原因: 翻转后控制点的视觉位置改变了,例如原本在左上角的 nw 控制点水平翻转后在视觉上变成了右上角cursor 应该显示为 ne-resize 而非 nw-resize


UI 图标库规范

验证 TDesign 图标库导出名称

场景: 使用 tdesign-icons-react 库中的图标时。

错误示例:

import { RobotIcon, NumberIcon } from 'tdesign-icons-react'; 
// 错误:这两个图标在库中并不存在,会导致运行时报错

正确示例:

import { ServiceIcon, BulletpointIcon } from 'tdesign-icons-react';
// 正确:使用库中实际存在的相近图标

原因: tdesign-icons-react 的图标导出名称有时与直觉不符(例如没有 RobotIcon 而是 ServiceIcon)。在引入新图标前务必通过 IDE 补全功能验证其存在。



React 加载状态规范

避免 Suspense 导致的布局抖动

场景: 使用 React.lazySuspense 加载组件时,如果 fallback 高度与实际内容差异巨大,会导致页面跳动。

错误示例:

// 错误fallback 只有一行文字高度,加载后容器瞬间撑开
<Suspense fallback={<div>加载中...</div>}>
  <ChatMessagesArea />
</Suspense>

正确示例:

// 正确fallback 撑满容器或具有固定高度
<Suspense fallback={<div className="chat-loading--full"><Spinner /></div>}>
  <ChatMessagesArea />
</Suspense>

// CSS
.chat-loading--full {
  flex: 1;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

原因: 布局抖动Layout Shift严重影响用户体验通过为加载状态预留空间可以保持视觉稳定性。


API 与任务处理规范

优先使用结构化数据而非字符串解析

场景: 在 UI 层展示复杂信息(如 AI 生成参数)时。

错误示例:

// 错误:通过正则或 split 解析拼接好的显示文本
const parts = textContent.split(' 模型: ');
const modelId = parts[1]?.trim();

正确示例:

// 正确:在数据源头保留结构化 Context
const userChatMsg = {
  role: 'user',
  textContent: '...',
  aiContext: context, // 存储原始对象
};

// UI 直接读取
const modelId = chatMessage.aiContext?.model?.id;

原因: 字符串解析极其脆弱,格式微调会导致解析失败;结构化数据提供类型安全且更易维护。


LLM 响应格式兼容性

场景: 解析 AI 模型返回的工作流 JSON 时,不同模型返回格式可能不同。

错误示例:

// 错误:只处理标准格式,忽略其他可能的格式
function parseToolCalls(response: string) {
  const parsed = JSON.parse(response);
  // 只处理 {"content": "...", "next": [...]} 格式
  return parsed.next.map(item => ({...}));
}

正确示例:

// 正确:支持多种 AI 返回格式
function parseToolCalls(response: string): ToolCall[] {
  // 格式 1: {"content": "分析完成", "next": [{"mcp": "...", "args": {...}}]}
  const workflowJson = parseWorkflowJson(response);
  if (workflowJson?.next.length > 0) {
    return workflowJson.next.map(...);
  }

  // 格式 2: {"content": "[{\"mcp\": \"...\", \"args\": {...}}]"}
  // content 字段本身是 JSON 数组字符串
  if (next.length === 0 && parsed.content?.trim().startsWith('[')) {
    const contentParsed = JSON.parse(parsed.content);
    if (Array.isArray(contentParsed)) next = contentParsed;
  }

  // 格式 3: [{"mcp": "...", "args": {...}}] 直接数组
  if (cleaned.trim().startsWith('[')) {
    const parsed = JSON.parse(cleaned);
    if (Array.isArray(parsed)) return parsed.map(...);
  }

  // 格式 4: 支持 name/mcp 和 arguments/args 字段名
  const name = parsed.name || parsed.mcp;
  const args = parsed.arguments || parsed.args || parsed.params;
  
  return toolCalls;
}

原因: 不同 LLM 模型GPT、Gemini、DeepSeek 等)对同一 prompt 可能返回不同格式的 JSON必须兼容多种格式以确保工作流正确执行。


工作流状态同步使用事件驱动 + 初始对账

场景: 在 UI 组件中同步工作流执行状态时。

错误示例:

// 错误:轮询 IndexedDB 同步状态(多条同步路径互相竞争导致状态闪烁)
class WorkflowStatusSyncService {
  subscribe(workflowId: string, callback: StatusChangeCallback): () => void {
    // 每秒从 IndexedDB 读取 → 与事件驱动路径竞争 → 状态覆盖
  }
}

正确示例:

// 正确:统一事件驱动路径 + 挂载时一次性对账
// TaskQueueService → RxJS → useTaskWorkflowSync → WorkflowContext + WorkZone + ChatDrawer
useEffect(() => {
  // 初始对账:处理挂载前已完成的终态任务
  const allTasks = taskQueueService.getAllTasks();
  for (const task of allTasks) {
    if (task.status === 'completed' || task.status === 'failed') {
      processTaskEvent({ type: 'taskUpdated', task, timestamp: Date.now() });
    }
  }
  // 订阅后续实时事件
  const sub = taskQueueService.observeTaskUpdates().subscribe(processTaskEvent);
  return () => sub.unsubscribe();
}, []);

原因:

  1. 单一事件驱动路径消除多路径竞争导致的状态闪烁
  2. 初始对账替代轮询,只执行一次,覆盖挂载前错过的终态事件
  3. TaskQueueService 内存 Map 是唯一数据源,无需从 IndexedDB 拉取

UI 图标库规范

验证 TDesign 图标库导出名称

场景: 使用 tdesign-icons-react 引入新图标时。

错误示例:

import { NumberIcon, RobotIcon } from 'tdesign-icons-react'; 
// ❌ 报错:这些名称在库中不存在,导致应用崩溃

正确示例:

import { BulletpointIcon, ServiceIcon } from 'tdesign-icons-react';
// ✅ 使用前先验证库中实际存在的导出名称

原因: TDesign 图标库命名不一定符合直觉,使用不存在的导出名会触发 SyntaxError 导致白屏。


E2E 测试规范

Playwright 元素选择器精度

场景: 选择工具栏按钮时,需要获取完整的可点击区域而非内部小元素。

错误示例:

// 错误getByRole('radio') 选择 13x13px 的 input 元素
const toolBtn = toolbar.locator('div').filter({ 
  has: page.getByRole('radio', { name: /画笔/ }) 
});
// 实际选中的是内部的 radio input而非外层按钮容器

正确示例:

// 正确:使用 label 选择器获取完整的按钮容器 (40x36px)
const toolBtn = toolbar.locator('label').filter({ 
  has: page.getByRole('radio', { name: /画笔/ }) 
});

原因: getByRole('radio') 会匹配到隐藏的 input 元素,其尺寸通常很小。使用 label 选择器可以获取实际的可点击区域。

CSS 定位避免内容截断

场景: 为元素添加标签/提示文字时,定位方式可能导致内容被截断。

错误示例:

/* 错误:使用 right + transform 定位,容易被父容器截断 */
.label {
  position: absolute;
  right: -10px;
  transform: translate(100%, -50%);
}

正确示例:

/* 正确:使用 left: 100% + margin内容不会被截断 */
.label {
  position: absolute;
  left: 100%;
  margin-left: 8px;
  transform: translateY(-50%);
}

原因: right 配合 transform: translate(100%) 会使元素超出父容器边界,如果父容器有 overflow: hidden 则内容被截断。

避免过度复杂的自动化系统

场景: 设计自动化工具(如 GIF 录制、截图生成)时的架构决策。

错误做法:

  • 设计复杂的 DSL 系统JSON 定义 + 执行器 + 时间戳裁剪)
  • 录制一个长视频然后按时间点裁剪多个 GIF
  • 尝试用一套配置生成所有内容

正确做法:

  • 每个 GIF 使用独立的测试录制
  • 简单的命令行参数控制(如 --trim 2.4
  • 一个命令完成一个任务(如 pnpm manual:gif:mindmap

原因:

  1. 长视频裁剪会带来"旧信息污染"(前一个操作残留影响后一个)
  2. DSL 元素选择器难以处理动态 UI 的时序问题
  3. 简单方案更易调试和维护,复杂系统的调试成本远超收益

定期清理未使用代码

场景: 功能开发过程中会产生实验性代码和辅助文件。

检查项:

# 查看未跟踪文件
git status --short | grep "^??"

# 检查文件是否被导入
grep -r "from.*filename" apps/ packages/

常见可清理的文件:

  • 未使用的 fixture 文件(如 test-data.ts
  • 重复功能的测试文件(如多个 visual spec 覆盖相同功能)
  • 错误创建的目录结构
  • 过时的文档(引用已删除代码的 md 文件)

原因: 未清理的代码会增加维护负担,误导后续开发者,也会增加 CI 执行时间。

Clipper 布尔运算结果处理

场景: 使用 clipper-lib 进行多边形布尔运算(合并、减去、相交等)后,需要区分外环和孔洞。

错误做法:

// 错误:依赖面积符号判断外环/孔洞
const outerRing = pathsWithArea.find(p => p.signedArea > 0);
const holes = pathsWithArea.filter(p => p.signedArea < 0);

正确做法:

// 正确:用面积大小判断,最大的是外环,其他是孔洞
const sortedByArea = [...pathsWithArea].sort((a, b) => b.absArea - a.absArea);
const outerRing = sortedByArea[0]; // 面积最大的是外环
const holes = sortedByArea.slice(1); // 其他都是孔洞

原因:

  1. Clipper 返回的路径方向(顺时针/逆时针取决于坐标系Y 轴向上还是向下)
  2. 在不同环境下,面积符号可能相反,导致孔洞被错误识别为外环
  3. 面积大小是稳定的判断依据:外环总是包含所有孔洞,因此面积最大

相关文件: packages/drawnix/src/transforms/precise-erase.ts, packages/drawnix/src/transforms/boolean.ts

Slate-React Leaf 组件 DOM 结构必须保持稳定

场景: 在 Slate-React 的 Leaf 组件中实现文本样式(如下划线、删除线)时

错误做法:

// 错误:根据条件动态切换 HTML 标签和 CSS 实现方式
const Leaf = ({ children, leaf, attributes }) => {
  const hasCustomStyle = leaf['text-decoration-style'];
  
  // 当样式变化时DOM 结构会从 <u>...</u> 变成 <span style={...}>...</span>
  if (leaf.underlined && !hasCustomStyle) {
    children = <u>{children}</u>;  // 有时用标签
  }
  
  const style = {};
  if (leaf.underlined && hasCustomStyle) {
    style.textDecoration = 'underline';  // 有时用 CSS
  }
  
  return <span style={style} {...attributes}>{children}</span>;
};
// 报错Cannot resolve a DOM node from Slate node

正确做法:

// 正确:始终使用同一种方式实现,保持 DOM 结构稳定
const Leaf = ({ children, leaf, attributes }) => {
  const style: CSSProperties = {};
  
  // 统一使用 CSS 实现,不使用 <u>、<s> 等标签
  if (leaf.underlined || leaf.strikethrough) {
    const decorations: string[] = [];
    if (leaf.underlined) decorations.push('underline');
    if (leaf.strikethrough) decorations.push('line-through');
    
    style.textDecoration = decorations.join(' ');
    if (leaf['text-decoration-style']) {
      style.textDecorationStyle = leaf['text-decoration-style'];
    }
  }
  
  return <span style={style} {...attributes}>{children}</span>;
};

原因:

  1. Slate-React 依赖稳定的 DOM 结构来追踪编辑器节点与 DOM 节点的映射关系
  2. 当根据样式条件动态切换 HTML 标签(<u>)和 CSStext-decorationDOM 结构会发生变化
  3. 这会导致 Slate 无法找到对应的 DOM 节点,抛出 "Cannot resolve a DOM node from Slate node" 错误
  4. 解决方案是选择一种实现方式并始终使用,推荐使用 CSS 因为它更灵活(支持自定义样式和颜色)

相关文件: packages/react-text/src/text.tsx

postmessage-duplex RPC 消息大小限制

场景: 使用 postmessage-duplex 进行 SW 与主线程通信,查询大量数据时

错误示例:

// 错误:一次性返回所有日志,可能超过 1MB 限制
async function getAllLogs() {
  const logs = await db.getAll(); // 可能有上千条,每条包含大字段
  return { logs }; // Message size exceeds limit (1048576 bytes)
}

// 错误:前端分页,但后端返回全量数据
async function getLogsPaginated(page, pageSize) {
  const allLogs = await db.getAll();
  return { logs: allLogs.slice((page-1)*pageSize, page*pageSize) };
}

正确示例:

// 正确:后端分页 + 精简数据
async function getLogsPaginated(page = 1, pageSize = 20, filter = {}) {
  let logs = await db.getAll();
  
  // 1. 先过滤(在分页前)
  if (filter.type) logs = logs.filter(l => l.type === filter.type);
  if (filter.status) logs = logs.filter(l => l.status === filter.status);
  
  // 2. 计算分页信息
  const total = logs.length;
  const totalPages = Math.ceil(total / pageSize);
  
  // 3. 分页
  const pagedLogs = logs.slice((page-1)*pageSize, page*pageSize);
  
  // 4. 精简数据,去掉大字段
  const compactLogs = pagedLogs.map(log => ({
    id: log.id,
    timestamp: log.timestamp,
    status: log.status,
    // 截断长文本
    prompt: log.prompt?.substring(0, 200),
    // 不传输 requestBody、responseBody 等大字段
  }));
  
  return { logs: compactLogs, total, page, pageSize, totalPages };
}

原因:

  • postmessage-duplex 默认消息大小限制为 1MB1048576 bytes
  • 日志数据通常包含大字段(请求体、响应体、图片 base64 等100 条日志可能超过 10MB
  • 解决方案:后端分页 + 精简数据,每页 20 条左右,去掉大字段
  • 如需完整数据,提供单条查询接口,按需获取

相关文件:

  • apps/web/src/sw/task-queue/llm-api-logger.ts - getLLMApiLogsPaginated
  • apps/web/src/sw/task-queue/channel-manager.ts - handleDebugGetLLMApiLogs

主线程直接读取 IndexedDB 优于 RPC

场景: 主线程需要从 Service Worker 读取存储在 IndexedDB 中的数据(任务列表、工作流状态等)

错误示例:

// 错误:通过 RPC 从 SW 读取 IndexedDB 数据
async function getAllTasks() {
  // 问题:
  // 1. postMessage 有 1MB 大小限制,大数据量会失败
  // 2. RPC 可能超时SW 更新、连接断开等)
  // 3. 需要复杂的分页和重试逻辑
  return await swChannelClient.listTasksPaginated({ offset: 0, limit: 1000 });
}

正确示例:

// 正确:主线程直接读取 IndexedDB
// 创建专用的 storage reader如 task-storage-reader.ts
class TaskStorageReader {
  async getAllTasks(): Promise<Task[]> {
    const db = await this.getDB();
    const store = db.transaction('tasks', 'readonly').objectStore('tasks');
    const tasks = await store.getAll();
    return tasks.map(convertSWTaskToTask);
  }
}

// 使用时RPC 作为 fallback
async function getAllTasks() {
  if (await taskStorageReader.isAvailable()) {
    return await taskStorageReader.getAllTasks();  // 直接读取
  }
  // Fallback to RPC仅在 IndexedDB 不可用时)
  return await swChannelClient.listTasksPaginated(...);
}

原因:

  • IndexedDB 是浏览器共享的,主线程和 SW 都可以直接访问
  • 直接读取避免了 postMessage 的 1MB 大小限制
  • 直接读取更稳定,不受 SW 连接状态影响
  • 写操作仍需通过 SW以保持数据一致性和触发业务逻辑

适用场景:

操作类型 推荐方式 原因
读取任务列表 直接读取 IndexedDB 数据量大,避免 RPC 限制
读取工作流状态 直接读取 IndexedDB 避免 RPC 超时问题
创建任务 通过 RPC SW 需执行业务逻辑
提交工作流 通过 RPC SW 需开始执行
读取内存状态 通过 RPC 数据只存在于 SW 内存

相关文件:

  • packages/drawnix/src/services/task-storage-reader.ts
  • packages/drawnix/src/services/workflow-storage-reader.ts
  • packages/drawnix/src/services/base-storage-reader.ts

RPC 超时与重连策略

场景: 关键 RPC 调用(如工作流提交)可能因 SW 连接不稳定而超时

错误示例:

// 错误:直接调用 RPC没有超时处理和重连逻辑
async submit(workflow: WorkflowDefinition): Promise<void> {
  if (!swChannelClient.isInitialized()) {
    throw new Error('SWChannelClient not initialized');
  }
  // 使用默认 120 秒超时,用户等待时间过长
  const result = await swChannelClient.submitWorkflow(workflow);
  if (!result.success) {
    throw new Error(result.error);
  }
}

正确示例:

// 正确:设置合理超时,超时时重连并重试
async submit(workflow: WorkflowDefinition): Promise<void> {
  const maxRetries = 2;
  const submitTimeout = 15000; // 15 秒超时
  
  const ensureSWReady = async (): Promise<boolean> => {
    if (!swChannelClient.isInitialized()) {
      try {
        await swChannelClient.initialize();
      } catch {
        return false;
      }
    }
    return true;
  };
  
  const submitWithTimeout = async () => {
    return Promise.race([
      swChannelClient.submitWorkflow(workflow),
      new Promise((_, reject) => 
        setTimeout(() => reject(new Error('timeout')), submitTimeout)
      )
    ]);
  };

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      await ensureSWReady();
      const result = await submitWithTimeout();
      if (result.success) return;
    } catch (error) {
      if (error.message === 'timeout' && attempt < maxRetries - 1) {
        // 超时时重新初始化连接
        await swChannelClient.initialize();
      }
    }
  }
  throw new Error('Submit failed after retries');
}

原因:

  • SW 可能因更新、重启等原因暂时无法响应
  • 默认 120 秒超时让用户等待时间过长
  • 15-30 秒是更合理的超时时间
  • 超时后主动重连可以解决临时的连接问题

相关文件:

  • packages/drawnix/src/services/workflow-submission-service.ts

降级路径必须保持与 SW 模式相同的功能行为

场景: 当 Service Worker 不可用时,代码会降级到主线程直接调用 API。降级路径需要保持与 SW 模式相同的功能行为,包括日志记录、错误处理等。

错误示例:

// 错误:降级路径没有记录 LLM API 日志
async function generateImageDirect(prompt: string): Promise<any> {
  const response = await fetch('/images/generations', {
    method: 'POST',
    body: JSON.stringify({ prompt }),
  });
  return response.json();
  // ❌ 没有记录日志,调试面板看不到这次调用
}

正确示例:

// 正确:降级路径也要记录日志
import { startLLMApiLog, completeLLMApiLog, failLLMApiLog } from './llm-api-logger';

async function generateImageDirect(prompt: string): Promise<any> {
  const startTime = Date.now();
  
  // 开始记录
  const logId = startLLMApiLog({
    endpoint: '/images/generations',
    model: modelName,
    taskType: 'image',
    prompt,
  });
  
  try {
    const response = await fetch('/images/generations', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
    });
    
    if (!response.ok) {
      const error = await response.text();
      failLLMApiLog(logId, {
        httpStatus: response.status,
        duration: Date.now() - startTime,
        errorMessage: error,
      });
      throw new Error(error);
    }
    
    const result = await response.json();
    completeLLMApiLog(logId, {
      httpStatus: response.status,
      duration: Date.now() - startTime,
      resultType: 'image',
    });
    return result;
  } catch (error) {
    failLLMApiLog(logId, {
      duration: Date.now() - startTime,
      errorMessage: error.message,
    });
    throw error;
  }
}

原因:

  • SW 模式和降级模式都写入同一个 IndexedDBllm-api-logs
  • 调试面板 /sw-debug.html?tab=llmapi 直接从 IndexedDB 读取日志
  • 如果降级路径不记录日志,调试时会漏掉这些 API 调用
  • 统一的日志记录有助于成本追踪和问题排查

相关文件:

  • packages/drawnix/src/services/media-executor/llm-api-logger.ts - 主线程日志记录器
  • apps/web/src/sw/task-queue/llm-api-logger.ts - SW 日志记录器
  • packages/drawnix/src/utils/gemini-api/services.ts - generateImageDirect
  • packages/drawnix/src/services/video-api-service.ts - submitVideoGeneration

分页查询必须在服务端过滤

场景: 实现带过滤条件的分页列表时

错误示例:

// 错误:后端分页,前端过滤
// 后端
async function getLogsPaginated(page, pageSize) {
  const allLogs = await db.getAll();
  return { 
    logs: allLogs.slice((page-1)*pageSize, page*pageSize),
    total: allLogs.length 
  };
}

// 前端
function getFilteredLogs() {
  // 只能过滤当前页,总数也不对
  return state.logs.filter(l => l.type === filter.type);
}

正确示例:

// 正确:过滤条件传到后端,先过滤再分页
// 后端
async function getLogsPaginated(page, pageSize, filter) {
  let logs = await db.getAll();
  
  // 先过滤
  if (filter.type) logs = logs.filter(l => l.type === filter.type);
  if (filter.status) logs = logs.filter(l => l.status === filter.status);
  
  // 再分页(基于过滤后的数据)
  const total = logs.length;
  const totalPages = Math.ceil(total / pageSize);
  const pagedLogs = logs.slice((page-1)*pageSize, page*pageSize);
  
  return { logs: pagedLogs, total, page, pageSize, totalPages };
}

// 前端
function onFilterChange() {
  // 过滤条件变化时重新加载第一页
  loadLogs(1);
}

原因:

  • 前端过滤只能处理当前页数据,无法看到其他页符合条件的数据
  • 分页信息(总数、总页数)会不准确
  • 过滤条件变化时应回到第一页,避免显示空白页

云端同步恢复数据时保留原始 URL

场景: 从云端同步/恢复任务数据到本地时,处理媒体 URL

错误示例:

// 错误:修改任务的 result.url 为新格式
const processedTasks = tasksToRestore.map(task => {
  if (task.result?.url) {
    const cacheUrl = `/__aitu_cache__/${taskType}/synced-${task.id}.${extension}`;
    return {
      ...task,
      result: {
        ...task.result,
        url: cacheUrl,  // 修改了原始 URL
        originalUrl: task.result.url,
      },
    };
  }
  return task;
});

问题: 画布上引用原始 URL 的元素无法显示,因为 Cache Storage 中只有新 URL 的数据。

正确示例:

// 正确:保留原始 URL将数据缓存到原始 URL 位置
const processedTasks = tasksToRestore.map(task => {
  if (task.result?.url?.startsWith("/__aitu_cache__/")) {
    return {
      ...task,
      result: {
        ...task.result,
        needsMediaDownload: true,  // 只添加标记,不修改 URL
      },
    };
  }
  return task;
});

// 下载媒体时,缓存到原始 URL 位置
async downloadAndCacheMediaForTask(task) {
  const cacheUrl = task.result?.url;  // 使用原始 URL
  const blob = await downloadFromGist(task.id);
  await unifiedCacheService.cacheToCacheStorageOnly(cacheUrl, blob);  // 缓存到原始位置
}

原因:

  • 画布元素引用的是原始 URL/__aitu_cache__/image/xxx.png
  • 修改 URL 会导致画布元素与缓存数据不匹配
  • 正确做法是将下载的数据缓存到原始 URL 对应的位置

增量同步优化:使用 checksum 减少网络请求

场景: 实现云端数据同步功能时

错误示例:

// 错误:每次都上传/下载所有数据
async pushToRemote() {
  const localData = await collectSyncData();
  const encryptedFiles = await serialize(localData);  // 序列化所有数据
  await gitHubApiService.updateGistFiles(encryptedFiles);  // 上传所有文件
}

正确示例:

// 正确:增量同步,只传输有变化的数据
async pushToRemote() {
  const localData = await collectSyncData();
  
  // 1. 获取远程 manifest 比较
  const remoteManifest = await getRemoteManifest();
  
  // 2. 使用 checksum 比较,只上传有变化的画板
  const filesToUpdate = {};
  for (const [boardId, board] of localData.boards) {
    const localChecksum = calculateBoardChecksum(board);
    const remoteInfo = remoteManifest?.boards[boardId];
    
    if (!remoteInfo || remoteInfo.checksum !== localChecksum) {
      filesToUpdate[boardFile(boardId)] = await encrypt(board);
    }
  }
  
  // 3. 只更新有变化的文件
  if (Object.keys(filesToUpdate).length > 0) {
    await gitHubApiService.updateGistFiles(filesToUpdate);
  }
}

原因:

  • 全量同步会产生大量不必要的网络请求
  • 使用 checksum 可以精确识别哪些数据有变化
  • 只传输变化的数据可以显著减少带宽和延迟
  • manifest/workspace/prompts/tasks 等小文件可以始终更新

同步数据双向合并:下载后自动上传

场景: 从远程下载数据时,需要与本地数据合并,确保双向同步

错误示例:

// 错误:单向合并,只下载远程有但本地没有的数据
async pullFromRemote() {
  const remoteTasks = await fetchRemoteTasks();
  const localTaskIds = new Set(localTasks.map(t => t.id));
  
  // 只恢复本地不存在的任务
  const tasksToRestore = remoteTasks.filter(t => !localTaskIds.has(t.id));
  await restoreTasks(tasksToRestore);
  
  // 问题:本地有但远程没有的数据不会同步到远程
}

正确示例:

// 正确:双向合并,基于 ID 去重,合并后自动上传
async pullFromRemote() {
  const remoteTasks = await fetchRemoteTasks();
  const localTaskMap = new Map(localTasks.map(t => [t.id, t]));
  
  // 1. 基于 ID 去重合并,使用 updatedAt 判断保留哪个版本
  const tasksToRestore: Task[] = [];
  for (const remoteTask of remoteTasks) {
    const localTask = localTaskMap.get(remoteTask.id);
    
    if (!localTask) {
      // 本地不存在,需要恢复
      tasksToRestore.push(remoteTask);
    } else if (remoteTask.updatedAt > localTask.updatedAt) {
      // 远程更新,使用远程版本
      tasksToRestore.push(remoteTask);
    }
    // 本地版本更新或相同,保留本地版本
  }
  
  await restoreTasks(tasksToRestore);
  
  // 2. 合并完成后,自动上传合并后的数据到远程
  await uploadMergedTasksToRemote();
}

async uploadMergedTasksToRemote() {
  // 收集本地所有数据(包括刚合并的)
  const allLocalTasks = getAllCompletedTasks();
  const tasksData = { completedTasks: allLocalTasks };
  const encrypted = await encrypt(tasksData);
  await updateRemoteFile(SYNC_FILES.TASKS, encrypted);
}

原因:

  • 单向合并会导致数据不一致A 设备新增的数据不会同步到 B 设备的远程
  • 基于 ID 去重避免重复数据
  • 使用 updatedAt 时间戳判断保留更新的版本,避免覆盖新数据
  • 合并后自动上传确保本地独有的数据也能同步到远程
  • 这是实现"最终一致性"的关键步骤

数据安全规范

破坏性操作前必须进行安全检查

场景: 执行删除、覆盖、清空等可能导致数据丢失的操作时

错误示例:

// 错误:直接执行删除,没有安全检查
async applyRemoteDeletions(toDeleteLocally: string[]) {
  for (const boardId of toDeleteLocally) {
    await workspaceStorageService.deleteBoard(boardId);  // 直接删除
  }
}

正确示例:

// 正确:执行安全检查后再删除
async applyRemoteDeletions(toDeleteLocally: string[]) {
  // 1. 执行安全检查
  const safetyCheck = performSafetyCheck({
    localBoards,
    toDeleteLocally,
    currentBoardId,    // 当前正在编辑的画板
    isFirstSync,       // 是否首次同步
    remoteManifest,
  });
  
  // 2. 处理安全检查结果
  if (safetyCheck.blockedReason) {
    throw new Error(safetyCheck.blockedReason);  // 严重错误,阻止执行
  }
  
  if (safetyCheck.warnings.length > 0) {
    // 有警告,需要用户确认
    return { needsConfirmation: true, warnings: safetyCheck.warnings };
  }
  
  // 3. 排除被保护的项目后执行删除
  const skippedIds = new Set(safetyCheck.skippedItems.map(item => item.id));
  const safeToDelete = toDeleteLocally.filter(id => !skippedIds.has(id));
  
  for (const boardId of safeToDelete) {
    await workspaceStorageService.deleteBoard(boardId);
  }
}

安全检查规则:

检查项 规则 处理方式
当前画板保护 正在编辑的画板不能被删除 跳过该项,继续其他操作
新设备保护 首次同步不执行任何删除 跳过所有删除操作
批量删除阈值 删除超过 50% 数据时警告 暂停,要求用户确认
全部删除阻止 不允许删除所有数据 阻止执行,显示错误
空数据保护 远程数据异常时不执行删除 跳过所有删除操作

原因:

  • 用户数据是核心资产,误删除可能导致不可恢复的损失
  • 同步冲突可能导致意外的批量删除
  • 安全检查提供多层保护,宁可同步失败也不能误删数据

不可逆操作需要用户输入确认文字

场景: 执行清空回收站、永久删除等不可恢复的操作时

错误示例:

// 错误:只用简单的确认对话框
<Button onClick={() => {
  if (confirm('确定要清空回收站吗?')) {
    emptyRecycleBin();
  }
}}>
  清空回收站
</Button>

正确示例:

// 正确:要求输入确认文字
function EmptyRecycleBinDialog({ onConfirm, onCancel }) {
  const [confirmText, setConfirmText] = useState('');
  const isValid = confirmText === '确认清空';
  
  return (
    <Dialog header="清空回收站">
      <p>此操作将永久删除回收站中的所有数据,无法恢复。</p>
      <ul>
        <li>{itemCount.boards} 个画板</li>
        <li>{itemCount.prompts} 条提示词</li>
      </ul>
      <p>请输入 <strong>确认清空</strong> 以继续:</p>
      <Input
        value={confirmText}
        onChange={setConfirmText}
        placeholder='输入"确认清空"'
      />
      <Button 
        theme="danger" 
        disabled={!isValid}  // 必须输入正确文字才能点击
        onClick={onConfirm}
      >
        永久删除
      </Button>
    </Dialog>
  );
}

原因:

  • 简单的确认对话框容易被用户习惯性点击"确定"
  • 输入确认文字强制用户阅读警告内容
  • 对于不可恢复的数据操作,额外的确认步骤可以有效防止误操作

使用软删除Tombstone支持数据恢复

场景: 实现跨设备同步的删除功能时

错误示例:

// 错误:直接删除远程文件
async deleteBoard(boardId: string) {
  await gitHubApiService.deleteGistFiles([`board_${boardId}.json`]);  // 直接删除
  delete manifest.boards[boardId];  // 从 manifest 中移除
}

正确示例:

// 正确:使用 tombstone 标记,保留文件便于恢复
async deleteBoard(boardId: string) {
  // 1. 添加删除标记,但不删除文件
  manifest.boards[boardId] = {
    ...manifest.boards[boardId],
    deletedAt: Date.now(),      // 删除时间戳
    deletedBy: deviceId,        // 删除设备
  };
  
  // 2. 更新 manifest
  await updateManifest(manifest);
  
  // 3. 删除本地数据
  await workspaceStorageService.deleteBoard(boardId);
}

// 恢复时:移除 tombstone 标记,下载远程文件
async restoreBoard(boardId: string) {
  const { deletedAt, deletedBy, ...rest } = manifest.boards[boardId];
  manifest.boards[boardId] = rest;  // 移除删除标记
  
  const boardData = await downloadBoard(boardId);  // 远程文件仍在
  await workspaceStorageService.saveBoard(boardData);
}

优势:

  • 删除可恢复:用户可以从回收站恢复误删的数据
  • 跨设备同步:其他设备同步时能正确识别已删除的项目
  • 永久删除可控:用户手动清空回收站时才真正删除远程文件

异步操作不应阻塞 UI 交互

场景: 删除、保存等操作需要同步到远程服务器时

错误示例:

// 错误:同步操作阻塞了 UI弹窗要等网络请求完成才关闭
async deleteBoard(id: string): Promise<void> {
  // 用户点击确认后,弹窗卡住等待网络请求
  const result = await syncEngine.syncBoardDeletion(id);  // 阻塞 2-3 秒
  if (result.success) {
    console.log('Synced to remote');
  }
  
  // 本地删除
  this.boards.delete(id);
  await storage.deleteBoard(id);
}

正确示例:

// 正确:异步执行同步,不阻塞本地操作
async deleteBoard(id: string): Promise<void> {
  // 异步同步到远程fire-and-forget
  import('./github-sync').then(({ syncEngine }) => {
    syncEngine.syncBoardDeletion(id).then((result) => {
      if (result.success) {
        console.log('Board synced to remote recycle bin');
      } else {
        console.warn('Failed to sync deletion:', result.error);
      }
    }).catch(console.warn);
  }).catch(console.warn);

  // 本地删除立即执行,弹窗立即关闭
  this.boards.delete(id);
  await storage.deleteBoard(id);
}

原因:

  • 用户期望点击"确认"后弹窗立即关闭
  • 网络请求可能慢或失败,不应影响本地操作体验
  • 远程同步失败时可以在下次同步时重试

关键操作应直接调用,不依赖事件订阅

场景: 需要在某个操作后触发另一个操作(如删除后同步到远程)

错误示例:

// 错误:依赖事件订阅来触发同步
// 问题:事件订阅可能有时序问题,订阅在事件触发后才建立

// 在 Context 中订阅事件
useEffect(() => {
  const subscription = workspaceService.observeEvents().subscribe({
    next: (event) => {
      if (event.type === 'boardDeleted') {
        // 可能收不到事件!因为订阅建立时机不确定
        syncEngine.syncBoardDeletion(event.payload.id);
      }
    }
  });
  return () => subscription.unsubscribe();
}, []);

// 在 Service 中发出事件
async deleteBoard(id: string) {
  this.boards.delete(id);
  this.emit('boardDeleted', { id });  // 事件可能没有订阅者
}

正确示例:

// 正确:在操作中直接调用,保证执行
async deleteBoard(id: string) {
  // 直接调用同步逻辑
  import('./github-sync').then(({ syncEngine }) => {
    syncEngine.syncBoardDeletion(id).catch(console.warn);
  });
  
  // 执行本地删除
  this.boards.delete(id);
  
  // 事件仅用于 UI 更新等非关键操作
  this.emit('boardDeleted', { id });
}

原因:

  • RxJS Subject 的事件是同步分发的,但订阅者的建立是异步的
  • React useEffect 中的订阅可能在组件渲染后才建立
  • 热更新HMR可能导致旧的订阅被替换
  • 关键业务逻辑不应依赖于"某处可能存在的订阅者"

自定义菜单/按钮组件的焦点样式和键盘导航

场景: 实现自定义菜单、下拉列表等交互组件时

错误示例:

// 错误:没有处理焦点样式,浏览器会显示默认的蓝色边框
.menu-item {
  background-color: transparent;
  border: 1px solid transparent;
  // 缺少 outline 处理
}
// 错误:菜单没有键盘导航支持
const Menu = ({ children }) => {
  return (
    <div className="menu">
      {children}
    </div>
  );
};

正确示例:

// 正确:移除默认焦点边框,用 focus-visible 提供键盘导航反馈
.menu-item {
  background-color: transparent;
  border: 1px solid transparent;
  outline: none;  // 移除浏览器默认蓝色焦点边框

  &:hover {
    background-color: var(--color-surface-primary-container);
  }

  &:focus-visible {
    // 仅键盘导航时显示视觉反馈(鼠标点击不会触发)
    background-color: var(--color-surface-primary-container);
  }
}
// 正确:完整的键盘导航和无障碍支持
const Menu = ({ children, onClose }) => {
  const containerRef = useRef<HTMLDivElement>(null);

  const getFocusableItems = useCallback(() => {
    return Array.from(
      containerRef.current?.querySelectorAll<HTMLButtonElement>(
        'button.menu-item:not([disabled])'
      ) || []
    );
  }, []);

  const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
    const items = getFocusableItems();
    const currentIndex = items.findIndex(item => item === document.activeElement);

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        items[(currentIndex + 1) % items.length]?.focus();
        break;
      case 'ArrowUp':
        event.preventDefault();
        items[(currentIndex - 1 + items.length) % items.length]?.focus();
        break;
      case 'Escape':
        event.preventDefault();
        onClose?.();
        break;
    }
  }, [getFocusableItems, onClose]);

  // 菜单打开时自动聚焦第一项
  useEffect(() => {
    requestAnimationFrame(() => {
      getFocusableItems()[0]?.focus();
    });
  }, []);

  return (
    <div ref={containerRef} role="menu" onKeyDown={handleKeyDown}>
      {children}
    </div>
  );
};

// MenuItem 添加 role="menuitem"
const MenuItem = ({ onClick, children }) => (
  <button className="menu-item" onClick={onClick} role="menuitem">
    {children}
  </button>
);

关键点:

  • outline: none 移除浏览器默认蓝色焦点边框
  • focus-visible 仅在键盘导航时提供视觉反馈,鼠标点击不会触发
  • 支持 ↑↓ 箭头键循环切换焦点
  • 支持 Escape 关闭菜单
  • 菜单打开时自动聚焦第一项
  • 添加 role="menu"role="menuitem" 支持无障碍

Frame 拖动元素检测时机

场景: Frame 移动时会"吸附"路过的元素,导致不相关元素被一起移动

错误示例:

// 每次移动都重新检测相交元素
board.afterChange = () => {
  if (movingFrameId) {
    const frameRectBefore = RectangleClient.getRectangleByPoints(lastFramePoints);
    const intersectingElements = board.children.filter((el) => {
      return isElementIntersectingRect(el, frameRectBefore);
    });
    // 移动所有相交的元素
    // 问题Frame 移动过程中会不断"吸附"路过的元素
  }
};

正确示例:

// 只在拖动开始时检测一次
let movingElementIds: Set<string> = new Set();

board.pointerDown = (event: PointerEvent) => {
  const selected = getSelectedElements(board);
  if (selected.length === 1 && isFrameElement(selected[0])) {
    movingFrameId = selected[0].id;
    lastFramePoints = [...(selected[0] as PlaitFrame).points];
    
    // 记录拖动开始时与 Frame 相交的元素 ID
    movingElementIds.clear();
    const frameRect = RectangleClient.getRectangleByPoints(lastFramePoints);
    board.children.forEach((el) => {
      if (el.id === movingFrameId) return;
      if (isFrameElement(el)) return;
      if (isElementIntersectingRect(el, frameRect)) {
        movingElementIds.add(el.id);
      }
    });
  }
};

board.afterChange = () => {
  if (movingFrameId) {
    // 只移动预先记录的元素
    const elementsToMove = board.children.filter((el) => {
      return movingElementIds.has(el.id);
    });
    // 移动这些元素...
  }
};

board.globalPointerUp = (event: PointerEvent) => {
  if (movingFrameId) {
    movingFrameId = null;
    lastFramePoints = null;
    movingElementIds.clear(); // 清理记录
  }
};

原因:

  • 如果每次移动都重新检测相交元素Frame 移动路径上的所有元素都会被"吸附"进来
  • 正确的做法是只在拖动开始时检测一次,记录初始相交的元素 ID
  • 移动过程中只移动这些预先记录的元素,不再重新检测
  • 拖动结束时清理记录,为下次拖动做准备

适用场景:

  • Frame 拖动同步移动内部元素
  • 任何需要"容器+内容"联动移动的场景
  • 避免移动过程中的意外元素吸附

Plait Selection 类型结构

场景: 使用 Plait 的 Selection 类型进行框选检测时

错误示例:

// 错误Selection 没有 ranges 属性
board.isRectangleHit = (element: PlaitElement, selection: Selection) => {
  const rect = RectangleClient.getRectangleByPoints(element.points);
  const selectionRect = RectangleClient.getRectangleByPoints(selection.ranges[0]);
  // 类型错误:类型"Selection"上不存在属性"ranges"
  return isRectIntersect(rect, selectionRect);
};

正确示例:

// 正确:使用 anchor 和 focus 属性
board.isRectangleHit = (element: PlaitElement, selection: Selection) => {
  const rect = RectangleClient.getRectangleByPoints(element.points);
  const selectionRect = RectangleClient.getRectangleByPoints([
    selection.anchor,
    selection.focus
  ]);
  return isRectIntersect(rect, selectionRect);
};

原因:

  • Plait 的 Selection 类型使用 anchorfocus 两个点来表示选区
  • anchor 是选区的起点,focus 是选区的终点
  • 不存在 ranges 属性,使用会导致类型错误

相关类型:

  • Selection.anchor: Point - 选区起点
  • Selection.focus: Point - 选区终点
  • 用于框选、文本选择等场景

Plait 事件处理器类型匹配

场景: 重写 Plait board 的事件处理器时

错误示例:

// 错误dblClick 期望 MouseEvent但提供了 PointerEvent
board.dblClick = (event: PointerEvent) => {
  const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
  // 类型错误:不能将类型"(event: PointerEvent) => void"分配给类型"(event: MouseEvent) => void"
};

正确示例:

// 正确:使用 MouseEvent 类型
board.dblClick = (event: MouseEvent) => {
  const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
  // 类型匹配,编译通过
};

原因:

  • Plait 的不同事件处理器期望不同的事件类型
  • dblClick 期望 MouseEvent,而 pointerDown/pointerMove/pointerUp 期望 PointerEvent
  • 必须使用正确的事件类型,否则会导致类型错误

常见事件类型:

  • board.dblClick: (event: MouseEvent) => void
  • board.pointerDown: (event: PointerEvent) => void
  • board.pointerMove: (event: PointerEvent) => void
  • board.pointerUp: (event: PointerEvent) => void