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,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);
}