feat(P1): #581 标题设置从剪辑编辑器完整迁移到智能剪辑 #590

Merged
auto-approve-bot merged 3 commits from feat/smart-edit-title-migration into develop 2026-07-19 15:25:11 +08:00
5 changed files with 614 additions and 429 deletions
@@ -26,6 +26,7 @@ import type {
GeneratedVideo,
MediaAsset,
TransitionEffect,
TitleConfig,
} from "@/api/editPlans"
import {
getMediaAssets,
@@ -61,7 +62,6 @@ import type {
ChromaKeyConfig,
StickerConfig,
CoverConfig,
TitleSettings,
} from "./types"
import {
DEFAULT_TRANSITION,
@@ -157,18 +157,14 @@ const EditingPlanner: React.FC = () => {
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
size: 24,
bold: false,
italic: false,
stroke: true,
shadow: true,
color: "#ffffff",
/* ── 标题配置(只读,从模板/计划继承) ── */
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
ai_auto_select: false,
content: "",
position: "bottom",
font_preset: "思源黑体",
font_color: "#ffffff",
font_size: 28,
})
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
@@ -368,15 +364,14 @@ const EditingPlanner: React.FC = () => {
}))
resetClips(mapped)
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config.ai_auto_select,
title: tpl.title_config.content,
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font: tpl.title_config.font_preset,
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}))
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
@@ -423,15 +418,14 @@ const EditingPlanner: React.FC = () => {
// 还原 config 中的编辑器状态
const cfg = plan.config
if (cfg.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content,
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font: cfg.title_config!.font_preset,
size: cfg.title_config!.font_size,
color: cfg.title_config!.font_color || "#ffffff",
}))
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
@@ -824,14 +818,7 @@ const EditingPlanner: React.FC = () => {
/** 构建剪辑计划 config(编辑器状态 → API config */
const buildPlanConfig = (): EditPlanConfig => ({
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -901,14 +888,7 @@ const EditingPlanner: React.FC = () => {
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
position: titleSettings.position,
},
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -1298,7 +1278,7 @@ const EditingPlanner: React.FC = () => {
currentCoverScheme={currentCoverScheme}
coverSchemes={COVER_SCHEMES}
aiCoverLoading={aiCoverLoading}
titleSettings={titleSettings}
titleConfig={titleConfig}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -1355,15 +1335,11 @@ const EditingPlanner: React.FC = () => {
<div className="ep-right-tab-content">
<ClipPropertiesPanel
selectedClip={selectedClip}
titleSettings={titleSettings}
subtitleSettings={subtitleSettings}
bgmSettings={bgmSettings}
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig)
}
@@ -1,11 +1,11 @@
/**
* 右栏设置面板 — V8 原型 1:1 还原
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
* 字幕设置 + BGM设置 + 片段详情
*/
import React, { useRef, useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import type { TemplateMode } from "@/api/editingPlanner"
import type { ClipData, ClipType, TitleSettings } from "../types"
import type { ClipData, ClipType } from "../types"
import { TRANSITION_OPTIONS } from "@/api/editPlans"
import type { AssetItem } from "@/api/assets"
@@ -33,13 +33,11 @@ interface BgmSettings {
interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
titleSettings: TitleSettings
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
@@ -92,174 +90,6 @@ const ANIMATION_OPTIONS = [
{ value: "typewriter", label: "打字机" },
]
/**
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
* 不绑定字体,用户可自由搭配任意字体
* 预览统一用系统字体展示效果
*/
const TITLE_PRESETS = [
{
key: "classic_white",
label: "经典白字",
style: {
size: 28,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
shadow: false,
},
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "black_gold",
label: "黑金质感",
style: {
size: 32,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: {
size: 24,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
fontSize: "18px",
},
},
{
key: "variety_show",
label: "综艺花字",
style: {
size: 36,
color: "#ff4081",
bold: true,
italic: false,
stroke: true,
shadow: true,
},
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: {
size: 24,
color: "#1a1a1a",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#1a1a1a",
fontSize: "17px",
},
},
{
key: "retro_film",
label: "复古胶片",
style: {
size: 28,
color: "#e8d5b7",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: {
size: 32,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: {
size: 28,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
]
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
@@ -269,13 +99,11 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
titleSettings,
subtitleSettings,
bgmSettings,
clipsCount,
totalDuration,
currentMode,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange: _onBgmSettingsChange,
onClipUpdate,
@@ -333,142 +161,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
}
return (
<div className="ep-right-panel">
{/* ═══ 标题设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📝</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label">AI </span>
<div
className={`ep-toggle ${titleSettings.aiAutoSelect ? "active" : ""}`}
onClick={() =>
onTitleSettingsChange({
aiAutoSelect: !titleSettings.aiAutoSelect,
})
}
>
<div className="ep-toggle-knob" />
</div>
</div>
{!titleSettings.aiAutoSelect && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={titleSettings.position}
onChange={(e) => onTitleSettingsChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={titleSettings.font}
onChange={(e) => onTitleSettingsChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={titleSettings.size}
onChange={(e) => onTitleSettingsChange({ size: Number(e.target.value) })}
/>
<span className="ep-slider-value">{titleSettings.size}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key
return (
<button
key={p.key}
className={`ep-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
onTitleSettingsChange({
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
})
}
title={p.label}
>
<span className="ep-title-preset-preview-text" style={p.previewStyle}>
</span>
<span className="ep-title-preset-card-label">{p.label}</span>
</button>
)
})}
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-style-btns">
<button
className={`ep-style-btn ${titleSettings.bold ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ bold: !titleSettings.bold })}
title="粗体"
>
<b>B</b>
</button>
<button
className={`ep-style-btn ${titleSettings.italic ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ italic: !titleSettings.italic })}
title="斜体"
>
<i>I</i>
</button>
<button
className={`ep-style-btn ${titleSettings.stroke ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ stroke: !titleSettings.stroke })}
title="描边"
>
S
</button>
<button
className={`ep-style-btn ${titleSettings.shadow ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ shadow: !titleSettings.shadow })}
title="阴影"
>
</button>
</div>
</div>
</>
)}
</div>
{/* ═══ 字幕设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
@@ -4,7 +4,8 @@
* 封面右侧竖排4个方案按钮
*/
import React from "react"
import type { ClipData, ClipType, TitleSettings } from "../types"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/editPlans"
interface CoverScheme {
key: string
@@ -26,7 +27,7 @@ interface PreviewPlayerProps {
currentCoverScheme: string
coverSchemes: CoverScheme[]
aiCoverLoading: boolean
titleSettings?: TitleSettings
titleConfig?: TitleConfig
subtitleSettings?: SubtitleSettings
onClipSelect: (clipId: string) => void
onCoverSchemeChange: (scheme: string) => void
@@ -51,7 +52,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
currentCoverScheme,
coverSchemes,
aiCoverLoading,
titleSettings,
titleConfig,
subtitleSettings,
onCoverSchemeChange,
onPlayPause,
@@ -90,27 +91,28 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
)}
{/* 标题实时预览 */}
{titleSettings && !titleSettings.aiAutoSelect && titleSettings.title && (
{titleConfig && !titleConfig.ai_auto_select && titleConfig.content && (
<div
className="ep-preview-title"
style={{
fontSize: `${Math.min(titleSettings.size, 20)}px`,
fontFamily: titleSettings.font,
fontWeight: titleSettings.bold ? "bold" : "normal",
fontStyle: titleSettings.italic ? "italic" : "normal",
textShadow: titleSettings.shadow ? "2px 2px 4px rgba(0,0,0,0.5)" : "none",
WebkitTextStroke: titleSettings.stroke ? "1px rgba(0,0,0,0.6)" : "none",
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
fontFamily: titleConfig.font_preset,
fontWeight: "bold",
fontStyle: "normal",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
WebkitTextStroke: "1px rgba(0,0,0,0.6)",
top:
titleSettings.position === "top"
titleConfig.position === "top"
? "8px"
: titleSettings.position === "center"
: titleConfig.position === "center"
? "50%"
: "auto",
bottom: titleSettings.position === "bottom" ? "30px" : "auto",
transform: titleSettings.position === "center" ? "translateY(-50%)" : "none",
bottom: titleConfig.position === "bottom" ? "30px" : "auto",
transform: titleConfig.position === "center" ? "translateY(-50%)" : "none",
color: titleConfig.font_color,
}}
>
{titleSettings.title}
{titleConfig.content}
</div>
)}
+368 -54
View File
@@ -31,7 +31,7 @@ import {
updateEditPlan,
getGenerationTaskResults,
} from "@/api/editPlans"
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans"
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
import { getEditingTemplates } from "@/api/editingPlanner"
import { getTitles } from "@/api/titles"
import apiClient from "@/api/client"
@@ -80,7 +80,138 @@ const STEPS = [
{ key: 5, label: "确认生成" },
]
/* ── 标题选项从 API 加载,不再硬编码 ── */
/* ── 标题设置常量 ── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
interface TitleSettings {
aiAutoSelect: boolean
title: string
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
}
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
const TITLE_PRESETS = [
{
key: "classic_white",
label: "经典白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "black_gold",
label: "黑金质感",
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
},
{
key: "variety_show",
label: "综艺花字",
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
},
{
key: "retro_film",
label: "复古胶片",
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
]
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
/* ================================================================
组件
@@ -111,8 +242,8 @@ const GeneratePage: React.FC = () => {
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
/* ── 标题 ── */
const [title, setTitle] = useState("")
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: () => getTitles(),
@@ -121,8 +252,16 @@ const GeneratePage: React.FC = () => {
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) {
setTitle(tpl.title_config.content)
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,
}))
}
}, [selectedTemplate, userTemplates])
@@ -184,8 +323,17 @@ const GeneratePage: React.FC = () => {
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config?.content) {
setTitle(config.title_config.content)
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
@@ -206,8 +354,19 @@ const GeneratePage: React.FC = () => {
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitle(plan.name)
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?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
@@ -454,12 +613,12 @@ const GeneratePage: React.FC = () => {
const handleGenerate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
title,
titleSettings,
selectedTemplate,
selectedMaterials,
voiceMode,
})
if (!title.trim()) {
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
}
@@ -504,9 +663,17 @@ const GeneratePage: React.FC = () => {
const plan = await createEditPlan({
template_id: selectedTemplate,
name: title.trim(),
name: titleSettings.title.trim(),
config: {
asset_ids: selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
...voiceConfig,
ratio: videoRatio,
style,
@@ -689,7 +856,7 @@ const GeneratePage: React.FC = () => {
message.error(finalMsg)
}
}, [
title,
titleSettings,
selectedMaterials,
selectedVoice,
voiceMode,
@@ -755,14 +922,14 @@ const GeneratePage: React.FC = () => {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 3 && !title.trim()) {
if (currentStep === 3 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep < 5) {
setCurrentStep((s) => s + 1)
}
}, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode])
}, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode])
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -1022,48 +1189,193 @@ const GeneratePage: React.FC = () => {
</div>
)
/** 步骤 3:选择标题 */
/** 步骤 3:选择标题 + 标题样式设置 */
const renderStep3 = () => (
<div className="xx-form-section">
<h3>📝 </h3>
<div className="xx-form-field">
<label></label>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={title || undefined}
onChange={(val) => setTitle(val || "")}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
{/* AI 自动选择开关 */}
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
onClick={() =>
setTitleSettings((prev) => ({ ...prev, aiAutoSelect: !prev.aiAutoSelect }))
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label></label>
<input
placeholder="输入自定义标题…"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={50}
/>
</div>
{userTitles.length === 0 && (
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 8 }}>
</p>
{!titleSettings.aiAutoSelect && (
<>
{/* 标题内容选择 */}
<div className="xx-form-field">
<label></label>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={titleSettings.title || undefined}
onChange={(val) => setTitleSettings((prev) => ({ ...prev, title: 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) => setTitleSettings((prev) => ({ ...prev, title: 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) =>
setTitleSettings((prev) => ({ ...prev, position: 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) => setTitleSettings((prev) => ({ ...prev, font: 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) =>
setTitleSettings((prev) => ({
...prev,
size: Number(e.target.value),
}))
}
/>
</div>
{/* 预设样式 */}
<div className="xx-form-field">
<label></label>
<div className="xx-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key
return (
<button
key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
setTitleSettings((prev) => ({
...prev,
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
}))
}
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={() => setTitleSettings((prev) => ({ ...prev, bold: !prev.bold }))}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, italic: !prev.italic }))}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, stroke: !prev.stroke }))}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, shadow: !prev.shadow }))}
title="阴影"
>
</button>
</div>
</div>
</div>
</>
)}
</div>
)
@@ -1594,7 +1906,7 @@ const GeneratePage: React.FC = () => {
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
<span className="xx-summary-value">{titleSettings.title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
@@ -1845,7 +2157,9 @@ const GeneratePage: React.FC = () => {
</div>
{/* 字幕预览 */}
<div className="xx-preview-caption">{title || "3秒抓住注意力,30秒讲清卖点"}</div>
<div className="xx-preview-caption">
{titleSettings.title || "3秒抓住注意力,30秒讲清卖点"}
</div>
{/* 时间线标题 */}
<div className="xx-preview-title"></div>
+201
View File
@@ -1136,3 +1136,204 @@
border: 1px solid var(--border-light, #f1f5f9);
border-radius: 20px;
}
/* ============================================================
标题设置(选择标题步骤)
============================================================ */
.xx-title-ai-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
margin-bottom: 18px;
}
.xx-toggle-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.xx-switch {
width: 44px;
height: 24px;
background: var(--border-color);
border-radius: 12px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.xx-switch.active {
background: var(--primary-color);
}
.xx-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.xx-switch.active .xx-switch-knob {
transform: translateX(20px);
}
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.xx-title-preset-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 14px 8px;
background: var(--bg-secondary);
border: 2px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.xx-title-preset-card:hover {
border-color: var(--primary-200);
background: var(--bg-primary);
}
.xx-title-preset-card.active {
border-color: var(--primary-color);
background: var(--primary-50);
}
.xx-title-preset-preview-text {
line-height: 1.4;
margin-bottom: 6px;
user-select: none;
}
.xx-title-preset-card-label {
font-size: 11px;
color: var(--text-secondary);
}
.xx-title-preset-card.active .xx-title-preset-card-label {
color: var(--primary-color);
font-weight: 500;
}
/* 样式按钮组 */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}