feat(generate): add Step4-7 components and hooks
- Step4TitleSettings + useStep4Title: title settings with AI generation and styling - Step5VoiceSelect + useStep5Voice: voice selection with preset/TTS/clone modes - Step6CoverSettings + useStep6Cover: cover settings with auto/frame/upload modes - Step7ConfirmGenerate + useStep7Generate: generation confirmation and progress Part of GeneratePage Phase 2 refactoring.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { LoadingOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const {
|
||||
userTitles,
|
||||
titleSettings,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
} = useStep4Title(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{/* AI 智能生成标题 */}
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={aiTitleInput}
|
||||
onChange={(e) => setAiTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGenerateAiTitles()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerateAiTitles}
|
||||
disabled={aiTitleGenerating || !aiTitleInput.trim()}
|
||||
>
|
||||
{aiTitleGenerating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGeneratedTitles && !aiTitleGenerating && aiTitleResults.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">
|
||||
为你生成 {aiTitleResults.length} 个标题
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-link-btn"
|
||||
onClick={handleRefreshAiTitles}
|
||||
disabled={aiTitleGenerating}
|
||||
>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{aiTitleResults.map((item, idx) => {
|
||||
const isSelected = titleSettings.title === item.title
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`xx-ai-title-card ${isSelected ? "selected" : ""} ${item.style}`}
|
||||
onClick={() => handleSelectAiTitle(item.title)}
|
||||
>
|
||||
<div className="xx-ai-title-card-text">{item.title}</div>
|
||||
<div className="xx-ai-title-card-tag">{item.highlight}</div>
|
||||
{isSelected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{aiTitleGenerating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>或手动选择</span>
|
||||
</div>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div
|
||||
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={toggleAiAutoSelect}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
{/* 标题内容选择 */}
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={titleSettings.title || undefined}
|
||||
onChange={(val) => updateTitle(val || "")}
|
||||
options={userTitles.map((t) => ({
|
||||
label: t.content,
|
||||
value: t.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={titleSettings.title}
|
||||
onChange={(e) => updateTitle(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题样式设置区 */}
|
||||
<div className="xx-title-style-section">
|
||||
<h4 className="xx-section-subtitle">标题样式</h4>
|
||||
|
||||
{/* 位置 + 字体 一行 */}
|
||||
<div className="xx-title-style-row">
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) => updatePosition(e.target.value)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) => updateFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="xx-form-field">
|
||||
<div className="xx-field-label-row">
|
||||
<label>字号</label>
|
||||
<span className="xx-field-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) => updateSize(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<div className="xx-title-presets-grid">
|
||||
{titlePresets.map((p) => {
|
||||
const isActive = activePreset === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => applyPreset(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={p.previewStyle as React.CSSProperties}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
||||
<div className="xx-form-field">
|
||||
<label>样式</label>
|
||||
<div className="xx-style-btns">
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={toggleBold}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={toggleItalic}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={toggleStroke}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={toggleShadow}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4TitleSettings
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Step 5 配音选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useStep5Voice } from "../hooks/useStep5Voice"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
const {
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
handleSelectRecommendedVoice,
|
||||
playingVoice,
|
||||
toggleVoicePlay,
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
cloneModalOpen,
|
||||
handleOpenCloneModal,
|
||||
handleCloneSuccess,
|
||||
handleSelectClonedVoice,
|
||||
VOICE_GENDER_ICON,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
} = useStep5Voice(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
{/* AI 智能推荐配音 */}
|
||||
<div className="xx-voice-recommend-section">
|
||||
<div className="xx-voice-recommend-header">
|
||||
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleVoiceRecommend}
|
||||
disabled={voiceRecommendLoading || presetVoicesLoading}
|
||||
>
|
||||
{voiceRecommendLoading ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
推荐中
|
||||
</>
|
||||
) : hasVoiceRecommend ? (
|
||||
"换一批"
|
||||
) : (
|
||||
"智能推荐"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceRecommendLoading && (
|
||||
<div className="xx-voice-recommend-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
根据视频内容为你匹配最合适的音色…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
|
||||
<div className="xx-voice-recommend-list">
|
||||
{voiceRecommendations.map((voiceId) => {
|
||||
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
|
||||
if (!v) return null
|
||||
const isSelected = voiceMode === "preset" && selectedVoice === v.voice_id
|
||||
return (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => handleSelectRecommendedVoice(v.voice_id)}
|
||||
>
|
||||
<div className="xx-voice-recommend-avatar">
|
||||
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
|
||||
</div>
|
||||
<div className="xx-voice-recommend-info">
|
||||
<div className="xx-voice-recommend-name">{v.name}</div>
|
||||
<div className="xx-voice-recommend-desc">{v.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="xx-voice-recommend-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && !hasVoiceRecommend && (
|
||||
<div className="xx-voice-recommend-empty">
|
||||
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
</div>
|
||||
|
||||
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{presetVoices.slice(0, 3).map((v) => (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-choice-item ${
|
||||
voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectPresetVoice(v.voice_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={voiceMode === "preset" && selectedVoice === v.voice_id}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
handleSelectPresetVoice(v.voice_id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="xx-voice-choice-check">✓</span>
|
||||
<div className="xx-voice-choice-avatar">{VOICE_GENDER_ICON[v.gender] ?? "✨"}</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>{v.name}</h4>
|
||||
<p>{v.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{presetVoicesLoading && (
|
||||
<div className="xx-voice-choice-item" style={{ opacity: 0.5 }}>
|
||||
<div className="xx-voice-choice-avatar">⏳</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>加载中…</h4>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 克隆我的声音 */}
|
||||
<div
|
||||
className={`xx-voice-choice-item ${voiceMode === "clone" ? "selected" : ""}`}
|
||||
onClick={handleSelectCloneVoice}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={voiceMode === "clone"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
handleSelectCloneVoice()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="xx-voice-choice-check">✓</span>
|
||||
<div
|
||||
className="xx-voice-choice-avatar"
|
||||
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
||||
>
|
||||
🎤
|
||||
</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>克隆我的声音</h4>
|
||||
<p>上传语音样本克隆</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设音色详细列表(当 voiceMode === preset 时显示 API 返回的音色) */}
|
||||
{voiceMode === "preset" && (
|
||||
<div>
|
||||
<div className="xx-form-field">
|
||||
<label>从配音库选择</label>
|
||||
<select value={selectedVoice} onChange={(e) => handleSelectPresetVoice(e.target.value)}>
|
||||
<option value="">请选择配音…</option>
|
||||
{presetVoicesLoading ? (
|
||||
<option disabled>加载中…</option>
|
||||
) : (
|
||||
presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name} — {v.description}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
{/* 试听按钮 */}
|
||||
{presetVoices.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{presetVoices.slice(0, 4).map((v) => (
|
||||
<button
|
||||
key={v.voice_id}
|
||||
className="xx-btn xx-btn-ghost"
|
||||
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
|
||||
onClick={() => toggleVoicePlay(v.voice_id, v.preview_url)}
|
||||
>
|
||||
{playingVoice === v.voice_id ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 停止
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined /> {v.name}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义录制 */}
|
||||
{voiceMode === "custom" && (
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="输入配音文案,点击合成按钮生成语音…"
|
||||
value={customVoiceText}
|
||||
onChange={(e) => setCustomVoiceText(e.target.value)}
|
||||
maxLength={500}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 100,
|
||||
border: "1px solid var(--border-color, #e2e8f0)",
|
||||
borderRadius: "var(--radius-sm, 10px)",
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
|
||||
onClick={handleSynthesizeVoice}
|
||||
>
|
||||
<AudioOutlined /> {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
||||
</button>
|
||||
</div>
|
||||
{ttsError && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--error, #ef4444)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</Text>
|
||||
)}
|
||||
{customAudioUrl && completedTtsJobId && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
|
||||
onClick={handleOpenSaveModal}
|
||||
>
|
||||
<SaveOutlined /> 存为素材
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 存为素材弹窗 ── */}
|
||||
{saveModalOpen && (
|
||||
<div className="xx-save-modal-overlay" onClick={() => setSaveModalOpen(false)}>
|
||||
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="xx-save-modal-header">
|
||||
<span>保存到配音库</span>
|
||||
<button className="xx-save-modal-close" onClick={() => setSaveModalOpen(false)}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-save-modal-body">
|
||||
<label className="xx-save-modal-label">素材名称</label>
|
||||
<input
|
||||
className="xx-save-modal-input"
|
||||
placeholder="留空则自动生成名称"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="xx-save-modal-label">
|
||||
标签
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
(可选)
|
||||
</span>
|
||||
</label>
|
||||
<div className="xx-save-modal-tags">
|
||||
{saveTagIds.map((id) => {
|
||||
const tag = allTags.find((t) => t.id === id)
|
||||
return tag ? (
|
||||
<span key={id} className="xx-save-modal-tag active">
|
||||
{tag.name}
|
||||
<CloseOutlined
|
||||
className="xx-save-modal-tag-remove"
|
||||
onClick={() =>
|
||||
setSaveTagIds((prev) => prev.filter((x) => x !== id))
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
<input
|
||||
className="xx-save-modal-tag-input"
|
||||
placeholder="输入标签名回车添加"
|
||||
value={saveNewTag}
|
||||
onChange={(e) => setSaveNewTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddTagInModal(saveNewTag)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allTags.length > 0 && (
|
||||
<div className="xx-save-modal-tag-presets">
|
||||
{allTags
|
||||
.filter((t) => !saveTagIds.includes(t.id))
|
||||
.slice(0, 12)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="xx-save-modal-tag-preset"
|
||||
onClick={() => setSaveTagIds((prev) => [...prev, t.id])}
|
||||
>
|
||||
{t.name}
|
||||
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-save-modal-footer">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={() => setSaveModalOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
disabled={saveToLibraryMutation.isPending}
|
||||
onClick={handleConfirmSave}
|
||||
>
|
||||
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆声音展开区域 */}
|
||||
{voiceMode === "clone" && (
|
||||
<div className="xx-clone-section">
|
||||
<p className="xx-clone-section-title">克隆我的声音</p>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
|
||||
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 36, fontSize: 13 }}
|
||||
onClick={handleOpenCloneModal}
|
||||
>
|
||||
<ThunderboltOutlined /> 克隆新声音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 轮询提示 */}
|
||||
{hasProcessing && (
|
||||
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
|
||||
<span className="xx-clone-polling-dot" />
|
||||
正在同步克隆进度...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已克隆声音列表 */}
|
||||
{clonedVoices.length > 0 && (
|
||||
<div className="xx-clone-voices-list">
|
||||
{clonedVoices.map((cv) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
|
||||
const isReady = cv.status === "ready"
|
||||
const selected = selectedClonedVoice === cv.id
|
||||
return (
|
||||
<div
|
||||
key={cv.id}
|
||||
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${
|
||||
!isReady ? "disabled" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isReady) handleSelectClonedVoice(cv.id)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={isReady ? 0 : -1}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className={`xx-clone-avatar ${cv.status}`}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-info">
|
||||
<div className="xx-clone-name">{cv.name}</div>
|
||||
<div className="xx-clone-status">
|
||||
<span
|
||||
className="xx-clone-status-dot"
|
||||
style={{ background: statusCfg.color }}
|
||||
/>
|
||||
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
|
||||
{isReady && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatDuration(cv.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected && isReady && (
|
||||
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clonedVoices.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
暂无克隆音色,点击「克隆新声音」开始
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
marginTop: 10,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
💡 提示:录音环境越安静,克隆效果越好。
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5VoiceSelect
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Step 6 封面设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
} = useStep6Cover(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setFrameTime(t)}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img
|
||||
src={coverSettings.upload_url}
|
||||
alt="封面预览"
|
||||
className="xx-cover-preview-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step6CoverSettings
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
const {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
} = useStep7Generate(props)
|
||||
|
||||
const { onRetry, onDismissError } = props
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onRetry}
|
||||
>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step7ConfirmGenerate
|
||||
Reference in New Issue
Block a user