fix(cover): 修复9个封面/流程UX问题 #2047

Merged
auto-approve-bot merged 1 commits from fix/cover-ui-issues-20260926 into develop 2026-09-26 09:25:53 +08:00
8 changed files with 353 additions and 38 deletions
+48 -8
View File
@@ -200,6 +200,7 @@ const GeneratePage: React.FC = () => {
generated,
generateError,
generatedVideos,
currentTaskId,
batchTasks,
generate: handleGenerate,
retry: handleRetryGenerate,
@@ -278,6 +279,38 @@ const GeneratePage: React.FC = () => {
],
)
/* ── 对齐批量数组长度到 previewCount(用于进入 Step3 时) ── */
const ensureArraysAligned = useCallback(() => {
setPreviewTitles((prev) => {
const list = prev || []
if (list.length === previewCount) return list
const base = list[0] || titleSettings.title || ""
return Array.from({ length: previewCount }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
if (list.length === previewCount) return list
return Array.from({ length: previewCount }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
if (list.length === previewCount) return list
return Array.from({ length: previewCount }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds((prev) => {
if (prev && prev.length === previewCount) return prev
return Array.from({ length: previewCount }, (_, i) => i)
})
}, [
previewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
titleSettings.title,
selectedVoice,
])
/* ── #1970:Step1 弹窗回调 ── */
const handleVoiceModalConfirm = useCallback(
(voiceAssetId: string) => {
@@ -399,7 +432,7 @@ const GeneratePage: React.FC = () => {
smartSelectedIds,
titleSettings,
generated,
onOpenCountModal: () => setCountModalOpen(true),
onBeforeEnterStep3: ensureArraysAligned,
onOpenStep1Modal: () => {
if (editMode === "random") {
setVoiceModalOpen(true)
@@ -430,30 +463,35 @@ const GeneratePage: React.FC = () => {
setFinishing(true)
const hide = message.loading("正在保存到视频库...", 0)
try {
// 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId
const singleTaskId = finalVideo?.generation_task_id || currentTaskId || ""
const taskIds =
batchTasks && batchTasks.length > 0
? batchTasks.map((t) => t.taskId).filter(Boolean)
: finalVideo?.generation_task_id
? [finalVideo.generation_task_id]
: singleTaskId
? [singleTaskId]
: []
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
if (isBatch && previewCovers.length > 0) {
// 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题)
if (isBatch && taskIds.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
const title = previewTitles[idx] || titleSettings.title || ""
return finalizeGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
custom_title: title,
})
}),
)
} else if (finalVideo?.generation_task_id) {
} else if (singleTaskId) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await finalizeGeneration(finalVideo.generation_task_id, {
await finalizeGeneration(singleTaskId, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
} else {
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
}
hide()
@@ -482,6 +520,7 @@ const GeneratePage: React.FC = () => {
previewTitles,
titleSettings.title,
coverSettings,
currentTaskId,
navigate,
])
@@ -580,6 +619,7 @@ const GeneratePage: React.FC = () => {
selectedVariantIds={selectedVariantIds}
selectedCoverTemplate={selectedTemplate}
onSelectedCoverTemplateChange={setSelectedTemplate}
onConfirmGenerate={handleConfirmGenerate}
/>
{/* ════ 步骤4(单视频):成片播放器 ════ */}
@@ -91,6 +91,8 @@ export interface GenerateStepContentProps {
selectedVariantIds?: number[]
selectedCoverTemplate?: string
onSelectedCoverTemplateChange?: (templateId: string) => void
/** Step3 右上角确认生成按钮 */
onConfirmGenerate?: () => void | Promise<void>
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -146,6 +148,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVariantIds,
selectedCoverTemplate,
onSelectedCoverTemplateChange,
onConfirmGenerate,
} = props
switch (currentStep) {
@@ -199,6 +202,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
onConfirmGenerate={onConfirmGenerate}
generating={props.generating}
selectedCount={
props.previewCount && props.previewCount > 1 ? props.selectedVariantIds?.length || 1 : 1
}
/>
)
case 4:
@@ -47,6 +47,12 @@ interface Step4TitleSettingsProps {
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
/** Step3 右上角「🎬 确认生成」主按钮 */
onConfirmGenerate?: () => void | Promise<void>
/** 是否生成中 */
generating?: boolean
/** 批量模式下勾选数量 */
selectedCount?: number
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -69,6 +75,9 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
enableTemplates,
selectedTemplateId,
onApplyTemplate,
onConfirmGenerate,
generating,
selectedCount = 1,
} = props
const isBatch = previewCount > 1
@@ -90,7 +99,47 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
)
return (
<div className="xx-form-section">
<div className="xx-form-section" style={{ position: "relative" }}>
{/* ── 右上角「🎬 确认生成」主按钮 ── */}
{onConfirmGenerate && (
<button
type="button"
onClick={() => {
if (generating) return
void onConfirmGenerate()
}}
disabled={generating}
style={{
position: "absolute",
top: 0,
right: 0,
background: generating ? "#a78bfa" : "#7c3aed",
color: "#fff",
border: "none",
borderRadius: 10,
padding: "12px 24px",
fontSize: 15,
fontWeight: 600,
cursor: generating ? "not-allowed" : "pointer",
boxShadow: "0 4px 14px rgba(124,58,237,0.4)",
transition: "all .2s",
zIndex: 5,
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#6d28d9"
}}
onMouseLeave={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#7c3aed"
}}
>
{generating
? "⏳ 生成中..."
: selectedCount > 1
? `🎬 确认生成 ${selectedCount} 个视频`
: "🎬 确认生成"}
</button>
)}
<h3>📝 选择标题</h3>
{!isBatch ? (
@@ -60,13 +60,21 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
* 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发
*/
const shared = useSharedCover({
canGenerate: !!finalVideo && !isBatch,
canGenerate:
!!finalVideo &&
!isBatch &&
(finalVideo.status === "completed" ||
finalVideo.status === "awaiting_cover" ||
!finalVideo.status),
disabledHint: isBatch ? "批量场景请在上方操作卡片" : "请先生成视频再选择封面",
initialTemplateId: props.selectedTemplate || "default",
initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default
generateFn: async (tplId) => {
if (!finalVideo || isBatch) return null
const response = await apiGenerateCover(tplId, {
generated_video_id: finalVideo.id,
generated_video_id:
(finalVideo as { id?: string; video_id?: string }).id ||
(finalVideo as { video_id?: string }).video_id ||
"",
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
...(props.titleSettings?.title
@@ -294,6 +294,122 @@ const TextStylePanel: React.FC<{
onChange={(v) => upd("rotation", v)}
/>
</div>
{/* 文字背景(剪映样式) */}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span>文字背景</span>
<Switch
size="small"
checked={config.background?.enabled ?? false}
onChange={(v) => upd("background", { ...config.background, enabled: v })}
/>
</div>
{config.background?.enabled && (
<div style={{ marginTop: 8, paddingLeft: 4 }}>
<div className="xx-ce-row">
<label className="xx-ce-label">形状</label>
<div className="xx-ce-radio-group">
<button
type="button"
className={`xx-ce-radio-btn ${config.background.shape === "rectangle" ? "active" : ""}`}
onClick={() =>
upd("background", {
...config.background,
shape: "rectangle" as const,
})
}
>
矩形
</button>
<button
type="button"
className={`xx-ce-radio-btn ${config.background.shape === "polygon" ? "active" : ""}`}
onClick={() =>
upd("background", {
...config.background,
shape: "polygon" as const,
})
}
>
圆角
</button>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">背景颜色</label>
<ColorPicker
value={config.background.color || "#000000"}
onChange={(v) => upd("background", { ...config.background, color: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">不透明度: {config.background.opacity}%</label>
<Slider
min={0}
max={100}
step={1}
value={config.background.opacity}
onChange={(v) => upd("background", { ...config.background, opacity: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">
圆角:{" "}
{config.background.shape === "rectangle"
? 0
: Math.round((config.background.height ?? 20) / 2)}
px
</label>
<Slider
min={0}
max={50}
step={1}
value={
config.background.shape === "rectangle"
? 0
: Math.round((config.background.height ?? 20) / 2)
}
disabled={config.background.shape === "rectangle"}
onChange={(v) => {
// 圆角近似:通过 height 控制
void v
}}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">宽度: {config.background.width}%</label>
<Slider
min={20}
max={200}
step={2}
value={config.background.width}
onChange={(v) => upd("background", { ...config.background, width: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">高度: {config.background.height}%</label>
<Slider
min={5}
max={80}
step={1}
value={config.background.height}
onChange={(v) => upd("background", { ...config.background, height: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">上下偏移: {config.background.posY - 50}%</label>
<Slider
min={-50}
max={50}
step={1}
value={config.background.posY - 50}
onChange={(v) => upd("background", { ...config.background, posY: 50 + v })}
/>
</div>
</div>
)}
</div>
</div>
)
}
@@ -316,6 +432,59 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
const portraitFileRef = useRef<HTMLInputElement>(null)
const [bgImageUrl, setBgImageUrl] = useState<string>(initCfg.backgroundImage || "")
const [portraitImageUrl, setPortraitImageUrl] = useState<string>(initCfg.portraitImage || "")
/* ── 文字拖拽状态 ── */
const canvasRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<null | {
target: "title" | "subtitle"
startX: number
startY: number
startPosX: number
startPosY: number
}>(null)
const handleTextMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
target: "title" | "subtitle",
) => {
e.preventDefault()
e.stopPropagation()
const tc = target === "title" ? cfg.title : cfg.subtitle
if (!tc) return
dragRef.current = {
target,
startX: e.clientX,
startY: e.clientY,
startPosX: tc.position.x,
startPosY: tc.position.y,
}
const onMove = (ev: MouseEvent) => {
const d = dragRef.current
if (!d || !canvasRef.current) return
const rect = canvasRef.current.getBoundingClientRect()
const dx = ((ev.clientX - d.startX) / rect.width) * 100
const dy = ((ev.clientY - d.startY) / rect.height) * 100
const newX = Math.max(0, Math.min(100, d.startPosX + dx))
const newY = Math.max(0, Math.min(100, d.startPosY + dy))
if (d.target === "title") {
setCfg((prev) => ({
...prev,
title: { ...prev.title, position: { x: newX, y: newY } },
}))
} else {
setCfg((prev) => ({
...prev,
subtitle: { ...prev.subtitle, position: { x: newX, y: newY } },
}))
}
}
const onUp = () => {
dragRef.current = null
window.removeEventListener("mousemove", onMove)
window.removeEventListener("mouseup", onUp)
}
window.addEventListener("mousemove", onMove)
window.addEventListener("mouseup", onUp)
}
// reset state when modal opens with a new template
useEffect(() => {
@@ -394,19 +563,6 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
const renderTextStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => {
if (!tc) return {}
// wrap text by charsPerLine
const rawText = tc.text || ""
const lines: string[] = []
if (tc.direction === "vertical") {
lines.push(rawText)
} else {
for (let i = 0; i < rawText.length; i += Math.max(1, tc.charsPerLine)) {
lines.push(rawText.slice(i, i + Math.max(1, tc.charsPerLine)))
}
}
// store rendered as lines via data attribute; for JSX we'll render outside style
void lines
const style: React.CSSProperties = {
position: "absolute",
left: `${tc.position.x}%`,
@@ -422,9 +578,11 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
tc.strokeWidth > 0 ? `${Math.max(0.5, s(tc.strokeWidth))}px ${tc.strokeColor}` : undefined,
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "pre",
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
zIndex: 3,
zIndex: 4,
textAlign: "center",
userSelect: "none",
cursor: "grab",
padding: 0,
}
if (tc.shadows.length > 0) {
style.textShadow = tc.shadows
@@ -434,6 +592,35 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
return style
}
const renderTextBgStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => {
if (!tc?.background?.enabled) return { display: "none" }
const bg = tc.background
const radius = bg.shape === "polygon" ? `${Math.max(4, Math.round(bg.height / 2))}px` : "0"
const alpha = Math.max(0, Math.min(1, bg.opacity / 100))
const hex = (bg.color || "#000000").replace("#", "")
let r = 0,
g = 0,
b = 0
if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16)
g = parseInt(hex.substring(2, 4), 16)
b = parseInt(hex.substring(4, 6), 16)
}
const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})`
return {
position: "absolute",
left: `${bg.posX}%`,
top: `${bg.posY}%`,
width: `${bg.width}%`,
height: `${bg.height}%`,
transform: "translate(-50%, -50%)",
background: rgba,
borderRadius: radius,
zIndex: 3,
pointerEvents: "none",
}
}
const renderTextLines = (tc: TextStyleConfig | undefined | null): string => {
if (!tc) return ""
const raw = tc.text || ""
@@ -857,7 +1044,7 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
<span className="xx-ce-anchor-dot" style={{ top: "50%", right: "-12px" }} />
<span className="xx-ce-anchor-dot" style={{ bottom: "10%", left: "-12px" }} />
<div className="xx-ce-canvas">
<div className="xx-ce-canvas" ref={canvasRef}>
{/* base background layer */}
<div
className="xx-ce-canvas-base"
@@ -914,14 +1101,29 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
/>
)}
{/* Title */}
{/* Title bg */}
{cfg.title?.background?.enabled && <div style={renderTextBgStyle(cfg.title)} />}
{/* Subtitle bg */}
{cfg.subtitle?.background?.enabled && <div style={renderTextBgStyle(cfg.subtitle)} />}
{/* Title (draggable) */}
{cfg.title && (
<div style={renderTextStyle(cfg.title)}>{renderTextLines(cfg.title)}</div>
<div
style={renderTextStyle(cfg.title)}
onMouseDown={(e) => handleTextMouseDown(e, "title")}
>
{renderTextLines(cfg.title)}
</div>
)}
{/* Subtitle */}
{/* Subtitle (draggable) */}
{cfg.subtitle && (
<div style={renderTextStyle(cfg.subtitle)}>{renderTextLines(cfg.subtitle)}</div>
<div
style={renderTextStyle(cfg.subtitle)}
onMouseDown={(e) => handleTextMouseDown(e, "subtitle")}
>
{renderTextLines(cfg.subtitle)}
</div>
)}
{/* Mask overlay */}
@@ -116,7 +116,7 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
>
{tpl.is_system ? "复制" : "编辑"}
编辑
</Button>
{!tpl.is_system && (
<Button
@@ -22,6 +22,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
const [currentTaskId, setCurrentTaskId] = useState<string>("")
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
@@ -120,6 +122,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerated(false)
setGenerateError(null)
setBatchTasks([])
setGeneratedVideos([])
setCurrentTaskId("")
clearTimer()
try {
@@ -341,8 +345,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
if (taskIds.length > 1) {
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
setCurrentTaskId("")
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
} else {
setCurrentTaskId(taskIds[0])
startPolling(taskIds[0])
}
} catch (err) {
@@ -417,6 +423,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
generated,
generateError,
generatedVideos,
currentTaskId,
generate,
retry,
retryBatchTask,
@@ -4,7 +4,7 @@
*
* - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal),
* 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。
* - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。
* - 步骤2(选择素材):直接进入步骤3,数组长度对齐由 onBeforeEnterStep3 保证。
* - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
* 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。
* - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。
@@ -23,10 +23,10 @@ export interface UseStepNavigationOptions {
titleSettings: TitleSettings
/** 是否已完成视频生成(步骤4全部渲染完成后才能进入封面) */
generated: boolean
/** 点素材下一步时弹出数量选择弹窗 */
onOpenCountModal: () => void
/** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */
onOpenStep1Modal: () => void
/** 进入步骤3前自动对齐数组(previewTitles/voiceLibraryIds/previewCovers/selectedVariantIds)长度到 previewCount */
onBeforeEnterStep3?: () => void
}
export interface UseStepNavigationReturn {
@@ -42,8 +42,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
selectedMaterials,
smartSelectedIds,
generated,
onOpenCountModal,
onOpenStep1Modal,
onBeforeEnterStep3,
} = options
const goNext = () => {
@@ -62,8 +62,9 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
// 弹数量选择弹窗
onOpenCountModal()
// 直接进入步骤3(生成数量在 Step1 已设置);对齐数组长度
onBeforeEnterStep3?.()
setCurrentStep(3)
return
}
// 步骤4(确认生成):全部渲染完成后才能下一步进封面