fix(generate): Step6 封面上传走 OSS + 标题模板恢复内联大卡片网格 #2061
@@ -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
|
||||
Reference in New Issue
Block a user