## 构建、打包与分发系统重构 (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>
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
// ============================================================
|
||
// 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);
|
||
}
|