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:
387
src/modules/canvas/stores/useCanvasStore.ts
Normal file
387
src/modules/canvas/stores/useCanvasStore.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user