## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
30 KiB
Infinite Canvas — PySide6 → React/Electron 重构方案
基于
infinite_canvas_140/源码分析,映射到当前 React + TypeScript + Electron 技术栈。 重点关注:文件夹 → 媒体文件映射逻辑(核心差异点)
一、技术栈映射
| PySide6(原版) | React/Electron(目标) | 说明 |
|---|---|---|
QGraphicsView |
虚拟滚动 <canvas> / position:absolute 布局 |
无限画布渲染 |
QGraphicsScene |
Zustand Store (items 集合) | 场景中所有 item 的状态 |
QGraphicsItem |
React 组件 + absolute 定位 |
单个缩略图卡片 |
QThreadPool + QRunnable |
Web Worker / Electron IPC | 后台任务(扫描/加载) |
Signal.connect |
Event Bus (bus.ts) 已就绪 |
事件解耦 |
QSharedMemory |
app.getSingleInstanceLock() |
单实例检测 |
QPixmapCache |
Blob URL + Map<cache> |
图片内存缓存 |
| sidecar JSON 文件 | 同方案(独立的 .meta.json 文件) |
元数据旁加载 |
os.walk 扫描 |
Electron fs.readdir / IPC |
文件系统遍历 |
二、文件夹 → 媒体文件映射机制(重点)
2.1 目录结构约定
原版项目的核心映射逻辑在 ScanCoordinator._build_scan_roots() 和 _group_key_for_path() 中。
目录结构遵循固定的 AIGC 生产管线约定:
工作区根目录 (ROOT_STORAGE)
└── 项目A/
├── shots/ ← 分镜/关键帧/视频/音频/口型
│ ├── E01/ ← 集数 (episode)
│ │ ├── SC001/ ← 镜头号 (shot)
│ │ │ ├── Storyboard/ ← 阶段:分镜
│ │ │ │ ├── E01_SC001_001.jpg
│ │ │ │ ├── E01_SC001_001.meta.json ← 旁加载元数据
│ │ │ │ └── ...
│ │ │ ├── Keyframe/ ← 阶段:关键帧
│ │ │ │ └── ...
│ │ │ ├── Video/ ← 阶段:视频
│ │ │ │ └── ...
│ │ │ ├── Audio/ ← 阶段:音频
│ │ │ │ └── ...
│ │ │ └── LipSync/ ← 阶段:口型
│ │ │ └── ...
│ │ └── SC002/
│ │ └── ...
│ └── E02/
│ └── ...
└── assets/ ← 资产
├── chr/ ← 角色
│ ├── 角色名A/
│ │ ├── image001.png
│ │ ├── image001.meta.json ← 包含 tags, description
│ │ └── ...
│ └── ...
├── prp/ ← 道具
└── set/ ← 场景
2.2 路径 → 阶段/分组 的解析算法
这是原版的核心逻辑,决定了一个文件属于哪个 阶段(Stage) 和哪个 分组(Group)。
┌─────────────── 解析入口函数 ──────────────────────────────┐
detectStage(filePath: string): StageProfile
├─ 路径含有 "/Storyboard/" → Stage.Storyboard
│ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"]
│ ├─ uses_episode: true
│ ├─ uses_shot_query: true
│ ├─ audit_enabled: true
│ └─ sidecar_fields: ["remark", "audit_status"]
│
├─ 路径含有 "/Keyframe/" → Stage.Keyframe
│ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"]
│ ├─ uses_episode: true
│ ├─ uses_shot_query: true
│ ├─ audit_enabled: true
│ └─ sidecar_fields: ["description","remark","audit_status"]
│
├─ 路径含有 "/Video/" → Stage.Video
│ ├─ includes: ["mp4","webm","mov","avi","mkv"]
│ ├─ uses_episode: true
│ ├─ uses_shot_query: true
│ ├─ audit_enabled: true
│ └─ sidecar_fields: ["description","remark","audit_status"]
│
├─ 路径含有 "/Audio/" → Stage.Audio
│ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"]
│ ├─ uses_episode: true
│ └─ sidecar_fields: ["remark"]
│
├─ 路径含有 "/LipSync/" → Stage.LipSync
│ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"]
│ ├─ uses_episode: true
│ └─ sidecar_fields: ["remark"]
│
└─ 路径含有 "/assets/" → Stage.Assets
├─ includes: ["jpg","jpeg","png","webp","bmp","gif","mp4","webm"]
└─ sidecar_fields: ["description","tags"]
┌─────────────── 分组键提取 ──────────────────────────────┐
groupKeyForPath(filePath: string): string
│
├─ 若 stage === Assets:
│ ├─ 向上找 assets/ 目录
│ ├─ assets 下一层 → type (chr/prp/set)
│ ├─ type 下一层 → name (角色名/道具名/场景名)
│ └─ 返回 "{type}/{name}" 例: "chr/主角A"
│
└─ 若 stage ≠ Assets:
├─ 向上找到 shots/ 目录
├─ shots 下一层 → episode (E01)
├─ episode 下一层 → shot (SC001)
└─ 返回 "{episode}/{shot}" 例: "E01/SC001"
2.3 扫描根目录计算
buildScanRoots(projectPath, episodePath, stage): string[]
│
├─ 若 stage === Assets:
│ ├─ assetTypeFilter === "全部" → [project/assets/]
│ ├─ "角色" → [project/assets/chr/]
│ ├─ "道具" → [project/assets/prp/]
│ └─ "场景" → [project/assets/set/]
│
└─ 若 stage !== Assets:
├─ 若指定了 episodePath → episodes = [episodePath]
├─ 否则 → episodes = project/shots/ 下的所有子目录
└─ 对每个 episode:
├─ 列出所有 shot_name 子目录
├─ 若 shot_query 不为空 → 过滤匹配的 shot
└─ 对每个 shot:追加 {shot_dir}/{stage}/
2.4 Electron 中的 IPC 实现要点
由于浏览器没有 os.walk,文件扫描必须走 Electron 主进程:
渲染进程 主进程
───────── ────────
useDirectoryScan(projectPath, ipcMain.handle('fs:scan-dir',
stage, filters) (event, {roots, exts, recursive}) => {
→ contextBridge const results = []
.scanDirectory({roots, for (const root of roots) {
exts, recursive}) walkSync(root, (file) => {
← Promise<CatalogItem[]> if exts.has(ext) {
→ dispatch(setCatalog(items)) results.push({
path: file,
metadata: readSidecar(file)
})
}
})
}
return results
})
⚠️ 安全注意:
- 扫描根目录必须从用户选择的 workspace root 派生,不能接受任意路径
- 使用 contextBridge 暴露白名单方法,禁止渲染进程直接访问 fs 模块
- 大目录扫描应分批 emit(每 80 个一批),避免主进程阻塞
三、React 状态架构
3.1 Store 设计 (Zustand)
对应原版 CatalogModel + SelectionModel + CanvasStateMachine 三者:
// stores/useCanvasStore.ts
interface CanvasState {
// ── Catalog(原 CatalogModel._items)──
items: CatalogItem[]; // 全量文件列表
totalCount: number;
// ── Selection(原 SelectionModel)──
selectedIds: Set<number>;
// ── Stage & Filters ──
currentStage: StageType; // Storyboard | Keyframe | Video | Audio | LipSync | Assets
keywordQuery: string;
tagQuery: string;
auditFilter: AuditFilter; // all | pending | approved | rework | deprecated
assetTypeFilter: AssetType; // 全部 | 角色 | 道具 | 场景
shotQuery: string;
// ── Layout ──
layoutMode: 'grid' | 'single';
cols: number;
rowsPerPage: number;
singleRowsPerPage: number;
showShotPlaceholders: boolean;
// ── Pagination ──
currentPage: number;
pageEpoch: number; // 防止过期回调污染的版本号
// ── Canvas transform ──
zoom: number; // 当前缩放倍率
panOffset: { x: number; y: number };
// ── Interaction mode ──
mode: 'BROWSE' | 'SELECT' | 'REORDER';
// ── Meta ──
isScanning: boolean;
scanProgress: { found: number; total: number | null };
}
3.2 CatalogItem 实体
interface CatalogItem {
id: number; // 全局唯一序号
path: string; // 文件系统绝对路径
fileName: string; // 文件名(不含路径)
ext: string; // 扩展名(小写)
stage: StageType; // 所属阶段
episodeName: string; // 集数名(从路径提取)
shotName: string; // 镜头名(从路径提取)
groupKey: string; // 分组键("E01/SC001" 或 "chr/主角A")
metadata: MediaMetadata | null; // 旁加载元数据
}
interface MediaMetadata {
// 通用字段
remark?: string;
description?: string; // AIGC Prompt
tags?: string[];
original_name?: string;
// 审核字段(仅 Storyboard / Keyframe / Video)
audit_status?: 'pending' | 'approved' | 'rework' | 'deprecated';
// 媒体属性(扫描时填充)
width?: number;
height?: number;
duration?: number; // 视频/音频时长
fileSize?: number;
}
四、组件树
<InfiniteCanvasPage> ← 路由 /canvas(Drawer 叠加模式)
├─ <CanvasStageOverlay> ← 阶段切换 Tab(左上角浮层)
├─ <CanvasFilterBar> ← 项目/集数/镜头/关键词/审核筛选(顶部浮层)
├─ <CanvasModeBar> ← 模式切换(右下角浮层)
│
├─ <CanvasViewport> ← 核心画布:CSS transform 驱动的虚拟视口
│ │ transform: scale(zoom) translate(panOffset)
│ │
│ ├─ <ShotPlaceholder>×N ← 占位符(single 模式下空镜头行)
│ ├─ <RowLabel>×N ← 行标签(single 模式下左侧镜头号)
│ │
│ └─ <MediaCard>×N ← 核心渲染单元 = 原版 StoryboardItem
│ ├─ props: { item, x, y, w, h, behaviors[] }
│ ├─ <CheckerboardBackground /> ← 棋盘格背景(图片加载前)
│ ├─ <LODImage /> ← 多级 LOD 图片
│ ├─ <AuditBadge /> ← 审核角标(pending/approved/rework/deprecated)
│ ├─ <SelectionCheckbox /> ← 选择框(SELECT 模式下可见)
│ └─ <MediaCardContextMenu /> ← 右键菜单
│
├─ <PagerOverlay> ← 分页器(底部居中浮层)
├─ <ZoomOverlay> ← 缩放控件(右下角浮层)
└─ <QuickEditPanel> ← 快速编辑面板(选中 item 时右侧弹出)
4.1 为什么用 CSS Transform 而不是 <canvas>
| 方案 | 优点 | 缺点 |
|---|---|---|
<canvas> |
像素级控制,无 DOM 开销 | 无法使用 React 组件、无障碍差 |
| CSS Transform | 天然 React 友好、CSS 动画 | 大量 item 时 DOM 节点多 |
选择 CSS Transform + 虚拟化:只渲染视口内的 item,离屏 item 不挂载 DOM。
这等价于原版的分页机制——用户只会看到当前页的 ~200-400 个 item。
五、LOD 图片加载策略
5.1 缩略图尺寸分级
Level 0: 512px — 缩略图(快速扫描/小缩放)
Level 1: 1024px — 中等(1:1 显示)
Level 2: 2048px — 高清(放大查看)
Level 3: 4096px — 原图(仅在 MediaPreviewWindow 中使用)
5.2 浏览器端的实现方式
原版用 QThreadPool + QImage.scaled() 生成缩略图。浏览器端有两种策略:
策略 A:服务端缩略图(推荐)
GET /api/v1/media/thumbnail?path={path}&size=512
→ 后端生成缩略图 → 返回 JPEG/WebP
→ 优势:不阻塞渲染进程,支持 CDN 缓存
策略 B:本地缩略图(Electron 离线模式)
IPC → 主进程 sharp 库缩放 → base64 → Blob URL
→ 优势:离线可用,不依赖后端
5.3 加载调度器设计
对应原版 LoadScheduler:
// hooks/useMediaLoader.ts
class LoadScheduler {
private inflight = new Set<string>(); // (path, level) 去重
private pending: Array<LoadRequest> = []; // 等待队列
private maxConcurrent = 10; // 最大并发
request(path: string, level: number): Promise<BlobURL> {
const key = `${path}@${level}`;
if (this.inflight.has(key)) {
// 已在加载中 → 返回同一个 Promise(复用)
return this.pending.find(r => r.key === key)!.promise;
}
if (this.inflight.size >= this.maxConcurrent) {
// 排队等待
return this.enqueue(key, path, level);
}
return this.startLoad(key, path, level);
}
}
5.4 视口感知的加载优先级
// 在 CanvasViewport 的 scroll/resize 事件中:
function updateVisibleItems() {
const visible = getVisibleItemIds(viewportRect, allItemRects);
// 1. 取消已离开视口的加载请求
cancelOutOfView(visible);
// 2. 视口中心的 item 优先加载高分辨率
const center = viewportCenter();
visible.sort((a, b) => distance(center, a) - distance(center, b));
// 3. 可见 → level 基于 zoom 决定,不可见 → 不加载
for (const item of visible) {
const level = zoomToLODLevel(currentZoom);
scheduler.request(item.path, level);
}
}
六、数据流全链路
6.1 打开工作区 → 扫描 → 显示
用户选择工作区 (Electron dialog.showOpenDialog)
→ IPC: 'fs:open-workspace'
→ 主进程验证目录结构(是否有 shots/ 和 assets/)
→ 返回 projectList
→ store.setCurrentWorkspace(rootDir)
用户选择项目/集数/阶段
→ CanvasFilterBar.onChange
→ store.setFilters({project, episode, stage})
→ useDirectoryScan hook 自动触发
useDirectoryScan(stage, scanRoots, filters):
→ 防抖 200ms(避免连续点选触发多次扫描)
→ IPC: 'fs:scan-dir'({roots, exts, recursive})
→ 主进程 walkSync:
for each file:
if ext in allowedExts:
if passesFilters(file, keyword, tag, audit):
readSidecar(file) // 并行读取 .meta.json
mainWindow.webContents.send('scan:found', {path, metadata})
→ 分批 emit(每 80 个一批,30ms 间隔)
→ 渲染进程收到 → store.addItems(batch)
→ 若 item 在当前页 → 创建 MediaCard 组件
→ MediaCard 挂载 → useMediaLoader.request(path) → 异步加载缩略图
→ onLoad → setBlobURL → <img src={blobURL} />
→ 扫描完成 → store.setScanFinished(true)
6.2 分页切换
PagerOverlay.onClick(page)
→ store.requestPage(page)
→ 防抖 100ms
→ store.gotoPage(page):
1. pageEpoch += 1 ← 过期标记
2. 中止所有 inflight 加载
3. 清空当前页 DOM(卸载 MediaCard 组件)
4. 计算 pageBounds(page) ← grid: [start, end];
single: [group_start, group_end]
5. 创建新的 MediaCard 组件
6. 触发 LOD 加载
7. 更新 PagerOverlay 显示
6.3 文件导入(拖放/粘贴)
CanvasViewport.onDrop(files)
→ 提取文件路径
→ 若非 Assets 阶段:
ShotPickerDialog: 选择目标集数/镜头
→ IPC: 'fs:import-file'({src, destDir, prefix})
→ 主进程 fs.copyFile + 创建 .meta.json
→ 若 Assets 阶段:
AssetImportDialog: 选择资产类型/名称 + 标签
→ IPC: 'fs:import-asset'({src, projectPath, assetType, assetName, tags})
→ integrationBus.files_changed.emit(importedPaths)
→ 触发增量扫描(仅扫描新增文件)
七、布局算法
7.1 Grid 模式
function gridPosition(itemId: number, page: number, cols: number): Position {
const start = (page - 1) * cols * rowsPerPage;
const localIdx = itemId - start;
const row = Math.floor(localIdx / cols);
const col = localIdx % cols;
return {
x: col * (ITEM_W + ITEM_SPACING),
y: row * (ITEM_H + ITEM_SPACING),
};
}
7.2 Single 模式(按镜头号分组,每行横向排列)
function singlePosition(itemId: number, page: number): Position {
const groupIdx = itemToGroup[itemId]; // ← groupKey 映射
const groupsOnPage = getGroupBounds(page); // [startGroup, endGroup)
const row = groupIdx - groupsOnPage[0];
const col = groupItems[groupIdx].indexOf(itemId);
return {
x: ROW_LABEL_WIDTH + SPACING + col * (ITEM_W + ITEM_SPACING),
y: row * (ITEM_H + ITEM_SPACING),
};
}
八、与现有项目的集成
8.1 路由
无限画布作为一个 Drawer 叠加层,通过事件总线打开(符合 QT 多窗口模式):
// 某个入口按钮:
emit(EVENTS.OPEN_PAGE, {
target: '/infinite-canvas',
modalType: 'drawer',
props: {
workspaceRoot: '/path/to/project',
initialStage: 'Keyframe',
},
});
8.2 复用现有基础设施
| 现有模块 | 用在哪 |
|---|---|
src/utils/bus.ts |
事件解耦(替代 Qt Signal) |
src/infrastructure/storage/ |
媒体缓存元数据持久化(sql.js) |
src/services/request.ts |
CDN 缩略图下载(带 Auth) |
src/utils/media-upload.ts |
buildAccept / 类型校验 |
src/hooks/use-async.ts |
useAsyncData 加载状态管理 |
src/components/frame/PageDispatcher.tsx |
Drawer 模式打开画布 |
8.3 新增模块
src/pages/infinite-canvas/
├── CanvasPage.tsx ← 页面入口(Drawer 内容)
├── stores/
│ └── useCanvasStore.ts ← Zustand Store(上文设计)
├── hooks/
│ ├── useDirectoryScan.ts ← IPC 扫描 Hook
│ ├── useMediaLoader.ts ← LOD 加载调度
│ └── useCanvasInteraction.ts ← 拖拽/缩放/平移交互
├── components/
│ ├── CanvasViewport.tsx ← 核心画布(CSS Transform + 虚拟化)
│ ├── MediaCard.tsx ← 缩略图卡片
│ ├── MediaCardContextMenu.tsx ← 右键菜单
│ ├── MediaPreview.tsx ← 全屏预览(Lightbox)
│ ├── ShotPlaceholder.tsx ← 空镜头占位符
│ ├── RowLabel.tsx ← 行标签
│ ├── PagerOverlay.tsx ← 分页器浮层
│ ├── ZoomOverlay.tsx ← 缩放控件
│ ├── StageTabs.tsx ← 阶段切换
│ ├── FilterBar.tsx ← 筛选栏
│ ├── QuickEditPanel.tsx ← 快速编辑
│ ├── ModeSwitch.tsx ← 模式切换
│ └── AssetImportDialog.tsx ← 资产导入对话框
└── electron/
├── scanner-ipc.ts ← 主进程文件扫描 IPC handler
└── thumbnail-worker.ts ← 缩略图生成 Worker
九、实施路径(分阶段)
Phase 1:目录浏览(文件夹 → 文件映射核心)
- Electron IPC 目录扫描(
scanner-ipc.ts) buildScanRoots()+groupKeyForPath()算法实现useCanvasStore基础状态- 静态列表展示扫描结果(先做表格验证,不走画布)
Phase 2:画布渲染
CanvasViewportCSS Transform 虚拟视口MediaCard+ 棋盘格背景- LOD 缩略图加载
- 分页(Grid 模式)
Phase 3:交互完善
- BROWSE / SELECT / REORDER 模式
- 右键菜单 + ContextMenuBuilder
- 拖放导入
- 快速编辑面板
Phase 4:性能优化
- 虚拟化(只渲染视口内 item)
- Web Worker 缩略图生成
- 防抖/节流优化
- 增量扫描(watchdog)
十、关键差异记录
| 原版 (PySide6) | 重构版 (React/Electron) | 理由 |
|---|---|---|
QThreadPool 后台线程扫描 |
Electron IPC → 主进程 fs 操作 |
浏览器沙箱不能直接操作文件系统 |
QImage.scaled() 本地缩略图 |
服务端 API 或 sharp 在主进程生成 |
浏览器 Image 解码有限 |
QGraphicsItem.paint() 自绘 |
React 组件 + <img> / <video> |
React 声明式渲染更易维护 |
| QGraphicsScene 自动脏区域管理 | 虚拟化 + React.memo + useMemo | 手动控制渲染范围 |
sidecar 是独立 .json 文件 |
同方案 | 保持兼容,不改变存储格式 |
QSharedMemory 单实例锁 |
app.requestSingleInstanceLock() |
Electron 标准 API |
QOpenGLWidget 硬件加速 |
浏览器 GPU 合成(无需额外操作) | CSS transform 天然触发 GPU 合成 |
十一、补充注意事项
11.1 旁加载元数据 JSON 格式(sidecar)
原版 MetadataService 将元数据和图片文件同目录、同名、不同扩展名存放:
SC001_Storyboard_001.jpg ← 图片
SC001_Storyboard_001.json ← 元数据(同目录)
JSON 结构(_default_sidecar_metadata):
{
"schema_version": 1,
"favorite": false,
"remark": "",
"description": "",
"tags": [],
"create_time": "2026-06-22 14:30:00",
"original_name": "SC001_Storyboard_001.jpg",
"audit_status": "pending"
}
⚠️ 关键设计:
audit_status仅对 Storyboard / Keyframe / Video 阶段有效,默认值"pending"- 审核状态流转:
pending → approved → rework → deprecated - JSON 文件与图片文件是一一对应关系,删除图片时需要同时清理 JSON
- 重构时必须读写兼容已有 JSON 格式(
schema_version用于未来迁移)
11.2 审核状态汇聚算法
原版 MetadataService 有两个汇聚函数,用于计算"镜头"和"集数"层的总体审核状态:
computeStageAuditStatus(stageDir):
对该阶段目录下所有文件:
├─ 任一 audit_status === "rework" → 返回 "rework"
├─ 全部 audit_status === "approved" → 返回 "approved"
└─ 否则 → 返回 "pending"
computeShotAuditStatus(shotDir):
收集 [Storyboard, Keyframe, Video] 三个阶段的状态:
├─ 任一阶段 "rework" → 返回 "rework"
├─ 任一阶段 "pending" → 返回 "pending"
└─ 全部 "approved" → 返回 "approved"
Web 端需要在扫描完成后批量计算这些汇聚值,缓存到 Store 中供 UI 角标使用。
11.3 Shot 名称匹配算法(shot_query 过滤)
shotNameMatchesQuery(shotName: string, query: string): boolean
├─ query 为空 → 匹配所有
├─ query.toUpperCase() in shotName.toUpperCase() → 匹配
├─ query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
│ 例: query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
└─ 不匹配 → 过滤掉该镜头目录
这个算法是用户在筛选栏输入 1 就能过滤出所有编号含 1 的镜头的关键。
11.4 CanvasLauncher 上下文推导
原版 CanvasLauncher.launch() 在打开无限画布时,会根据当前 Tab 和选中路径自动推导 projectPath 和 episodePath:
若 tab 为 Storyboard/Keyframe/Video/Audio/LipSync:
├─ episodePath 为空 → 从 currentShotPath 向上取一级(父目录)
└─ projectPath 为空 → 沿 episodePath 向上找到 "shots" 的父目录
若 tab 为 Assets:
└─ 只需要 projectPath,不需要 episodePath
重构时 CanvasPage 的打开入口也需要实现同样的推导逻辑,确保用户在 Tab 中选中镜头后点击"无限画布"能直接定位到正确目录。
11.5 Behavior 链在 React 中的落地
原版的 IItemBehavior 协议链是运行时组合的。React 中建议编译时组合:
// 原版:运行时循环调用
// for (const behavior of behaviors) {
// if (behavior.onMousePress(item, event)) break;
// }
// React 版:编译时 HOC / Hook 组合
function MediaCard({ item, mode, ... }: MediaCardProps) {
// 每个行为是一个 Hook,按需启用
const selection = useSelectionBehavior(item, { enabled: mode !== 'BROWSE' });
const contextMenu = useContextMenuBehavior(item, { builder: menuBuilder });
const drag = useDragBehavior(item, { enabled: mode !== 'REORDER' });
const preview = usePreviewBehavior(item);
return (
<div
onClick={selection.handleClick}
onContextMenu={contextMenu.handleContextMenu}
onDragStart={drag.handleDragStart}
onDoubleClick={preview.handleDoubleClick}
// ...
>
{/* ... */}
</div>
);
}
11.6 跨窗口通信(Canvas ↔ 宿主 Tab)
原版通过 IntegrationBus(全局单例 Signal)实现画布修改后通知宿主 Tab 刷新:
无限画布中修改了 sidecar 元数据
→ MetadataService.update_sidecar_metadata()
→ EventBus.sidecar_changed.emit([path])
→ IntegrationBus.sidecar_changed.emit([path]) ← 全局信号
→ CanvasLauncher 中的 refresh_timer (250ms 防抖)
→ 宿主 Tab 调用 refresh_callback(tabName)
→ Tab 重新加载数据
React 版中,这个链路可以用事件总线 + 防抖实现:
// 画布中:
emit(EVENTS.SIDECAR_CHANGED, { paths: [changedPath] });
// CanvasLauncher/PageDispatcher 中:
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const handler = () => {
clearTimeout(timer);
timer = setTimeout(() => refreshCurrentTab(), 250);
};
on(EVENTS.SIDECAR_CHANGED, handler);
return () => off(EVENTS.SIDECAR_CHANGED, handler);
}, []);
同样,files_changed(文件导入后)也需要通知宿主刷新。
11.7 文件变更检测(增量扫描)
原版通过 integration_bus.files_changed 支持外部文件变化后触发 refresh_all()。重构时需要实现:
- 方案 A:Electron 主进程使用
fs.watch(或chokidar)监听工作区目录变化 → IPC 通知渲染进程 - 方案 B:每次画布窗口获得焦点时,对比文件列表指纹(最后修改时间),有变化才重新扫描
- ⚠️ 推荐方案 B:
fs.watch在大目录下有性能问题,而且用户可能通过其他程序修改文件
11.8 DOM 渲染数量的硬上限
原版通过分页自然限制同时渲染的 item 数量(200-400 个)。React 版即使做虚拟化,也应该设置硬上限:
| 布局模式 | Max items/页 | 说明 |
|---|---|---|
| Grid | cols × rows(默认 9×16=144) |
网格布局 |
| Single | singleRowsPerPage × 平均每组文件数 |
可能达到数百个 |
- ⚠️ 如果
singleRowsPerPage设为 9999("显示全部"),渲染 10000+ 个 MediaCard 会导致浏览器卡死 - 建议强制上限:单页最多渲染 500 个 item,超过时自动分割
11.9 Blob URL 生命周期管理
原版 QPixmap 由 Qt 自动管理。React 版的 URL.createObjectURL(blob) 必须显式释放:
// MediaCard 组件:
useEffect(() => {
return () => {
// 组件卸载时释放 blob URL
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [blobUrl]);
// 切页时批量释放:
function gotoPage(newPage: number) {
// 👇 必须在 scene.clear() 前释放所有 blob URL
for (const item of currentPageItems) {
if (item.blobUrl) URL.revokeObjectURL(item.blobUrl);
}
// ...
}
⚠️ 不释放的后果:每个 blob URL 占用内存直到页面关闭,浏览数百页后可能耗尽浏览器内存。
11.10 棋盘格背景的 CSS 实现
原版用 QPainter 绘制 2×2 像素棋盘格。CSS 实现:
.media-card-checkerboard {
background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
}
比原版的 QImage 缓存方案简单得多,且纯 CSS 零内存开销。
11.11 树浏览器(ShotBrowser)与画布的联动
原版 AIGCManagerApp 左侧的 ShotBrowser(项目→集数→镜头三层树)通过 shotSelected 信号驱动右侧 Tab 加载。重构时这块逻辑可以复用项目已有的 ModelSelector / 树形组件模式:
左侧 ShotBrowser(树组件) 右侧画布
───────────────────── ────────
选择项目 → setProject(projectPath) → FilterBar 更新项目下拉
选择集数 → setEpisode(episodePath) → FilterBar 更新集数下拉
选择镜头 → setShot(shotPath) → 按镜头筛选 + 切换 StageTab
树节点通过 computeShotAuditStatus() 计算审核状态角标颜色(绿=全通过、黄=待审核、红=需返工)。