605 lines
22 KiB
TypeScript
605 lines
22 KiB
TypeScript
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||
import { scriptApi, ScriptShot, CategoryItem } from '../../api/modules/script';
|
||
import { createTask, getTaskStatus } from '../../api/modules/task';
|
||
import { adaptScriptShots } from '../../api/adapters/scriptAdapter';
|
||
import { useProjectStore, usePointStore } from '../../store';
|
||
import { toast } from '../../store/uiStore';
|
||
import { useProgressStore } from '../../store/progressStore';
|
||
import { ShotStats } from '../../components/ShotStats';
|
||
import ConfirmModal from '../../components/Modal/ConfirmModal';
|
||
import { usePointsCheck } from '../../hooks/usePointsCheck';
|
||
import { getFriendlyErrorMessage } from '../../utils/errorMessage';
|
||
import './ScriptCreation.css';
|
||
|
||
|
||
|
||
/**
|
||
* 脚本生成页面
|
||
*/
|
||
export default function ScriptCreation() {
|
||
// 使用 selector 模式订阅 store,确保组件正确响应状态变化
|
||
const segments = useProjectStore(state => state.segments);
|
||
const setSegments = useProjectStore(state => state.setSegments);
|
||
const markStepsDirty = useProjectStore(state => state.markStepsDirty);
|
||
const updateSegment = useProjectStore(state => state.updateSegment);
|
||
const categoryCode = useProjectStore(state => state.categoryCode);
|
||
const filename = useProjectStore(state => state.filename);
|
||
const setTopic = useProjectStore(state => state.setTopic);
|
||
const setCategoryCode = useProjectStore(state => state.setCategoryCode);
|
||
const setFilename = useProjectStore(state => state.setFilename);
|
||
|
||
const [generating, setGenerating] = useState(false);
|
||
const requestLock = useRef(false);
|
||
|
||
// 分类列表(从后端动态加载)
|
||
const [categories, setCategories] = useState<CategoryItem[]>([]);
|
||
// 选中的大类和文件名
|
||
const [selectedCategory, setSelectedCategory] = useState<string>(categoryCode || '');
|
||
const [selectedFilename, setSelectedFilename] = useState<string>(filename || '');
|
||
|
||
// 加载分类列表(带本地缓存 + 静默刷新)
|
||
useEffect(() => {
|
||
const { cached, refresh } = scriptApi.getCategoriesCached();
|
||
if (cached) {
|
||
setCategories(cached);
|
||
}
|
||
refresh
|
||
.then(data => {
|
||
setCategories(data);
|
||
})
|
||
.catch((err: Error) => {
|
||
console.error('[分类加载失败]', err);
|
||
if (!cached) {
|
||
// 始终显示详细错误,方便跨平台诊断(Windows WebView2 网络问题需要具体信息)
|
||
toast.error(`加载分类列表失败: ${err.message}`);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
// 分类列表加载后,恢复之前保存的选中状态
|
||
useEffect(() => {
|
||
if (categories.length === 0) {return;}
|
||
if (categoryCode && filename) {
|
||
// 验证保存的文件名是否在当前分类列表中有效
|
||
const catExists = categories.some(c => c.code === categoryCode);
|
||
const fileExists = categories.some(c =>
|
||
c.code === categoryCode && c.files.some(f => f.filename === filename)
|
||
);
|
||
if (catExists && fileExists) {
|
||
setSelectedCategory(categoryCode);
|
||
setSelectedFilename(filename);
|
||
}
|
||
}
|
||
}, [categories, categoryCode, filename]);
|
||
|
||
// 编辑状态:存储正在编辑的字段键(如 "1-scene", "2-voiceover")
|
||
const [editingFields, setEditingFields] = useState<Set<string>>(new Set());
|
||
|
||
// 润色状态
|
||
const [polishingState, setPolishingState] = useState<{
|
||
id: number;
|
||
type: 'scene' | 'prompt' | 'voiceover';
|
||
} | null>(null);
|
||
|
||
// 展开/收起状态
|
||
const [expandedSegments, setExpandedSegments] = useState<Set<number>>(new Set());
|
||
// 标记是否已初始化展开状态(防止收起后被 useEffect 重新展开)
|
||
const hasInitExpandedRef = useRef(false);
|
||
|
||
// 重新生成确认弹窗
|
||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
||
|
||
const shots = useMemo(() => segments || [], [segments]);
|
||
const generated = shots.length > 0;
|
||
|
||
// 检测是否已有后续步骤数据
|
||
const hasSubsequentData = useMemo(() => {
|
||
return shots.some(s => s.videoPath || s.alignmentResult || s.burnedVideoPath);
|
||
}, [shots]);
|
||
|
||
// 首次有脚本内容时默认展开所有镜头(只执行一次,不覆盖用户手动收起)
|
||
useEffect(() => {
|
||
if (!hasInitExpandedRef.current && segments.length > 0) {
|
||
hasInitExpandedRef.current = true;
|
||
setExpandedSegments(new Set(segments.map(s => s.id)));
|
||
}
|
||
}, [segments]);
|
||
|
||
// 使用 useMemo 缓存统计计算,避免每次渲染重复计算
|
||
const stats = useMemo(
|
||
() => ({
|
||
totalWords: shots.reduce(
|
||
(a, s) => a + (typeof s.voiceover === 'string' ? s.voiceover.length : 0),
|
||
0
|
||
),
|
||
totalDuration: shots.reduce((a, s) => {
|
||
const durationStr = typeof s.duration === 'string' ? s.duration : '5s';
|
||
const seconds = parseFloat(durationStr.replace(/[^0-9.]/g, '') || '0');
|
||
return a + seconds;
|
||
}, 0),
|
||
segmentCount: shots.filter(s => s.type !== 'empty_shot').length,
|
||
emptyShotCount: shots.filter(s => s.type === 'empty_shot').length,
|
||
}),
|
||
[shots]
|
||
);
|
||
|
||
// stats 直接传递给 ShotStats 组件,无需解构
|
||
|
||
/**
|
||
* 轮询任务状态直到完成或失败
|
||
*/
|
||
const pollTask = async (taskId: string, onProgress: (msg: string) => void): Promise<unknown> => {
|
||
const interval = 2000; // 2 秒
|
||
const timeout = 300_000; // 5 分钟
|
||
const start = Date.now();
|
||
|
||
while (Date.now() - start < timeout) {
|
||
const status = await getTaskStatus(taskId);
|
||
onProgress(status.message || `进度 ${status.progress}%`);
|
||
|
||
if (status.status === 'completed') {
|
||
return status.result;
|
||
}
|
||
if (status.status === 'failed') {
|
||
throw new Error(status.error || '任务执行失败');
|
||
}
|
||
// pending / running / waiting 继续轮询
|
||
await new Promise(r => setTimeout(r, interval));
|
||
}
|
||
|
||
throw new Error('任务超时,请稍后到任务列表查看结果');
|
||
};
|
||
|
||
/**
|
||
* 执行生成脚本(异步任务 + 轮询)
|
||
*/
|
||
const doGenerate = async () => {
|
||
if (!selectedCategory || !selectedFilename) {
|
||
toast.warning('请选择创作主题');
|
||
return;
|
||
}
|
||
|
||
// 前置积分检查:脚本生成
|
||
const scriptPoints = usePointStore.getState().getRule('script')?.points || 5;
|
||
const ok = await checkBalance(scriptPoints, '脚本生成');
|
||
if (!ok) {return;}
|
||
|
||
// 请求去重锁:防止网络延迟期间快速点击发起多个请求
|
||
if (requestLock.current) {
|
||
return;
|
||
}
|
||
requestLock.current = true;
|
||
|
||
setGenerating(true);
|
||
const progress = useProgressStore.getState();
|
||
progress.show('脚本生成');
|
||
|
||
try {
|
||
// 1. 创建异步任务
|
||
const { taskId } = await createTask('script', {
|
||
category: selectedCategory,
|
||
filename: selectedFilename,
|
||
});
|
||
|
||
// 2. 轮询任务状态
|
||
const result = await pollTask(taskId, (msg) => progress.update(msg));
|
||
|
||
if (!result) {
|
||
progress.error('任务结果为空');
|
||
return;
|
||
}
|
||
|
||
// 3. 解析结果:异步任务返回 { title, scenes, total_duration, style, shot_count }
|
||
const resultObj = result as Record<string, unknown>;
|
||
const scenes = resultObj.scenes as ScriptShot[] | undefined;
|
||
|
||
if (!scenes || scenes.length === 0) {
|
||
progress.error('生成分镜为空');
|
||
return;
|
||
}
|
||
|
||
let normalizedShots: ScriptShot[];
|
||
try {
|
||
normalizedShots = adaptScriptShots(scenes);
|
||
} catch (e) {
|
||
console.error('[ScriptCreation] 数据适配失败:', e, '原始数据:', scenes);
|
||
progress.error('数据解析失败');
|
||
return;
|
||
}
|
||
|
||
if (normalizedShots.length === 0) {
|
||
progress.error('生成分镜为空');
|
||
return;
|
||
}
|
||
|
||
setSegments(normalizedShots);
|
||
setExpandedSegments(new Set(normalizedShots.map(s => s.id)));
|
||
markStepsDirty(1);
|
||
progress.success('脚本生成成功', scriptPoints);
|
||
|
||
// 设置标题
|
||
const title = typeof resultObj.title === 'string' ? resultObj.title : '';
|
||
if (title) {
|
||
setTopic(title);
|
||
}
|
||
} catch (error) {
|
||
if (handleError(error, '脚本生成', 5)) {
|
||
progress.hide();
|
||
return;
|
||
}
|
||
progress.error(getFriendlyErrorMessage(error, '脚本生成失败,请稍后重试'));
|
||
} finally {
|
||
requestLock.current = false;
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 生成脚本入口(带确认弹窗)
|
||
*/
|
||
const handleGenerate = () => {
|
||
if (hasSubsequentData) {
|
||
setShowRegenerateConfirm(true);
|
||
return;
|
||
}
|
||
doGenerate();
|
||
};
|
||
|
||
/**
|
||
* 切换字段编辑状态
|
||
*/
|
||
const toggleEditField = useCallback((id: number, field: 'scene' | 'prompt' | 'voiceover') => {
|
||
const key = `${id}-${field}`;
|
||
setEditingFields(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) {
|
||
next.delete(key);
|
||
} else {
|
||
next.add(key);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
/**
|
||
* 处理字段更新
|
||
*/
|
||
const handleFieldChange = useCallback(
|
||
(id: number, field: 'scene' | 'prompt' | 'voiceover' | 'duration', value: string) => {
|
||
if (field === 'duration') {
|
||
const actualDuration = parseFloat(value.replace(/[^0-9.]/g, '')) || 5;
|
||
updateSegment(id, { [field]: value, actualDuration });
|
||
} else {
|
||
updateSegment(id, { [field]: value });
|
||
}
|
||
},
|
||
[updateSegment]
|
||
);
|
||
|
||
/**
|
||
* 切换分镜展开/收起
|
||
*/
|
||
const toggleExpand = useCallback((id: number) => {
|
||
setExpandedSegments(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) {
|
||
next.delete(id);
|
||
} else {
|
||
next.add(id);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
/**
|
||
* 展开所有镜头
|
||
*/
|
||
const expandAll = useCallback(() => {
|
||
setExpandedSegments(new Set(shots.map(s => s.id)));
|
||
}, [shots]);
|
||
|
||
/**
|
||
* 收起所有镜头
|
||
*/
|
||
const collapseAll = useCallback(() => {
|
||
setExpandedSegments(new Set());
|
||
}, []);
|
||
|
||
// 字段编辑的key生成函数
|
||
/**
|
||
* 处理润色
|
||
*/
|
||
const { checkBalance, handleError, PointsModal } = usePointsCheck();
|
||
|
||
const handlePolish = async (
|
||
id: number,
|
||
content: string,
|
||
type: 'scene' | 'prompt' | 'voiceover'
|
||
) => {
|
||
// 前置积分检查:润色
|
||
const polishPoints = usePointStore.getState().getRule('polish')?.points || 1;
|
||
const ok = await checkBalance(polishPoints, type === 'voiceover' ? '文案润色' : '画面润色');
|
||
if (!ok) {return;}
|
||
|
||
setPolishingState({ id, type });
|
||
try {
|
||
// 后端润色接口:scene=画面描述, voiceover=配音文本
|
||
const polishType = type === 'scene' || type === 'prompt' ? 'scene' : 'voiceover';
|
||
// 获取当前镜头的类型(分镜/空镜)
|
||
const shot = shots.find(s => s.id === id);
|
||
const shotType = shot?.type === 'empty_shot' ? 'empty_shot' : 'segment';
|
||
const result = await scriptApi.polish(id, content, polishType, shotType);
|
||
handleFieldChange(id, type, result);
|
||
} catch (error) {
|
||
if (handleError(error, type === 'voiceover' ? '文案润色' : '画面润色', 1)) {return;}
|
||
console.error('润色失败:', error);
|
||
toast.error('润色失败,请重试');
|
||
} finally {
|
||
setPolishingState(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="step-layout script-creation">
|
||
{/* Left Panel - 参数配置 */}
|
||
<div className="step-panel-left">
|
||
{/* 创作主题 */}
|
||
<div className="panel-section">
|
||
<label className="panel-label">创作主题</label>
|
||
{categories.map((cat) => (
|
||
<div key={cat.code}>
|
||
<div className="topic-groups">
|
||
{cat.files.map((file) => (
|
||
<button
|
||
key={file.filename}
|
||
className={`option-card ${selectedCategory === cat.code && selectedFilename === file.filename ? 'selected' : ''}`}
|
||
onClick={() => {
|
||
setSelectedCategory(cat.code);
|
||
setSelectedFilename(file.filename);
|
||
setTopic(file.label);
|
||
setCategoryCode(cat.code);
|
||
setFilename(file.filename);
|
||
}}
|
||
title={file.desc}
|
||
>
|
||
<div className="option-card-label">{file.label}</div>
|
||
<div className="option-card-desc">{file.desc}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 生成按钮 */}
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ width: '100%' }}
|
||
onClick={handleGenerate}
|
||
disabled={generating}
|
||
>
|
||
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)`}
|
||
</button>
|
||
|
||
{/* 生成进度由全局 ProgressModal 统一展示 */}
|
||
</div>
|
||
|
||
{/* Right Panel - 分镜编辑 */}
|
||
<div className="step-panel-right">
|
||
{generated ? (
|
||
<div className="script-editor">
|
||
{/* 统计和操作栏 */}
|
||
<div className="script-editor-header">
|
||
<label className="panel-label">脚本分镜</label>
|
||
<div className="script-editor-actions">
|
||
<button className="btn btn-ghost btn-sm" onClick={expandAll}>
|
||
展开全部
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={collapseAll}>
|
||
收起全部
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 🆕 使用通用 ShotStats 组件 */}
|
||
<ShotStats stats={stats} />
|
||
|
||
{/* 镜头列表 */}
|
||
{shots.map(seg => {
|
||
const isEmptyShot = seg.type === 'empty_shot';
|
||
return (
|
||
<div
|
||
key={seg.id}
|
||
className={`script-segment ${expandedSegments.has(seg.id) ? 'expanded' : ''}`}
|
||
>
|
||
<div className="segment-header" onClick={() => toggleExpand(seg.id)}>
|
||
<div className="segment-title-group">
|
||
<span className="segment-title">
|
||
{`镜头 ${seg.id}`}
|
||
<span
|
||
className="shot-type-badge"
|
||
style={{
|
||
backgroundColor: 'rgba(34, 197, 94, 0.15)',
|
||
color: 'rgb(34, 197, 94)',
|
||
marginLeft: '8px',
|
||
fontSize: '10px',
|
||
padding: 'var(--spacing-2xs) var(--spacing-xs)',
|
||
borderRadius: '4px',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{isEmptyShot ? '空镜' : '分镜'}
|
||
</span>
|
||
</span>
|
||
<span className="segment-duration">
|
||
{typeof seg.duration === 'string' ? seg.duration : '5s'}
|
||
</span>
|
||
</div>
|
||
<div className="segment-header-actions">
|
||
{/* 展开/收起指示器 */}
|
||
<span className="expand-indicator">
|
||
{expandedSegments.has(seg.id) ? (
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
>
|
||
<polyline points="18 15 12 9 6 15" />
|
||
</svg>
|
||
) : (
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
>
|
||
<polyline points="6 9 12 15 18 9" />
|
||
</svg>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{expandedSegments.has(seg.id) && (
|
||
<div className="segment-body">
|
||
{/* 画面描述(分镜:scene,空镜:prompt)—— 只读 */}
|
||
<div className="segment-field">
|
||
<div className="segment-field-header">
|
||
<span className="segment-field-label">画面描述</span>
|
||
</div>
|
||
<p className="segment-field-value">
|
||
{typeof seg.scene === 'string' ? seg.scene : '未设置画面描述'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 配音文本/画外音(两种类型都有) */}
|
||
<div className="segment-field">
|
||
<div className="segment-field-header">
|
||
<span className="segment-field-label">配音文本</span>
|
||
<div className="segment-field-actions">
|
||
<button
|
||
className="btn btn-ghost btn-xs"
|
||
disabled={!!polishingState}
|
||
onClick={() =>
|
||
handlePolish(
|
||
seg.id,
|
||
typeof seg.voiceover === 'string' ? seg.voiceover : '',
|
||
'voiceover'
|
||
)
|
||
}
|
||
>
|
||
{polishingState?.id === seg.id && polishingState?.type === 'voiceover' ? (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
style={{ animation: 'spin 1s linear infinite' }}
|
||
>
|
||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||
</svg>
|
||
) : null}
|
||
润色
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost btn-xs"
|
||
onClick={() => toggleEditField(seg.id, 'voiceover')}
|
||
>
|
||
{editingFields.has(`${seg.id}-voiceover`) ? '完成' : '编辑'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{editingFields.has(`${seg.id}-voiceover`) ? (
|
||
<textarea
|
||
value={typeof seg.voiceover === 'string' ? seg.voiceover : ''}
|
||
onChange={e => handleFieldChange(seg.id, 'voiceover', e.target.value)}
|
||
rows={3}
|
||
autoFocus
|
||
placeholder="输入配音文本..."
|
||
/>
|
||
) : (
|
||
<p
|
||
className="segment-field-value"
|
||
onClick={() => toggleEditField(seg.id, 'voiceover')}
|
||
style={{ minHeight: '60px' }}
|
||
>
|
||
{typeof seg.voiceover === 'string' ? (
|
||
seg.voiceover || (
|
||
<span
|
||
style={{ color: 'var(--text-tertiary)', fontStyle: 'italic' }}
|
||
>
|
||
点击编辑文案...
|
||
</span>
|
||
)
|
||
) : (
|
||
<span style={{ color: 'var(--text-tertiary)', fontStyle: 'italic' }}>
|
||
点击编辑文案...
|
||
</span>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state">
|
||
<div className="empty-state-icon">
|
||
<svg
|
||
width="48"
|
||
height="48"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||
<polyline points="14 2 14 8 20 8" />
|
||
<line x1="16" y1="13" x2="8" y2="13" />
|
||
<line x1="16" y1="17" x2="8" y2="17" />
|
||
<polyline points="10 9 9 9 8 9" />
|
||
</svg>
|
||
</div>
|
||
<p className="empty-state-title">暂无脚本内容</p>
|
||
<p className="empty-state-desc">
|
||
在左侧选择创作主题后点击{'"'}生成脚本{'"'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ConfirmModal
|
||
open={showRegenerateConfirm}
|
||
onClose={() => setShowRegenerateConfirm(false)}
|
||
onConfirm={() => {
|
||
setShowRegenerateConfirm(false);
|
||
doGenerate();
|
||
}}
|
||
onCancel={() => setShowRegenerateConfirm(false)}
|
||
type="warning"
|
||
title="重新生成脚本"
|
||
description={
|
||
'该操作将覆盖视频、字幕等现有数据,\n' +
|
||
'且消费积分不返还。是否继续?'
|
||
}
|
||
confirmText="继续生成"
|
||
cancelText="取消"
|
||
confirmButtonType="danger"
|
||
/>
|
||
<PointsModal />
|
||
</div>
|
||
);
|
||
}
|