ca4a0b1303
- 用户数据隔离:所有用户数据按 users/{user_id}/ 隔离,Rust IPC 命令自治读取 auth.json
- 安全加固:delete_local_product/rename_local_product/export_product 增加前缀校验
- 移除音调(pitch)功能:从 VoiceSynthesis、projectStore、types 等完全移除
- 动态视频分辨率:根据素材最小高度自动选择 720p/1080p,9:16 比例强校验
- ASS 字幕按目标分辨率等比例缩放(720p 和 1080p 视觉一致)
- Canvas 预览支持参数化 playResY,预览与压制效果一致
- 配音合成增加台词字数校验弹窗(语速>1.0时要求更多字)
- BGM 默认音量从 25% 调至 15%
- 素材选择提示文案更新(9:16 比例,5-60秒)
- 视频校验从严格 1080x1920 改为 9:16 比例判断
- 背景图片弹窗宽度从 440px 放大到 560px
733 lines
26 KiB
TypeScript
733 lines
26 KiB
TypeScript
/**
|
||
* 封面设计页面 (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<T>(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<string>): 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<BgImage[]>([]);
|
||
const [lastModalBgIds, setLastModalBgIds] = useState<Set<string>>(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<HTMLInputElement | null>(null);
|
||
const blobUrlsRef = useRef<Set<string>>(new Set());
|
||
const [bgList, setBgList] = useState<BgImage[]>([]);
|
||
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="step-layout cover-design-variant cover-design">
|
||
{isStepDirty && (
|
||
<div className="step-dirty-banner" style={{ gridColumn: '1 / -1', margin: '0 0 var(--spacing-md) 0' }}>
|
||
<span>上游数据已更新,当前封面可能与最新内容不匹配,建议重新设计</span>
|
||
</div>
|
||
)}
|
||
<ConfirmModal
|
||
open={showPointsModal}
|
||
type="warning"
|
||
title="积分不足"
|
||
description="请先充值积分后再进行尝试"
|
||
confirmText="立即充值"
|
||
cancelText="稍后再说"
|
||
confirmButtonType="danger"
|
||
onConfirm={() => {
|
||
setShowPointsModal(false);
|
||
setShowRechargeModal(true);
|
||
}}
|
||
onCancel={() => setShowPointsModal(false)}
|
||
onClose={() => setShowPointsModal(false)}
|
||
/>
|
||
<RechargeModal
|
||
open={showRechargeModal}
|
||
onClose={() => setShowRechargeModal(false)}
|
||
onRechargeSuccess={() => {
|
||
fetchBalance();
|
||
setShowPointsModal(false);
|
||
}}
|
||
/>
|
||
|
||
{/* 背景图弹窗 */}
|
||
<Modal
|
||
open={bgModalOpen}
|
||
onClose={() => setBgModalOpen(false)}
|
||
title="选择背景图片"
|
||
width="560px"
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div className="modal-bg-grid">
|
||
{/* 第 1 个卡片:本地上传 */}
|
||
<button
|
||
className="modal-bg-thumb modal-bg-upload"
|
||
onClick={() => localBgRef.current?.click()}
|
||
title="使用本地图片作为背景"
|
||
>
|
||
<div className="upload-placeholder">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||
<polyline points="17,8 12,3 7,8" />
|
||
<line x1="12" y1="3" x2="12" y2="15" />
|
||
</svg>
|
||
<span>本地上传</span>
|
||
</div>
|
||
</button>
|
||
<input
|
||
ref={localBgRef}
|
||
type="file"
|
||
accept="image/*"
|
||
style={{ display: 'none' }}
|
||
onChange={handleLocalBgUpload}
|
||
/>
|
||
{modalBgs.map((bg) => (
|
||
<button
|
||
key={bg.id}
|
||
className={`modal-bg-thumb ${config.backgroundImage === bg.src ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setConfig((prev) => ({ ...prev, backgroundImage: bg.src }));
|
||
setBgModalOpen(false);
|
||
}}
|
||
title={bg.name}
|
||
>
|
||
<img
|
||
src={bg.src}
|
||
alt={bg.name}
|
||
loading="lazy"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
const parent = (e.target as HTMLImageElement).parentElement;
|
||
if (parent) {parent.classList.add('modal-bg-error');}
|
||
}}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={shuffleModalBgs}
|
||
disabled={bgList.length <= 3}
|
||
>
|
||
换一组
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setBgModalOpen(false)}>取消</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 封面形象弹窗 */}
|
||
<Modal
|
||
open={avatarModalOpen}
|
||
onClose={() => setAvatarModalOpen(false)}
|
||
title="选择封面形象"
|
||
width="480px"
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div className="modal-avatar-grid">
|
||
<button
|
||
className={`modal-avatar-thumb ${config.avatarImage === null ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setConfig((prev) => ({ ...prev, avatarImage: null }));
|
||
setAvatarModalOpen(false);
|
||
}}
|
||
>
|
||
<div className="avatar-placeholder">不使用</div>
|
||
</button>
|
||
{coverAvatars.map((avatar) => (
|
||
<button
|
||
key={avatar.id}
|
||
className={`modal-avatar-thumb ${config.avatarImage === avatar.imageUrl ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setConfig((prev) => ({ ...prev, avatarImage: avatar.imageUrl }));
|
||
setAvatarModalOpen(false);
|
||
}}
|
||
title={avatar.name}
|
||
>
|
||
<img
|
||
src={avatar.imageUrl}
|
||
alt={avatar.name}
|
||
loading="lazy"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
const parent = (e.target as HTMLImageElement).parentElement;
|
||
if (parent) {parent.classList.add('avatar-thumb-error');}
|
||
}}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{coverAvatars.length === 0 && (
|
||
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 'var(--font-xs)', padding: '8px 0' }}>
|
||
暂无封面形象,请在「内容管理 → 封面形象」中上传
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setAvatarModalOpen(false)}>取消</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 左侧:配置区域 */}
|
||
<div className="step-panel-left">
|
||
<div
|
||
className="cover-config-scroll"
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
overflowY: 'auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 'var(--spacing-lg)',
|
||
}}
|
||
>
|
||
{/* 视觉素材:背景图 + 封面形象 同一行 */}
|
||
<div className="panel-section">
|
||
<label className="panel-label">视觉素材</label>
|
||
<div className="visual-assets-row">
|
||
{/* 背景图卡片 */}
|
||
<button
|
||
className="visual-asset-card"
|
||
onClick={openBgModal}
|
||
title="点击更换背景"
|
||
>
|
||
<div className="visual-asset-image-wrap">
|
||
<div className="visual-asset-label">背景图片</div>
|
||
{config.backgroundImage ? (
|
||
<img
|
||
src={config.backgroundImage}
|
||
alt="背景"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="visual-asset-placeholder">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||
<polyline points="21 15 16 10 5 21" />
|
||
</svg>
|
||
<span>选择背景</span>
|
||
</div>
|
||
)}
|
||
<div className="visual-asset-overlay">
|
||
<span className="visual-asset-overlay-text">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||
</svg>
|
||
更换
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
{/* 封面形象卡片 */}
|
||
<button
|
||
className="visual-asset-card"
|
||
onClick={() => setAvatarModalOpen(true)}
|
||
title="点击更换形象"
|
||
>
|
||
<div className="visual-asset-image-wrap">
|
||
<div className="visual-asset-label">封面形象</div>
|
||
{currentAvatar ? (
|
||
<img
|
||
src={currentAvatar.imageUrl}
|
||
alt={currentAvatar.name}
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="visual-asset-placeholder">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||
<circle cx="12" cy="7" r="4" />
|
||
</svg>
|
||
<span>选择形象</span>
|
||
</div>
|
||
)}
|
||
<div className="visual-asset-overlay">
|
||
<span className="visual-asset-overlay-text">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||
</svg>
|
||
更换
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 文案配置 */}
|
||
<div className="panel-section">
|
||
<label className="panel-label">文案配置</label>
|
||
<div className="title-input-row">
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder="输入主标题"
|
||
value={config.mainTitle}
|
||
maxLength={6}
|
||
onChange={(e) =>
|
||
setConfig((prev) => ({ ...prev, mainTitle: e.target.value }))
|
||
}
|
||
/>
|
||
<button
|
||
className="btn btn-ghost title-generate-btn"
|
||
onClick={() => handleGenerateTitle('main')}
|
||
disabled={isGeneratingMainTitle}
|
||
title="根据脚本内容智能生成主标题"
|
||
>
|
||
{isGeneratingMainTitle ? '生成中...' : '智能生成'}
|
||
</button>
|
||
</div>
|
||
<div className="title-input-row" style={{ marginTop: 8 }}>
|
||
<textarea
|
||
className="input subtitle-textarea"
|
||
placeholder="输入副标题"
|
||
value={config.subtitle}
|
||
maxLength={52}
|
||
rows={2}
|
||
onChange={(e) =>
|
||
setConfig((prev) => ({ ...prev, subtitle: e.target.value }))
|
||
}
|
||
/>
|
||
<button
|
||
className="btn btn-ghost title-generate-btn"
|
||
onClick={() => handleGenerateTitle('sub')}
|
||
disabled={isGeneratingSubTitle}
|
||
title="根据脚本内容智能生成副标题"
|
||
>
|
||
{isGeneratingSubTitle ? '生成中...' : '智能生成'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div style={{ display: 'flex', gap: 12, marginTop: 'auto', flexShrink: 0 }}>
|
||
<button
|
||
className="btn btn-primary"
|
||
disabled={isDesigning || !canGenerate}
|
||
onClick={handleGenerate}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{isDesigning
|
||
? '生成中...'
|
||
: coverPath
|
||
? `重新生成(${usePointStore.getState().getRule('cover_design')?.points || 2}积分)`
|
||
: `生成封面(${usePointStore.getState().getRule('cover_design')?.points || 2}积分)`}
|
||
</button>
|
||
{coverPath && (
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={async () => {
|
||
try {
|
||
const filename = coverPath.split(/[\\/]/).pop() || 'cover.png';
|
||
const targetPath = await save({
|
||
defaultPath: filename,
|
||
filters: [{ name: '图片', extensions: ['png'] }],
|
||
});
|
||
if (!targetPath) {return;}
|
||
const res = await invoke<{ code: number; data?: string; message: string }>('export_product', {
|
||
sourcePath: coverPath,
|
||
targetPath,
|
||
});
|
||
if (res.code === 200) {
|
||
toast.success('封面导出成功');
|
||
} else {
|
||
toast.error(res.message || '导出失败');
|
||
}
|
||
} catch {
|
||
toast.error('导出失败');
|
||
}
|
||
}}
|
||
style={{ flex: 1 }}
|
||
>
|
||
导出封面
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:竖屏预览 */}
|
||
<div className="step-panel-right video-gen-right">
|
||
<div className="video-preview-area">
|
||
<div className="video-preview-header">封面预览</div>
|
||
<div className="video-preview-wrapper">
|
||
<div className="video-preview-container">
|
||
<canvas
|
||
ref={canvasRef}
|
||
style={{
|
||
width: '337px',
|
||
height: '600px',
|
||
display: 'block',
|
||
}}
|
||
/>
|
||
{!config.backgroundImage && (
|
||
<div className="cover-preview-placeholder">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||
<polyline points="21 15 16 10 5 21" />
|
||
</svg>
|
||
<span>请先选择背景图片</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|