fix(generate): Step6 本地上传封面走 OSS + 标题样式恢复 Modal 弹窗模式
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m5s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m54s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m18s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 30s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s

- Step6CoverSettings: 单视频本地上传封面时,使用 uploadAssetDirect 实际上传到图片素材库 OSS,拿到真实 URL 再 set;上传期间按钮 loading;原 useEffect 把 setOnUploadFile 置为 null 改为注册 uploadLocalCover 处理函数
- GeneratePage.handleFinish: 增加 blob: URL 兜底过滤(blob: URL 是浏览器本地临时地址,后端无法下载,此时不传 cover_url 让后端回退自动截帧封面,避免 400 保存失败);custom_title 空串不传 undefined
- 新增 TitleStyleModal 组件:标题样式改为弹窗模式(参考封面模板/字幕模板 Modal),内置「爆款预设」+「自定义」两个 Tab,底部取消/保存按钮,保存才提交草稿到父组件
- Step4TitleSettings: 移除内联 TitleStylePanel,改为「标题样式」按钮 + Modal 触发入口

Closes: Step6「完成」按钮保存失败 + 标题设置内联铺参数回归
This commit is contained in:
xiaoxia
2026-09-27 11:12:54 +08:00
parent c7288cd46b
commit 41a0724184
5 changed files with 381 additions and 53 deletions
+9 -4
View File
@@ -441,19 +441,24 @@ const GeneratePage: React.FC = () => {
await Promise.all(
batchTasks.map(async (task) => {
const vi = task.variantIndex
const coverUrl = previewCovers[vi] || ""
const rawCoverUrl = previewCovers[vi] || ""
const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl
const title = previewTitles[vi] || titleSettings.title || ""
return finalizeGeneration(task.taskId, {
cover_url: coverUrl || undefined,
custom_title: title,
custom_title: title || undefined,
})
}),
)
} else if (singleTaskId) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
// 单视频:cover_url 仅在非 blob: 本地预览地址时才传;blob: URL 浏览器本地临时地址,
// 后端无法下载,此时不传让后端回退自动截帧封面(避免 400 保存失败)。
// 正常流程本地上传完成后 uploadLocalCover 会把 URL 替换为 OSS 真实 URL,这里仅兜底异常场景。
const rawCoverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl
await finalizeGeneration(singleTaskId, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
custom_title: titleSettings.title || undefined,
})
} else {
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
@@ -6,13 +6,12 @@
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React, { useMemo } from "react"
import React, { useMemo, useState } from "react"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS } from "../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import { useStep4Title } from "../hooks/useStep4Title"
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
import TitleStylePanel from "./title/TitleStylePanel"
import TitleStyleModal from "./title/TitleStyleModal"
import Button from "@/components/ui/Button"
import type { TitleTemplate } from "@/components/title/template-types"
interface Step4TitleSettingsProps {
@@ -58,13 +57,6 @@ interface Step4TitleSettingsProps {
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
const t = useStep4Title(props)
const {
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
@@ -72,14 +64,12 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
previewCount = 1,
previewTitles,
onPreviewTitlesChange,
enableTemplates,
selectedTemplateId,
onApplyTemplate,
onConfirmGenerate,
generating,
selectedCount = 1,
} = props
const [showTitleStyleModal, setShowTitleStyleModal] = useState(false)
const isBatch = previewCount > 1
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
@@ -184,27 +174,30 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
</div>
)}
{/* 标题样式面板(全局共用) */}
<TitleStylePanel
{/* 标题样式:点击打开弹窗(参考字幕模板/封面模板 Modal 模式) */}
<TitleStyleModal
open={showTitleStyleModal}
onClose={() => setShowTitleStyleModal(false)}
titleText={previewTitles?.[0] ?? t.titleSettings.title}
settings={t.titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
showCoverToggle
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
onSave={(nextSettings, nextPreset) => {
onUpdateStyle?.(nextSettings)
if (nextPreset) onApplyPreset(nextPreset)
}}
/>
<div className="xx-form-field">
<label>标题样式</label>
<Button
buttonType="ghost"
onClick={() => setShowTitleStyleModal(true)}
style={{ justifyContent: "flex-start", textAlign: "left" }}
>
{activePreset
? titlePresets.find((p) => p.key === activePreset)?.label || "自定义样式"
: "点击选择标题样式(预设 / 字体 / 颜色 / 描边 / 阴影)"}
</Button>
</div>
</div>
)
}
@@ -5,8 +5,8 @@
*
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
*/
import React, { useEffect, useMemo } from "react"
import { Modal, Spin } from "antd"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Modal, Spin, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
@@ -17,6 +17,7 @@ import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateCover as apiGenerateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
@@ -159,9 +160,65 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}
}, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange])
/** 单视频本地上传封面:选完文件后上传到素材库 OSS,拿到真实 URL 再 set */
const [uploadingLocalCover, setUploadingLocalCover] = useState(false)
const { coverSettings: curCoverSettings, onCoverSettingsChange } = props
const uploadLocalCover = useCallback(
async (file: File): Promise<string | null> => {
const hide = message.loading("正在上传封面...", 0)
setUploadingLocalCover(true)
try {
// 立即创建 blob URL 用于即时预览,同时异步上传 OSS
const previewUrl = URL.createObjectURL(file)
onCoverSettingsChange({
...curCoverSettings,
upload_url: previewUrl,
thumbnail_url: previewUrl,
mode: "upload",
})
// 查找图片素材库(复用批量封面的逻辑)
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
hide()
message.error("未找到素材库,请先创建图片素材库")
return previewUrl
}
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
const realUrl = result?.url || ""
if (!realUrl) {
hide()
message.warning("上传完成但未获取到URL,将使用本地预览")
return previewUrl
}
hide()
// 替换 blob URL 为真实 OSS URL(blob 用于预览过渡,finalize 时必须用真实 URL)
onCoverSettingsChange({
...curCoverSettings,
upload_url: realUrl,
thumbnail_url: realUrl,
mode: "upload",
})
message.success("封面上传成功")
return realUrl
} catch (err) {
hide()
console.error("[Step6] 封面上传失败:", err)
message.error("封面上传失败,请重试")
return null
} finally {
setUploadingLocalCover(false)
}
},
[curCoverSettings, onCoverSettingsChange],
)
useEffect(() => {
shared.setOnUploadFile(() => null)
}, [shared])
// 单视频:注册实际上传函数;批量场景已由 batchCovers.uploadOne 接管,
// 这里不要覆盖(批量时 input ref 绑定到 batchUploadRef,不走 shared.handleFileInputChange)
if (!isBatch) {
shared.setOnUploadFile((file) => uploadLocalCover(file))
}
}, [shared, isBatch, uploadLocalCover])
const batchUploadRef = React.useRef<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
@@ -399,7 +456,12 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
</Button>
<Button buttonType="ghost" onClick={shared.handleUploadClick}>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={uploadingLocalCover}
loading={uploadingLocalCover}
>
📷 本地上传
</Button>
<input
@@ -407,18 +469,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
const url = URL.createObjectURL(file)
props.onCoverSettingsChange({
...props.coverSettings,
upload_url: url,
thumbnail_url: url,
mode: "upload",
})
}}
onChange={shared.handleFileInputChange}
/>
<input
ref={batchUploadRef}
@@ -0,0 +1,90 @@
.tsm-body {
padding: 0;
}
.tsm-preview {
width: 100%;
background: #111;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
display: flex;
justify-content: center;
overflow: hidden;
}
.tsm-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
max-height: 440px;
overflow-y: auto;
padding: 4px 2px 8px;
}
.tsm-preset-card {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
border: 2px solid #e5e7eb;
border-radius: 10px;
padding: 8px 6px 6px;
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
}
.tsm-preset-card:hover {
border-color: #c4b5fd;
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(124, 58, 237, 0.12);
}
.tsm-preset-card.active {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18);
}
.tsm-preset-preview {
width: 100%;
aspect-ratio: 9 / 16;
background: #1a1a1a;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.tsm-preset-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
font-size: 12px;
color: #374151;
}
.tsm-preset-emoji {
font-size: 14px;
}
.tsm-preset-label {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
.tsm-custom-panel {
max-height: 440px;
overflow-y: auto;
padding: 4px 2px 8px;
}
.tsm-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid #f3f4f6;
}
@@ -0,0 +1,189 @@
/**
* 标题样式弹窗(Step3/智能剪辑用)
*
* 交互:
* - 点击"标题样式"按钮/卡片打开
* - 默认 Tab「预设」:10 个爆款预设卡片网格(Canvas 实时预览),点击选中
* - Tab「自定义」:详细参数(位置/字体/字号/粗斜/描边/阴影/颜色/背景)
* - 底部「取消」「保存」按钮:保存才会把临时编辑的 settings 应用到父组件
*
* 设计参考:CoverSettingsModal / 字幕模板选择弹窗
*/
import React, { useEffect, useState } from "react"
import { Tabs } from "antd"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import type { TitleSettings } from "../../types"
import { POSITION_OPTIONS } from "../../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import TitleStylePanel from "./TitleStylePanel"
import TitleMiniPreview from "./TitleMiniPreview"
import { titleStyleConfigToCamel } from "@/components/title/utils"
import { TITLE_PRESETS as TITLE_PRESETS_DATA } from "@/components/title/constants"
import "./TitleStyleModal.css"
interface TitleStyleModalProps {
open: boolean
onClose: () => void
/** 当前标题文字(用于预览) */
titleText?: string
/** 当前生效的标题样式 */
settings: TitleSettings
/** 保存回调:把编辑后的 settings 回传父组件 */
onSave: (settings: TitleSettings, presetKey: string | null) => void
/** 当前选中的爆款预设 key(用于高亮) */
activePreset?: string | null
}
/** 把预设应用到 base settings(参考 useTitleStyleUpdaters.applyPreset 逻辑) */
function applyPresetToSettings(base: TitleSettings, presetKey: string): TitleSettings {
const preset = TITLE_PRESETS_DATA.find((p) => p.key === presetKey)
if (!preset) return base
return {
...base,
...(titleStyleConfigToCamel(preset.style) as Partial<TitleSettings>),
lineOverrides: [],
}
}
const TitleStyleModal: React.FC<TitleStyleModalProps> = ({
open,
onClose,
titleText = "标题",
settings,
onSave,
activePreset: externalActivePreset = null,
}) => {
// 弹窗内维护一份草稿 settings,点保存才提交到父组件;取消/关闭丢弃
const [draft, setDraft] = useState<TitleSettings>(settings)
const [activePreset, setActivePreset] = useState<string | null>(externalActivePreset)
// 每次打开弹窗,用父组件最新 settings 重置草稿
useEffect(() => {
if (open) {
setDraft(settings)
setActivePreset(externalActivePreset)
}
}, [open, settings, externalActivePreset])
const handleApplyPreset = (presetKey: string) => {
setActivePreset(presetKey)
const next = applyPresetToSettings(draft, presetKey)
setDraft(next)
}
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
setDraft((prev) => ({ ...prev, ...patch }))
// 手动调了参数,清除预设高亮(因为不再严格匹配某个预设)
setActivePreset(null)
}
const handleUpdatePosition = (position: string) =>
handleUpdateStyle({ position: position as TitleSettings["position"] })
const handleUpdateFont = (font: string) => handleUpdateStyle({ font })
const handleUpdateSize = (size: number) => handleUpdateStyle({ size })
const handleToggleBold = () => handleUpdateStyle({ bold: !draft.bold })
const handleToggleItalic = () => handleUpdateStyle({ italic: !draft.italic })
const handleToggleStroke = () => handleUpdateStyle({ stroke: !draft.stroke })
const handleToggleShadow = () => handleUpdateStyle({ shadow: !draft.shadow })
const handleSave = () => {
onSave(draft, activePreset)
onClose()
}
return (
<Modal open={open} onCancel={onClose} title="标题样式" width={720} footer={null} destroyOnClose>
<div className="tsm-body">
{/* 顶部预览 */}
<div className="tsm-preview">
<TitleMiniPreview
settings={draft}
width={640}
sampleText={titleText || "标题"}
transparent={false}
portrait
/>
</div>
<Tabs
defaultActiveKey="presets"
size="small"
items={[
{
key: "presets",
label: "爆款预设",
children: (
<div className="tsm-presets-grid">
{TITLE_PRESETS_DATA.map((p) => {
const previewSettings = applyPresetToSettings(draft, p.key)
const isActive = activePreset === p.key
return (
<button
key={p.key}
type="button"
className={`tsm-preset-card${isActive ? " active" : ""}`}
onClick={() => handleApplyPreset(p.key)}
title={p.label}
>
<div className="tsm-preset-preview">
<TitleMiniPreview
settings={previewSettings}
width={140}
sampleText="标题"
/>
</div>
<div className="tsm-preset-meta">
<span className="tsm-preset-emoji">{p.emoji}</span>
<span className="tsm-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
),
},
{
key: "custom",
label: "自定义",
children: (
<div className="tsm-custom-panel">
<TitleStylePanel
settings={draft}
onUpdatePosition={handleUpdatePosition}
onUpdateFont={handleUpdateFont}
onUpdateSize={handleUpdateSize}
onToggleBold={handleToggleBold}
onToggleItalic={handleToggleItalic}
onToggleStroke={handleToggleStroke}
onToggleShadow={handleToggleShadow}
onApplyPreset={handleApplyPreset}
onUpdateStyle={handleUpdateStyle}
activePreset={activePreset}
titlePresets={[]}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
paramsOnly
hideEditor
/>
</div>
),
},
]}
/>
</div>
{/* 底部按钮栏 */}
<div className="tsm-footer">
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={handleSave}>
保存
</Button>
</div>
</Modal>
)
}
export default TitleStyleModal