/** * Project Store - Zustand * ======================= */ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import { persist } from 'zustand/middleware'; import { localProjectApi, generateProjectId, getCurrentProjectId, setCurrentProjectId } from '../api/modules/localStorage'; import type { ProjectState, ScriptShot, MetaOverrides } from '../types/project'; import { buildProjectMeta, migrateMeta, BLANK_META_OVERRIDES } from '../utils/projectMeta'; export type { ScriptShot } from '../types/project'; interface ProjectActions { // 分镜操作 updateSegment: (_id: number, _data: Partial) => void; setSegments: (_segments: ScriptShot[]) => void; // 其他操作 setTopic: (_topic: string) => void; setTitle: (_title: string) => void; setCurrentStep: (_step: number) => void; setAvatarMaterial: (_path?: string, _name?: string, _duration?: number) => void; setSelectedVoiceId: (_voiceId: string) => void; setFinalVideoPath: (_path: string | undefined) => void; setCoverPath: (_path: string | undefined) => void; setCoverConfig: (_config: ProjectState['coverConfig']) => void; setExportedAt: (_timestamp: number | undefined) => void; setDubbingAudioDuration: (_duration: number | undefined) => void; setVoiceSpeed: (_speed: number) => void; setVoiceVolume: (_volume: number) => void; setBgmMusic: (_id: number | undefined, _title: string | undefined, _path: string | undefined) => void; setBgmVolume: (_volume: number) => void; setCategoryCode: (_code: string) => void; setFilename: (_filename: string) => void; setIsLoading: (_loading: boolean) => void; setHasHydrated: (_hydrated: boolean) => void; markStepsDirty: (_fromStep: number) => void; clearStepDirty: (_stepId: number) => void; reset: () => void; } interface ProjectGetters { hasHydrated: boolean; } type ProjectStore = ProjectState & ProjectActions & ProjectGetters; const initialState: Omit< ProjectStore, keyof ProjectActions | keyof ProjectGetters > = { id: '', title: '', status: 'draft', segments: [], currentStep: 1, createdAt: 0, updatedAt: 0, version: 1, _isLoading: false, voiceSpeed: 1.0, voiceVolume: 0, _hasHydrated: false, }; // 元数据保存队列(避免并发覆盖) let metaSavePromise: Promise = Promise.resolve(); /** * 立即保存项目元数据(不防抖,不保存 segments) * 用于 coverPath / finalVideoPath / exportedAt 等关键字段的持久化 * * 实现细节: * 1. 先读取磁盘上的现有 meta.json,避免覆盖其他字段 * 2. 只覆盖传入的 overrides 字段(显式传 undefined 表示清除) * 3. 通过 Promise 链串行化,避免并发写导致数据丢失 */ export async function saveMetaToLocalFile(overrides: MetaOverrides = {}) { const projectId = getCurrentProjectId(); if (!projectId) { console.error('[ProjectStore] Cannot save meta: no project ID'); return; } const task = async () => { try { const rawExisting = await localProjectApi.loadMeta(projectId); const existing = rawExisting ? migrateMeta(rawExisting) : null; // 核心:buildProjectMeta 自动合并所有字段,无需逐个判断 const meta = buildProjectMeta(projectId, existing, overrides); await localProjectApi.saveMeta(projectId, meta); } catch (err) { console.error('[ProjectStore] Save meta failed:', err); } }; metaSavePromise = metaSavePromise.then(task).catch((err) => { console.error('[ProjectStore] Save meta failed:', err); throw err; }); await metaSavePromise; } // 当前项目 ID(内存中) let currentProjectId: string = getCurrentProjectId(); export const useProjectStore = create()( immer( persist( (set, get) => ({ ...initialState, get hasHydrated() { return get()._hasHydrated; }, // 获取当前项目 ID get currentProjectId() { return currentProjectId; }, setIsLoading: (loading: boolean) => set(state => { state._isLoading = loading; }), setHasHydrated: (hydrated: boolean) => set(state => { state._hasHydrated = hydrated; }), updateSegment: (id, data) => set(state => { const shotIndex = state.segments.findIndex(s => s.id === id); if (shotIndex !== -1) { // 直接修改 draft 属性,让 Immer 正确追踪数组元素变化 Object.assign(state.segments[shotIndex], data); state.updatedAt = Date.now(); } else { // no-op: 分镜不存在时不处理 } // 自动保存已移除,数据将在点击下一步时统一落盘 }), setSegments: segments => set(state => { state.segments = segments; state.updatedAt = Date.now(); // 自动保存已移除,数据将在点击下一步时统一落盘 }), setTopic: topic => set(state => { state.topic = topic; }), setTitle: title => set(state => { state.title = title; state.updatedAt = Date.now(); }), setCurrentStep: step => set(state => { state.currentStep = step; }), setAvatarMaterial: (path, name, duration) => { set(state => { state.avatarMaterialPath = path; state.avatarMaterialName = name; state.avatarMaterialDuration = duration; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ avatarMaterialPath: path, avatarMaterialName: name, avatarMaterialDuration: duration }); }, setSelectedVoiceId: voiceId => { set(state => { state.selectedVoiceId = voiceId; state.updatedAt = Date.now(); }); }, setFinalVideoPath: path => { set(state => { state.finalVideoPath = path; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ finalVideoPath: path }); }, setCoverPath: path => { set(state => { state.coverPath = path; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ coverPath: path }); }, setCoverConfig: config => { set(state => { state.coverConfig = config; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ coverConfig: config }); }, setExportedAt: timestamp => { set(state => { state.exportedAt = timestamp; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ exportedAt: timestamp }); }, setDubbingAudioDuration: duration => { set(state => { state.dubbingAudioDuration = duration; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ dubbingAudioDuration: duration }); }, setVoiceSpeed: speed => set(state => { state.voiceSpeed = speed; state.updatedAt = Date.now(); }), setVoiceVolume: volume => set(state => { state.voiceVolume = volume; state.updatedAt = Date.now(); }), setBgmMusic: (id, title, path) => { set(state => { state.bgmMusicId = id; state.bgmMusicTitle = title; state.bgmMusicPath = path; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ bgmMusicId: id, bgmMusicTitle: title, bgmMusicPath: path }); }, setBgmVolume: volume => { set(state => { state.bgmVolume = volume; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ bgmVolume: volume }); }, setCategoryCode: code => { set(state => { state.categoryCode = code; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ categoryCode: code }); }, setFilename: filename => { set(state => { state.filename = filename; state.updatedAt = Date.now(); }); saveMetaToLocalFile({ filename }); }, markStepsDirty: (fromStep: number) => { set(state => { if (!state.stepDirtyFlags) { state.stepDirtyFlags = {}; } for (let i = fromStep + 1; i <= 6; i++) { state.stepDirtyFlags[i] = true; } state.updatedAt = Date.now(); }); const flags = get().stepDirtyFlags || {}; const newFlags: Record = {}; for (let i = fromStep + 1; i <= 6; i++) { newFlags[i] = true; } saveMetaToLocalFile({ stepDirtyFlags: { ...flags, ...newFlags } }); }, clearStepDirty: (stepId: number) => { set(state => { if (state.stepDirtyFlags) { state.stepDirtyFlags[stepId] = false; } state.updatedAt = Date.now(); }); const flags = get().stepDirtyFlags || {}; saveMetaToLocalFile({ stepDirtyFlags: { ...flags, [stepId]: false } }); }, reset: () => { currentProjectId = ''; setCurrentProjectId(''); set({ ...initialState, ...BLANK_META_OVERRIDES, segments: [], _hasHydrated: true, }); }, }), { name: 'ai-video-project-config', // 只保存配置,不保存项目数据(项目数据存文件) partialize: (state) => ({ // 只保存 UI 状态,不保存项目业务数据 _isLoading: state._isLoading, // 不保存: segments, finalVideoPath, coverPath 等 }), onRehydrateStorage: () => state => { if (state) { state.setHasHydrated(true); } }, } ) ) ); // 注:移除自动保存,改为在"进入第二步"时手动创建项目 // 符合文档逻辑:第一步完成进入第二步时创建项目文件 /** * 初始化加载项目 * 应用启动时调用 */ export async function initProjectStore(projectId?: string): Promise { const id = projectId || getCurrentProjectId(); currentProjectId = id; setCurrentProjectId(id); const { meta: rawMeta, segments } = await localProjectApi.loadProject(id); if (rawMeta || segments.length > 0) { const meta = rawMeta ? migrateMeta(rawMeta) : null; // 先清空旧数据,再加载新项目数据,防止字段残留 useProjectStore.setState({ ...BLANK_META_OVERRIDES, segments: segments as ScriptShot[], ...(meta || {}), _hasHydrated: true, }); } else { // 新项目 useProjectStore.setState({ ...BLANK_META_OVERRIDES, segments: [], _hasHydrated: true, }); } } /** * 创建新项目 * @param topic 创作灵感主题,用于生成标题 * @param segments 可选的初始分镜数据(从第一步带入第二步时) */ export async function createNewProject(topic?: string, segments?: ScriptShot[]): Promise { const newId = generateProjectId(); currentProjectId = newId; setCurrentProjectId(newId); // 彻底重置 Store,防止旧项目数据残留 useProjectStore.setState({ ...initialState, ...BLANK_META_OVERRIDES, segments: segments || [], _hasHydrated: true, }); // 使用 buildProjectMeta 自动构建 meta(包含 title 生成逻辑) const meta = buildProjectMeta(newId, null, { topic }); await localProjectApi.saveMeta(newId, meta); return newId; } /** * 切换项目 */ export async function switchProject(projectId: string): Promise { // 切换项目 ID currentProjectId = projectId; setCurrentProjectId(projectId); // 加载新项目 await initProjectStore(projectId); }