fix(cover): 修复9个封面/流程UX问题 #2047
@@ -200,6 +200,7 @@ const GeneratePage: React.FC = () => {
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
currentTaskId,
|
||||
batchTasks,
|
||||
generate: handleGenerate,
|
||||
retry: handleRetryGenerate,
|
||||
@@ -278,6 +279,38 @@ const GeneratePage: React.FC = () => {
|
||||
],
|
||||
)
|
||||
|
||||
/* ── 对齐批量数组长度到 previewCount(用于进入 Step3 时) ── */
|
||||
const ensureArraysAligned = useCallback(() => {
|
||||
setPreviewTitles((prev) => {
|
||||
const list = prev || []
|
||||
if (list.length === previewCount) return list
|
||||
const base = list[0] || titleSettings.title || ""
|
||||
return Array.from({ length: previewCount }, (_, i) => list[i] ?? (i === 0 ? base : ""))
|
||||
})
|
||||
setVoiceLibraryIds((prev) => {
|
||||
const list = prev || []
|
||||
if (list.length === previewCount) return list
|
||||
return Array.from({ length: previewCount }, (_, i) => list[i] ?? selectedVoice ?? "")
|
||||
})
|
||||
setPreviewCovers((prev) => {
|
||||
const list = prev || []
|
||||
if (list.length === previewCount) return list
|
||||
return Array.from({ length: previewCount }, (_, i) => list[i] ?? "")
|
||||
})
|
||||
setSelectedVariantIds((prev) => {
|
||||
if (prev && prev.length === previewCount) return prev
|
||||
return Array.from({ length: previewCount }, (_, i) => i)
|
||||
})
|
||||
}, [
|
||||
previewCount,
|
||||
setPreviewTitles,
|
||||
setVoiceLibraryIds,
|
||||
setPreviewCovers,
|
||||
setSelectedVariantIds,
|
||||
titleSettings.title,
|
||||
selectedVoice,
|
||||
])
|
||||
|
||||
/* ── #1970:Step1 弹窗回调 ── */
|
||||
const handleVoiceModalConfirm = useCallback(
|
||||
(voiceAssetId: string) => {
|
||||
@@ -399,7 +432,7 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
generated,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
onBeforeEnterStep3: ensureArraysAligned,
|
||||
onOpenStep1Modal: () => {
|
||||
if (editMode === "random") {
|
||||
setVoiceModalOpen(true)
|
||||
@@ -430,30 +463,35 @@ const GeneratePage: React.FC = () => {
|
||||
setFinishing(true)
|
||||
const hide = message.loading("正在保存到视频库...", 0)
|
||||
try {
|
||||
// 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId
|
||||
const singleTaskId = finalVideo?.generation_task_id || currentTaskId || ""
|
||||
const taskIds =
|
||||
batchTasks && batchTasks.length > 0
|
||||
? batchTasks.map((t) => t.taskId).filter(Boolean)
|
||||
: finalVideo?.generation_task_id
|
||||
? [finalVideo.generation_task_id]
|
||||
: singleTaskId
|
||||
? [singleTaskId]
|
||||
: []
|
||||
|
||||
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
|
||||
if (isBatch && previewCovers.length > 0) {
|
||||
// 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题)
|
||||
if (isBatch && taskIds.length > 0) {
|
||||
await Promise.all(
|
||||
taskIds.map(async (taskId, idx) => {
|
||||
const coverUrl = previewCovers[idx] || ""
|
||||
const title = previewTitles[idx] || titleSettings.title || ""
|
||||
return finalizeGeneration(taskId, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: previewTitles[idx] || titleSettings.title || "",
|
||||
custom_title: title,
|
||||
})
|
||||
}),
|
||||
)
|
||||
} else if (finalVideo?.generation_task_id) {
|
||||
} else if (singleTaskId) {
|
||||
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
|
||||
await finalizeGeneration(finalVideo.generation_task_id, {
|
||||
await finalizeGeneration(singleTaskId, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: titleSettings.title || "",
|
||||
})
|
||||
} else {
|
||||
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
|
||||
}
|
||||
|
||||
hide()
|
||||
@@ -482,6 +520,7 @@ const GeneratePage: React.FC = () => {
|
||||
previewTitles,
|
||||
titleSettings.title,
|
||||
coverSettings,
|
||||
currentTaskId,
|
||||
navigate,
|
||||
])
|
||||
|
||||
@@ -580,6 +619,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
selectedCoverTemplate={selectedTemplate}
|
||||
onSelectedCoverTemplateChange={setSelectedTemplate}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤4(单视频):成片播放器 ════ */}
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface GenerateStepContentProps {
|
||||
selectedVariantIds?: number[]
|
||||
selectedCoverTemplate?: string
|
||||
onSelectedCoverTemplateChange?: (templateId: string) => void
|
||||
/** Step3 右上角确认生成按钮 */
|
||||
onConfirmGenerate?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -146,6 +148,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVariantIds,
|
||||
selectedCoverTemplate,
|
||||
onSelectedCoverTemplateChange,
|
||||
onConfirmGenerate,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
@@ -199,6 +202,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={onPreviewTitlesChange}
|
||||
onConfirmGenerate={onConfirmGenerate}
|
||||
generating={props.generating}
|
||||
selectedCount={
|
||||
props.previewCount && props.previewCount > 1 ? props.selectedVariantIds?.length || 1 : 1
|
||||
}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
|
||||
@@ -47,6 +47,12 @@ interface Step4TitleSettingsProps {
|
||||
enableTemplates?: boolean
|
||||
selectedTemplateId?: string | null
|
||||
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
|
||||
/** Step3 右上角「🎬 确认生成」主按钮 */
|
||||
onConfirmGenerate?: () => void | Promise<void>
|
||||
/** 是否生成中 */
|
||||
generating?: boolean
|
||||
/** 批量模式下勾选数量 */
|
||||
selectedCount?: number
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
@@ -69,6 +75,9 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
enableTemplates,
|
||||
selectedTemplateId,
|
||||
onApplyTemplate,
|
||||
onConfirmGenerate,
|
||||
generating,
|
||||
selectedCount = 1,
|
||||
} = props
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
@@ -90,7 +99,47 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-form-section" style={{ position: "relative" }}>
|
||||
{/* ── 右上角「🎬 确认生成」主按钮 ── */}
|
||||
{onConfirmGenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (generating) return
|
||||
void onConfirmGenerate()
|
||||
}}
|
||||
disabled={generating}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: generating ? "#a78bfa" : "#7c3aed",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 10,
|
||||
padding: "12px 24px",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: generating ? "not-allowed" : "pointer",
|
||||
boxShadow: "0 4px 14px rgba(124,58,237,0.4)",
|
||||
transition: "all .2s",
|
||||
zIndex: 5,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#6d28d9"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#7c3aed"
|
||||
}}
|
||||
>
|
||||
{generating
|
||||
? "⏳ 生成中..."
|
||||
: selectedCount > 1
|
||||
? `🎬 确认生成 ${selectedCount} 个视频`
|
||||
: "🎬 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{!isBatch ? (
|
||||
|
||||
@@ -60,13 +60,21 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
* 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发
|
||||
*/
|
||||
const shared = useSharedCover({
|
||||
canGenerate: !!finalVideo && !isBatch,
|
||||
canGenerate:
|
||||
!!finalVideo &&
|
||||
!isBatch &&
|
||||
(finalVideo.status === "completed" ||
|
||||
finalVideo.status === "awaiting_cover" ||
|
||||
!finalVideo.status),
|
||||
disabledHint: isBatch ? "批量场景请在上方操作卡片" : "请先生成视频再选择封面",
|
||||
initialTemplateId: props.selectedTemplate || "default",
|
||||
initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default
|
||||
generateFn: async (tplId) => {
|
||||
if (!finalVideo || isBatch) return null
|
||||
const response = await apiGenerateCover(tplId, {
|
||||
generated_video_id: finalVideo.id,
|
||||
generated_video_id:
|
||||
(finalVideo as { id?: string; video_id?: string }).id ||
|
||||
(finalVideo as { video_id?: string }).video_id ||
|
||||
"",
|
||||
video_url: finalVideo.file_url || finalVideo.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
...(props.titleSettings?.title
|
||||
|
||||
@@ -294,6 +294,122 @@ const TextStylePanel: React.FC<{
|
||||
onChange={(v) => upd("rotation", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字背景(剪映样式) */}
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>文字背景</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.background?.enabled ?? false}
|
||||
onChange={(v) => upd("background", { ...config.background, enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
{config.background?.enabled && (
|
||||
<div style={{ marginTop: 8, paddingLeft: 4 }}>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">形状</label>
|
||||
<div className="xx-ce-radio-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-ce-radio-btn ${config.background.shape === "rectangle" ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
upd("background", {
|
||||
...config.background,
|
||||
shape: "rectangle" as const,
|
||||
})
|
||||
}
|
||||
>
|
||||
矩形
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-ce-radio-btn ${config.background.shape === "polygon" ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
upd("background", {
|
||||
...config.background,
|
||||
shape: "polygon" as const,
|
||||
})
|
||||
}
|
||||
>
|
||||
圆角
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">背景颜色</label>
|
||||
<ColorPicker
|
||||
value={config.background.color || "#000000"}
|
||||
onChange={(v) => upd("background", { ...config.background, color: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">不透明度: {config.background.opacity}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={config.background.opacity}
|
||||
onChange={(v) => upd("background", { ...config.background, opacity: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">
|
||||
圆角:{" "}
|
||||
{config.background.shape === "rectangle"
|
||||
? 0
|
||||
: Math.round((config.background.height ?? 20) / 2)}
|
||||
px
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
value={
|
||||
config.background.shape === "rectangle"
|
||||
? 0
|
||||
: Math.round((config.background.height ?? 20) / 2)
|
||||
}
|
||||
disabled={config.background.shape === "rectangle"}
|
||||
onChange={(v) => {
|
||||
// 圆角近似:通过 height 控制
|
||||
void v
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">宽度: {config.background.width}%</label>
|
||||
<Slider
|
||||
min={20}
|
||||
max={200}
|
||||
step={2}
|
||||
value={config.background.width}
|
||||
onChange={(v) => upd("background", { ...config.background, width: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">高度: {config.background.height}%</label>
|
||||
<Slider
|
||||
min={5}
|
||||
max={80}
|
||||
step={1}
|
||||
value={config.background.height}
|
||||
onChange={(v) => upd("background", { ...config.background, height: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">上下偏移: {config.background.posY - 50}%</label>
|
||||
<Slider
|
||||
min={-50}
|
||||
max={50}
|
||||
step={1}
|
||||
value={config.background.posY - 50}
|
||||
onChange={(v) => upd("background", { ...config.background, posY: 50 + v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -316,6 +432,59 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
const portraitFileRef = useRef<HTMLInputElement>(null)
|
||||
const [bgImageUrl, setBgImageUrl] = useState<string>(initCfg.backgroundImage || "")
|
||||
const [portraitImageUrl, setPortraitImageUrl] = useState<string>(initCfg.portraitImage || "")
|
||||
/* ── 文字拖拽状态 ── */
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<null | {
|
||||
target: "title" | "subtitle"
|
||||
startX: number
|
||||
startY: number
|
||||
startPosX: number
|
||||
startPosY: number
|
||||
}>(null)
|
||||
|
||||
const handleTextMouseDown = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
target: "title" | "subtitle",
|
||||
) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const tc = target === "title" ? cfg.title : cfg.subtitle
|
||||
if (!tc) return
|
||||
dragRef.current = {
|
||||
target,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startPosX: tc.position.x,
|
||||
startPosY: tc.position.y,
|
||||
}
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const d = dragRef.current
|
||||
if (!d || !canvasRef.current) return
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const dx = ((ev.clientX - d.startX) / rect.width) * 100
|
||||
const dy = ((ev.clientY - d.startY) / rect.height) * 100
|
||||
const newX = Math.max(0, Math.min(100, d.startPosX + dx))
|
||||
const newY = Math.max(0, Math.min(100, d.startPosY + dy))
|
||||
if (d.target === "title") {
|
||||
setCfg((prev) => ({
|
||||
...prev,
|
||||
title: { ...prev.title, position: { x: newX, y: newY } },
|
||||
}))
|
||||
} else {
|
||||
setCfg((prev) => ({
|
||||
...prev,
|
||||
subtitle: { ...prev.subtitle, position: { x: newX, y: newY } },
|
||||
}))
|
||||
}
|
||||
}
|
||||
const onUp = () => {
|
||||
dragRef.current = null
|
||||
window.removeEventListener("mousemove", onMove)
|
||||
window.removeEventListener("mouseup", onUp)
|
||||
}
|
||||
window.addEventListener("mousemove", onMove)
|
||||
window.addEventListener("mouseup", onUp)
|
||||
}
|
||||
|
||||
// reset state when modal opens with a new template
|
||||
useEffect(() => {
|
||||
@@ -394,19 +563,6 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
|
||||
const renderTextStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => {
|
||||
if (!tc) return {}
|
||||
// wrap text by charsPerLine
|
||||
const rawText = tc.text || ""
|
||||
const lines: string[] = []
|
||||
if (tc.direction === "vertical") {
|
||||
lines.push(rawText)
|
||||
} else {
|
||||
for (let i = 0; i < rawText.length; i += Math.max(1, tc.charsPerLine)) {
|
||||
lines.push(rawText.slice(i, i + Math.max(1, tc.charsPerLine)))
|
||||
}
|
||||
}
|
||||
// store rendered as lines via data attribute; for JSX we'll render outside style
|
||||
void lines
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${tc.position.x}%`,
|
||||
@@ -422,9 +578,11 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
tc.strokeWidth > 0 ? `${Math.max(0.5, s(tc.strokeWidth))}px ${tc.strokeColor}` : undefined,
|
||||
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "pre",
|
||||
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
|
||||
zIndex: 3,
|
||||
zIndex: 4,
|
||||
textAlign: "center",
|
||||
userSelect: "none",
|
||||
cursor: "grab",
|
||||
padding: 0,
|
||||
}
|
||||
if (tc.shadows.length > 0) {
|
||||
style.textShadow = tc.shadows
|
||||
@@ -434,6 +592,35 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
return style
|
||||
}
|
||||
|
||||
const renderTextBgStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => {
|
||||
if (!tc?.background?.enabled) return { display: "none" }
|
||||
const bg = tc.background
|
||||
const radius = bg.shape === "polygon" ? `${Math.max(4, Math.round(bg.height / 2))}px` : "0"
|
||||
const alpha = Math.max(0, Math.min(1, bg.opacity / 100))
|
||||
const hex = (bg.color || "#000000").replace("#", "")
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
if (hex.length === 6) {
|
||||
r = parseInt(hex.substring(0, 2), 16)
|
||||
g = parseInt(hex.substring(2, 4), 16)
|
||||
b = parseInt(hex.substring(4, 6), 16)
|
||||
}
|
||||
const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
return {
|
||||
position: "absolute",
|
||||
left: `${bg.posX}%`,
|
||||
top: `${bg.posY}%`,
|
||||
width: `${bg.width}%`,
|
||||
height: `${bg.height}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: rgba,
|
||||
borderRadius: radius,
|
||||
zIndex: 3,
|
||||
pointerEvents: "none",
|
||||
}
|
||||
}
|
||||
|
||||
const renderTextLines = (tc: TextStyleConfig | undefined | null): string => {
|
||||
if (!tc) return ""
|
||||
const raw = tc.text || ""
|
||||
@@ -857,7 +1044,7 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
<span className="xx-ce-anchor-dot" style={{ top: "50%", right: "-12px" }} />
|
||||
<span className="xx-ce-anchor-dot" style={{ bottom: "10%", left: "-12px" }} />
|
||||
|
||||
<div className="xx-ce-canvas">
|
||||
<div className="xx-ce-canvas" ref={canvasRef}>
|
||||
{/* base background layer */}
|
||||
<div
|
||||
className="xx-ce-canvas-base"
|
||||
@@ -914,14 +1101,29 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{/* Title bg */}
|
||||
{cfg.title?.background?.enabled && <div style={renderTextBgStyle(cfg.title)} />}
|
||||
{/* Subtitle bg */}
|
||||
{cfg.subtitle?.background?.enabled && <div style={renderTextBgStyle(cfg.subtitle)} />}
|
||||
|
||||
{/* Title (draggable) */}
|
||||
{cfg.title && (
|
||||
<div style={renderTextStyle(cfg.title)}>{renderTextLines(cfg.title)}</div>
|
||||
<div
|
||||
style={renderTextStyle(cfg.title)}
|
||||
onMouseDown={(e) => handleTextMouseDown(e, "title")}
|
||||
>
|
||||
{renderTextLines(cfg.title)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtitle */}
|
||||
{/* Subtitle (draggable) */}
|
||||
{cfg.subtitle && (
|
||||
<div style={renderTextStyle(cfg.subtitle)}>{renderTextLines(cfg.subtitle)}</div>
|
||||
<div
|
||||
style={renderTextStyle(cfg.subtitle)}
|
||||
onMouseDown={(e) => handleTextMouseDown(e, "subtitle")}
|
||||
>
|
||||
{renderTextLines(cfg.subtitle)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mask overlay */}
|
||||
|
||||
@@ -116,7 +116,7 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
onClick={() => onEditTemplate(tpl)}
|
||||
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
|
||||
>
|
||||
{tpl.is_system ? "复制" : "编辑"}
|
||||
编辑
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
|
||||
@@ -22,6 +22,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string>("")
|
||||
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
|
||||
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
|
||||
|
||||
@@ -120,6 +122,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
setBatchTasks([])
|
||||
setGeneratedVideos([])
|
||||
setCurrentTaskId("")
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
@@ -341,8 +345,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
if (taskIds.length > 1) {
|
||||
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
|
||||
setCurrentTaskId("")
|
||||
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
|
||||
} else {
|
||||
setCurrentTaskId(taskIds[0])
|
||||
startPolling(taskIds[0])
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -417,6 +423,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
currentTaskId,
|
||||
generate,
|
||||
retry,
|
||||
retryBatchTask,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal),
|
||||
* 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。
|
||||
* - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。
|
||||
* - 步骤2(选择素材):直接进入步骤3,数组长度对齐由 onBeforeEnterStep3 保证。
|
||||
* - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
|
||||
* 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。
|
||||
* - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。
|
||||
@@ -23,10 +23,10 @@ export interface UseStepNavigationOptions {
|
||||
titleSettings: TitleSettings
|
||||
/** 是否已完成视频生成(步骤4全部渲染完成后才能进入封面) */
|
||||
generated: boolean
|
||||
/** 点素材下一步时弹出数量选择弹窗 */
|
||||
onOpenCountModal: () => void
|
||||
/** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */
|
||||
onOpenStep1Modal: () => void
|
||||
/** 进入步骤3前自动对齐数组(previewTitles/voiceLibraryIds/previewCovers/selectedVariantIds)长度到 previewCount */
|
||||
onBeforeEnterStep3?: () => void
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -42,8 +42,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
generated,
|
||||
onOpenCountModal,
|
||||
onOpenStep1Modal,
|
||||
onBeforeEnterStep3,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
@@ -62,8 +62,9 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
// 弹数量选择弹窗
|
||||
onOpenCountModal()
|
||||
// 直接进入步骤3(生成数量在 Step1 已设置);对齐数组长度
|
||||
onBeforeEnterStep3?.()
|
||||
setCurrentStep(3)
|
||||
return
|
||||
}
|
||||
// 步骤4(确认生成):全部渲染完成后才能下一步进封面
|
||||
|
||||
Reference in New Issue
Block a user