Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 533e060e38 | |||
| ebf2d5bfa8 | |||
| 7f34cf45a2 | |||
| 5e256586ea | |||
| 936f984d17 | |||
| ad8aebd1b0 |
@@ -2,230 +2,93 @@
|
||||
* 智能剪辑页面 — V21 原型 1:1 还原
|
||||
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留共享状态、步骤切换、整体布局
|
||||
* 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/
|
||||
* 生成核心逻辑封装在 useGenerateVideo hook
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
* 步骤导航 → hooks/useStepNavigation
|
||||
* 步骤内容 → components/GenerateStepContent
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { message, Modal } from "antd"
|
||||
import { ThunderboltOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import React from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import Step1TemplateSelect from "./components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "./components/Step2MaterialSelect"
|
||||
import Step3GeneratePreview from "./components/Step3GeneratePreview"
|
||||
import Step4TitleSettings from "./components/Step4TitleSettings"
|
||||
import Step5VoiceSelect from "./components/Step5VoiceSelect"
|
||||
import Step6CoverSettings from "./components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "./components/Step7ConfirmGenerate"
|
||||
import { DEFAULT_COVER_SETTINGS } from "./constants"
|
||||
import type { TitleSettings } from "./types"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import "./generate.css"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
/* ── 表单状态 ── */
|
||||
const formState = useGenerateFormState()
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
setCoverSettings,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 模板(从 API 加载) ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
|
||||
/* ── 素材(共享:step2 选择、step7 展示、生成使用) ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 标题设置(共享:step4 编辑、step7 展示、生成使用) ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置(共享:step6 编辑、step7 展示、生成使用) ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates])
|
||||
|
||||
/* ── 配音(共享:step5 选择、step7 展示、生成使用) ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>("")
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("")
|
||||
|
||||
/* ── 预置音色 API(共享:step5 选择、step7 展示) ── */
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 克隆声音(共享:step5 管理、step7 展示) ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── URL 参数:从模板编辑器跳转过来时携带 edit_plan_id + plan_config ── */
|
||||
const [searchParams] = useSearchParams()
|
||||
const editPlanId = searchParams.get("edit_plan_id")
|
||||
const planConfigStr = searchParams.get("plan_config")
|
||||
|
||||
/** 解析 plan_config 并自动填充表单 */
|
||||
useEffect(() => {
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: { content?: string; ai_auto_select?: boolean }
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
total_duration?: number
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
.map((s) => s.media_asset_id)
|
||||
.filter((id): id is string => !!id)
|
||||
if (assetIds.length > 0) {
|
||||
setSelectedMaterials(assetIds)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("解析 plan_config 失败:", err)
|
||||
}
|
||||
}, [planConfigStr])
|
||||
|
||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||
useEffect(() => {
|
||||
if (!editPlanId || planConfigStr) return
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载模板草稿配置失败:", err)
|
||||
}
|
||||
}
|
||||
loadPlanConfig()
|
||||
}, [editPlanId, planConfigStr])
|
||||
|
||||
/* ── 克隆成功回调 ── */
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
setCloneModalOpen(false)
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -256,136 +119,6 @@ const GeneratePage: React.FC = () => {
|
||||
generateCount,
|
||||
})
|
||||
|
||||
/* ── 预览弹窗状态 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const goNext = () => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
message.warning("请先选择一个模板")
|
||||
return
|
||||
}
|
||||
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goPrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((s) => s - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 渲染当前步骤 ── */
|
||||
const renderCurrentStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<Step1TemplateSelect
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onSelectTemplate={setSelectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<Step2MaterialSelect
|
||||
materialMode={materialMode}
|
||||
onMaterialModeChange={setMaterialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
onSelectedMaterialsChange={setSelectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={setSmartSelectedIds}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step3GeneratePreview
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={setTitleSettings}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
onSelectedClonedVoiceChange={setSelectedClonedVoice}
|
||||
clonedVoices={clonedVoices}
|
||||
addClone={addClone}
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={setCloneModalOpen}
|
||||
titleText={titleSettings.title}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
return (
|
||||
<Step7ConfirmGenerate
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
title={titleSettings.title}
|
||||
voiceMode={voiceMode}
|
||||
selectedVoice={selectedVoice}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
coverSettings={coverSettings}
|
||||
generateCount={generateCount}
|
||||
onGenerateCountChange={setGenerateCount}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
渲染 — 主页面
|
||||
================================================================ */
|
||||
@@ -402,35 +135,55 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-generate-layout">
|
||||
{/* ════ 左侧:表单区 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
{/* 当前步骤 */}
|
||||
{renderCurrentStep()}
|
||||
<GenerateStepContent
|
||||
currentStep={currentStep}
|
||||
userTemplates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onSelectTemplate={setSelectedTemplate}
|
||||
materialMode={materialMode}
|
||||
onMaterialModeChange={setMaterialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
onSelectedMaterialsChange={setSelectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={setSmartSelectedIds}
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={setTitleSettings}
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
onSelectedClonedVoiceChange={setSelectedClonedVoice}
|
||||
clonedVoices={clonedVoices}
|
||||
addClone={addClone}
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={setCloneModalOpen}
|
||||
generateCount={generateCount}
|
||||
onGenerateCountChange={setGenerateCount}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<div className="xx-step-actions">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 7 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={goNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onGenerate={handleGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:生成结果 ════ */}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { ThunderboltOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface GenerateStepActionsProps {
|
||||
currentStep: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onGenerate: () => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
}
|
||||
|
||||
export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
currentStep,
|
||||
onPrev,
|
||||
onNext,
|
||||
onGenerate,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-step-actions">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={onPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 7 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerateStepActions
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3GeneratePreview from "../components/Step3GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
export interface GenerateStepContentProps {
|
||||
currentStep: number
|
||||
/* 模板 */
|
||||
userTemplates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
/* 素材 */
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (id: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
/* 生成 */
|
||||
generateCount: number
|
||||
onGenerateCountChange: (n: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
const {
|
||||
currentStep,
|
||||
userTemplates,
|
||||
selectedTemplate,
|
||||
onSelectTemplate,
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
clonedVoices,
|
||||
addClone,
|
||||
hasProcessing,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<Step1TemplateSelect
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<Step2MaterialSelect
|
||||
materialMode={materialMode}
|
||||
onMaterialModeChange={onMaterialModeChange}
|
||||
selectedMaterials={selectedMaterials}
|
||||
onSelectedMaterialsChange={onSelectedMaterialsChange}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step3GeneratePreview
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={onVoiceModeChange}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
onSelectedClonedVoiceChange={onSelectedClonedVoiceChange}
|
||||
clonedVoices={clonedVoices}
|
||||
addClone={addClone}
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={onCloneModalOpenChange}
|
||||
titleText={titleSettings.title}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
duration={duration}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
return (
|
||||
<Step7ConfirmGenerate
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
title={titleSettings.title}
|
||||
voiceMode={voiceMode}
|
||||
selectedVoice={selectedVoice}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
coverSettings={coverSettings}
|
||||
generateCount={generateCount}
|
||||
onGenerateCountChange={onGenerateCountChange}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
onRetry={onRetry}
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default GenerateStepContent
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* GeneratePage 表单状态管理
|
||||
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
/* 步骤 */
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
|
||||
/* 模板 */
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
materialMode: "manual" | "auto"
|
||||
setMaterialMode: (mode: "manual" | "auto") => void
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
setSelectedVoice: (id: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
setSelectedClonedVoice: (id: string) => void
|
||||
presetVoices: PresetVoiceItem[]
|
||||
|
||||
/* 克隆弹窗 */
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
style: string
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
|
||||
/* URL 参数 */
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const editPlanId = searchParams.get("edit_plan_id")
|
||||
const planConfigStr = searchParams.get("plan_config")
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板(从 API 加载) ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates])
|
||||
|
||||
/* ── 配音 ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState("")
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
|
||||
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 预览弹窗 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/** 解析 plan_config 并自动填充表单 */
|
||||
useEffect(() => {
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: {
|
||||
content?: string
|
||||
ai_auto_select?: boolean
|
||||
position?: string
|
||||
font_preset?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
}
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
total_duration?: number
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
.map((s) => s.media_asset_id)
|
||||
.filter((id): id is string => !!id)
|
||||
if (assetIds.length > 0) {
|
||||
setSelectedMaterials(assetIds)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("解析 plan_config 失败:", err)
|
||||
}
|
||||
}, [planConfigStr])
|
||||
|
||||
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
||||
useEffect(() => {
|
||||
if (!editPlanId || planConfigStr) return
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载模板草稿配置失败:", err)
|
||||
}
|
||||
}
|
||||
loadPlanConfig()
|
||||
}, [editPlanId, planConfigStr])
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
setCoverSettings,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
export interface UseStepNavigationOptions {
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
goNext: () => void
|
||||
goPrev: () => void
|
||||
}
|
||||
|
||||
export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNavigationReturn => {
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
message.warning("请先选择一个模板")
|
||||
return
|
||||
}
|
||||
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goPrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((s) => s - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return { goNext, goPrev }
|
||||
}
|
||||
Reference in New Issue
Block a user