You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

9.4 KiB

分镜看板重叠问题修复记录

日期: 2026-02-08 类型: Bug 修复 影响范围: 分镜看板拖拽排序功能 严重程度: 高(影响核心功能)

问题描述

用户报告分镜看板存在以下问题:

  1. 分镜元素块重叠:多个分镜块在时间轴上重叠显示
  2. 拖动排序错误:拖拽分镜后,刷新页面位置不正确

问题根本原因

1. 数据模型冲突

数据库中同时维护了两套定位系统:

# server/app/models/storyboard.py
class Storyboard(SQLModel, table=True):
    # 时间轴定位
    start_time: Decimal = Field(...)  # 第162行
    end_time: Decimal = Field(...)    # 第166行

    # 排序索引
    order_index: int = Field(...)     # 第178行

核心矛盾:两套系统同时存在,但没有明确的"唯一真相来源"(Single Source of Truth)。

2. 后端计算逻辑

后端在 _calculate_storyboard_board_tracks 方法中会根据 orderIndex 重新计算 startTime/endTime

# server/app/services/storyboard_board_service.py:364-394
current_time = 0.0

for storyboard in storyboards:
    start_time = current_time
    duration = float(storyboard.actual_duration or storyboard.estimated_duration or 3.0)
    end_time = current_time + duration

    # 使用计算的值,忽略数据库中的值
    tracks['storyboard'].items.append(StoryboardBoardItem(
        start_time=start_time,
        end_time=end_time,
        ...
    ))

    current_time = end_time

关键发现:后端完全忽略数据库中存储的 startTime/endTime,而是根据 orderIndex 顺序累积计算。

3. 前端更新逻辑冲突

前端在拖拽结束时会同时更新两个值:

// client/src/hooks/useStoryboardBoardLogic.ts:1417-1422(修复前)
reorderStoryboards({ projectId: currentProjectId, orderedIds });
storyboardTimeUpdates.forEach((update) => {
  updateStoryboard({
    id: update.id,
    data: { startTime: update.startTime, endTime: update.endTime },
  });
});

问题:前端更新了数据库中的 startTime/endTime,但下次刷新时后端会重新计算,导致数据丢失。

4. 数据流冲突示意图

用户拖拽分镜
    ↓
前端计算新的 startTime/endTime(基于像素位置)
    ↓
更新数据库(同时更新 orderIndex 和 startTime/endTime)
    ↓
刷新页面
    ↓
后端根据 orderIndex 重新计算 startTime/endTime(忽略数据库值)
    ↓
前端渲染时使用后端计算的值
    ↓
如果计算逻辑不一致 → 重叠或错位

5. 具体重叠场景

场景示例

  1. 初始状态(orderIndex: [0, 1, 2])

    • 分镜1: 0-3s
    • 分镜2: 3-6s
    • 分镜3: 6-9s
  2. 用户拖拽分镜2到最后

    • 前端计算: 分镜1(0-3s), 分镜3(3-6s), 分镜2(6-9s)
    • 更新数据库: orderIndex=[0, 2, 1], startTime/endTime 也被更新
  3. 刷新页面

    • 后端读取 orderIndex: [0, 2, 1]
    • 后端重新计算: 分镜1(0-3s), 分镜3(3-6s), 分镜2(6-9s)
    • 但如果数据库中的 startTime/endTime 没有被正确更新,或者后端使用了数据库值,就会出现不一致

解决方案

核心原则

根据用户需求"抛开时间轴的概念,按照 orderIndex 排序",采用 orderIndex 为唯一真相来源 的架构:

  1. orderIndex 是唯一的排序依据
  2. startTime/endTime 由 orderIndex + duration 实时计算
  3. 前端拖拽只更新 orderIndex
  4. 数据库中的 startTime/endTime 作为缓存(可选)

修改内容

1. 前端修改:移除拖拽时更新 startTime/endTime

文件: client/src/hooks/useStoryboardBoardLogic.ts

修改位置: handleDragEnd 方法(约第1399-1422行)

修改前:

const orderedIds = reorderedStoryboards.map((item) => item.id.toString());
const storyboardTimeUpdates = reorderedStoryboards
  .map((item) => {
    const pos = finalPositions[item.id];
    if (!pos) return null;
    if (pos.startTime === item.startTime && pos.endTime === item.endTime) return null;
    return { id: item.id.toString(), startTime: pos.startTime, endTime: pos.endTime };
  })
  .filter(
    (update): update is { id: string; startTime: number; endTime: number } =>
      update !== null
  );

updateStoryboardBoard({
  projectId: currentProjectId,
  tracks: newTracks,
});

reorderStoryboards({ projectId: currentProjectId, orderedIds });
storyboardTimeUpdates.forEach((update) => {
  updateStoryboard({
    id: update.id,
    data: { startTime: update.startTime, endTime: update.endTime },
  });
});

修改后:

const orderedIds = reorderedStoryboards.map((item) => item.id.toString());

// 注意:不再更新 startTime/endTime 到数据库
// 后端会根据 orderIndex 自动重新计算 startTime/endTime
// 这样可以确保数据一致性,避免重叠问题

updateStoryboardBoard({
  projectId: currentProjectId,
  tracks: newTracks,
});

reorderStoryboards({ projectId: currentProjectId, orderedIds });

修改原因:

  • 移除了 storyboardTimeUpdates 的计算和更新逻辑
  • 只保留 reorderStoryboards 调用,仅更新 orderIndex
  • 后端会根据 orderIndex 自动重新计算 startTime/endTime

验证修改

1. 代码检查

已确认以下内容:

  • handleDragEnd 中移除了 storyboardTimeUpdates 相关代码
  • 保留了 reorderStoryboards 调用(更新 orderIndex)
  • 保留了 updateStoryboardBoard 调用(更新前端缓存)
  • 其他地方没有直接更新 startTime/endTime 的逻辑

2. 数据流验证

修改后的数据流:

用户拖拽分镜
    ↓
前端计算新的排序(基于插入位置)
    ↓
更新 orderIndex 到数据库
    ↓
刷新页面
    ↓
后端根据 orderIndex 重新计算 startTime/endTime
    ↓
前端渲染(使用后端计算的值)
    ↓
数据一致,无重叠

测试建议

1. 基础功能测试

  • 创建 3-5 个分镜
  • 拖拽分镜改变顺序
  • 刷新页面,验证顺序保持正确
  • 验证分镜块之间没有重叠
  • 验证分镜块之间的间隔正常

2. 边界情况测试

  • 拖拽第一个分镜到最后
  • 拖拽最后一个分镜到最前
  • 拖拽中间分镜到任意位置
  • 连续多次拖拽
  • 拖拽后立即刷新页面

3. 大数据量测试

  • 创建 50+ 分镜
  • 测试拖拽性能
  • 验证虚拟滚动是否正常工作
  • 验证骨架数据加载是否正常

后续优化工作

1. handleInsertStoryboard 方法重构(可选)

当前问题handleInsertStoryboard 方法仍然使用基于时间的插入逻辑,与 orderIndex-based 架构不一致。

建议:重构为基于 orderIndex 的插入逻辑:

  • 根据插入位置计算目标 orderIndex
  • 调用后端 API 插入新分镜
  • 后端自动调整后续分镜的 orderIndex

优先级:低(不影响当前拖拽排序功能)

2. 数据库 startTime/endTime 字段处理

当前状态:数据库中仍然存储 startTime/endTime,但这些值可能与实际渲染不一致。

选项 A:保留字段作为缓存

  • 优点:可以用于快速查询和排序
  • 缺点:需要维护数据一致性

选项 B:移除字段,完全依赖 orderIndex

  • 优点:数据模型更简洁,无一致性问题
  • 缺点:需要数据库迁移

建议:暂时保留字段,观察一段时间后再决定。

3. 虚拟滚动完整实现

当前状态:已实现骨架数据和虚拟滚动辅助函数,但尚未在组件层面完全集成。

待完成

  • 在 StoryboardBoardPanel 中实现滚动监听
  • 根据可见范围加载分页数据
  • 实现预加载策略

参考ADR 003: 分镜看板基于 orderIndex 的性能优化

相关文件

前端

  • client/src/hooks/useStoryboardBoardLogic.ts - 主要修改文件
  • client/src/hooks/useStoryboardBoardVirtualScroll.ts - 虚拟滚动辅助
  • client/src/hooks/api/useStoryboardBoard.ts - API Hooks
  • client/src/services/api/storyboard-board.ts - API 客户端
  • client/src/types/storyboard-board.ts - 类型定义

后端

  • server/app/models/storyboard.py - 数据模型
  • server/app/services/storyboard_board_service.py - 业务逻辑
  • server/app/api/v1/storyboard_board.py - API 路由

文档

  • docs/client/changelogs/2026-02-08-storyboard-board-optimization.md - 性能优化实施记录
  • docs/server/adrs/03-storyboard-keyframe-management.md - 分镜关键帧管理 ADR

总结

修复效果

通过移除前端拖拽时更新 startTime/endTime 的逻辑,确保了:

  1. 数据一致性:orderIndex 是唯一真相来源,startTime/endTime 由后端统一计算
  2. 无重叠问题:后端按 orderIndex 顺序累积计算,保证分镜块连续排列
  3. 拖拽排序正确:只更新 orderIndex,刷新后位置保持正确

架构优势

  1. 简化数据流:单向数据流,前端 → orderIndex → 后端计算 → 前端渲染
  2. 易于维护:计算逻辑集中在后端,前端只负责展示
  3. 性能优化:配合骨架数据和虚拟滚动,支持大规模数据

注意事项

  1. 数据库字段:startTime/endTime 字段仍然存在,但不再作为主要数据源
  2. 向后兼容:现有数据不受影响,后端会自动重新计算
  3. 后续优化:handleInsertStoryboard 方法需要重构(优先级低)