/** * 封面设计页面 (Step 5) - Fabric.js 版 * ================================ * * 使用 Fabric.js Canvas 实时预览封面: * - 背景图:点击卡片弹窗选图(6 张随机) * - 封面形象:点击卡片弹窗选人 * - 模板:双标题居中 * - 导出:PNG 高清导出 */ import { useState, useEffect, useRef } from 'react'; interface FileWithPath extends File { path?: string; } import { useCoverAvatarStore } from '../../store'; import { invoke } from '@tauri-apps/api/core'; import { save } from '@tauri-apps/plugin-dialog'; import { useProjectStore } from '../../store'; import { getCurrentProjectId } from '../../api/modules/localStorage'; import { useCoverFabric } from '../../hooks/useCoverFabric'; import { useProgressStore } from '../../store/progressStore'; import { scriptApi } from '../../api/modules/script'; import { coverApi, CoverBackground } from '../../api/modules/cover'; import { pointsApi } from '../../api/modules/points'; import { usePointStore, useAuthStore } from '../../store'; import RechargeModal from '../../components/RechargeModal/RechargeModal'; import ConfirmModal from '../../components/Modal/ConfirmModal'; import Modal from '../../components/Modal/Modal'; import { toast } from '../../store/uiStore'; import { getFriendlyErrorMessage } from '../../utils/errorMessage'; import { usePointsCheck } from '../../hooks/usePointsCheck'; import './CoverDesign.css'; interface BgImage { id: string; src: string; name: string; } function shuffleArray(arr: T[]): T[] { const result = [...arr]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } function pickSeven(all: BgImage[], lastIds: Set): BgImage[] { if (all.length <= 7) {return shuffleArray(all);} let shuffled = shuffleArray(all); for (let i = 0; i < 10; i++) { const picked = shuffled.slice(0, 7); const pickedIds = new Set(picked.map((b) => b.id)); const allSame = pickedIds.size === 7 && lastIds.size === 7 && [...pickedIds].every((id) => lastIds.has(id)); if (!allSame) {return picked;} shuffled = shuffleArray(all); } return shuffled.slice(0, 7); } export default function CoverDesign() { const coverConfig = useProjectStore((state) => state.coverConfig); const coverPath = useProjectStore((state) => state.coverPath); const setCoverPath = useProjectStore((state) => state.setCoverPath); const setCoverConfig = useProjectStore((state) => state.setCoverConfig); const stepDirtyFlags = useProjectStore((state) => state.stepDirtyFlags); const isStepDirty = (stepDirtyFlags?.[5] ?? false) && !!coverPath; const projectId = getCurrentProjectId(); const [config, setConfig] = useState<{ template: 'dual-title'; backgroundImage: string | null; mainTitle: string; subtitle: string; avatarImage: string | null; }>({ template: 'dual-title', backgroundImage: null, mainTitle: '', subtitle: '', avatarImage: null, }); const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false); const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false); const [isDesigning, setIsDesigning] = useState(false); const [showPointsModal, setShowPointsModal] = useState(false); const showRechargeModal = usePointStore(state => state.showRechargeModal); const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal); const fetchBalance = usePointStore(state => state.fetchBalance); // 弹窗状态 const [bgModalOpen, setBgModalOpen] = useState(false); const [avatarModalOpen, setAvatarModalOpen] = useState(false); // 背景图配置 const [modalBgs, setModalBgs] = useState([]); const [lastModalBgIds, setLastModalBgIds] = useState>(new Set()); const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric(); const { checkBalance } = usePointsCheck(); // 智能生成标题 const handleGenerateTitle = async (titleType: 'main' | 'sub') => { const projectStore = useProjectStore.getState(); const utterances = projectStore.subtitleAlignment?.utterances; if (!utterances || utterances.length === 0) { toast.error('暂无脚本内容,请先生成脚本'); return; } const titlePoints = usePointStore.getState().getRule('title')?.points || 1; const canProceed = await checkBalance(titlePoints, '标题生成', true); if (!canProceed) {return;} const scriptContent = utterances.map(u => u.text).join('\n'); const maxLength = titleType === 'main' ? 6 : 26; if (titleType === 'main') { setIsGeneratingMainTitle(true); } else { setIsGeneratingSubTitle(true); } try { const res = await scriptApi.generateTitle({ scriptContent, titleType, maxLength, usage: 'cover', }); setConfig((prev) => ({ ...prev, [titleType === 'main' ? 'mainTitle' : 'subtitle']: res.title, })); toast.success(`${titleType === 'main' ? '主标题' : '副标题'}生成成功`); } catch (e) { const msg = getFriendlyErrorMessage(e, '标题生成失败,请稍后重试'); if (msg.includes('积分不足')) { setShowPointsModal(true); return; } toast.error(msg); } finally { if (titleType === 'main') { setIsGeneratingMainTitle(false); } else { setIsGeneratingSubTitle(false); } } }; // 加载背景图 — 系统图库 + 本地上传 const localBgRef = useRef(null); const blobUrlsRef = useRef>(new Set()); const [bgList, setBgList] = useState([]); useEffect(() => { const loadBackgrounds = async () => { try { const backgrounds = await coverApi.getBackgrounds('bk'); const list: BgImage[] = backgrounds.map((bg: CoverBackground) => ({ id: String(bg.id), src: bg.url, name: bg.title || '背景图', })); setBgList(list); } catch (err) { console.error('[CoverDesign] 加载背景图失败:', err); } }; loadBackgrounds(); }, []); // 本地上传背景图 const handleLocalBgUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) {return;} // 释放旧的 Blob URL const oldBg = config.backgroundImage; if (oldBg && oldBg.startsWith('blob:')) { URL.revokeObjectURL(oldBg); blobUrlsRef.current.delete(oldBg); } const path = (file as FileWithPath).path || (file as FileWithPath).webkitRelativePath || ''; if (!path) { // Tauri 文件选择器通常有 path,如果没有则使用 Object URL const url = URL.createObjectURL(file); blobUrlsRef.current.add(url); setConfig(prev => ({ ...prev, backgroundImage: url })); } else { setConfig(prev => ({ ...prev, backgroundImage: path })); } setBgModalOpen(false); // 清空 input 以便下次选择同一文件 e.target.value = ''; }; // 弹窗展示 7 张背景图 + 1 个上传入口 const openBgModal = () => { const picked = pickSeven(bgList, lastModalBgIds); setModalBgs(picked); setLastModalBgIds(new Set(picked.map(b => b.id))); setBgModalOpen(true); }; // 弹窗换一组 const shuffleModalBgs = () => { const picked = pickSeven(bgList, lastModalBgIds); setModalBgs(picked); setLastModalBgIds(new Set(picked.map(b => b.id))); }; // 组件卸载时释放所有 Blob URL useEffect(() => { const urls = blobUrlsRef.current; return () => { urls.forEach(url => URL.revokeObjectURL(url)); urls.clear(); }; }, []); // 初始化 Canvas useEffect(() => { initCanvas(); }, [initCanvas]); // 加载之前保存的封面配置 useEffect(() => { if (!coverConfig) {return;} setConfig((prev) => ({ template: coverConfig.template || prev.template, backgroundImage: coverConfig.backgroundImage ?? prev.backgroundImage, mainTitle: coverConfig.mainTitle ?? coverConfig.caption ?? prev.mainTitle, subtitle: coverConfig.subtitle ?? prev.subtitle, avatarImage: coverConfig.avatarImage ?? prev.avatarImage, })); }, [coverConfig]); // 背景图/形象变化时自动保存 coverConfig(防抖,只监听视觉素材) useEffect(() => { if (!projectId) {return;} const timer = setTimeout(() => { useProjectStore.getState().setCoverConfig({ template: config.template, backgroundImage: config.backgroundImage, mainTitle: config.mainTitle, subtitle: config.subtitle, avatarImage: config.avatarImage, }); }, 300); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.backgroundImage, config.avatarImage, projectId]); // 配置变化时重新渲染 Canvas useEffect(() => { const timer = setTimeout(() => { renderCover(config); }, 50); return () => clearTimeout(timer); }, [config, renderCover]); // 生成封面图 const handleGenerate = async () => { if (!projectId) {return;} if (!config.mainTitle.trim()) {return;} const coverPoints = usePointStore.getState().getRule('cover_design')?.points || 2; await fetchBalance(); const currentBalance = usePointStore.getState().balance; if (currentBalance < coverPoints) { setShowPointsModal(true); return; } setShowPointsModal(false); setIsDesigning(true); useProgressStore.getState().show('封面设计'); try { useProgressStore.getState().update('正在设计封面...'); const oldCoverPath = useProjectStore.getState().coverPath; if (oldCoverPath) { try { await invoke<{ code: number; message: string }>('delete_project_file', { projectId, filePath: oldCoverPath, }); } catch { // no-op } } const dataUrl = exportPng(); if (!dataUrl) { throw new Error('封面设计失败'); } const base64 = dataUrl.split(',')[1]; const result = await invoke<{ code: number; data?: string; message: string; }>('save_project_asset', { projectId, filename: `cover_${Date.now()}.png`, base64Data: base64, }); if (result.code !== 200 || !result.data) { throw new Error(result.message); } const coverDesignPoints = usePointStore.getState().getRule('cover_design')?.points || 2; await pointsApi.consumePoints({ points: coverDesignPoints, sourceType: 'cover_design', sourceId: `cover_design_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`, description: '封面设计', }); await usePointStore.getState().fetchBalance(); setCoverPath(result.data); setCoverConfig({ template: config.template, backgroundImage: config.backgroundImage, mainTitle: config.mainTitle.trim(), subtitle: config.subtitle.trim(), avatarImage: config.avatarImage, }); useProjectStore.getState().clearStepDirty(5); useProjectStore.getState().markStepsDirty(5); useProgressStore.getState().success('封面设计完成', coverDesignPoints); } catch (error: unknown) { const message = (error instanceof Error ? error.message : String(error)) || '封面设计失败'; console.error('封面设计失败:', error); if (message.includes('402') || message.includes('积分不足')) { useProgressStore.getState().hide(); setShowPointsModal(true); } else { useProgressStore.getState().error(message); } } finally { setIsDesigning(false); } }; // 封面形象库 const { coverAvatars, loadCoverAvatars } = useCoverAvatarStore(); useEffect(() => { loadCoverAvatars(); }, [loadCoverAvatars]); const currentAvatar = coverAvatars.find(a => a.imageUrl === config.avatarImage); const canGenerate = !!config.mainTitle.trim(); return (
{isStepDirty && (
上游数据已更新,当前封面可能与最新内容不匹配,建议重新设计
)} { setShowPointsModal(false); setShowRechargeModal(true); }} onCancel={() => setShowPointsModal(false)} onClose={() => setShowPointsModal(false)} /> setShowRechargeModal(false)} onRechargeSuccess={() => { fetchBalance(); setShowPointsModal(false); }} /> {/* 背景图弹窗 */} setBgModalOpen(false)} title="选择背景图片" width="560px" >
{/* 第 1 个卡片:本地上传 */} {modalBgs.map((bg) => ( ))}
{/* 封面形象弹窗 */} setAvatarModalOpen(false)} title="选择封面形象" width="480px" >
{coverAvatars.map((avatar) => ( ))}
{coverAvatars.length === 0 && (
暂无封面形象,请在「内容管理 → 封面形象」中上传
)}
{/* 左侧:配置区域 */}
{/* 视觉素材:背景图 + 封面形象 同一行 */}
{/* 背景图卡片 */} {/* 封面形象卡片 */}
{/* 文案配置 */}
setConfig((prev) => ({ ...prev, mainTitle: e.target.value })) } />