feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一

## 构建、打包与分发系统重构 (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>
This commit is contained in:
2026-06-29 18:41:07 +08:00
parent e0d91335c7
commit 337d6c1205
187 changed files with 5252 additions and 1702 deletions

View File

@@ -0,0 +1,38 @@
// ============================================================
// episode-menu.ts — 集数选择器右键菜单定义
//
// 菜单结构:新建集数 | 统计集数 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface EpisodeMenuOptions {
canCreate: boolean;
canOpenFolder: boolean;
onCreate: () => void;
onStats: () => void;
onOpenFolder: () => void;
}
export function getEpisodeMenuItems(opts: EpisodeMenuOptions): ContextMenuItems {
return [
{
key: 'new-episode',
label: '新建集数',
onClick: opts.onCreate,
disabled: !opts.canCreate,
},
{
key: 'stats-episode',
label: '统计集数',
onClick: opts.onStats,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: opts.onOpenFolder,
disabled: !opts.canOpenFolder,
},
];
}

View File

@@ -0,0 +1,15 @@
// ============================================================
// menus — 无限画布右键菜单定义统一导出
// ============================================================
export { getProjectMenuItems } from './project-menu';
export type { ProjectMenuOptions } from './project-menu';
export { getEpisodeMenuItems } from './episode-menu';
export type { EpisodeMenuOptions } from './episode-menu';
export { getShotMenuItems } from './shot-menu';
export type { ShotMenuOptions } from './shot-menu';
export { getMediaCardMenuItems } from './media-card-menu';
export type { MediaCardMenuOptions } from './media-card-menu';

View File

@@ -0,0 +1,54 @@
// ============================================================
// media-card-menu.ts — 画布媒体卡片右键菜单定义
//
// Assets 阶段:
// 复制文件路径 | 在文件夹中打开
//
// 非 Assets 阶段Storyboard / Keyframe / Video 等):
// 标记为待审核 | 标记为通过 | 标记为返修 | 标记为已弃用 | —— | 复制文件路径 | 在文件夹中打开
//
// 通用操作(复制路径、在文件夹中打开)统一放到底部分隔线以下。
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
import type { AuditStatus } from '../../stores/useCanvasStore';
/** 审核状态 → 菜单显示文本 */
const AUDIT_LABELS: Record<AuditStatus, string> = {
pending: '标记为待审核',
approved: '标记为通过',
rework: '标记为返修',
deprecated: '标记为已弃用',
};
export interface MediaCardMenuOptions {
/** 是否可审核(非 Assets 阶段为 true */
isAuditable: boolean;
onSetAudit: (status: AuditStatus) => void;
onCopyPath: () => void;
onOpenFolder: () => void;
}
export function getMediaCardMenuItems(opts: MediaCardMenuOptions): ContextMenuItems {
const items: ContextMenuItems = [];
if (opts.isAuditable) {
const statuses: AuditStatus[] = ['pending', 'approved', 'rework', 'deprecated'];
for (const status of statuses) {
items.push({
key: `audit-${status}`,
label: AUDIT_LABELS[status],
onClick: () => opts.onSetAudit(status),
});
}
items.push('divider');
}
// 底部通用操作
items.push(
{ key: 'copy-path', label: '复制文件路径', onClick: opts.onCopyPath },
{ key: 'open-folder', label: '在文件夹中打开', onClick: opts.onOpenFolder },
);
return items;
}

View File

@@ -0,0 +1,46 @@
// ============================================================
// project-menu.ts — 项目选择器右键菜单定义
//
// 菜单结构:新建项目 | 统计项目 | 批量创建镜头 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface ProjectMenuOptions {
/** 是否可以新建(如未选工作区则禁用) */
canCreate: boolean;
/** 当前选中项目(用于"在文件夹中打开" */
canOpenFolder: boolean;
onCreate: () => void;
onStats: () => void;
onBatchShots: () => void;
onOpenFolder: () => void;
}
export function getProjectMenuItems(opts: ProjectMenuOptions): ContextMenuItems {
return [
{
key: 'new-project',
label: '新建项目',
onClick: opts.onCreate,
disabled: !opts.canCreate,
},
{
key: 'stats-project',
label: '统计项目',
onClick: opts.onStats,
},
{
key: 'batch-shots',
label: '批量创建镜头',
onClick: opts.onBatchShots,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: opts.onOpenFolder,
disabled: !opts.canOpenFolder,
},
];
}

View File

@@ -0,0 +1,50 @@
// ============================================================
// shot-menu.ts — 镜头列表项右键菜单定义
//
// 菜单结构:新建镜头 | —— | 重命名 | 删除 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface ShotMenuOptions {
/** 是否可操作(未选项目/集数时禁用) */
canOperate: boolean;
/** 选中的镜头名称 */
shotName: string;
onNewShot: () => void;
onRename: (shotName: string) => void;
onDelete: (shotName: string) => void;
onOpenFolder: (shotName: string) => void;
}
export function getShotMenuItems(opts: ShotMenuOptions): ContextMenuItems {
return [
{
key: 'new-shot',
label: '新建镜头',
onClick: opts.onNewShot,
disabled: !opts.canOperate,
},
'divider',
{
key: 'rename-shot',
label: '重命名',
onClick: () => opts.onRename(opts.shotName),
disabled: !opts.canOperate,
},
{
key: 'delete-shot',
label: '删除',
onClick: () => opts.onDelete(opts.shotName),
disabled: !opts.canOperate,
danger: true,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: () => opts.onOpenFolder(opts.shotName),
disabled: !opts.canOperate,
},
];
}

View File

@@ -0,0 +1,7 @@
// ============================================================
// hooks — 无限画布自定义 Hook
// ============================================================
export { useDirectoryScan } from './useDirectoryScan';
export { useCanvasInteraction } from './useCanvasInteraction';
export { useSidecarSync } from './useSidecarSync';

View File

@@ -0,0 +1,72 @@
// ============================================================
// useCanvasInteraction — 画布交互 Hook
//
// 提供:
// - 键盘快捷键处理(模式切换、全选、删除等)
// - 模式切换回调
// - 当前交互模式的状态
// ============================================================
import { useEffect, useCallback } from 'react';
import { useCanvasStore, type InteractionMode } from '../stores/useCanvasStore';
export function useCanvasInteraction() {
const interactionMode = useCanvasStore((s) => s.interactionMode);
const setInteractionMode = useCanvasStore((s) => s.setInteractionMode);
const clearSelection = useCanvasStore((s) => s.clearSelection);
const switchMode = useCallback(
(mode: InteractionMode) => {
setInteractionMode(mode);
if (mode !== 'SELECT') {
clearSelection();
}
},
[setInteractionMode, clearSelection],
);
// 键盘快捷键
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// 避免在输入框中处理快捷键
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
switch (e.key) {
case 'v':
case 'V':
// V → BROWSE mode
if (!e.ctrlKey && !e.metaKey) {
switchMode('BROWSE');
}
break;
case 's':
case 'S':
// S → SELECT mode
if (!e.ctrlKey && !e.metaKey) {
switchMode('SELECT');
}
break;
case 'r':
case 'R':
// R → REORDER mode
if (!e.ctrlKey && !e.metaKey) {
switchMode('REORDER');
}
break;
case 'Escape':
// Esc → 取消选择 / 回到 BROWSE
clearSelection();
if (interactionMode !== 'BROWSE') {
switchMode('BROWSE');
}
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [switchMode, clearSelection, interactionMode]);
return { interactionMode, switchMode };
}

View File

@@ -0,0 +1,164 @@
// ============================================================
// useDirectoryScan — 目录扫描 Hook
//
// 根据 selectedProject / selectedEpisode / selectedShot / currentStage
// 智能选择扫描策略:
// - Assets 阶段 → scanAssetFiles深度递归扫描 assets/
// - 非 Assets 阶段 → scanStageFiles扫描 shots/{ep}/{shot}/{stage}/
//
// 防抖 300ms窗口聚焦时自动重扫。
// ============================================================
import { useEffect, useRef, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { useCanvasStore } from '../stores/useCanvasStore';
export function useDirectoryScan() {
// 选择性订阅
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const selectedProject = useCanvasStore((s) => s.selectedProject);
const selectedEpisode = useCanvasStore((s) => s.selectedEpisode);
const selectedShot = useCanvasStore((s) => s.selectedShot);
const currentStage = useCanvasStore((s) => s.currentStage);
const assetTypeFilter = useCanvasStore((s) => s.assetTypeFilter);
const keywordQuery = useCanvasStore((s) => s.keywordQuery);
const tagQuery = useCanvasStore((s) => s.tagQuery);
const clearItems = useCanvasStore((s) => s.clearItems);
const appendItems = useCanvasStore((s) => s.appendItems);
const setIsScanning = useCanvasStore((s) => s.setIsScanning);
const setScanProgress = useCanvasStore((s) => s.setScanProgress);
const getDataSource = useCanvasStore((s) => s.getDataSource);
const getProjectPath = useCanvasStore((s) => s.getProjectPath);
// 扫描 abort 控制器 ref
const abortRef = useRef<AbortController | null>(null);
// 防抖 timer
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 当前 epoch
const epochRef = useRef(0);
const doScan = useCallback(async () => {
const projectPath = getProjectPath();
if (!projectPath) {
console.log('[useDirectoryScan] ⏸️ 跳过扫描projectPath 为空(未选择项目)');
clearItems();
return;
}
// 取消之前的扫描
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const epoch = ++epochRef.current;
const isAssets = currentStage === 'Assets';
console.log(`[useDirectoryScan] 🔍 开始扫描 (epoch=${epoch})`, {
projectPath,
stage: currentStage,
isAssets,
selectedEpisode: selectedEpisode || '(未选)',
selectedShot: selectedShot || '(未选)',
});
if (!isAssets && (!selectedEpisode || !selectedShot)) {
console.log('[useDirectoryScan] ⏸️ 跳过扫描:非 Assets 阶段需先选择集数和镜头');
clearItems();
return;
}
const t0 = performance.now();
setIsScanning(true);
setScanProgress({ found: 0, currentPath: '' });
try {
const dataSource = getDataSource();
let entries: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
if (isAssets) {
// Assets 阶段:深度递归扫描
entries = await dataSource.scanAssetFiles({
projectPath,
assetTypeFilter,
});
} else {
// 非 Assets 阶段:扫描指定镜头的阶段目录
entries = await dataSource.scanStageFiles({
projectPath,
episode: selectedEpisode!,
shot: selectedShot!,
stage: currentStage,
});
}
// Epoch 检查
if (epoch !== epochRef.current) {
console.log(`[useDirectoryScan] ⚠️ epoch 过期 (${epoch}${epochRef.current}),丢弃结果`);
return;
}
if (controller.signal.aborted) {
console.log('[useDirectoryScan] ⚠️ 扫描已被 abort丢弃结果');
return;
}
const elapsed = (performance.now() - t0).toFixed(0);
console.log(`[useDirectoryScan] ✅ 扫描完成 (epoch=${epoch}, 耗时=${elapsed}ms)`, {
totalEntries: entries.length,
});
clearItems();
appendItems(entries);
setScanProgress(null);
} catch (err) {
if (controller.signal.aborted || epoch !== epochRef.current) return;
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : new Error(String(err)));
} finally {
if (epoch === epochRef.current) {
setIsScanning(false);
}
}
}, [
selectedProject,
selectedEpisode,
selectedShot,
currentStage,
assetTypeFilter,
clearItems,
appendItems,
setIsScanning,
setScanProgress,
getDataSource,
getProjectPath,
]);
// 防抖触发扫描
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
void doScan();
}, 100);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [doScan, workspaceRoot, keywordQuery, tagQuery]);
// 窗口聚焦时重新扫描
useEffect(() => {
const handleFocus = () => {
setTimeout(() => {
void doScan();
}, 500);
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [doScan]);
return { rescan: doScan };
}

View File

@@ -0,0 +1,59 @@
// ============================================================
// useSidecarSync — Sidecar 元数据防抖同步 Hook
//
// QuickEditPanel 编辑 → 250ms 防抖 → dataSource.writeSidecar() → 保存到 JSON。
// Phase 4 扩展:写入后通过事件总线通知宿主窗口刷新。
// ============================================================
import { useRef, useCallback } from 'react';
import { useCanvasStore, getCanvasStore } from '../stores/useCanvasStore';
import type { SidecarMetadata } from '../utils/ICanvasDataSource';
export function useSidecarSync() {
const updateItemMetadata = useCanvasStore((s) => s.updateItemMetadata);
const getDataSource = useCanvasStore((s) => s.getDataSource);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<Map<number, SidecarMetadata>>(new Map());
/**
* 提交元数据更新250ms 防抖)。
* 更新 Store → 延迟写入 sidecar JSON失败时保留 Store 中的副本)。
*/
const commitMetadata = useCallback(
(itemId: number, filePath: string, partial: Partial<SidecarMetadata>) => {
const current =
pendingRef.current.get(itemId) ||
({} as SidecarMetadata);
const merged = { ...current, ...partial };
pendingRef.current.set(itemId, merged);
// 立即更新 Store乐观更新
updateItemMetadata(itemId, partial);
// 防抖写入 sidecar
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
const batch = new Map(pendingRef.current);
pendingRef.current.clear();
const dataSource = getDataSource();
const store = getCanvasStore();
for (const [id, meta] of batch) {
try {
const item = store.getState().items.find(
(i) => i.id === id,
);
if (item) {
await dataSource.writeSidecar(item.path, meta);
}
} catch {
console.warn(`[useSidecarSync] 写入 sidecar 失败: ${filePath}`);
}
}
}, 250);
},
[updateItemMetadata, getDataSource],
);
return { commitMetadata };
}

View File

@@ -0,0 +1,387 @@
// ============================================================
// useCanvasStore — 无限画布状态管理 (Zustand)
//
// 对应原版 CatalogModel + SelectionModel + CanvasStateMachine
// ============================================================
import { createContext, useContext, createElement, type ReactNode } from 'react';
import { createStore, useStore } from 'zustand';
import type { ICanvasDataSource, SidecarMetadata } from '../utils/ICanvasDataSource';
import {
extractEpisodeName,
extractShotName,
groupKeyForPath,
} from '../utils/scanner';
// ---------- 公开类型 ----------
/** 阶段类型(按 UI Tab 顺序排列) */
export type StageType = 'Assets' | 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync';
export type AuditStatus = 'pending' | 'approved' | 'rework' | 'deprecated';
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
export type LayoutMode = 'grid' | 'single';
export type InteractionMode = 'BROWSE' | 'SELECT' | 'REORDER';
// ---------- CatalogItem ----------
/** 画布中单个文件条目的完整状态 */
export interface CatalogItem {
id: number;
path: string;
fileName: string;
ext: string;
stage: StageType;
episodeName: string;
shotName: string;
groupKey: string;
metadata: SidecarMetadata | null;
fileSize: number;
modifiedAt: string;
/** 已加载的缩略图 Blob URL */
blobUrl?: string;
/** 已加载的 LOD 级别 */
loadedLevel?: number;
}
// ---------- Store State ----------
export interface CanvasState {
// ── 工作区 ──
workspaceRoot: string | null;
// ── 目录导航 ──
/** workspaceRoot 下的项目文件夹名列表 */
projects: string[];
/** 当前选中的项目名(非完整路径) */
selectedProject: string | null;
/** selectedProject/shots/ 下的集数名列表 */
episodes: string[];
/** 当前选中的集数名 */
selectedEpisode: string | null;
/** selectedProject/shots/episode/ 下的镜头名列表 */
shots: string[];
/** 当前选中的镜头名 */
selectedShot: string | null;
// ── Catalog ──
items: CatalogItem[];
totalCount: number;
// ── Selection ──
selectedIds: Set<number>;
// ── Stage & Filters ──
currentStage: StageType;
keywordQuery: string;
tagQuery: string;
auditFilter: AuditStatus | 'all';
assetTypeFilter: AssetTypeFilter;
/** 仅显示收藏条目metadata.favorite === true */
favoriteOnly: boolean;
// ── Layout ──
layoutMode: LayoutMode;
cols: number;
rowsPerPage: number;
singleRowsPerPage: number;
// ── Pagination ──
currentPage: number;
pageEpoch: number;
// ── Canvas transform ──
zoom: number;
panOffset: { x: number; y: number };
// ── Interaction mode ──
interactionMode: InteractionMode;
// ── Meta ──
isScanning: boolean;
scanProgress: { found: number; currentPath: string } | null;
// ── Actions ──
setWorkspace: (root: string | null) => void;
/** 计算当前完整项目路径 */
getProjectPath: () => string | null;
setProjects: (projects: string[]) => void;
selectProject: (name: string | null) => void;
setEpisodes: (episodes: string[]) => void;
selectEpisode: (name: string | null) => void;
setShots: (shots: string[]) => void;
selectShot: (name: string | null) => void;
setStage: (stage: StageType) => void;
setFilters: (
filters: Partial<
Pick<
CanvasState,
'keywordQuery' | 'tagQuery' | 'auditFilter' | 'assetTypeFilter' | 'favoriteOnly'
>
>,
) => void;
setLayoutMode: (mode: LayoutMode) => void;
setScanProgress: (progress: CanvasState['scanProgress']) => void;
setIsScanning: (isScanning: boolean) => void;
appendItems: (entries: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>) => void;
clearItems: () => void;
gotoPage: (page: number) => void;
setZoom: (zoom: number) => void;
setPanOffset: (offset: { x: number; y: number }) => void;
setInteractionMode: (mode: InteractionMode) => void;
toggleSelection: (id: number) => void;
selectAll: () => void;
clearSelection: () => void;
updateItemMetadata: (id: number, metadata: Partial<SidecarMetadata>) => void;
loadThumbnail: (id: number, blobUrl: string, level: number) => void;
/** 获取 dataSource供 hooks 使用) */
getDataSource: () => ICanvasDataSource;
}
// ---------- Store 逻辑工厂 ----------
function createCanvasStoreLogic(dataSource: ICanvasDataSource, initialWorkspace: string | null) {
return createStore<CanvasState>()((set, get) => ({
workspaceRoot: initialWorkspace,
projects: [],
selectedProject: null,
episodes: [],
selectedEpisode: null,
shots: [],
selectedShot: null,
items: [],
totalCount: 0,
selectedIds: new Set(),
currentStage: 'Storyboard',
keywordQuery: '',
tagQuery: '',
auditFilter: 'all',
assetTypeFilter: '全部',
favoriteOnly: false,
layoutMode: 'grid',
cols: 9,
rowsPerPage: 16,
singleRowsPerPage: 16,
currentPage: 1,
pageEpoch: 0,
zoom: 1.0,
panOffset: { x: 0, y: 0 },
interactionMode: 'BROWSE',
isScanning: false,
scanProgress: null,
// ── Actions ──
setWorkspace: (root) =>
set({
workspaceRoot: root,
projects: [],
selectedProject: null,
episodes: [],
selectedEpisode: null,
shots: [],
selectedShot: null,
currentPage: 1,
items: [],
totalCount: 0,
}),
getProjectPath: () => {
const { workspaceRoot, selectedProject } = get();
if (!workspaceRoot || !selectedProject) return null;
return `${workspaceRoot}\\${selectedProject}`;
},
setProjects: (projects) => set({ projects }),
// 选中项目:立即清空 items 并标记扫描中,避免旧数据显示残留
selectProject: (name) =>
set({
selectedProject: name,
episodes: [],
selectedEpisode: null,
shots: [],
selectedShot: null,
items: [],
totalCount: 0,
isScanning: true,
}),
setEpisodes: (episodes) => set({ episodes }),
// 选中集数:立即清空 items 并标记扫描中
selectEpisode: (name) =>
set({
selectedEpisode: name,
shots: [],
selectedShot: null,
items: [],
totalCount: 0,
isScanning: true,
}),
setShots: (shots) => set({ shots }),
// 选中镜头:立即清空 items 并标记扫描中
selectShot: (name) =>
set({
selectedShot: name,
items: [],
totalCount: 0,
isScanning: true,
}),
// 切换阶段:立即清空 items 并标记扫描中
setStage: (stage) =>
set({ currentStage: stage, currentPage: 1, items: [], totalCount: 0, isScanning: true }),
setFilters: (filters) => set(filters as Partial<CanvasState>),
setLayoutMode: (mode) => set({ layoutMode: mode, currentPage: 1 }),
setScanProgress: (progress) => set({ scanProgress: progress }),
setIsScanning: (scanning) => set({ isScanning: scanning }),
appendItems: (entries) => {
const state = get();
const nextId = state.items.length;
const stage = state.currentStage;
const newItems: CatalogItem[] = entries.map((entry, i) => ({
id: nextId + i + 1,
path: entry.path,
fileName: entry.fileName,
ext: entry.ext,
stage,
episodeName: extractEpisodeName(entry.path),
shotName: extractShotName(entry.path),
groupKey: groupKeyForPath(entry.path, stage),
metadata: null,
fileSize: entry.fileSize,
modifiedAt: entry.modifiedAt,
}));
set({
items: [...state.items, ...newItems],
totalCount: state.items.length + newItems.length,
});
},
clearItems: () => set({ items: [], totalCount: 0, currentPage: 1 }),
gotoPage: (page) =>
set({ currentPage: page, pageEpoch: get().pageEpoch + 1 }),
setZoom: (zoom) => set({ zoom: Math.max(0.02, Math.min(16.0, zoom)) }),
setPanOffset: (panOffset) => set({ panOffset }),
setInteractionMode: (mode) => set({ interactionMode: mode }),
toggleSelection: (id) => {
const selected = new Set(get().selectedIds);
if (selected.has(id)) selected.delete(id);
else selected.add(id);
set({ selectedIds: selected });
},
selectAll: () => {
const ids = new Set(get().items.map((i) => i.id));
set({ selectedIds: ids });
},
clearSelection: () => set({ selectedIds: new Set() }),
updateItemMetadata: (id, partialMeta) => {
set({
items: get().items.map((item) =>
item.id === id
? {
...item,
metadata: {
...(item.metadata || {
schema_version: 1,
favorite: false,
remark: '',
description: '',
tags: [],
create_time: new Date().toISOString(),
original_name: item.fileName,
}),
...partialMeta,
} as SidecarMetadata,
}
: item,
),
});
},
loadThumbnail: (id, blobUrl, level) => {
set({
items: get().items.map((item) =>
item.id === id ? { ...item, blobUrl, loadedLevel: level } : item,
),
});
},
getDataSource: () => dataSource,
}));
}
// ---------- Context & Provider ----------
type CanvasStore = ReturnType<typeof createCanvasStoreLogic>;
/** 模块级 Store 引用(供非组件代码如 useSidecarSync 同步读取状态) */
let _canvasStore: CanvasStore | null = null;
/** 获取原始 Store 实例(用于 .getState() 同步读取) */
export function getCanvasStore(): CanvasStore {
if (!_canvasStore) {
throw new Error('CanvasStore 尚未初始化,请在 CanvasStoreProvider 内部使用');
}
return _canvasStore;
}
const CanvasStoreContext = createContext<CanvasStore | null>(null);
/**
* 创建 Canvas Store 和对应的 Provider 组件。
* 接收 ICanvasDataSource 实例,注入到 Store 逻辑中。
*/
export function createCanvasStoreProvider(dataSource: ICanvasDataSource, initialWorkspace?: string | null) {
const store = createCanvasStoreLogic(dataSource, initialWorkspace ?? null);
_canvasStore = store;
function CanvasStoreProvider({ children }: { children: ReactNode }) {
return createElement(CanvasStoreContext.Provider, { value: store }, children);
}
return { CanvasStoreProvider, store };
}
// ---------- Hook ----------
/**
* 从 Zustand Store 中选择状态。
* 使用选择器模式,仅在选中状态变化时触发重渲染。
*
* 用法:
* const items = useCanvasStore(s => s.items);
* const zoom = useCanvasStore(s => s.zoom);
*/
export function useCanvasStore<T>(selector: (state: CanvasState) => T): T {
const store = useContext(CanvasStoreContext);
if (!store) {
throw new Error('useCanvasStore 必须在 CanvasStoreProvider 内部使用');
}
return useStore(store, selector);
}

View File

@@ -0,0 +1,183 @@
// ============================================================
// ICanvasDataSource — 数据源抽象接口
//
// 定义离线(本地文件系统)和在线(远程 API模式的统一契约。
// 未来切换到在线协作时只需替换实现,上层代码无需修改。
//
// 设计原则:
// 1. Store 和 UI 组件不直接依赖 IPC 或 HTTP
// 2. 所有文件操作通过此接口调用
// 3. 测试时可用 Mock 实现替换
// ============================================================
// ---------- 扫描相关 ----------
/** 目录扫描请求参数 */
export interface ScanRequest {
/** 扫描根目录列表(绝对路径,离线模式)或项目 ID 列表(在线模式) */
roots: string[];
/** 允许的文件扩展名(小写,不含点) */
extensions: string[];
/** 是否递归扫描子目录 */
recursive: boolean;
}
/** 扫描进度回调 */
export type ScanProgressCallback = (progress: { found: number; currentPath: string }) => void;
/** 扫描结果条目(文件元信息) */
export interface ScanEntry {
/** 文件绝对路径(离线模式)/ 文件 URI在线模式 */
path: string;
/** 文件名(不含路径) */
fileName: string;
/** 扩展名(小写,不含点) */
ext: string;
/** 文件大小(字节) */
fileSize: number;
/** 修改时间 ISO 字符串 */
modifiedAt: string;
}
// ---------- 侧加载元数据 ----------
/** 旁加载元数据 JSONsidecar 文件) */
export interface SidecarMetadata {
schema_version: number;
favorite: boolean;
remark: string;
description: string;
tags: string[];
create_time: string;
original_name: string;
/** 审核状态(仅 Storyboard / Keyframe / Video 阶段有效) */
audit_status?: 'pending' | 'approved' | 'rework' | 'deprecated';
}
/** 默认 sidecar 元数据 */
export function getDefaultSidecarMetadata(fileName: string): SidecarMetadata {
return {
schema_version: 1,
favorite: false,
remark: '',
description: '',
tags: [],
create_time: new Date().toISOString().replace('T', ' ').slice(0, 19),
original_name: fileName,
audit_status: 'pending',
};
}
// ---------- 缩略图 ----------
/** 缩略图请求参数 */
export interface ThumbnailRequest {
/** 源文件绝对路径 */
filePath: string;
/** 目标尺寸(像素,按长边等比缩放) */
size: 512 | 1024 | 2048 | 4096;
}
/** 缩略图生成结果 */
export interface ThumbnailResult {
/** Base64 编码的图片数据 */
data: string;
/** MIME 类型(如 "image/jpeg" */
mimeType: string;
/** 原始宽度 */
width: number;
/** 原始高度 */
height: number;
}
// ---------- 数据源能力标志 ----------
/** 数据源能力标志 */
export interface DataSourceCapabilities {
/** 是否支持离线操作 */
offline: boolean;
/** 是否支持实时协作WebSocket 同步) */
realtime: boolean;
/** 是否支持服务器端缩略图生成 */
thumbnails: boolean;
}
// ---------- 主接口 ----------
/**
* 画布数据源接口
*
* 所有文件系统和 API 操作都通过此接口,确保:
* 1. 上层 Store 和组件不直接依赖 IPC 或 HTTP
* 2. 离线 / 在线模式可平滑切换
* 3. 测试时可用 Mock 实现替换
*/
export interface ICanvasDataSource {
/** 数据源能力标志(只读) */
readonly capabilities: DataSourceCapabilities;
/** 扫描目录,返回匹配文件列表 */
scanDirectory(
request: ScanRequest,
onProgress?: ScanProgressCallback,
): Promise<ScanEntry[]>;
/**
* 扫描项目(推荐方式)。
* 接收 projectPath + 筛选条件,主进程内部完成目录探索 + 文件遍历。
* 渲染进程无需直接访问 fs 模块。
*/
scanProject(params: {
projectPath: string;
stage: string;
assetTypeFilter: string;
shotQuery: string;
}): Promise<ScanEntry[]>;
/** 读取文件的旁加载元数据sidecar JSON */
readSidecar(filePath: string): Promise<SidecarMetadata | null>;
/** 写入文件的旁加载元数据 */
writeSidecar(filePath: string, metadata: SidecarMetadata): Promise<void>;
/** 生成缩略图(返回 base64 data URL */
generateThumbnail(request: ThumbnailRequest): Promise<ThumbnailResult>;
/** 获取文件二进制数据base64 编码) */
readFileData(filePath: string): Promise<string | null>;
/** 获取文件访问 URL用于 <img> / <video> 的 src 属性) */
getFileUrl(filePath: string): Promise<string>;
/** 检查文件/目录是否存在 */
fileExists(filePath: string): Promise<boolean>;
/** 复制文件到目录(导入操作),返回目标路径 */
copyFile(src: string, destDir: string, fileName: string): Promise<string>;
/** 删除文件及其 sidecar */
deleteFile(filePath: string): Promise<void>;
/** 打开目录选择对话框,返回选中的路径 */
openDirectoryDialog(): Promise<string | null>;
/** 列出目录下的子文件夹名称(非递归,排除隐藏目录) */
listSubdirs(parentPath: string): Promise<string[]>;
/** 扫描指定镜头的阶段文件 */
scanStageFiles(params: {
projectPath: string;
episode: string;
shot: string;
stage: string;
}): Promise<ScanEntry[]>;
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: {
projectPath: string;
assetTypeFilter: string;
}): Promise<ScanEntry[]>;
/** 创建新目录,返回创建的完整路径 */
createDirectory(parentPath: string, dirName: string): Promise<string>;
}

View File

@@ -0,0 +1,253 @@
// ============================================================
// LocalFileDataSource — 离线模式Electron IPC → 本地文件系统
//
// 所有文件系统操作通过 window.canvaspreload 注入)桥接到主进程。
// 非 Electron 环境(浏览器开发模式)降级返回空数据/抛出友好错误。
// ============================================================
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
import type {
ICanvasDataSource,
ScanRequest,
ScanEntry,
ScanProgressCallback,
SidecarMetadata,
ThumbnailRequest,
ThumbnailResult,
DataSourceCapabilities,
} from './ICanvasDataSource';
// ---------- IPC 返回类型 ----------
/** scanDirectory IPC 返回类型 */
type ScanDirResult = { success: boolean; data: ScanEntry[]; error?: string };
/** readSidecar / writeSidecar IPC 返回类型 */
type SidecarResult = { success: boolean; data: SidecarMetadata | null; error?: string };
type WriteSidecarResult = { success: boolean; error?: string };
/** generateThumbnail IPC 返回类型 */
type ThumbnailResultIpc = { success: boolean; data: ThumbnailResult; error?: string };
/** copyFile IPC 返回类型 */
type CopyFileResult = { success: boolean; data: string; error?: string };
/** 通用文件操作返回类型 */
type FileExistsResult = { success: boolean; data: boolean };
type FileDialogResult = { canceled: boolean; filePaths: string[] };
type FileReadResult = { success: boolean; data: string };
// ---------- 辅助 ----------
/** 检查 canvas API 是否可用 */
function hasCanvasApi(): boolean {
return hasIpc() && typeof window.canvas !== 'undefined';
}
/** 包装 IPC 调用:非 Electron 环境抛出友好错误 */
async function requireCanvasApi(): Promise<void> {
if (!hasCanvasApi()) {
throw new Error('当前环境不支持文件系统操作。请在 HeiXiu 桌面应用中打开无限画布。');
}
}
// ---------- 实现 ----------
export class LocalFileDataSource implements ICanvasDataSource {
readonly capabilities: DataSourceCapabilities = {
offline: true,
realtime: false,
thumbnails: true,
};
async scanDirectory(
request: ScanRequest,
_onProgress?: ScanProgressCallback,
): Promise<ScanEntry[]> {
await requireCanvasApi();
const result = (await window.canvas.scanDirectory({
roots: request.roots,
extensions: request.extensions,
recursive: request.recursive,
})) as ScanDirResult;
if (!result.success) {
throw new Error(result.error || '目录扫描失败');
}
return result.data;
}
async scanProject(params: {
projectPath: string;
stage: string;
assetTypeFilter: string;
shotQuery: string;
}): Promise<ScanEntry[]> {
await requireCanvasApi();
console.log('[LocalFileDataSource] 📡 scanProject IPC → 主进程', params);
const t0 = performance.now();
const result = (await window.canvas.scanProject(params)) as ScanDirResult;
const elapsed = (performance.now() - t0).toFixed(0);
if (!result.success) {
console.error(`[LocalFileDataSource] ❌ scanProject 失败 (${elapsed}ms):`, result.error);
throw new Error(result.error || '项目扫描失败');
}
console.log(`[LocalFileDataSource] ✅ scanProject 返回 (${elapsed}ms): ${result.data.length}`);
return result.data;
}
async readSidecar(filePath: string): Promise<SidecarMetadata | null> {
await requireCanvasApi();
const result = (await window.canvas.readSidecar({ filePath })) as SidecarResult;
if (!result.success) return null;
return result.data;
}
async writeSidecar(filePath: string, metadata: SidecarMetadata): Promise<void> {
await requireCanvasApi();
const result = (await window.canvas.writeSidecar({
filePath,
metadata,
})) as WriteSidecarResult;
if (!result.success) {
throw new Error(result.error || '写入元数据失败');
}
}
async generateThumbnail(request: ThumbnailRequest): Promise<ThumbnailResult> {
await requireCanvasApi();
const result = (await window.canvas.generateThumbnail({
filePath: request.filePath,
size: request.size,
})) as ThumbnailResultIpc;
if (!result.success) {
throw new Error(result.error || '生成缩略图失败');
}
return result.data;
}
async readFileData(filePath: string): Promise<string | null> {
await requireCanvasApi();
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_READ, {
relativePath: filePath,
})) as FileReadResult | undefined;
return result?.success ? result.data : null;
}
async getFileUrl(filePath: string): Promise<string> {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const imageExts = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
if (imageExts.includes(ext)) {
try {
const thumb = await this.generateThumbnail({ filePath, size: 1024 });
return `data:${thumb.mimeType};base64,${thumb.data}`;
} catch {
return `file://${filePath}`;
}
}
return `file://${filePath}`;
}
async fileExists(filePath: string): Promise<boolean> {
await requireCanvasApi();
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_EXISTS, {
relativePath: filePath,
})) as FileExistsResult | undefined;
return result?.success ? result.data : false;
}
async copyFile(src: string, destDir: string, fileName: string): Promise<string> {
await requireCanvasApi();
const result = (await window.canvas.copyFile({
src,
destDir,
fileName,
})) as CopyFileResult;
if (!result.success) {
throw new Error(result.error || '复制文件失败');
}
return result.data;
}
async deleteFile(filePath: string): Promise<void> {
await requireCanvasApi();
await safeIpcInvoke(BIDIRECTIONAL.FILE_DELETE, { relativePath: filePath });
const sidecarPath = filePath.replace(/\.[^.]+$/, '.json');
await safeIpcInvoke(BIDIRECTIONAL.FILE_DELETE, { relativePath: sidecarPath });
}
async openDirectoryDialog(): Promise<string | null> {
await requireCanvasApi();
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_DIALOG, {
title: '选择工作区根目录',
properties: ['openDirectory'],
})) as FileDialogResult | undefined;
if (!result || result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
}
async listSubdirs(parentPath: string): Promise<string[]> {
await requireCanvasApi();
const result = (await window.canvas.listSubdirs({ parentPath })) as {
success: boolean;
data: string[];
error?: string;
};
if (!result.success) {
throw new Error(result.error || '列出子目录失败');
}
return result.data;
}
async scanStageFiles(params: {
projectPath: string;
episode: string;
shot: string;
stage: string;
}): Promise<ScanEntry[]> {
await requireCanvasApi();
console.log('[LocalFileDataSource] 📡 scanStageFiles IPC → 主进程', params);
const t0 = performance.now();
const result = (await window.canvas.scanStageFiles(params)) as ScanDirResult;
const elapsed = (performance.now() - t0).toFixed(0);
if (!result.success) {
console.error(`[LocalFileDataSource] ❌ scanStageFiles 失败 (${elapsed}ms):`, result.error);
throw new Error(result.error || '扫描阶段文件失败');
}
console.log(`[LocalFileDataSource] ✅ scanStageFiles 返回 (${elapsed}ms): ${result.data.length}`);
return result.data;
}
async scanAssetFiles(params: {
projectPath: string;
assetTypeFilter: string;
}): Promise<ScanEntry[]> {
await requireCanvasApi();
console.log('[LocalFileDataSource] 📡 scanAssetFiles IPC → 主进程', params);
const t0 = performance.now();
const result = (await window.canvas.scanAssetFiles(params)) as ScanDirResult;
const elapsed = (performance.now() - t0).toFixed(0);
if (!result.success) {
console.error(`[LocalFileDataSource] ❌ scanAssetFiles 失败 (${elapsed}ms):`, result.error);
throw new Error(result.error || '扫描资产文件失败');
}
console.log(`[LocalFileDataSource] ✅ scanAssetFiles 返回 (${elapsed}ms): ${result.data.length}`);
return result.data;
}
async createDirectory(parentPath: string, dirName: string): Promise<string> {
await requireCanvasApi();
console.log('[LocalFileDataSource] 📡 createDirectory IPC → 主进程', { parentPath, dirName });
const result = (await window.canvas.createDirectory({ parentPath, dirName })) as {
success: boolean;
data: string;
error?: string;
};
if (!result.success) {
console.error('[LocalFileDataSource] ❌ createDirectory 失败:', result.error);
throw new Error(result.error || '创建目录失败');
}
console.log('[LocalFileDataSource] ✅ createDirectory 成功:', result.data);
return result.data;
}
}

View File

@@ -0,0 +1,70 @@
// ============================================================
// constants.ts — 无限画布常量配置
// ============================================================
import type { StageType } from '../stores/useCanvasStore';
// ---------- 布局 ----------
/** 网格模式:默认列数 */
export const DEFAULT_COLS = 9;
/** 网格模式:每页行数 */
export const DEFAULT_ROWS_PER_PAGE = 16;
/** 单行模式:每页镜头数 */
export const DEFAULT_SINGLE_ROWS = 16;
/** 缩略图卡片尺寸(像素) */
export const ITEM_W = 160;
export const ITEM_H = 120;
export const ITEM_SPACING = 8;
/** 行标签宽度single 模式) */
export const ROW_LABEL_WIDTH = 80;
/** 单页最大渲染数量(硬上限) */
export const MAX_ITEMS_PER_PAGE = 500;
// ---------- LOD 缩略图 ----------
/** LOD 级别 → 像素尺寸 */
export const LOD_LEVELS = [512, 1024, 2048, 4096] as const;
export type LODLevel = (typeof LOD_LEVELS)[number];
/** 最大并发缩略图加载数 */
export const MAX_CONCURRENT_LOADS = 10;
// ---------- 阶段配置 ----------
export interface StageConfig {
key: StageType;
label: string;
icon: string;
auditEnabled: boolean;
}
/** 六个阶段的静态配置 */
export const STAGE_CONFIGS: StageConfig[] = [
{ key: 'Assets', label: '资产', icon: '📦', auditEnabled: false },
{ key: 'Storyboard', label: '分镜', icon: '🎬', auditEnabled: true },
{ key: 'Keyframe', label: '关键帧', icon: '🖼️', auditEnabled: true },
{ key: 'Video', label: '视频', icon: '🎥', auditEnabled: true },
{ key: 'Audio', label: '音频', icon: '🎵', auditEnabled: false },
{ key: 'LipSync', label: '口型', icon: '👄', auditEnabled: false },
];
// ---------- 审核状态 ----------
export const AUDIT_STATUS_LABELS: Record<string, string> = {
all: '全部',
pending: '待审核',
approved: '已通过',
rework: '需返工',
deprecated: '已废弃',
};
export const AUDIT_STATUS_COLORS: Record<string, string> = {
pending: '#FAAD14',
approved: '#52C41A',
rework: '#FF4D4F',
deprecated: '#8C8C8C',
};

View File

@@ -0,0 +1,30 @@
// ============================================================
// utils — 无限画布工具函数、常量与数据源
// ============================================================
export {
gridPosition,
gridPageInfo,
gridCanvasSize,
singleLayout,
singlePageInfo,
} from './layout';
export {
STAGE_CONFIGS,
ITEM_W,
ITEM_H,
ITEM_SPACING,
ROW_LABEL_WIDTH,
MAX_ITEMS_PER_PAGE,
LOD_LEVELS,
AUDIT_STATUS_COLORS,
} from './constants';
export type { LODLevel } from './constants';
export type {
ICanvasDataSource,
ScanRequest,
ScanEntry,
ScanProgressCallback,
SidecarMetadata,
} from './ICanvasDataSource';
export { LocalFileDataSource } from './LocalFileDataSource';

View File

@@ -0,0 +1,170 @@
// ============================================================
// layout.ts — 画布布局算法
//
// 实现 Grid 模式和 Single 模式的坐标计算。
// ============================================================
import type { CatalogItem } from '../stores/useCanvasStore';
import {
ITEM_W,
ITEM_H,
ITEM_SPACING,
ROW_LABEL_WIDTH,
MAX_ITEMS_PER_PAGE,
} from './constants';
// ---------- 通用类型 ----------
export interface Position {
x: number;
y: number;
}
export interface PageInfo {
/** 总页数 */
totalPages: number;
/** 当前页的 item 索引范围 [start, end) */
startIndex: number;
endIndex: number;
}
// ---------- Grid 模式 ----------
/**
* Grid 模式:计算 item 的绝对像素坐标。
*
* @param index item 在当前页 items 数组中的局部索引0-based
* @param cols 每行列数
*/
export function gridPosition(index: number, cols: number): Position {
const row = Math.floor(index / cols);
const col = index % cols;
return {
x: col * (ITEM_W + ITEM_SPACING),
y: row * (ITEM_H + ITEM_SPACING),
};
}
/**
* Grid 模式:计算分页信息。
*
* @param totalItems 全量过滤后的 item 总数
* @param cols 每行列数
* @param rowsPerPage 每页行数
* @param currentPage 当前页码1-based
*/
export function gridPageInfo(
totalItems: number,
cols: number,
rowsPerPage: number,
currentPage: number,
): PageInfo {
const perPage = Math.min(cols * rowsPerPage, MAX_ITEMS_PER_PAGE);
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const page = Math.max(1, Math.min(currentPage, totalPages));
const startIndex = (page - 1) * perPage;
const endIndex = Math.min(startIndex + perPage, totalItems);
return { totalPages, startIndex, endIndex };
}
/**
* Grid 模式:计算画布总尺寸(像素)。
*/
export function gridCanvasSize(
itemsOnPage: number,
cols: number,
): { width: number; height: number } {
const rows = Math.ceil(itemsOnPage / cols);
const width = cols * (ITEM_W + ITEM_SPACING) - ITEM_SPACING;
const height = rows * (ITEM_H + ITEM_SPACING) - ITEM_SPACING;
return { width, height };
}
// ---------- Single 模式 ----------
/**
* Single 模式:按 groupKey 分组,每组一行横向排列。
* 返回每个 item 的位置和画布尺寸。
*
* @param itemsOnPage 当前页的 item 列表
*/
export function singleLayout(
itemsOnPage: CatalogItem[],
): { positions: Map<number, Position>; canvasWidth: number; canvasHeight: number } {
// 按 groupKey 分组(保持插入顺序)
const groups = new Map<string, CatalogItem[]>();
for (const item of itemsOnPage) {
const key = item.groupKey || 'unknown';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
const positions = new Map<number, Position>();
let maxWidth = 0;
let row = 0;
for (const [, groupItems] of groups) {
let col = 0;
for (const item of groupItems) {
positions.set(item.id, {
x: ROW_LABEL_WIDTH + ITEM_SPACING + col * (ITEM_W + ITEM_SPACING),
y: row * (ITEM_H + ITEM_SPACING),
});
col++;
}
const rowWidth =
ROW_LABEL_WIDTH + ITEM_SPACING + col * (ITEM_W + ITEM_SPACING);
if (rowWidth > maxWidth) maxWidth = rowWidth;
row++;
}
const canvasHeight = row * (ITEM_H + ITEM_SPACING) - ITEM_SPACING;
return { positions, canvasWidth: maxWidth, canvasHeight };
}
/**
* Single 模式:计算分页信息。
* 每页最多 singleRowsPerPage 个镜头组。
*/
export function singlePageInfo(
items: CatalogItem[],
singleRowsPerPage: number,
currentPage: number,
): PageInfo {
// 收集所有 groupKey保持顺序、去重
const groupKeys: string[] = [];
const seen = new Set<string>();
for (const item of items) {
const key = item.groupKey || 'unknown';
if (!seen.has(key)) {
seen.add(key);
groupKeys.push(key);
}
}
const totalPages = Math.max(1, Math.ceil(groupKeys.length / singleRowsPerPage));
const page = Math.max(1, Math.min(currentPage, totalPages));
const startGroupIdx = (page - 1) * singleRowsPerPage;
const endGroupIdx = Math.min(startGroupIdx + singleRowsPerPage, groupKeys.length);
// 将 group key 范围转换为 item 索引范围
let startIndex = 0;
let endIndex = items.length;
let groupIdx = 0;
let currentGroup = '';
for (let i = 0; i < items.length; i++) {
const key = items[i].groupKey || 'unknown';
if (key !== currentGroup) {
if (groupIdx === startGroupIdx) startIndex = i;
if (groupIdx === endGroupIdx) {
endIndex = i;
break;
}
currentGroup = key;
groupIdx++;
}
}
return { totalPages, startIndex, endIndex };
}

View File

@@ -0,0 +1,252 @@
// ============================================================
// scanner.ts — 文件扫描算法
//
// 基于 infinite_canvas_140.md §2.2-2.3 的分析实现:
// - detectStage(filePath) — 从路径检测阶段
// - groupKeyForPath(filePath, stage) — 提取分组键
// - buildScanRoots(...) — 计算扫描根目录
// - getStageExtensions(stage) — 阶段对应的文件扩展名
// - shotNameMatchesQuery(shotName, query) — 镜头名称匹配
// ============================================================
import type { StageType } from '../stores/useCanvasStore';
// ---------- 阶段检测 ----------
/** 阶段映射:路径关键词 → StageType */
const STAGE_PATTERNS: Array<{ segment: string; stage: StageType }> = [
{ segment: 'Storyboard', stage: 'Storyboard' },
{ segment: 'Keyframe', stage: 'Keyframe' },
{ segment: 'Video', stage: 'Video' },
{ segment: 'Audio', stage: 'Audio' },
{ segment: 'LipSync', stage: 'LipSync' },
];
/**
* 从文件路径中检测所属阶段。
* 查找路径中是否包含阶段目录名(如 "/Storyboard/")。
* Assets 阶段特殊处理:路径中含有 "/assets/" 即为 Assets。
*/
export function detectStage(filePath: string): StageType {
const normalized = filePath.replace(/\\/g, '/');
for (const { segment, stage } of STAGE_PATTERNS) {
if (normalized.includes(`/${segment}/`)) {
return stage;
}
}
if (normalized.includes('/assets/')) {
return 'Assets';
}
return 'Storyboard'; // 默认
}
// ---------- 分组键提取 ----------
/**
* 从文件路径中提取分组键。
*
* Assets 阶段:
* 向上找到 assets/ 目录 → 下一层为 typechr/prp/set
* → 再下一层为 name角色名/道具名/场景名)
* → 返回 "chr/主角A"
*
* 非 Assets 阶段:
* 向上找到 shots/ 目录 → 下一层为 episodeE01
* → 再下一层为 shotSC001
* → 返回 "E01/SC001"
*/
export function groupKeyForPath(filePath: string, stage: StageType): string {
const normalized = filePath.replace(/\\/g, '/');
if (stage === 'Assets') {
const parts = normalized.split('/');
const assetsIdx = parts.lastIndexOf('assets');
if (assetsIdx >= 0 && assetsIdx + 2 < parts.length) {
const assetType = parts[assetsIdx + 1]; // chr / prp / set
const assetName = parts[assetsIdx + 2]; // 角色名 / 道具名 / 场景名
return `${assetType}/${assetName}`;
}
return 'assets/unknown';
}
// 非 Assets查找 shots/ 目录
const parts = normalized.split('/');
const shotsIdx = parts.lastIndexOf('shots');
if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) {
const episode = parts[shotsIdx + 1]; // E01
const shot = parts[shotsIdx + 2]; // SC001
return `${episode}/${shot}`;
}
return 'unknown/unknown';
}
/** 从路径中提取集数名(如 "E01" */
export function extractEpisodeName(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const parts = normalized.split('/');
const shotsIdx = parts.lastIndexOf('shots');
if (shotsIdx >= 0 && shotsIdx + 1 < parts.length) {
return parts[shotsIdx + 1];
}
return '';
}
/** 从路径中提取镜头名(如 "SC001" */
export function extractShotName(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const parts = normalized.split('/');
const shotsIdx = parts.lastIndexOf('shots');
if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) {
return parts[shotsIdx + 2];
}
return '';
}
// ---------- 扫描根目录计算 ----------
/** 资产类型筛选 */
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
/** buildScanRoots 参数 */
export interface BuildScanRootsParams {
projectPath: string;
episodePath: string | null;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
}
/**
* 计算文件扫描的根目录列表。
*
* Assets 阶段:
* "全部" → [project/assets/]
* "角色" → [project/assets/chr/]
* "道具" → [project/assets/prp/]
* "场景" → [project/assets/set/]
*
* 非 Assets 阶段:
* 遍历 project/shots/ 下的所有集数目录,
* 对每个集数遍历所有 shot 子目录,
* 追加 {shotDir}/{stage}/
* 若指定 shotQuery → 仅匹配的镜头
*/
export function buildScanRoots(params: BuildScanRootsParams): string[] {
const { projectPath, episodePath, stage, assetTypeFilter, shotQuery } = params;
if (stage === 'Assets') {
const assetsDir = `${projectPath}/assets`;
switch (assetTypeFilter) {
case '角色':
return [`${assetsDir}/chr`];
case '道具':
return [`${assetsDir}/prp`];
case '场景':
return [`${assetsDir}/set`];
default:
return [assetsDir];
}
}
// 非 Assets遍历 shots/ 目录
const shotsDir = `${projectPath}/shots`;
const fs = requireNodeFs();
if (!fs || !fs.existsSync(shotsDir)) return [];
const roots: string[] = [];
const episodes = episodePath
? [episodePath]
: fs.readdirSync(shotsDir).filter((name: string) => {
const full = `${shotsDir}/${name}`;
return (
!name.startsWith('.') &&
fs.statSync(full).isDirectory()
);
});
for (const ep of episodes) {
const epDir = episodePath || `${shotsDir}/${ep}`;
if (!fs.existsSync(epDir)) continue;
const shotNames = fs.readdirSync(epDir).filter((name: string) => {
const full = `${epDir}/${name}`;
return (
!name.startsWith('.') &&
fs.statSync(full).isDirectory()
);
});
for (const shot of shotNames) {
// shotQuery 过滤
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
const stageDir = `${epDir}/${shot}/${stage}`;
if (fs.existsSync(stageDir)) {
roots.push(stageDir);
}
}
}
return roots;
}
// ---------- Shot 名称匹配 ----------
/**
* 镜头名称匹配算法§11.3)。
*
* - query 为空 → 匹配所有
* - query.toUpperCase() in shotName.toUpperCase() → 匹配
* - query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
*
* 例query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
*/
export function shotNameMatchesQuery(shotName: string, query: string): boolean {
if (!query) return true;
const upperShot = shotName.toUpperCase();
const upperQuery = query.toUpperCase();
if (upperShot.includes(upperQuery)) return true;
// 纯数字匹配:提取 shotName 中的所有数字位
if (/^\d+$/.test(query)) {
const digits = upperShot.replace(/\D/g, '');
if (digits.includes(query)) return true;
}
return false;
}
// ---------- 阶段扩展名映射 ----------
/**
* 每个阶段允许的文件扩展名(不含点,小写)。
*/
export function getStageExtensions(stage: StageType): string[] {
switch (stage) {
case 'Storyboard':
case 'Keyframe':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
case 'Video':
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
case 'Audio':
case 'LipSync':
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
case 'Assets':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
}
}
// ---------- 内部工具 ----------
/**
* 尝试获取 Node.js fs 模块(仅在 Electron 环境可用)。
* 渲染进程中通过 vite-plugin-electron-renderer 暴露。
* 注意:此处仅用于 buildScanRoots 中的同步读取目录结构(低频操作)。
* 大量文件扫描走 IPC → 主进程。
*/
function requireNodeFs(): typeof import('fs') | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('fs');
} catch {
return null;
}
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// CanvasApp — 无限画布根组件
//
// 通过 hash 路由 #/canvas 进入的轻量级入口。
// 不加载 AppProvider / Auth / Storage / Settings与主应用解耦。
//
// 工作区路径:
// 优先读取设置中的"团队创作存储仓库路径"localStorage key: ele-heixiu-team-repo-path
// 未设置时由用户在画布工具栏中手动选择。
// ============================================================
import { useMemo, useEffect } from 'react';
import { logger } from '@/shared/utils/bus';
import { LocalFileDataSource } from '../utils/LocalFileDataSource';
import { createCanvasStoreProvider, useCanvasStore } from '../stores/useCanvasStore';
import { CanvasPage } from './CanvasPage';
/** localStorage 键名 —— 与 SettingsProvider 保持一致 */
const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
/** 从 localStorage 读取已保存的团队创作仓库路径 */
function readStoredTeamRepoPath(): string | null {
try {
const stored = localStorage.getItem(TEAM_REPO_PATH_KEY);
return stored?.trim() || null;
} catch {
return null;
}
}
/**
* 无限画布根组件。
*
* 职责:
* 1. 创建 LocalFileDataSource离线模式数据源实例
* 2. 通过工厂函数创建 Zustand Store + Provider
* 3. 自动填充设置中保存的工作区路径
* 4. 初始化后自动加载项目列表
* 5. 渲染 CanvasPage全屏画布 UI
*
* 切换在线模式:
* 将 `new LocalFileDataSource()` 替换为 `new RemoteDataSource(projectId, wsUrl)`
* Provider 和 Page 层零改动。
*/
export default function CanvasApp() {
const { CanvasStoreProvider } = useMemo(() => {
const dataSource = new LocalFileDataSource();
const initialWorkspace = readStoredTeamRepoPath();
console.log('[CanvasApp] 🎨 初始化无限画布', {
dataSourceType: dataSource.constructor.name,
capabilities: dataSource.capabilities,
initialWorkspace: initialWorkspace || '(未设置 — 需在工具栏手动选择)',
hash: window.location.hash,
userAgent: navigator.userAgent.includes('Electron') ? 'Electron' : 'Browser',
});
return createCanvasStoreProvider(dataSource, initialWorkspace);
}, []);
return (
<CanvasStoreProvider>
<WorkspaceInitializer />
<CanvasPage />
</CanvasStoreProvider>
);
}
/**
* 内部组件:工作区初始化后自动加载项目列表。
* 放在 Provider 内部以访问 store。
*/
function WorkspaceInitializer() {
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const setProjects = useCanvasStore((s) => s.setProjects);
const getDataSource = useCanvasStore((s) => s.getDataSource);
useEffect(() => {
if (!workspaceRoot) return;
const dataSource = getDataSource();
console.log('[CanvasApp] 📋 加载项目列表:', workspaceRoot);
dataSource
.listSubdirs(workspaceRoot)
.then((projects) => {
console.log('[CanvasApp] ✅ 项目列表:', projects);
setProjects(projects);
})
.catch((err) => {
logger.warn('canvas', '加载项目列表失败', err instanceof Error ? err : new Error(String(err)));
});
}, [workspaceRoot, setProjects, getDataSource]);
return null;
}

View File

@@ -0,0 +1,263 @@
// ============================================================
// CanvasPage — 无限画布页面AntD Layout 双栏布局)
//
// ┌──────────────┬─────────────────────────────────────────┐
// │ CanvasSidebar│ CanvasToolbar │
// │ ├ 项目选择 │ ├ StageTabs + 无限画布 │
// │ ├ 集数选择 │ ├ CategoryTabs + FilterBar │
// │ ├ 镜头搜索 │ ├────────────────────────────────────┤
// │ ├ 镜头列表 │ │ CanvasViewport或 Placeholder
// │ └ 刷新按钮 │ │ │
// └──────────────┴─────────────────────────────────────────┘
// └── 底部状态栏 ─────────────────────────┘
// ============================================================
import { useState, useCallback } from 'react';
import { Layout } from 'antd';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../stores/useCanvasStore';
import { useDirectoryScan } from '../hooks/useDirectoryScan';
import { CanvasToolbar } from './toolbar/CanvasToolbar';
import { CanvasViewport } from './core/CanvasViewport';
import { MediaPreview } from './core/MediaPreview';
import { CanvasSidebar } from './navigation/CanvasSidebar';
const { Sider, Content } = Layout;
/** 中心区域占位提示 */
function Placeholder({
icon,
title,
hint,
isDark,
}: {
icon: string;
title: string;
hint: string;
isDark: boolean;
}) {
return (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div style={{ fontSize: 48, marginBottom: 16 }}>{icon}</div>
<div style={{ fontSize: 14, color: isDark ? '#A5A3C0' : '#6B7280' }}>{title}</div>
<div
style={{
fontSize: 13,
marginTop: 8,
color: isDark ? '#6E6B90' : '#9CA3AF',
}}
>
{hint}
</div>
</div>
);
}
export function CanvasPage() {
const { isDark } = useTheme();
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const selectedProject = useCanvasStore((s) => s.selectedProject);
const selectedEpisode = useCanvasStore((s) => s.selectedEpisode);
const selectedShot = useCanvasStore((s) => s.selectedShot);
const items = useCanvasStore((s) => s.items);
const isScanning = useCanvasStore((s) => s.isScanning);
const currentStage = useCanvasStore((s) => s.currentStage);
// 启动目录扫描监听
useDirectoryScan();
// ── 全屏预览状态 ──
const [previewItemId, setPreviewItemId] = useState<number | null>(null);
const handlePreviewOpen = useCallback((itemId: number) => {
setPreviewItemId(itemId);
}, []);
const handlePreviewClose = useCallback(() => {
setPreviewItemId(null);
}, []);
const isAssets = currentStage === 'Assets';
/** 根据当前选中状态决定主视图内容 */
const renderMainContent = () => {
if (!workspaceRoot) {
return (
<Placeholder
icon="📂"
title="请先选择一个工作区目录"
hint="从左侧边栏打开或切换工作区"
isDark={isDark}
/>
);
}
if (!selectedProject) {
return (
<Placeholder
icon="📁"
title="请先选择一个项目"
hint="从左侧边栏的项目下拉列表中选择"
isDark={isDark}
/>
);
}
if (isScanning) {
return (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔍</div>
<div style={{ fontSize: 14 }}></div>
</div>
);
}
if (!isAssets && !selectedEpisode) {
return (
<Placeholder
icon="🎬"
title="请先选择集数"
hint="从左侧边栏的集数下拉列表中选择"
isDark={isDark}
/>
);
}
if (!isAssets && !selectedShot) {
return (
<Placeholder
icon="🎞️"
title="请先选择镜头"
hint="从左侧边栏的镜头列表中点击选择"
isDark={isDark}
/>
);
}
if (items.length === 0) {
return (
<Placeholder
icon="📭"
title={`当前阶段「${currentStage}」没有匹配的文件`}
hint="请确认目录结构,或切换阶段标签"
isDark={isDark}
/>
);
}
return <CanvasViewport onPreviewOpen={handlePreviewOpen} previewActive={previewItemId !== null} />;
};
const borderColor = isDark ? '#2E2A5A' : '#E5E7EB';
return (
<Layout
style={{
width: '100vw',
height: '100vh',
overflow: 'hidden',
}}
hasSider
>
{/* ── 左侧导航边栏 ── */}
<Sider
width={220}
style={{
background: isDark ? '#0F0D21' : '#F5F3FF',
borderRight: `1px solid ${borderColor}`,
overflow: 'hidden',
}}
trigger={null}
>
<CanvasSidebar />
</Sider>
{/* ── 右侧主内容区 ── */}
<Layout
style={{
background: isDark ? '#0F0D1E' : '#F5F3FF',
}}
>
<Content
style={{
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 顶部工具栏(两行:阶段标签 + 分类标签/筛选) */}
<CanvasToolbar />
{/* 主画布区域 */}
<div
style={{
flex: 1,
display: 'flex',
overflow: 'hidden',
}}
>
{renderMainContent()}
</div>
{/* 底部状态栏 */}
<div
style={{
height: 28,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
padding: '0 12px',
borderTop: `1px solid ${borderColor}`,
background: isDark ? '#15132E' : '#EEF2FF',
fontSize: 12,
color: isDark ? '#6E6B90' : '#9CA3AF',
}}
>
<span>线 · </span>
<span style={{ margin: '0 8px' }}>|</span>
<span>
{isScanning
? '扫描中…'
: workspaceRoot
? `项目:${selectedProject || '(未选)'} · 阶段:${currentStage} · ${items.length} 个文件`
: '准备就绪'}
</span>
{selectedEpisode && (
<>
<span style={{ margin: '0 4px' }}>|</span>
<span>{selectedEpisode}</span>
</>
)}
{selectedShot && (
<>
<span style={{ margin: '0 4px' }}>|</span>
<span>{selectedShot}</span>
</>
)}
<span style={{ flex: 1 }} />
<span>Phase 1</span>
</div>
</Content>
</Layout>
{/* 全屏预览(页面级别渲染,与画布 DOM 解耦) */}
<MediaPreview itemId={previewItemId} onClose={handlePreviewClose} />
</Layout>
);
}

View File

@@ -0,0 +1,419 @@
// ============================================================
// CanvasViewport — 核心画布组件
//
// CSS Transform 虚拟视口:
// - Grid 模式:固定列数,均匀网格布局
// - Single 模式:按 groupKey 分组,每组一行横向排列
// - 滚轮缩放:滚轮直接调整 zoom无需修饰键Ctrl/Cmd + 滚轮精细缩放
// - Ctrl/Cmd + 鼠标拖拽:按住 Ctrl(Win) 或 Cmd(Mac) 后 mousedown + mousemove 平移
// - 分页叠加层PagerOverlay + ZoomOverlay
// ============================================================
import { useRef, useCallback, useEffect, useState, useMemo } from 'react';
import { logger } from '@/shared/utils/bus';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { MediaCard } from './MediaCard';
import { MediaCardContextMenu } from './MediaCardContextMenu';
import { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls';
import {
gridPosition,
gridPageInfo,
gridCanvasSize,
singleLayout,
singlePageInfo,
} from '../../utils/layout';
import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../../utils/constants';
/** 缩放倍率范围 */
const ZOOM_MIN = 0.02;
const ZOOM_MAX = 16.0;
interface CanvasViewportProps {
/** 双击卡片时回调 */
onPreviewOpen: (itemId: number) => void;
/** 预览是否打开(阻止画布缩放/拖拽) */
previewActive: boolean;
}
export function CanvasViewport({ onPreviewOpen, previewActive }: CanvasViewportProps) {
const { isDark } = useTheme();
const items = useCanvasStore((s) => s.items);
const layoutMode = useCanvasStore((s) => s.layoutMode);
const cols = useCanvasStore((s) => s.cols);
const rowsPerPage = useCanvasStore((s) => s.rowsPerPage);
const singleRowsPerPage = useCanvasStore((s) => s.singleRowsPerPage);
const currentPage = useCanvasStore((s) => s.currentPage);
const zoom = useCanvasStore((s) => s.zoom);
const panOffset = useCanvasStore((s) => s.panOffset);
const interactionMode = useCanvasStore((s) => s.interactionMode);
const favoriteOnly = useCanvasStore((s) => s.favoriteOnly);
const setZoom = useCanvasStore((s) => s.setZoom);
const setPanOffset = useCanvasStore((s) => s.setPanOffset);
// 拖拽状态(需按住 Ctrl/Cmd 修饰键)
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const containerRef = useRef<HTMLDivElement>(null);
// 拖拽修饰键状态Ctrl / Cmd—— 双通道
const [isPanModifier, setIsPanModifier] = useState(false);
const panModifierRef = useRef(false);
// 预览打开时锁定视口事件(防止穿透缩放/拖拽画布)
const previewActiveRef = useRef(false);
// ref 版 setZoom + currentZoom — 供原生 wheel handler 稳定读写。
// 避免 Zustand v5 中 getState() 在连续原生事件中返回批处理前的旧值,
// 导致每次滚轮都从同一个起点计算(日志反复出现"100% → 90%")。
const setZoomRef = useRef(setZoom);
useEffect(() => { setZoomRef.current = setZoom; }, [setZoom]);
const currentZoomRef = useRef(zoom);
useEffect(() => { currentZoomRef.current = zoom; }, [zoom]);
// 同步预览状态到 ref供原生 wheel 监听器读取,避免闭包过期)
useEffect(() => {
previewActiveRef.current = previewActive;
}, [previewActive]);
// ── 收藏过滤 ──
// 客户端过滤:当 favoriteOnly 为 true 时仅展示 metadata.favorite === true 的条目
const displayItems = useMemo(() => {
if (!favoriteOnly) return items;
return items.filter((item) => item.metadata?.favorite === true);
}, [items, favoriteOnly]);
// ── 分页计算 ──
const pageItems = useMemo(() => {
if (displayItems.length === 0) return [];
if (layoutMode === 'grid') {
const { startIndex, endIndex } = gridPageInfo(
displayItems.length,
cols,
rowsPerPage,
currentPage,
);
return displayItems.slice(startIndex, endIndex);
}
// single mode
const { startIndex, endIndex } = singlePageInfo(
displayItems,
singleRowsPerPage,
currentPage,
);
return displayItems.slice(startIndex, endIndex);
}, [displayItems, layoutMode, cols, rowsPerPage, singleRowsPerPage, currentPage]);
// ── 位置计算 ──
const { positions, canvasWidth, canvasHeight, rowLabels } = useMemo(() => {
if (layoutMode === 'grid') {
const positions = new Map<number, { x: number; y: number }>();
pageItems.forEach((item, idx) => {
positions.set(item.id, gridPosition(idx, cols));
});
const size = gridCanvasSize(pageItems.length, cols);
return {
positions,
canvasWidth: Math.max(size.width, 400),
canvasHeight: Math.max(size.height, 300),
rowLabels: new Map<number, string>(),
};
}
// single mode
const layout = singleLayout(pageItems);
// 构建行标签映射
const labels = new Map<number, string>();
const seen = new Set<string>();
let row = 0;
for (const item of pageItems) {
if (!seen.has(item.groupKey)) {
seen.add(item.groupKey);
labels.set(row, item.groupKey);
row++;
}
}
return {
positions: layout.positions,
canvasWidth: Math.max(layout.canvasWidth, 400),
canvasHeight: Math.max(layout.canvasHeight, 300),
rowLabels: labels,
};
}, [layoutMode, pageItems, cols]);
// ── Ctrl/Cmd + 拖拽平移 ──
// 按住 CtrlWindows或 CmdMac后鼠标左键拖拽平移画布。
// 修饰键松开时自动取消拖拽,防止画布粘滞。
// 同时支持 Ctrl 和 Cmd 组合keyup 后检查是否仍有一个修饰键被按住)。
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Control' && e.key !== 'Meta') return;
if (e.repeat) return;
e.preventDefault();
setIsPanModifier(true);
panModifierRef.current = true;
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key !== 'Control' && e.key !== 'Meta') return;
// 检查是否所有拖拽修饰键均已松开
if (!e.ctrlKey && !e.metaKey) {
setIsPanModifier(false);
panModifierRef.current = false;
setIsDragging(false); // 松修饰键时取消拖拽
}
};
// 窗口失焦时重置修饰键状态(防止切出应用导致状态卡住)
const handleBlur = () => {
setIsPanModifier(false);
panModifierRef.current = false;
setIsDragging(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, []);
// ── 滚轮缩放 ──
// 纯滚轮 → 快速缩放10%/tickCtrl/Cmd + 滚轮 → 精细缩放3%/tick
// Mac 触控板捏合手势 → macOS 自动合成 Ctrl+wheel 事件 → 精细缩放。
//
// 使用 window 级原生监听器({ passive: false })而非 React onWheel
// 因为 React 18+ / Electron 42 中 React 合成事件一律为 passivepreventDefault 会抛异常。
//
// ⚠️ 通过 refsetZoomRef + currentZoomRef读写 zoom
// 避免 Zustand v5 中 getState() 在原生事件循环中返回批处理前的旧值。
useEffect(() => {
const handler = (e: WheelEvent) => {
// 仅处理容器内部的滚轮事件(避免 ShotList 等其他区域触发)
if (!containerRef.current?.contains(e.target as Node)) return;
// 预览打开时锁定视口,防止穿透缩放
if (previewActiveRef.current) return;
try {
e.preventDefault();
} catch (err) {
logger.warn('canvas', 'preventDefault 失败passive 冲突)', err instanceof Error ? err : undefined);
return;
}
// 从 ref 读取当前 zoom避免 Zustand v5 getState() 在原生事件中返回旧值)
const currentZoom = currentZoomRef.current;
// Ctrl/Cmd → 精细缩放3%/tick纯滚轮 → 快速缩放10%/tick
const sensitivity = (e.ctrlKey || e.metaKey) ? 0.0003 : 0.001;
const delta = -e.deltaY * sensitivity;
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, currentZoom + delta));
// 同步更新 ref供下一帧滚轮事件读取最新值
currentZoomRef.current = newZoom;
setZoomRef.current(newZoom);
};
window.addEventListener('wheel', handler, { passive: false });
return () => window.removeEventListener('wheel', handler);
}, []);
// ── 拖拽平移 ──
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// 预览打开时锁定视口,防止穿透拖拽
if (previewActiveRef.current) return;
// 仅鼠标左键 + 非交互子元素(如按钮)
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('button')) return;
// 必须按住 Ctrl/Cmd 才能拖拽平移画布
if (!panModifierRef.current) return;
setIsDragging(true);
dragStart.current = {
x: e.clientX,
y: e.clientY,
panX: panOffset.x,
panY: panOffset.y,
};
},
[panOffset],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPanOffset({
x: dragStart.current.panX + dx,
y: dragStart.current.panY + dy,
});
},
[isDragging, setPanOffset],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// 全局 mouseup拖拽到视口外时释放
useEffect(() => {
const handler = () => setIsDragging(false);
window.addEventListener('mouseup', handler);
return () => window.removeEventListener('mouseup', handler);
}, []);
// ── 键盘快捷键 ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// 缩放重置
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
setZoom(1.0);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [setZoom]);
// ── 渲染 ──
const transform = `scale(${zoom}) translate(${panOffset.x}px, ${panOffset.y}px)`;
return (
<div
ref={containerRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
cursor: isDragging
? 'grabbing'
: isPanModifier
? 'grab'
: 'default',
background: isDark ? '#0F0D1E' : '#F9FAFB',
// Ctrl/Cmd 拖拽时禁用文本选择,防止误抓媒体元素
userSelect: isPanModifier ? 'none' : undefined,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragStart={(e) => {
// Ctrl/Cmd 拖拽时阻止浏览器原生的图片/链接拖拽行为
if (panModifierRef.current) e.preventDefault();
}}
>
{/* 画布内容 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
transform,
transformOrigin: '0 0',
width: canvasWidth,
height: canvasHeight,
// 拖拽修饰键激活时禁用缩略图的鼠标事件,所有事件穿透到容器层
pointerEvents: isPanModifier ? 'none' : undefined,
}}
>
{/* Single 模式的行标签 */}
{layoutMode === 'single' &&
Array.from(rowLabels.entries()).map(([row, label]) => (
<div
key={`label-${row}`}
style={{
position: 'absolute',
left: 0,
top: row * (ITEM_H + ITEM_SPACING),
width: ROW_LABEL_WIDTH,
height: ITEM_H,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 600,
color: isDark ? '#C4B5FD' : '#6366F1',
background: isDark
? 'rgba(30, 27, 75, 0.6)'
: 'rgba(238, 242, 255, 0.8)',
borderRadius: 4,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={label}
>
{label}
</div>
))}
{/* MediaCard 渲染(受硬上限保护) */}
{pageItems.slice(0, MAX_ITEMS_PER_PAGE).map((item) => {
const pos = positions.get(item.id);
if (!pos) return null;
return (
<MediaCardContextMenu key={item.id} itemId={item.id} x={pos.x} y={pos.y}>
<MediaCard
itemId={item.id}
x={0}
y={0}
zoom={zoom}
mode={interactionMode}
onDoubleClick={onPreviewOpen}
/>
</MediaCardContextMenu>
);
})}
{/* DOM 硬上限警告 */}
{pageItems.length > MAX_ITEMS_PER_PAGE && (
<div
style={{
position: 'absolute',
top: canvasHeight + 16,
left: 0,
width: canvasWidth,
padding: '8px 16px',
borderRadius: 4,
background: 'rgba(255, 77, 79, 0.1)',
border: '1px solid rgba(255, 77, 79, 0.3)',
color: '#FF4D4F',
fontSize: 13,
textAlign: 'center',
}}
>
{pageItems.length} {MAX_ITEMS_PER_PAGE}
使
</div>
)}
{/* 空画布提示 */}
{pageItems.length === 0 && (
<div
style={{
width: canvasWidth,
height: canvasHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#9CA3AF',
fontSize: 14,
}}
>
</div>
)}
</div>
{/* 浮层控件 */}
<PagerOverlay />
<ZoomOverlay />
<ModeSwitch />
</div>
);
}

View File

@@ -0,0 +1,146 @@
// ============================================================
// LODImage — 多级清晰度图片组件
//
// 优先级:
// 1. 已缓存的 Blob URLStore 中 item.blobUrl
// 2. dataSource.generateThumbnail() → base64 data URL
// 3. 棋盘格背景占位(加载前)
//
// 图片加载完成后通过 CanvasStore.loadThumbnail 缓存 URL。
// ============================================================
import { useState, useEffect, useRef } from 'react';
import { logger } from '@/shared/utils/bus';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { LOD_LEVELS } from '../../utils/constants';
import type { LODLevel } from '../../utils/constants';
interface LODImageProps {
itemId: number;
filePath: string;
/** 当前需要的 LOD 级别(基于 zoom 计算) */
targetLevel: number; // 0-3 index into LOD_LEVELS
}
/**
* 根据 zoom 倍率确定 LOD 级别索引。
* zoom < 0.5 → 512px, < 1.0 → 1024px, < 2.0 → 2048px, >= 2.0 → 4096px
*/
export function zoomToLODIndex(zoom: number): number {
if (zoom < 0.5) return 0;
if (zoom < 1.0) return 1;
if (zoom < 2.0) return 2;
return 3;
}
export function LODImage({ itemId, filePath, targetLevel }: LODImageProps) {
const blobUrl = useCanvasStore((s) => {
const item = s.items.find((i) => i.id === itemId);
return item?.blobUrl;
});
const loadedLevel = useCanvasStore((s) => {
const item = s.items.find((i) => i.id === itemId);
return item?.loadedLevel;
});
const loadThumbnail = useCanvasStore((s) => s.loadThumbnail);
const getDataSource = useCanvasStore((s) => s.getDataSource);
const [src, setSrc] = useState<string | null>(blobUrl || null);
const [loading, setLoading] = useState(!blobUrl);
const [error, setError] = useState(false);
const loadingRef = useRef(false);
// 当 Store 中的 blobUrl 更新时同步
useEffect(() => {
if (blobUrl) {
setSrc(blobUrl);
setLoading(false);
}
}, [blobUrl]);
// 按需加载更高清晰度的缩略图
useEffect(() => {
// 已有足够清晰的版本
if (loadedLevel !== undefined && loadedLevel >= targetLevel) return;
// 正在加载中
if (loadingRef.current) return;
const requiredSize = LOD_LEVELS[Math.min(targetLevel, LOD_LEVELS.length - 1)] as LODLevel;
loadingRef.current = true;
const dataSource = getDataSource();
dataSource
.generateThumbnail({ filePath, size: requiredSize })
.then((thumb) => {
const url = `data:${thumb.mimeType};base64,${thumb.data}`;
setSrc(url);
setLoading(false);
setError(false);
loadThumbnail(itemId, url, targetLevel);
loadingRef.current = false;
})
.catch((err) => {
logger.warn('canvas', 'LOD 图片加载失败', err instanceof Error ? err : undefined);
setLoading(false);
setError(true);
loadingRef.current = false;
});
}, [itemId, filePath, targetLevel, loadedLevel, loadThumbnail, getDataSource]);
if (error) {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
background: '#f5f5f5',
}}
>
🖼
</div>
);
}
return (
<div
style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
// 棋盘格背景
backgroundImage: `
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%)
`,
backgroundSize: '16px 16px',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
}}
>
{src && (
<img
src={src}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
top: 0,
left: 0,
opacity: loading ? 0.3 : 1,
transition: 'opacity 0.2s',
}}
onLoad={() => setLoading(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,187 @@
// ============================================================
// MediaCard — 缩略图卡片(画布核心渲染单元)
//
// 功能:
// - LOD 多级缩略图加载LODImage 组件)
// - 棋盘格背景(图片加载前由 LODImage 内部提供)
// - 审核角标pending/approved/rework/deprecated
// - 文件名标签
// - 选中高亮SELECT 模式)
// - 视频/音频的首帧/图标
// ============================================================
import { useCallback, memo } from 'react';
import { LODImage, zoomToLODIndex } from './LODImage';
import { useCanvasStore, type InteractionMode } from '../../stores/useCanvasStore';
import { AUDIT_STATUS_COLORS, ITEM_W, ITEM_H } from '../../utils/constants';
export interface MediaCardProps {
itemId: number;
/** 在画布中的绝对像素坐标 */
x: number;
y: number;
/** 当前视图缩放倍率 */
zoom: number;
/** 当前交互模式 */
mode: InteractionMode;
/** 双击回调 → 打开全屏预览 */
onDoubleClick?: (itemId: number) => void;
}
/**
* MediaCard 使用 React.memo 优化:仅在以下属性变化时重渲染:
* - itemId / x / y位置变化
* - zoom影响 LOD 级别)
* - mode影响选中行为和 UI
* - blobUrl / auditStatus从 Store 选择器获取)
*/
export const MediaCard = memo(function MediaCard({ itemId, x, y, zoom, mode, onDoubleClick }: MediaCardProps) {
const item = useCanvasStore((s) => s.items.find((i) => i.id === itemId));
const selectedIds = useCanvasStore((s) => s.selectedIds);
const toggleSelection = useCanvasStore((s) => s.toggleSelection);
const isVideo = ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(item?.ext || '');
const isAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(item?.ext || '');
const isSelected = selectedIds.has(itemId);
const handleClick = useCallback(() => {
if (mode === 'SELECT') {
toggleSelection(itemId);
}
}, [mode, itemId, toggleSelection]);
const handleDoubleClick = useCallback(() => {
onDoubleClick?.(itemId);
}, [itemId, onDoubleClick]);
// 启动原生文件拖拽(拖放缩略图到 Photoshop/Explorer 等外部应用)
const handleDragStart = useCallback((e: React.DragEvent) => {
if (mode !== 'BROWSE' || !item) return;
if (typeof window.canvas?.startDrag === 'function') {
e.preventDefault();
e.stopPropagation();
window.canvas.startDrag({
filePath: item.path,
fileName: item.fileName,
}).catch(() => {
// 静默忽略拖拽失败(文件可能已被移动或删除)
});
}
}, [mode, item]);
if (!item) return null;
const lodIndex = zoomToLODIndex(zoom);
const auditStatus = item.metadata?.audit_status;
const auditColor = auditStatus ? AUDIT_STATUS_COLORS[auditStatus] : undefined;
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: ITEM_W,
height: ITEM_H,
borderRadius: 4,
overflow: 'hidden',
cursor: mode === 'SELECT' ? 'pointer' : 'default',
border: isSelected
? '2px solid #6366F1'
: '1px solid rgba(0,0,0,0.08)',
boxShadow: isSelected
? '0 0 8px rgba(99, 102, 241, 0.4)'
: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'border 0.1s, box-shadow 0.1s',
background: '#fff',
}}
draggable={mode === 'BROWSE'}
onDragStart={handleDragStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{/* 缩略图区域 */}
<div style={{ width: ITEM_W, height: ITEM_H - 22, position: 'relative' }}>
{isVideo || isAudio ? (
/* 视频/音频:显示图标 */
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 32,
background: '#f0f0f0',
}}
>
{isVideo ? '🎥' : '🎵'}
</div>
) : (
<LODImage itemId={itemId} filePath={item.path} targetLevel={lodIndex} />
)}
{/* 审核角标 */}
{auditStatus && auditColor && (
<div
style={{
position: 'absolute',
top: 4,
right: 4,
width: 10,
height: 10,
borderRadius: '50%',
background: auditColor,
border: '1px solid rgba(255,255,255,0.8)',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
}}
title={auditStatus}
/>
)}
{/* 选中勾选SELECT 模式) */}
{mode === 'SELECT' && (
<div
style={{
position: 'absolute',
top: 4,
left: 4,
width: 18,
height: 18,
borderRadius: 3,
background: isSelected ? '#6366F1' : 'rgba(255,255,255,0.8)',
border: `2px solid ${isSelected ? '#6366F1' : '#d1d5db'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: '#fff',
}}
>
{isSelected ? '✓' : ''}
</div>
)}
</div>
{/* 文件名标签 */}
<div
style={{
height: 22,
padding: '0 4px',
display: 'flex',
alignItems: 'center',
fontSize: 10,
color: '#6B7280',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
background: '#FAFAFA',
borderTop: '1px solid #F0F0F0',
}}
title={item.fileName}
>
{item.fileName}
</div>
</div>
);
});

View File

@@ -0,0 +1,87 @@
// ============================================================
// MediaCardContextMenu — 画布媒体卡片右键菜单
//
// 菜单项定义见 menus/media-card-menu.ts
// 组件职责:读取 Store 状态 + 绑定 IPC 操作 → 生成菜单项
// ============================================================
import { message } from 'antd';
import { useCallback } from 'react';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { useSidecarSync } from '../../hooks/useSidecarSync';
import { getMediaCardMenuItems } from '../../controllers/menus';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
import { ITEM_W, ITEM_H } from '../../utils/constants';
import type { AuditStatus } from '../../stores/useCanvasStore';
interface MediaCardContextMenuProps {
itemId: number;
children: React.ReactNode;
/** 画布中的绝对定位 */
x: number;
y: number;
}
export function MediaCardContextMenu({ itemId, children, x, y }: MediaCardContextMenuProps) {
const item = useCanvasStore((s) => s.items.find((i) => i.id === itemId));
const { commitMetadata } = useSidecarSync();
// ── 回调:复制文件路径 ──
const handleCopyPath = useCallback(async () => {
if (!item) return;
try {
await navigator.clipboard.writeText(item.path);
void message.success('已复制文件路径');
} catch {
void message.error('复制失败');
}
}, [item]);
// ── 回调:在文件夹中打开 ──
const handleOpenFolder = useCallback(async () => {
if (!item) return;
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, {
filePath: item.path,
});
}, [item]);
// ── 回调:设置审核状态 ──
const handleSetAudit = useCallback(
(status: AuditStatus) => {
if (!item) return;
commitMetadata(itemId, item.path, { audit_status: status });
void message.success(`审核状态已更新为:${status}`);
},
[item, itemId, commitMetadata],
);
if (!item) return <>{children}</>;
const isAuditable = ['Storyboard', 'Keyframe', 'Video', 'Audio', 'LipSync'].includes(item.stage);
// ── 通过菜单定义函数生成 ContextMenuItems ──
const menuItems = getMediaCardMenuItems({
isAuditable,
onSetAudit: handleSetAudit,
onCopyPath: handleCopyPath,
onOpenFolder: handleOpenFolder,
});
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: ITEM_W,
height: ITEM_H,
}}
>
<ContextMenu items={menuItems}>
{children}
</ContextMenu>
</div>
);
}

View File

@@ -0,0 +1,400 @@
// ============================================================
// MediaPreview — 全屏/灯箱预览
//
// 双击 MediaCard 时弹出,显示大图/视频/音频。
// - 图片全屏显示原图4096px LOD
// - 视频HTML5 <video> 播放器
// - 音频HTML5 <audio> 播放器
//
// 文件加载:通过 local-media:// 自定义协议加载本地文件,
// 主进程 protocol.handle 拦截并返回文件内容。
// ============================================================
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Button } from 'antd';
import { CloseOutlined, LeftOutlined, RightOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons';
import { useCanvasStore } from '../../stores/useCanvasStore';
interface MediaPreviewProps {
itemId: number | null;
onClose: () => void;
}
/** 将文件路径转为 local-media:// URL通过自定义协议安全加载本地文件 */
function toMediaUrl(filePath: string): string {
// 优先通过 preload 注入的 canvas.getMediaUrl
if (typeof window.canvas?.getMediaUrl === 'function') {
return window.canvas.getMediaUrl(filePath);
}
// 回退(非 Electron 环境,一般不可用)
return `file://${filePath.replace(/\\/g, '/')}`;
}
export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
const items = useCanvasStore((s) => s.items);
const [currentId, setCurrentId] = useState(itemId);
// 同步外部 itemId 变化
useEffect(() => {
setCurrentId(itemId);
}, [itemId]);
// ⚠️ useMemo 必须在所有条件 return 之前调用Rules of Hooks
const navigate = useMemo(() => {
return (direction: -1 | 1) => {
const idx = items.findIndex((i) => i.id === currentId);
const next = idx + direction;
if (next >= 0 && next < items.length) {
setCurrentId(items[next].id);
}
};
}, [items, currentId]);
// ── 缩放 & 拖拽状态 ──
const [previewZoom, setPreviewZoom] = useState(1);
const [previewPan, setPreviewPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const imgContainerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
// ref 版 zoom供稳定闭包的 native wheel listener 读取)
const previewZoomRef = useRef(previewZoom);
useEffect(() => { previewZoomRef.current = previewZoom; }, [previewZoom]);
// 切换图片时重置缩放/平移
useEffect(() => {
setPreviewZoom(1);
setPreviewPan({ x: 0, y: 0 });
}, [currentId]);
// 预览内滚轮缩放(随 currentId 变化重新绑定,解决首次挂载时 ref 为 null 的问题)
useEffect(() => {
const el = imgContainerRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const delta = -e.deltaY * 0.002;
const cur = previewZoomRef.current;
const newZoom = Math.max(0.1, Math.min(8.0, cur + delta));
setPreviewZoom(newZoom);
};
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
}, [currentId]);
// ── 拖拽平移(仅在预览内)──
const handlePreviewMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('button')) return;
e.stopPropagation(); // 阻止冒泡到 CanvasViewport
setIsPanning(true);
panStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: previewPan.x,
panY: previewPan.y,
};
}, [previewPan]);
const handlePreviewMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning) return;
e.stopPropagation();
const dx = e.clientX - panStartRef.current.x;
const dy = e.clientY - panStartRef.current.y;
setPreviewPan({
x: panStartRef.current.panX + dx,
y: panStartRef.current.panY + dy,
});
}, [isPanning]);
const handlePreviewMouseUp = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsPanning(false);
}, []);
// 全局 mouseup拖拽到预览外释放
useEffect(() => {
const handler = () => setIsPanning(false);
window.addEventListener('mouseup', handler);
return () => window.removeEventListener('mouseup', handler);
}, []);
// ── 键盘媒体控制 ──
// 所有按键在 capture 阶段拦截,优先于原生 <video controls>
// 空格 → 暂停/播放stopPropagation 阻止原生二次触发)
// ← / → → 快退/快进 0.5 秒(直接设 currentTime
// ↑ / ↓ → 透传到原生 controls 处理音量
useEffect(() => {
if (currentId == null) return;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
const video = videoRef.current;
const audio = audioRef.current;
const media = video || audio;
if (!media) return;
// 空格 → 暂停/播放
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (media.paused) void media.play();
else media.pause();
return;
}
// ← / → → 快退/快进 0.5 秒
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
const delta = e.key === 'ArrowLeft' ? -0.5 : 0.5;
media.currentTime = Math.max(0, media.currentTime + delta);
return;
}
// ↑ / ↓ → 不拦截,透传给原生 controls原生音量 ±10%
};
// capture: true → 在原生 video controls 之前拦截
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [currentId]);
// 媒体元素挂载后自动聚焦(确保原生 controls 能接收 ↑/↓ 音量事件)
useEffect(() => {
const timer = setTimeout(() => {
if (videoRef.current) videoRef.current.focus();
else if (audioRef.current) audioRef.current.focus();
}, 100);
return () => clearTimeout(timer);
}, [currentId]);
if (currentId == null) return null;
const currentItem = items.find((i) => i.id === currentId);
if (!currentItem) return null;
const isImage = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'].includes(
currentItem.ext,
);
const isVideo = ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(
currentItem.ext,
);
const isAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(
currentItem.ext,
);
const mediaUrl = toMediaUrl(currentItem.path);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 9999,
background: 'rgba(0, 0, 0, 0.92)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* 顶栏 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 48,
display: 'flex',
alignItems: 'center',
padding: '0 16px',
background: 'rgba(0,0,0,0.5)',
}}
>
<span style={{ color: '#fff', fontSize: 13, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{currentItem.fileName}
</span>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
style={{ color: '#fff' }}
/>
</div>
{/* 内容 */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 48 }}>
{isImage && (
<div
ref={imgContainerRef}
style={{
maxWidth: '90vw',
maxHeight: '85vh',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: isPanning ? 'grabbing' : previewZoom > 1 ? 'grab' : 'default',
}}
onMouseDown={handlePreviewMouseDown}
onMouseMove={handlePreviewMouseMove}
onMouseUp={handlePreviewMouseUp}
>
<img
src={mediaUrl}
alt={currentItem.fileName}
draggable={false}
style={{
maxWidth: previewZoom === 1 ? '100%' : 'none',
maxHeight: previewZoom === 1 ? '100%' : 'none',
objectFit: 'contain',
borderRadius: 4,
transform: `translate(${previewPan.x}px, ${previewPan.y}px) scale(${previewZoom})`,
transformOrigin: 'center center',
userSelect: 'none',
}}
onLoad={(e) => {
// 预留naturalWidth/Height 可用于智能初始缩放
void e.currentTarget.naturalWidth;
}}
/>
</div>
)}
{isVideo && (
<video
ref={videoRef}
src={mediaUrl}
controls
autoPlay
tabIndex={0}
style={{ maxWidth: '90vw', maxHeight: '85vh', borderRadius: 4, outline: 'none' }}
/>
)}
{isAudio && (
<div style={{ textAlign: 'center', color: '#fff' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🎵</div>
<div style={{ fontSize: 16, marginBottom: 12 }}>{currentItem.fileName}</div>
<audio ref={audioRef} src={mediaUrl} controls autoPlay />
</div>
)}
</div>
{/* 导航按钮 */}
<Button
type="text"
icon={<LeftOutlined />}
onClick={() => navigate(-1)}
style={{
position: 'absolute',
left: 16,
top: '50%',
transform: 'translateY(-50%)',
color: '#fff',
fontSize: 24,
width: 48,
height: 48,
}}
/>
<Button
type="text"
icon={<RightOutlined />}
onClick={() => navigate(1)}
style={{
position: 'absolute',
right: 16,
top: '50%',
transform: 'translateY(-50%)',
color: '#fff',
fontSize: 24,
width: 48,
height: 48,
}}
/>
{/* 缩放指示器(仅图片模式显示) */}
{isImage && (
<div
style={{
position: 'absolute',
bottom: 48,
right: 16,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: '2px 6px',
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
<Button
type="text"
size="small"
icon={<ZoomOutOutlined />}
disabled={previewZoom <= 0.1}
onClick={() => setPreviewZoom(Math.max(0.1, previewZoom - 0.25))}
style={{ color: '#fff' }}
/>
<span
style={{
color: '#fff',
fontSize: 12,
fontWeight: 600,
minWidth: 44,
textAlign: 'center',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={() => {
setPreviewZoom(1);
setPreviewPan({ x: 0, y: 0 });
}}
title="重置缩放 (100%)"
>
{Math.round(previewZoom * 100)}%
</span>
<Button
type="text"
size="small"
icon={<ZoomInOutlined />}
disabled={previewZoom >= 8.0}
onClick={() => setPreviewZoom(Math.min(8.0, previewZoom + 0.25))}
style={{ color: '#fff' }}
/>
</div>
)}
{/* 底栏信息 */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
color: '#aaa',
fontSize: 11,
}}
>
{currentItem.groupKey} · {currentItem.stage} · {items.findIndex((i) => i.id === currentId) + 1} / {items.length}
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
// ============================================================
// ViewportControls — 画布浮层控件
//
// 合并 PagerOverlay + ZoomOverlay + ModeSwitch 三个控件。
// 统一放置于画布视口底部,通过 position: absolute 定位。
// ============================================================
import { Button } from 'antd';
import {
LeftOutlined,
RightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
ExpandOutlined,
EyeOutlined,
CheckSquareOutlined,
MenuOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
import { gridPageInfo, singlePageInfo } from '../../utils/layout';
import { MAX_ITEMS_PER_PAGE } from '../../utils/constants';
// ── 分页控件 ──
export function PagerOverlay() {
const { isDark } = useTheme();
const items = useCanvasStore((s) => s.items);
const currentPage = useCanvasStore((s) => s.currentPage);
const layoutMode = useCanvasStore((s) => s.layoutMode);
const cols = useCanvasStore((s) => s.cols);
const rowsPerPage = useCanvasStore((s) => s.rowsPerPage);
const singleRowsPerPage = useCanvasStore((s) => s.singleRowsPerPage);
const gotoPage = useCanvasStore((s) => s.gotoPage);
const totalPages =
layoutMode === 'grid'
? gridPageInfo(items.length, cols, rowsPerPage, currentPage).totalPages
: singlePageInfo(items, singleRowsPerPage, currentPage).totalPages;
const perPageText =
layoutMode === 'grid'
? `${Math.min(cols * rowsPerPage, MAX_ITEMS_PER_PAGE)}`
: `${singleRowsPerPage}`;
if (totalPages <= 1) return null;
return (
<div
style={{
position: 'absolute',
bottom: 36,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 12px',
borderRadius: 20,
background: isDark ? 'rgba(30, 27, 75, 0.9)' : 'rgba(255, 255, 255, 0.9)',
border: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
backdropFilter: 'blur(4px)',
}}
>
<Button
type="text"
size="small"
icon={<LeftOutlined />}
disabled={currentPage <= 1}
onClick={() => gotoPage(currentPage - 1)}
/>
<span
style={{
fontSize: 13,
fontWeight: 500,
color: isDark ? '#E8E6F0' : '#1E1B4B',
minWidth: 80,
textAlign: 'center',
}}
>
{currentPage} / {totalPages}
</span>
<Button
type="text"
size="small"
icon={<RightOutlined />}
disabled={currentPage >= totalPages}
onClick={() => gotoPage(currentPage + 1)}
/>
<span
style={{
fontSize: 11,
color: isDark ? '#6E6B90' : '#9CA3AF',
borderLeft: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
paddingLeft: 8,
}}
>
{items.length} · {perPageText}/
</span>
</div>
);
}
// ── 缩放控件 ──
export function ZoomOverlay() {
const { isDark } = useTheme();
const zoom = useCanvasStore((s) => s.zoom);
const setZoom = useCanvasStore((s) => s.setZoom);
const zoomPercent = Math.round(zoom * 100);
return (
<div
style={{
position: 'absolute',
bottom: 36,
right: 16,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: '2px 4px',
borderRadius: 8,
background: isDark ? 'rgba(30, 27, 75, 0.9)' : 'rgba(255, 255, 255, 0.9)',
border: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
backdropFilter: 'blur(4px)',
}}
>
<Button
type="text"
size="small"
icon={<ZoomOutOutlined />}
disabled={zoom <= 0.02}
onClick={() => setZoom(zoom - 0.1)}
/>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: isDark ? '#E8E6F0' : '#1E1B4B',
minWidth: 44,
textAlign: 'center',
cursor: 'pointer',
}}
onClick={() => setZoom(1.0)}
title="重置为 100%"
>
{zoomPercent}%
</span>
<Button
type="text"
size="small"
icon={<ZoomInOutlined />}
disabled={zoom >= 16.0}
onClick={() => setZoom(zoom + 0.1)}
/>
<Button
type="text"
size="small"
icon={<ExpandOutlined />}
onClick={() => setZoom(1.0)}
title="重置为 100%"
/>
</div>
);
}
// ── 模式切换 ──
export function ModeSwitch() {
const { isDark } = useTheme();
const { interactionMode, switchMode } = useCanvasInteraction();
const modes = [
{ key: 'BROWSE' as const, icon: <EyeOutlined />, label: '浏览', shortcut: 'V' },
{ key: 'SELECT' as const, icon: <CheckSquareOutlined />, label: '选择', shortcut: 'S' },
{ key: 'REORDER' as const, icon: <MenuOutlined />, label: '排序', shortcut: 'R' },
];
return (
<div
style={{
position: 'absolute',
bottom: 36,
right: 160,
display: 'flex',
gap: 2,
padding: '2px 4px',
borderRadius: 8,
background: isDark ? 'rgba(30, 27, 75, 0.9)' : 'rgba(255, 255, 255, 0.9)',
border: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
backdropFilter: 'blur(4px)',
}}
>
{modes.map(({ key, icon, label, shortcut }) => (
<Button
key={key}
type={interactionMode === key ? 'primary' : 'text'}
size="small"
icon={icon}
onClick={() => switchMode(key)}
title={`${label}模式 (${shortcut})`}
style={{
fontSize: 12,
fontWeight: interactionMode === key ? 600 : 400,
}}
>
<span style={{ marginLeft: 2, fontSize: 11 }}>{shortcut}</span>
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,11 @@
// ============================================================
// core — 核心画布:视口 + 媒体卡片 + 浮层控件
// ============================================================
export { CanvasViewport } from './CanvasViewport';
export { MediaCard } from './MediaCard';
export type { MediaCardProps } from './MediaCard';
export { LODImage, zoomToLODIndex } from './LODImage';
export { MediaCardContextMenu } from './MediaCardContextMenu';
export { MediaPreview } from './MediaPreview';
export { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls';

View File

@@ -0,0 +1,430 @@
// ============================================================
// CanvasSidebar — 左侧导航边栏
//
// 从上到下依次为:
// 1. 项目下拉选择器ProjectSelector
// 2. 集数下拉选择器EpisodeSelectorAssets 阶段隐藏)
// 3. 镜头号搜索输入框
// 4. 镜头单选列表(每项支持右键菜单,定义见 menus/shot-menu.ts
// 5. 刷新镜头列表按钮
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Input, Button, App, Modal } from 'antd';
import { SearchOutlined, ReloadOutlined, FolderOpenOutlined } from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { ProjectSelector } from './ProjectSelector';
import { EpisodeSelector } from './EpisodeSelector';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { getShotMenuItems } from '../../controllers/menus';
import { DirectoryCreateModal } from './DirectoryCreateModal';
export function CanvasSidebar() {
const { isDark } = useTheme();
const { message } = App.useApp();
// ── Store 订阅 ──
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const selectedProject = useCanvasStore((s) => s.selectedProject);
const selectedEpisode = useCanvasStore((s) => s.selectedEpisode);
const selectedShot = useCanvasStore((s) => s.selectedShot);
const shots = useCanvasStore((s) => s.shots);
const currentStage = useCanvasStore((s) => s.currentStage);
const selectShot = useCanvasStore((s) => s.selectShot);
const setShots = useCanvasStore((s) => s.setShots);
const setWorkspace = useCanvasStore((s) => s.setWorkspace);
const setProjects = useCanvasStore((s) => s.setProjects);
const getDataSource = useCanvasStore((s) => s.getDataSource);
// ── 本地状态 ──
const [shotSearchQuery, setShotSearchQuery] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const [createShotOpen, setCreateShotOpen] = useState(false);
const isAssets = currentStage === 'Assets';
// ── 镜头搜索过滤 ──
const filteredShots = shotSearchQuery.trim()
? shots.filter((s) => s.toLowerCase().includes(shotSearchQuery.toLowerCase()))
: shots;
// ── 选择镜头 ──
const handleShotClick = useCallback(
(shotName: string) => {
selectShot(shotName);
},
[selectShot],
);
// ── 镜头目录基础路径 ──
const shotsBasePath = workspaceRoot && selectedProject
? `${workspaceRoot}\\${selectedProject}\\shots`
: '';
// ── 刷新镜头列表 ──
const handleRefreshShots = useCallback(async () => {
if (!workspaceRoot || !selectedProject || !selectedEpisode) return;
try {
setIsRefreshing(true);
const dataSource = getDataSource();
const shotsPath = `${workspaceRoot}\\${selectedProject}\\shots\\${selectedEpisode}`;
const updated = await dataSource.listSubdirs(shotsPath);
setShots(updated);
message.success(`镜头列表已刷新(${updated.length} 个镜头)`);
} catch (err) {
logger.warn('canvas', '刷新镜头列表失败', err instanceof Error ? err : undefined);
message.error(`刷新失败: ${(err as Error).message}`);
} finally {
setIsRefreshing(false);
}
}, [workspaceRoot, selectedProject, selectedEpisode, getDataSource, setShots, message]);
// ── 打开/切换工作区 ──
const handleOpenWorkspace = async () => {
try {
const dataSource = getDataSource();
const dir = await dataSource.openDirectoryDialog();
if (dir) {
setWorkspace(dir);
try {
const projects = await dataSource.listSubdirs(dir);
setProjects(projects);
} catch (err) {
logger.warn('canvas', '加载项目列表失败', err instanceof Error ? err : new Error(String(err)));
}
}
} catch (err) {
logger.warn('canvas', '打开目录失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '打开目录失败');
}
};
// ── 右键菜单操作 ──
// 新建镜头
const handleCreateShot = useCallback(async (dirName: string) => {
if (!shotsBasePath) return;
try {
const dataSource = getDataSource();
const episodePath = `${shotsBasePath}\\${selectedEpisode}`;
await dataSource.createDirectory(episodePath, dirName);
// 刷新镜头列表
const updated = await dataSource.listSubdirs(episodePath);
setShots(updated);
// 自动选中
selectShot(dirName);
message.success(`镜头 "${dirName}" 已创建`);
} catch (err) {
logger.warn('canvas', '创建镜头失败', err instanceof Error ? err : undefined);
message.error(`创建失败: ${(err as Error).message}`);
}
}, [shotsBasePath, selectedEpisode, getDataSource, setShots, selectShot, message]);
// 重命名镜头
const handleRenameShot = useCallback((_shotName: string) => {
message.info('重命名功能待实现');
}, [message]);
// 删除镜头
const handleDeleteShot = useCallback((shotName: string) => {
if (!shotsBasePath || !selectedEpisode) return;
Modal.confirm({
title: '确认删除',
content: `确定要删除镜头「${shotName}」及其所有内容吗?此操作不可撤销。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const dataSource = getDataSource();
const shotPath = `${shotsBasePath}\\${selectedEpisode}\\${shotName}`;
await dataSource.deleteFile(shotPath);
// 刷新列表
const episodePath = `${shotsBasePath}\\${selectedEpisode}`;
const updated = await dataSource.listSubdirs(episodePath);
setShots(updated);
message.success(`已删除镜头「${shotName}`);
} catch (err) {
logger.warn('canvas', '删除镜头失败', err instanceof Error ? err : undefined);
message.error(`删除失败: ${(err as Error).message}`);
}
},
});
}, [shotsBasePath, selectedEpisode, getDataSource, setShots, message]);
// 在文件夹中打开
const handleOpenShotFolder = useCallback((shotName: string) => {
if (!shotsBasePath || !selectedEpisode) return;
const shotPath = `${shotsBasePath}\\${selectedEpisode}\\${shotName}`;
window.fileStorage?.showItemInFolder(shotPath);
}, [shotsBasePath, selectedEpisode]);
// 生成单个镜头项的右键菜单
const getShotContextItems = useCallback((shotName: string) =>
getShotMenuItems({
canOperate: !!workspaceRoot && !!selectedProject && !!selectedEpisode,
shotName,
onNewShot: () => setCreateShotOpen(true),
onRename: handleRenameShot,
onDelete: handleDeleteShot,
onOpenFolder: handleOpenShotFolder,
}), [workspaceRoot, selectedProject, selectedEpisode, handleRenameShot, handleDeleteShot, handleOpenShotFolder]);
// ── 样式变量 ──
const bg = isDark ? '#0F0D21' : '#F5F3FF';
const borderColor = isDark ? '#2D2A4A' : '#E5E0F0';
const sectionBg = isDark ? '#15132B' : '#EBE8F5';
const textColor = isDark ? '#C8C4E0' : '#4A4570';
const dimColor = isDark ? '#6E6B90' : '#9CA3AF';
const activeBg = isDark ? '#2D2A5A' : '#E8E4FF';
const activeColor = isDark ? '#C4B5FD' : '#6D28D9';
const hoverBg = isDark ? '#1E1B3B' : '#EDEAFA';
return (
<div
style={{
height: '100%',
background: bg,
borderRight: `1px solid ${borderColor}`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* ── 工作区选择 ── */}
<div
style={{
padding: '6px 8px',
borderBottom: `1px solid ${borderColor}`,
flexShrink: 0,
}}
>
<Button
type="dashed"
size="small"
block
icon={<FolderOpenOutlined />}
onClick={() => void handleOpenWorkspace()}
style={{
fontSize: 13,
color: isDark ? '#C4B5FD' : '#4338CA',
borderColor: isDark ? '#3E3A6A' : '#C7D2FE',
}}
>
{workspaceRoot ? '切换工作区' : '打开工作区'}
</Button>
{workspaceRoot && (
<div
style={{
marginTop: 4,
fontSize: 11,
color: dimColor,
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={workspaceRoot}
>
{workspaceRoot}
</div>
)}
</div>
{/* ── 导航选择器 ── */}
{workspaceRoot && (
<div
style={{
padding: '8px 8px 4px',
display: 'flex',
flexDirection: 'column',
gap: 6,
flexShrink: 0,
}}
>
<div style={{ fontSize: 11, fontWeight: 600, color: dimColor, marginBottom: -2 }}>
</div>
<ProjectSelector />
{!isAssets && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: dimColor, marginBottom: -2, marginTop: 4 }}>
</div>
<EpisodeSelector />
</>
)}
</div>
)}
{/* ── 镜头搜索 ── */}
{workspaceRoot && selectedEpisode && !isAssets && (
<div
style={{
padding: '8px 8px 4px',
flexShrink: 0,
}}
>
<Input
prefix={<SearchOutlined style={{ fontSize: 13, color: dimColor }} />}
placeholder="搜索镜头号…"
value={shotSearchQuery}
onChange={(e) => setShotSearchQuery(e.target.value)}
allowClear
size="small"
style={{
background: sectionBg,
borderColor: isDark ? '#3E3A6A' : '#D1D5DB',
color: textColor,
borderRadius: 4,
fontSize: 13,
}}
/>
</div>
)}
{/* ── 镜头列表(每项支持右键菜单) ── */}
<div
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
minHeight: 0,
padding: '2px 0',
scrollbarWidth: 'thin' as const,
scrollbarColor: isDark
? '#3E3A6A transparent'
: '#C7D2FE transparent',
}}
>
{isAssets ? (
<div
style={{
padding: '24px 12px',
fontSize: 13,
color: dimColor,
textAlign: 'center',
}}
>
</div>
) : !selectedEpisode ? (
<div
style={{
padding: '24px 12px',
fontSize: 13,
color: dimColor,
textAlign: 'center',
}}
>
</div>
) : filteredShots.length === 0 ? (
<div
style={{
padding: '16px 12px',
fontSize: 13,
color: dimColor,
textAlign: 'center',
}}
>
{shotSearchQuery.trim() ? '无匹配镜头' : '该集数下无镜头'}
</div>
) : (
<>
{/* 镜头计数 */}
<div
style={{
padding: '4px 12px',
fontSize: 11,
fontWeight: 600,
color: dimColor,
}}
>
<span style={{ fontWeight: 400, marginLeft: 4 }}>
({filteredShots.length}{shotSearchQuery.trim() ? ` / ${shots.length}` : ''})
</span>
</div>
{filteredShots.map((shot) => {
const active = shot === selectedShot;
return (
<ContextMenu key={shot} items={getShotContextItems(shot)}>
<div
onClick={() => handleShotClick(shot)}
style={{
padding: '5px 12px',
cursor: 'pointer',
fontSize: 13,
color: active ? activeColor : textColor,
background: active ? activeBg : 'transparent',
transition: 'background 0.15s',
userSelect: 'none',
borderLeft: active
? `3px solid ${activeColor}`
: '3px solid transparent',
}}
onMouseEnter={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background =
'transparent';
}}
>
{shot}
</div>
</ContextMenu>
);
})}
</>
)}
</div>
{/* ── 刷新按钮(固定在底部) ── */}
{workspaceRoot && selectedEpisode && !isAssets && (
<div
style={{
padding: '8px',
borderTop: `1px solid ${borderColor}`,
flexShrink: 0,
background: isDark ? '#0F0D1E' : '#EDE9F6',
}}
>
<Button
size="small"
block
icon={<ReloadOutlined spin={isRefreshing} />}
onClick={() => void handleRefreshShots()}
loading={isRefreshing}
style={{
fontSize: 13,
fontWeight: 500,
borderRadius: 4,
border: `1px solid ${isDark ? '#3E3A6A' : '#C7D2FE'}`,
background: isDark
? 'rgba(62, 58, 106, 0.4)'
: 'rgba(199, 210, 254, 0.3)',
color: isDark ? '#C4B5FD' : '#4338CA',
}}
>
</Button>
</div>
)}
{/* 新建镜头 Modal */}
<DirectoryCreateModal
open={createShotOpen}
title="新建镜头"
onOk={handleCreateShot}
onCancel={() => setCreateShotOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,74 @@
// ============================================================
// DirectoryCreateModal — 可复用新建目录弹窗
//
// 用法:
// <DirectoryCreateModal
// open={open}
// title="新建项目"
// onOk={(name) => handleCreate(name)}
// onCancel={() => setOpen(false)}
// />
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Input } from 'antd';
interface DirectoryCreateModalProps {
open: boolean;
title: string;
onOk: (name: string) => void;
onCancel: () => void;
}
/** 危险字符正则Windows + 通用非法字符) */
const DANGEROUS_CHARS = /[/\\:*?"<>|]/;
export function DirectoryCreateModal({ open, title, onOk, onCancel }: DirectoryCreateModalProps) {
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const handleOk = useCallback(() => {
const trimmed = value.trim();
if (!trimmed) {
setError('名称不能为空');
return;
}
if (DANGEROUS_CHARS.test(trimmed)) {
setError('名称包含非法字符(/ \\ : * ? " < > |');
return;
}
setError(null);
setValue('');
onOk(trimmed);
}, [value, onOk]);
const handleCancel = useCallback(() => {
setError(null);
setValue('');
onCancel();
}, [onCancel]);
return (
<Modal
title={title}
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="创建"
cancelText="取消"
destroyOnHidden={true}
>
<Input
placeholder="请输入目录名称"
value={value}
onChange={(e) => {
setValue(e.target.value);
if (error) setError(null);
}}
onPressEnter={handleOk}
status={error ? 'error' : undefined}
/>
{error && <div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>{error}</div>}
</Modal>
);
}

View File

@@ -0,0 +1,110 @@
// ============================================================
// EpisodeSelector — 集数下拉选择器
//
// 列出 selectedProject/shots/ 下的子文件夹作为可选集数。
// 右击菜单项定义见 menus/episode-menu.ts
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Select, App } from 'antd';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { DirectoryCreateModal } from './DirectoryCreateModal';
import { getEpisodeMenuItems } from '../../controllers/menus';
export function EpisodeSelector() {
const { message } = App.useApp();
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const selectedProject = useCanvasStore((s) => s.selectedProject);
const episodes = useCanvasStore((s) => s.episodes);
const selectedEpisode = useCanvasStore((s) => s.selectedEpisode);
const selectEpisode = useCanvasStore((s) => s.selectEpisode);
const setShots = useCanvasStore((s) => s.setShots);
const setEpisodes = useCanvasStore((s) => s.setEpisodes);
const currentStage = useCanvasStore((s) => s.currentStage);
const getDataSource = useCanvasStore((s) => s.getDataSource);
const [createOpen, setCreateOpen] = useState(false);
const handleSelect = useCallback(
async (name: string) => {
selectEpisode(name);
try {
if (!workspaceRoot || !selectedProject) return;
const dataSource = getDataSource();
const shotsPath = `${workspaceRoot}\\${selectedProject}\\shots\\${name}`;
const shots = await dataSource.listSubdirs(shotsPath);
setShots(shots);
} catch (err) {
logger.warn('canvas', '加载镜头列表失败', err instanceof Error ? err : new Error(String(err)));
}
},
[workspaceRoot, selectedProject, selectEpisode, setShots, getDataSource],
);
const handleCreate = useCallback(
async (dirName: string) => {
if (!workspaceRoot || !selectedProject) return;
try {
const dataSource = getDataSource();
const parentPath = `${workspaceRoot}\\${selectedProject}\\shots`;
await dataSource.createDirectory(parentPath, dirName);
const updated = await dataSource.listSubdirs(parentPath);
setEpisodes(updated);
await handleSelect(dirName);
message.success(`集数 "${dirName}" 已创建`);
} catch (err) {
logger.warn('canvas', '创建集数失败', err instanceof Error ? err : undefined);
message.error(`创建失败: ${(err as Error).message}`);
}
},
[workspaceRoot, selectedProject, getDataSource, setEpisodes, handleSelect, message],
);
// ── 右键菜单定义 ──
const menuItems = getEpisodeMenuItems({
canCreate: !!selectedProject,
canOpenFolder: !!selectedProject && !!selectedEpisode && !!workspaceRoot,
onCreate: () => setCreateOpen(true),
onStats: () => message.info('统计集数功能待实现'),
onOpenFolder: () => {
if (workspaceRoot && selectedProject && selectedEpisode) {
const path = `${workspaceRoot}\\${selectedProject}\\shots\\${selectedEpisode}`;
window.fileStorage?.showItemInFolder(path);
}
},
});
// Assets 阶段隐藏集数选择器
if (currentStage === 'Assets') return null;
const options = episodes.map((ep) => ({ value: ep, label: ep }));
return (
<>
<ContextMenu items={menuItems}>
<Select
value={selectedEpisode}
onChange={handleSelect}
placeholder="选择集数"
size="small"
style={{ width: '100%' }}
options={options}
disabled={!selectedProject}
showSearch
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
</ContextMenu>
<DirectoryCreateModal
open={createOpen}
title="新建集数"
onOk={handleCreate}
onCancel={() => setCreateOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,104 @@
// ============================================================
// ProjectSelector — 项目下拉选择器
//
// 列出 workspaceRoot 下的子文件夹作为可选项目。
// 右击菜单项定义见 menus/project-menu.ts
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Select, App } from 'antd';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { DirectoryCreateModal } from './DirectoryCreateModal';
import { getProjectMenuItems } from '../../controllers/menus';
export function ProjectSelector() {
const { message } = App.useApp();
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const projects = useCanvasStore((s) => s.projects);
const selectedProject = useCanvasStore((s) => s.selectedProject);
const selectProject = useCanvasStore((s) => s.selectProject);
const setEpisodes = useCanvasStore((s) => s.setEpisodes);
const setProjects = useCanvasStore((s) => s.setProjects);
const getDataSource = useCanvasStore((s) => s.getDataSource);
const [createOpen, setCreateOpen] = useState(false);
const handleSelect = useCallback(
async (name: string) => {
selectProject(name);
try {
const dataSource = getDataSource();
const epPath = `${workspaceRoot}\\${name}\\shots`;
const episodes = await dataSource.listSubdirs(epPath);
setEpisodes(episodes);
} catch (err) {
logger.warn('canvas', '加载集数列表失败', err instanceof Error ? err : new Error(String(err)));
}
},
[workspaceRoot, selectProject, setEpisodes, getDataSource],
);
const handleCreate = useCallback(
async (dirName: string) => {
if (!workspaceRoot) return;
try {
const dataSource = getDataSource();
await dataSource.createDirectory(workspaceRoot, dirName);
const updated = await dataSource.listSubdirs(workspaceRoot);
setProjects(updated);
await handleSelect(dirName);
message.success(`项目 "${dirName}" 已创建`);
} catch (err) {
logger.warn('canvas', '创建项目失败', err instanceof Error ? err : undefined);
message.error(`创建失败: ${(err as Error).message}`);
}
},
[workspaceRoot, getDataSource, setProjects, handleSelect, message],
);
// ── 右键菜单定义 ──
const menuItems = getProjectMenuItems({
canCreate: !!workspaceRoot,
canOpenFolder: !!selectedProject && !!workspaceRoot,
onCreate: () => setCreateOpen(true),
onStats: () => message.info('统计项目功能待实现'),
onBatchShots: () => message.info('批量创建镜头功能待实现'),
onOpenFolder: () => {
if (workspaceRoot && selectedProject) {
const path = `${workspaceRoot}\\${selectedProject}`;
window.fileStorage?.showItemInFolder(path);
}
},
});
const options = projects.map((p) => ({ value: p, label: p }));
return (
<>
<ContextMenu items={menuItems}>
<Select
value={selectedProject}
onChange={handleSelect}
placeholder="选择项目"
size="small"
style={{ width: '100%' }}
options={options}
disabled={!workspaceRoot}
showSearch
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
</ContextMenu>
<DirectoryCreateModal
open={createOpen}
title="新建项目"
onOk={handleCreate}
onCancel={() => setCreateOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,126 @@
// ============================================================
// ShotList — 左侧镜头单选列表
//
// 列出 selectedProject/shots/selectedEpisode/ 下的子文件夹。
// Assets 阶段隐藏。
// ============================================================
import { useCallback } from 'react';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { useTheme } from '@/app/providers/ThemeProvider';
const SHOT_LIST_WIDTH = 200;
export function ShotList() {
const { isDark } = useTheme();
const shots = useCanvasStore((s) => s.shots);
const selectedShot = useCanvasStore((s) => s.selectedShot);
const selectedEpisode = useCanvasStore((s) => s.selectedEpisode);
const selectShot = useCanvasStore((s) => s.selectShot);
const currentStage = useCanvasStore((s) => s.currentStage);
const handleClick = useCallback(
(shotName: string) => {
selectShot(shotName);
},
[selectShot],
);
// Assets 阶段隐藏镜头列表
if (currentStage === 'Assets') return null;
const bg = isDark ? '#0F0D21' : '#F5F3FF';
const borderColor = isDark ? '#2D2A4A' : '#E5E0F0';
const textColor = isDark ? '#C8C4E0' : '#4A4570';
const activeBg = isDark ? '#2D2A5A' : '#E8E4FF';
const activeColor = isDark ? '#C4B5FD' : '#6D28D9';
const hoverBg = isDark ? '#1E1B3B' : '#EDEAFA';
return (
<div
style={{
width: SHOT_LIST_WIDTH,
flexShrink: 0,
background: bg,
borderRight: `1px solid ${borderColor}`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 标题 */}
<div
style={{
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: textColor,
borderBottom: `1px solid ${borderColor}`,
flexShrink: 0,
}}
>
{shots.length > 0 && (
<span style={{ fontWeight: 400, marginLeft: 4 }}>({shots.length})</span>
)}
</div>
{/* 列表 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{!selectedEpisode ? (
<div
style={{
padding: '16px 12px',
fontSize: 12,
color: textColor,
textAlign: 'center',
}}
>
</div>
) : shots.length === 0 ? (
<div
style={{
padding: '16px 12px',
fontSize: 12,
color: textColor,
textAlign: 'center',
}}
>
</div>
) : (
shots.map((shot) => {
const active = shot === selectedShot;
return (
<div
key={shot}
onClick={() => handleClick(shot)}
style={{
padding: '6px 12px',
cursor: 'pointer',
fontSize: 13,
color: active ? activeColor : textColor,
background: active ? activeBg : 'transparent',
transition: 'background 0.15s',
userSelect: 'none',
}}
onMouseEnter={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background =
'transparent';
}}
>
{shot}
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
// ============================================================
// navigation — 项目/集数/镜头三级导航选择器
// ============================================================
export { ProjectSelector } from './ProjectSelector';
export { EpisodeSelector } from './EpisodeSelector';
export { ShotList } from './ShotList';
export { CanvasSidebar } from './CanvasSidebar';
// ContextMenu 已提升为全应用共享组件:@/shared/components/ContextMenuexport { DirectoryCreateModal } from './DirectoryCreateModal';

View File

@@ -0,0 +1,45 @@
// ============================================================
// CanvasFilterBar — 关键词搜索栏(精简版)
//
// 仅保留文件名/描述关键词搜索。
// 资产类型筛选 → 已移至 CategoryTabsAssets 阶段)。
// 审核状态筛选 → 已移至 CategoryTabs非 Assets 阶段)。
// ============================================================
import { useCallback } from 'react';
import { Input } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
export function CanvasFilterBar() {
const { isDark } = useTheme();
const keywordQuery = useCanvasStore((s) => s.keywordQuery);
const setFilters = useCanvasStore((s) => s.setFilters);
const handleKeywordChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setFilters({ keywordQuery: e.target.value });
},
[setFilters],
);
const inputStyle: React.CSSProperties = {
background: isDark ? '#1E1B4B' : '#FFFFFF',
borderColor: isDark ? '#3E3A6A' : '#D1D5DB',
color: isDark ? '#E8E6F0' : '#1E1B4B',
borderRadius: 4,
};
return (
<Input
placeholder="搜索文件名/描述…"
prefix={<SearchOutlined />}
value={keywordQuery}
onChange={handleKeywordChange}
allowClear
size="small"
style={{ width: 180, ...inputStyle }}
/>
);
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// CanvasToolbar — 画布内容区顶部工具栏(简化版)
//
// 包含两行:
// 第一行StageTabs阶段标签 + 无限画布按钮)
// 第二行CategoryTabs资产分类标签 + CanvasFilterBar搜索 + 审核)
//
// 注:项目/集数选择器已移至 CanvasSidebar。
// ============================================================
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { StageTabs } from './StageTabs';
import { CategoryTabs } from './CategoryTabs';
import { CanvasFilterBar } from './CanvasFilterBar';
export function CanvasToolbar() {
const { isDark } = useTheme();
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
const isScanning = useCanvasStore((s) => s.isScanning);
const totalCount = useCanvasStore((s) => s.totalCount);
const barBg = isDark
? 'linear-gradient(180deg, #1E1B4B 0%, #15132E 100%)'
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)';
const borderColor = isDark ? '#2E2A5A' : '#E5E7EB';
return (
<div
style={{
flexShrink: 0,
borderBottom: `1px solid ${borderColor}`,
background: barBg,
}}
>
{/* 第一行:阶段标签 + 无限画布 + 扫描状态 */}
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 12px',
height: 36,
}}
>
<StageTabs />
<div style={{ flex: 1 }} />
{isScanning && (
<span
style={{
fontSize: 12,
color: isDark ? '#C4B5FD' : '#6366F1',
whiteSpace: 'nowrap',
}}
>
</span>
)}
{totalCount > 0 && (
<span
style={{
fontSize: 12,
color: isDark ? '#6E6B90' : '#9CA3AF',
whiteSpace: 'nowrap',
marginLeft: 8,
}}
>
{totalCount}
</span>
)}
</div>
{/* 第二行:分类标签 + 筛选栏 */}
{workspaceRoot && (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 12px 6px',
borderTop: `1px solid ${borderColor}`,
gap: 12,
}}
>
<CategoryTabs />
<div style={{ flex: 1 }} />
<CanvasFilterBar />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
// ============================================================
// CategoryTabs — 内容分类标签栏(阶段感知 + 数量徽标)
//
// Assets 阶段 → 资产分类:
// 全部 | 角色(chr) | 道具(prp) | 场景(set) | 收藏
// → 映射到 store.assetTypeFilter + favoriteOnly
//
// 其他阶段 → 审核分类:
// 所有[阶段名] | 收藏 | 待审核 | 通过 | 返修 | 已弃用
// → 映射到 store.auditFilter + favoriteOnly
//
// 每个标签显示对应数据的数量徽标(基于当前已加载 items 计算)。
// "收藏"标签额外显示媒体类型细分(视频/图片/音频)。
// ============================================================
import { useMemo } from 'react';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore, type AssetTypeFilter, type AuditStatus } from '../../stores/useCanvasStore';
interface CategoryTab {
key: string;
label: string;
}
// ── 工具:根据扩展名判断媒体类型 ──
type MediaType = 'video' | 'image' | 'audio' | 'other';
const VIDEO_EXTS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'aac', 'flac', 'ogg', 'wma', 'm4a']);
const IMAGE_EXTS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff', 'tga', 'exr', 'psd',
]);
function getMediaType(ext: string): MediaType {
const lower = ext.toLowerCase();
if (VIDEO_EXTS.has(lower)) return 'video';
if (IMAGE_EXTS.has(lower)) return 'image';
if (AUDIO_EXTS.has(lower)) return 'audio';
return 'other';
}
// ── 工具:根据目录路径推断资产类型 ──
function inferAssetType(path: string): AssetTypeFilter {
const lower = path.toLowerCase();
// 路径中包含 \chr\ → 角色,\prp\ → 道具,\set\ → 场景
if (lower.includes('\\chr\\') || lower.includes('/chr/')) return '角色';
if (lower.includes('\\prp\\') || lower.includes('/prp/')) return '道具';
if (lower.includes('\\set\\') || lower.includes('/set/')) return '场景';
return '全部';
}
export function CategoryTabs() {
const { isDark } = useTheme();
const currentStage = useCanvasStore((s) => s.currentStage);
const assetTypeFilter = useCanvasStore((s) => s.assetTypeFilter);
const auditFilter = useCanvasStore((s) => s.auditFilter);
const favoriteOnly = useCanvasStore((s) => s.favoriteOnly);
const items = useCanvasStore((s) => s.items);
const setFilters = useCanvasStore((s) => s.setFilters);
const isAssets = currentStage === 'Assets';
// 阶段中文名映射
const stageLabel: Record<string, string> = {
Assets: '资产',
Storyboard: '分镜',
Keyframe: '关键帧',
Video: '视频',
Audio: '音频',
LipSync: '口型',
};
// ── 分类数量统计 ──
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
let favoriteMediaCounts: Record<MediaType, number> = { video: 0, image: 0, audio: 0, other: 0 };
if (isAssets) {
// Assets 阶段:按 assetType (chr/prp/set) 分类统计
counts['全部'] = items.length;
counts['角色'] = 0;
counts['道具'] = 0;
counts['场景'] = 0;
counts['收藏'] = 0;
for (const item of items) {
const at = inferAssetType(item.path);
if (at === '角色') counts['角色']++;
else if (at === '道具') counts['道具']++;
else if (at === '场景') counts['场景']++;
if (item.metadata?.favorite) {
counts['收藏']++;
favoriteMediaCounts[getMediaType(item.ext)]++;
}
}
} else {
// 非 Assets 阶段:按审核状态 + 收藏统计
counts['all'] = items.length;
counts['pending'] = 0;
counts['approved'] = 0;
counts['rework'] = 0;
counts['deprecated'] = 0;
counts['收藏'] = 0;
for (const item of items) {
const status = item.metadata?.audit_status;
if (status) counts[status] = (counts[status] || 0) + 1;
if (item.metadata?.favorite) {
counts['收藏']++;
favoriteMediaCounts[getMediaType(item.ext)]++;
}
}
}
return { counts, favoriteMediaCounts };
}, [items, isAssets]);
// ── 根据阶段决定标签组 ──
const tabs: CategoryTab[] = useMemo(() => {
if (isAssets) {
return [
{ key: '全部', label: '全部' },
{ key: '角色', label: '角色' },
{ key: '道具', label: '道具' },
{ key: '场景', label: '场景' },
{ key: '收藏', label: '⭐ 收藏' },
];
}
const label = stageLabel[currentStage] || currentStage;
return [
{ key: 'all', label: `所有${label}` },
{ key: '收藏', label: '⭐ 收藏' },
{ key: 'pending', label: '待审核' },
{ key: 'approved', label: '通过' },
{ key: 'rework', label: '返修' },
{ key: 'deprecated', label: '已弃用' },
];
}, [isAssets, currentStage]);
// ── 点击处理 ──
const handleTabClick = (key: string) => {
if (isAssets) {
if (key === '收藏') {
setFilters({ favoriteOnly: !favoriteOnly, assetTypeFilter: '全部' });
} else {
setFilters({ assetTypeFilter: key as AssetTypeFilter, favoriteOnly: false });
}
} else {
if (key === '收藏') {
setFilters({ favoriteOnly: !favoriteOnly, auditFilter: 'all' });
} else {
setFilters({ auditFilter: key as AuditStatus | 'all', favoriteOnly: false });
}
}
};
// ── 判断标签是否激活 ──
const isTabActive = (tab: CategoryTab): boolean => {
if (tab.key === '收藏') return favoriteOnly;
if (isAssets) return !favoriteOnly && tab.key === assetTypeFilter;
return !favoriteOnly && tab.key === auditFilter;
};
// ── 收藏媒体细分提示 ──
const buildFavoriteTooltip = () => {
const { favoriteMediaCounts } = categoryCounts;
const parts: string[] = [];
if (favoriteMediaCounts.video > 0) parts.push(`视频:${favoriteMediaCounts.video}`);
if (favoriteMediaCounts.image > 0) parts.push(`图片:${favoriteMediaCounts.image}`);
if (favoriteMediaCounts.audio > 0) parts.push(`音频:${favoriteMediaCounts.audio}`);
if (favoriteMediaCounts.other > 0) parts.push(`其他:${favoriteMediaCounts.other}`);
return parts.length > 0 ? ` ${parts.join(' / ')}` : '';
};
const activeBg = isDark ? '#3E3A6A' : '#E0E7FF';
const activeColor = isDark ? '#C4B5FD' : '#4338CA';
const inactiveColor = isDark ? '#6E6B90' : '#9CA3AF';
const badgeBg = isDark ? 'rgba(110, 107, 144, 0.5)' : 'rgba(156, 163, 175, 0.3)';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 0,
padding: '2px 4px',
borderRadius: 6,
background: isDark ? 'rgba(21, 19, 46, 0.8)' : 'rgba(245, 243, 255, 0.8)',
border: `1px solid ${isDark ? '#2E2A5A' : '#E5E7EB'}`,
overflowX: 'auto',
scrollbarWidth: 'none' as const,
}}
>
{tabs.map((tab) => {
const isActive = isTabActive(tab);
const count = categoryCounts.counts[tab.key] ?? 0;
const tooltip = tab.key === '收藏' ? `${count} 个收藏${buildFavoriteTooltip()}` : undefined;
return (
<button
key={tab.key}
type="button"
onClick={() => handleTabClick(tab.key)}
title={tooltip ?? undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
padding: '3px 10px',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
fontSize: 13,
fontWeight: isActive ? 600 : 400,
background: isActive ? activeBg : 'transparent',
color: isActive ? activeColor : inactiveColor,
transition: 'all 0.15s',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{tab.label}
{count > 0 && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 18,
height: 17,
padding: '0 4px',
borderRadius: 8,
fontSize: 10,
fontWeight: 600,
background: badgeBg,
color: isActive ? activeColor : inactiveColor,
lineHeight: 1,
}}
>
{count}
</span>
)}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,107 @@
// ============================================================
// StageTabs — 阶段切换标签栏 + 无限画布按钮
//
// 六个阶段标签Assets / Storyboard / Keyframe / Video / Audio / LipSync
// 点击切换 store.currentStage触发 useDirectoryScan 重新扫描。
//
// "无限画布"按钮:通过 IPC 打开独立的画布 BrowserWindow
// 关闭其他页面不影响该窗口。
// ============================================================
import { useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Button } from 'antd';
import { ExpandOutlined } from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore, type StageType } from '../../stores/useCanvasStore';
import { STAGE_CONFIGS } from '../../utils/constants';
export function StageTabs() {
const { isDark } = useTheme();
const currentStage = useCanvasStore((s) => s.currentStage);
const setStage = useCanvasStore((s) => s.setStage);
const getProjectPath = useCanvasStore((s) => s.getProjectPath);
/** 打开独立的无限画布窗口 */
const handleOpenCanvasWindow = useCallback(() => {
try {
const projectPath = getProjectPath();
window.canvas?.openWindow(projectPath ?? undefined);
} catch (err) {
logger.warn('canvas', '打开无限画布窗口失败', err instanceof Error ? err : new Error(String(err)));
}
}, [getProjectPath]);
return (
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
{STAGE_CONFIGS.map((cfg) => {
const isActive = currentStage === cfg.key;
return (
<button
key={cfg.key}
type="button"
onClick={() => setStage(cfg.key as StageType)}
title={cfg.label}
style={{
padding: '4px 10px',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
fontSize: 13,
fontWeight: isActive ? 600 : 400,
background: isActive
? isDark
? '#3E3A6A'
: '#E0E7FF'
: 'transparent',
color: isActive
? isDark
? '#C4B5FD'
: '#4338CA'
: isDark
? '#6E6B90'
: '#9CA3AF',
transition: 'all 0.15s',
whiteSpace: 'nowrap',
}}
>
<span style={{ marginRight: 4 }}>{cfg.icon}</span>
{cfg.label}
</button>
);
})}
{/* 分隔线 */}
<div
style={{
width: 1,
height: 18,
background: isDark ? '#3E3A6A' : '#D1D5DB',
margin: '0 4px',
}}
/>
{/* 无限画布按钮 */}
<Button
size="small"
icon={<ExpandOutlined />}
onClick={() => void handleOpenCanvasWindow()}
title="在新窗口中打开无限画布(关闭其他页面不影响此窗口)"
style={{
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
border: `1px solid ${isDark ? '#3E3A6A' : '#C7D2FE'}`,
background: isDark
? 'rgba(62, 58, 106, 0.4)'
: 'rgba(224, 231, 255, 0.6)',
color: isDark ? '#C4B5FD' : '#4338CA',
padding: '2px 10px',
transition: 'all 0.15s',
}}
>
</Button>
</div>
);
}

View File

@@ -0,0 +1,8 @@
// ============================================================
// toolbar — 顶部工具栏:布局 + 筛选 + 阶段切换
// ============================================================
export { CanvasToolbar } from './CanvasToolbar';
export { CanvasFilterBar } from './CanvasFilterBar';
export { StageTabs } from './StageTabs';
export { CategoryTabs } from './CategoryTabs';