diff --git a/apps/web/src/api/generation/cover.ts b/apps/web/src/api/generation/cover.ts index 7c1d98bf6..948d4b3df 100644 --- a/apps/web/src/api/generation/cover.ts +++ b/apps/web/src/api/generation/cover.ts @@ -51,12 +51,18 @@ export interface GenerateCoverResponse { /** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */ export async function generateCover( - templateId: string, + templateId: string | undefined | null, data: GenerateCoverRequest, ): Promise { + // templateId 为空时不传该参数,让后端使用默认模板配置 + // (前端此前用 "default" 作为占位符,该 id 不存在于后端模板库会 404) + const params: Record = {} + if (templateId && templateId !== "default") { + params.template_id = templateId + } const response = await apiClient.post("/generation/generate-cover", data, { timeout: 300000, - params: { template_id: templateId }, + params, }) return response.data } diff --git a/apps/web/src/components/cover/useSharedCover.ts b/apps/web/src/components/cover/useSharedCover.ts index 4db30d162..574c5bddd 100644 --- a/apps/web/src/components/cover/useSharedCover.ts +++ b/apps/web/src/components/cover/useSharedCover.ts @@ -85,6 +85,13 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur config: t.config, })) setTemplates(list) + // 若当前选中 "default"(初始占位),自动解析为第一个系统模板的真实 id + // ("default" 不是后端真实模板 id,传过去会 404) + setSelectedTemplateId((prev) => { + if (prev !== "default") return prev + const firstSys = list.find((t) => t.is_system) + return firstSys?.id || list[0]?.id || "default" + }) } catch (err) { const axiosErr = err as { response?: { @@ -112,6 +119,11 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur } }, []) + useEffect(() => { + // 挂载时拉一次模板列表,用于把 "default" 占位符解析成真实模板 id + void reloadTemplates() + }, [reloadTemplates]) + useEffect(() => { if (showCoverSettings) { void reloadTemplates() @@ -193,7 +205,12 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur await deleteCoverTemplate(id) setTemplates((prev) => prev.filter((t) => t.id !== id)) if (selectedTemplateId === id) { - setSelectedTemplateId("default") + // 删除后选中第一个系统模板作为兜底,避免 magic string "default" 传后端 404 + setTemplates((prevAfter) => { + const firstSys = prevAfter.find((t) => t.is_system) + setSelectedTemplateId(firstSys?.id || prevAfter[0]?.id || "") + return prevAfter + }) } } catch (err) { const axiosErr = err as { @@ -231,7 +248,8 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur } setGenerating(true) try { - const url = await generateFn(selectedTemplateId || "default") + const tplId = selectedTemplateId && selectedTemplateId !== "default" ? selectedTemplateId : "" + const url = await generateFn(tplId) if (!url) { message.warning("封面生成未返回图片,请重试") } @@ -266,7 +284,7 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur const selectedTemplateName = templates.find((t) => t.id === selectedTemplateId)?.name || - (selectedTemplateId === "default" ? "默认模板" : "自定义") + (selectedTemplateId === "default" || !selectedTemplateId ? "默认模板" : "自定义") return { templates, diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index a94c0275e..2a24287ca 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -186,7 +186,7 @@ const Step6CoverSettings: React.FC = (props) => { * 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。 */ const batchCovers = useBatchCovers({ - selectedTemplate: shared.selectedTemplateId || "default", + selectedTemplate: shared.selectedTemplateId, generatedVideos: props.generatedVideos, titles: batchTitles, titleStyle: { diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx index 37923b6be..786a5ed5b 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useMemo, useState } from "react" import type { CoverTemplate } from "../../types/cover" import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" @@ -17,15 +17,58 @@ interface CoverSettingsModalProps { onCreateNew: () => void } -const GRADIENT_MAP: Record = { - default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)", - "bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)", - "elegant-black": "linear-gradient(135deg, #374151, #111827)", - "gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)", - "gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)", - "warm-orange": "linear-gradient(135deg, #f97316, #ea580c)", - "fresh-green": "linear-gradient(135deg, #22c55e, #15803d)", - "tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)", +/** 模板缩略图:优先渲染 thumbnail_url;加载失败/无图时展示占位 */ +const TemplateThumb: React.FC<{ tpl: CoverTemplate; isSelected: boolean }> = ({ + tpl, + isSelected, +}) => { + const [errored, setErrored] = useState(false) + const url = tpl.thumbnail_url && !errored ? tpl.thumbnail_url : "" + // 随机柔和渐变做占位,保证卡片不会灰成一片 + const placeholderBg = useMemo(() => { + const palettes = [ + ["#e0e0e0", "#c0c0c0"], + ["#ef4444", "#b91c1c"], + ["#374151", "#111827"], + ["#3b82f6", "#1d4ed8"], + ["#8b5cf6", "#6d28d9"], + ["#f97316", "#ea580c"], + ["#22c55e", "#15803d"], + ["#06b6d4", "#0e7490"], + ] + let h = 0 + for (const ch of tpl.id || tpl.name || "") h = (h * 31 + ch.charCodeAt(0)) >>> 0 + const [a, b] = palettes[h % palettes.length] + return `linear-gradient(135deg, ${a}, ${b})` + }, [tpl.id, tpl.name]) + + return ( +
+ {isSelected && ✓} + {url ? ( + {tpl.name} setErrored(true)} + style={{ + width: "100%", + height: "100%", + objectFit: "cover", + display: "block", + }} + /> + ) : ( + 🖼️ + )} +
+ ) } const CoverSettingsModal: React.FC = ({ @@ -97,13 +140,7 @@ const CoverSettingsModal: React.FC = ({ className={`xx-cover-template-card${isSelected ? " selected" : ""}`} onClick={() => onSelectTemplate(tpl.id)} > -
- {isSelected && ✓} - 🖼️ -
+
{tpl.name} diff --git a/apps/web/src/pages/generate/hooks/useBatchCovers.ts b/apps/web/src/pages/generate/hooks/useBatchCovers.ts index 859c893c0..80d607a01 100644 --- a/apps/web/src/pages/generate/hooks/useBatchCovers.ts +++ b/apps/web/src/pages/generate/hooks/useBatchCovers.ts @@ -107,54 +107,57 @@ export function useBatchCovers({ addBusy(index) try { const titleText = titles[index] || "" - const response = await generateCover(selectedTemplate || "default", { - generated_video_id: target.id, - video_url: target.file_url || target.download_url || "", - cover_type: "ai_frame", - ...(titleText - ? { - title_config: { - text: titleText, - font: titleStyle.font, - font_size: titleStyle.size, - font_color: titleStyle.color, - position: titleStyle.position, - bold: titleStyle.bold, - italic: titleStyle.italic, - stroke: titleStyle.stroke - ? { - enabled: true, - width: titleStyle.strokeWidth ?? 4, - color: titleStyle.strokeColor ?? "#000000", - } - : { enabled: false }, - shadow: titleStyle.shadow - ? { - enabled: true, - offset_x: titleStyle.shadowOffsetX ?? 2, - offset_y: titleStyle.shadowOffsetY ?? 2, - blur: titleStyle.shadowBlur ?? 4, - color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)", - } - : { enabled: false }, - line_height: titleStyle.lineHeight ?? 1.2, - margin_top: titleStyle.marginTop ?? 24, - max_chars_per_line: titleStyle.maxCharsPerLine ?? 0, - background: titleStyle.bgEnabled - ? { - enabled: true, - color: titleStyle.bgColor, - padding: titleStyle.bgPadding, - radius: titleStyle.bgRadius, - } - : { enabled: false }, - line_overrides: (titleStyle.lineOverrides ?? []) as Array< - Record - >, - }, - } - : {}), - }) + const response = await generateCover( + selectedTemplate && selectedTemplate !== "default" ? selectedTemplate : undefined, + { + generated_video_id: target.id, + video_url: target.file_url || target.download_url || "", + cover_type: "ai_frame", + ...(titleText + ? { + title_config: { + text: titleText, + font: titleStyle.font, + font_size: titleStyle.size, + font_color: titleStyle.color, + position: titleStyle.position, + bold: titleStyle.bold, + italic: titleStyle.italic, + stroke: titleStyle.stroke + ? { + enabled: true, + width: titleStyle.strokeWidth ?? 4, + color: titleStyle.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: titleStyle.shadow + ? { + enabled: true, + offset_x: titleStyle.shadowOffsetX ?? 2, + offset_y: titleStyle.shadowOffsetY ?? 2, + blur: titleStyle.shadowBlur ?? 4, + color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + line_height: titleStyle.lineHeight ?? 1.2, + margin_top: titleStyle.marginTop ?? 24, + max_chars_per_line: titleStyle.maxCharsPerLine ?? 0, + background: titleStyle.bgEnabled + ? { + enabled: true, + color: titleStyle.bgColor, + padding: titleStyle.bgPadding, + radius: titleStyle.bgRadius, + } + : { enabled: false }, + line_overrides: (titleStyle.lineOverrides ?? []) as Array< + Record + >, + }, + } + : {}), + }, + ) const url = response.cover?.image_url || response.cover?.thumbnail_url || "" if (url) { patchCover(index, url)