Files
ele-HeiXiu/src/modules/canvas/stores/useCanvasStore.ts
YoungestSongMo 337d6c1205 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>
2026-06-29 18:41:07 +08:00

388 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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);
}