From f741a0e76e2323cd2f6518e03ca427d49377fd2b Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Sun, 27 Sep 2026 21:35:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor(shared):=20=E6=8A=BD=E5=8F=96=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E9=80=89=E6=8B=A9=E4=B8=BA=E5=85=AC=E5=85=B1=E7=BB=84?= =?UTF-8?q?=E4=BB=B6CoverSelector=EF=BC=8C=E6=99=BA=E8=83=BD=E5=89=AA?= =?UTF-8?q?=E8=BE=91/AI=E6=95=B0=E5=AD=97=E4=BA=BA=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增公共目录 src/components/cover/(在原 useSharedCover/cover.css 基础上补全): - CoverSelector.tsx:公共主组件,支持inline/modal两种模式、props(showAutoGenerate/showTemplate/showUpload/canGenerate/aspectRatio/hint/onChange/onAutoGenerate/onTemplateChange等),自管理loading/上传状态 - CoverSettingsModal.tsx:封面模板选择弹窗(卡片网格+系统标签+编辑/删除) - CoverEditorModal.tsx:封面模板自定义编辑器(7面板:基础/人像/背景/主副标题/蒙版) - CoverModeSelector/FrameCoverPicker/UploadCoverPicker:子组件 - types.ts:CoverTemplate/CoverEditorConfig/TextStyleConfig/ALL_FONTS等公共类型+默认值(从pages/generate/types/cover迁移) - uploadCover.ts:OSS直传公共工具(uploadCoverToOSS/uploadCoverWithPreview),blob预览→OSS上传两阶段 - cover.css:新增cs-*前缀样式,保证公共组件自带样式不依赖页面CSS - index.ts:barrel统一导出 - 智能剪辑 Step6CoverSettings: - 单视频场景改为使用公共CoverSelector(inline模式),自动生成/模板/上传/预览统一 - 批量场景保留useBatchCovers+卡片网格逻辑,模板选择通过嵌入一个只开showTemplate的CoverSelector实现 - props解构消除hooks exhaustive-deps警告 - AI数字人 ModalCoverSelect: - 由原来自行拼接布局+内联按钮+弹窗改为使用公共CoverSelector(modal模式) - 保持AiAvatarCoverConfig回调签名不变 - 旧路径(pages/generate/components/cover-settings/*、pages/generate/types/cover.ts)改为re-export wrapper,保持向后兼容 - api/cover-templates.ts 和 useSharedCover.ts 改为从公共层import类型,消除pages反向依赖 - tsc/eslint/prettier/vite build全通过,CoverSelector独立打包为72KB懒加载chunk --- apps/web/src/api/cover-templates.ts | 2 +- .../src/components/cover/CoverEditorModal.tsx | 1166 ++++++++++++++++ .../components/cover/CoverModeSelector.tsx | 32 + .../src/components/cover/CoverSelector.tsx | 315 +++++ .../components/cover/CoverSettingsModal.tsx | 195 +++ .../src/components/cover/FrameCoverPicker.tsx | 58 + .../components/cover/UploadCoverPicker.tsx | 46 + apps/web/src/components/cover/cover.css | 87 ++ apps/web/src/components/cover/index.ts | 15 + apps/web/src/components/cover/types.ts | 318 +++++ apps/web/src/components/cover/uploadCover.ts | 62 + .../src/components/cover/useSharedCover.ts | 2 +- .../ai-avatar/components/ModalCoverSelect.tsx | 335 +---- .../components/Step6CoverSettings.tsx | 460 +++---- .../cover-settings/CoverEditorModal.tsx | 1168 +---------------- .../cover-settings/CoverModeSelector.tsx | 34 +- .../cover-settings/CoverSettingsModal.tsx | 197 +-- .../cover-settings/FrameCoverPicker.tsx | 60 +- .../cover-settings/UploadCoverPicker.tsx | 48 +- apps/web/src/pages/generate/types/cover.ts | 307 +---- 20 files changed, 2528 insertions(+), 2379 deletions(-) create mode 100644 apps/web/src/components/cover/CoverEditorModal.tsx create mode 100755 apps/web/src/components/cover/CoverModeSelector.tsx create mode 100644 apps/web/src/components/cover/CoverSelector.tsx create mode 100644 apps/web/src/components/cover/CoverSettingsModal.tsx create mode 100755 apps/web/src/components/cover/FrameCoverPicker.tsx create mode 100755 apps/web/src/components/cover/UploadCoverPicker.tsx create mode 100644 apps/web/src/components/cover/types.ts create mode 100644 apps/web/src/components/cover/uploadCover.ts diff --git a/apps/web/src/api/cover-templates.ts b/apps/web/src/api/cover-templates.ts index dd34da733..41929291b 100644 --- a/apps/web/src/api/cover-templates.ts +++ b/apps/web/src/api/cover-templates.ts @@ -3,7 +3,7 @@ * 后端路由: /api/v1/cover-templates */ import apiClient from "./client" -import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover" +import type { CoverTemplate, CoverEditorConfig } from "@/components/cover/types" export interface CoverTemplateListResponse { items: CoverTemplate[] diff --git a/apps/web/src/components/cover/CoverEditorModal.tsx b/apps/web/src/components/cover/CoverEditorModal.tsx new file mode 100644 index 000000000..bb8bdad48 --- /dev/null +++ b/apps/web/src/components/cover/CoverEditorModal.tsx @@ -0,0 +1,1166 @@ +import React, { useState, useCallback, useRef, useEffect } from "react" +import { Slider, Switch, Select, InputNumber, Input } from "antd" +import type { + CoverTemplate, + CoverEditorConfig, + TextStyleConfig, + TextDirection, + StrokeStyle, +} from "./types" +import { DEFAULT_EDITOR_CONFIG, ALL_FONTS } from "./types" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" +import "@/components/cover/cover.css" + +/** 安全合并模板 config 与默认配置(深拷贝 title/subtitle/shadows/background 等嵌套对象,避免共享引用 & 字段缺失) */ +const mergeEditorConfig = (partial?: Partial | null): CoverEditorConfig => { + const def = DEFAULT_EDITOR_CONFIG + const src = partial || {} + /** 兼容老模板:老版本 background 有 posX/posY/rotation,新版改为 offsetY(相对文字位置偏移)。 + * 老模板黑底默认 posY 通常是 50(与文字对齐)或 80(副标题偏下),统一归一为 offsetY=0, + * 因为新版背景位置已自动跟随文字位置,offsetY 仅做相对微调。 */ + const normalizeBg = (bg: Record | undefined) => { + if (!bg) return {} + // 兼容老模板字段:posX/posY/rotation 在新版中已改为 offsetY(背景位置自动跟随文字) + const normalized = { ...bg } + delete (normalized as Record).posX + delete (normalized as Record).posY + delete (normalized as Record).rotation + if (normalized.offsetY == null) normalized.offsetY = 0 + return normalized + } + const mergeText = ( + base: TextStyleConfig, + patch?: Partial | null, + ): TextStyleConfig => ({ + ...base, + ...(patch || {}), + position: { ...base.position, ...(patch?.position || {}) }, + background: { + ...base.background, + ...normalizeBg(patch?.background as Record | undefined), + }, + shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows], + }) + return { + ...def, + ...src, + title: mergeText(def.title, src.title), + subtitle: mergeText(def.subtitle, src.subtitle), + portraitPosition: { ...def.portraitPosition, ...(src.portraitPosition || {}) }, + backgroundPosition: { ...def.backgroundPosition, ...(src.backgroundPosition || {}) }, + maskPosition: { ...def.maskPosition, ...(src.maskPosition || {}) }, + } +} + +/* ── Props ── */ +interface CoverEditorModalProps { + open: boolean + onClose: () => void + template: CoverTemplate | null + onSave: (template: CoverTemplate) => void +} + +/* ── Section expand/collapse keys ── */ +type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask" + +/* ── Color picker sub-component ── */ +const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({ + value, + onChange, +}) => ( +
+ onChange(e.target.value)} /> + onChange(e.target.value)} + maxLength={7} + className="xx-ce-color-hex" + /> +
+) + +/* ── Position pair sub-component ── */ +const PositionPair: React.FC<{ + x: number + y: number + onChange: (pos: { x: number; y: number }) => void +}> = ({ x, y, onChange }) => ( +
+ onChange({ x: v ?? 0, y })} + style={{ width: "100%" }} + /> + onChange({ x, y: v ?? 0 })} + style={{ width: "100%" }} + /> +
+) + +/* ── Font Select options(合并预设+系统,用圆点颜色区分 tag) ── */ +const fontDotClass = (tag?: string) => { + if (tag === "preset") return "xx-ce-font-dot xx-ce-font-dot--preset" + if (tag === "hand") return "xx-ce-font-dot xx-ce-font-dot--hand" + if (tag === "serif") return "xx-ce-font-dot xx-ce-font-dot--serif" + if (tag === "mono") return "xx-ce-font-dot xx-ce-font-dot--mono" + return "xx-ce-font-dot xx-ce-font-dot--system" +} +const fontOptions = ALL_FONTS.map((f) => ({ + label: ( + + + {f.name} + + ), + value: f.name, +})) + +/* ── Find font family string from name ── */ +const getFontFamily = (name: string): string => { + const found = ALL_FONTS.find((f) => f.name === name) + return found ? found.family : "sans-serif" +} + +/* ── Text style panel (shared between title & subtitle) ── */ +const TextStylePanel: React.FC<{ + config: TextStyleConfig | undefined | null + onChange: (c: TextStyleConfig) => void + placeholder: string +}> = ({ config, onChange, placeholder }) => { + if (!config) return null + const upd = (key: K, val: TextStyleConfig[K]) => + onChange({ ...config, [key]: val }) + return ( +
+ {/* 文字内容 - 改为可输入 */} +
+ + upd("text", e.target.value)} + placeholder={placeholder} + autoSize={{ minRows: 1, maxRows: 3 }} + size="small" + maxLength={50} + showCount + /> +
+ + {/* 字体 */} +
+ + setName(e.target.value)} + placeholder="输入模板名称(如:我的爆款封面)" + /> +
+ + +
+
+ + {/* ── Layout ── */} +
+ {/* ── Left Panel ── */} +
+ {/* 1. 基础设置 */} +
+
toggle("basic")}> + 🔧 基础设置 + {sections.basic ? "▾" : "▸"} +
+ {sections.basic && ( +
+
+
+ 文字自动换行 + upd("autoSplitEnabled", v)} + /> +
+ {cfg.autoSplitEnabled && ( + <> +
+ + upd("titleMaxChars", v)} + /> +
+
+ + upd("subtitleMaxChars", v)} + /> +
+ + )} +
+ +
+
+ 背景模糊 + upd("blurEnabled", v)} + /> +
+ {cfg.blurEnabled && ( +
+ + upd("blurAmount", v)} + /> +
+ )} +
+
+ )} +
+ + {/* 2. 背景 */} +
+
toggle("background")}> + 🎨 背景设置 +
e.stopPropagation()}> + upd("backgroundEnabled", v)} + /> +
+
+ {sections.background && cfg.backgroundEnabled && ( +
+
+ +
+ + + {bgImageUrl && ( + + )} + + handleFile(e.target.files?.[0], setBgImageUrl, "backgroundImage") + } + /> +
+
+
+ + upd("backgroundColor", v)} + /> +
+
+ + upd("backgroundSize", v)} + /> +
+
+ + upd("backgroundPosition", pos)} + /> +
+
+ )} +
+ + {/* 3. 主标题 */} +
+
toggle("title")}> + 🔠 主标题 + {sections.title ? "▾" : "▸"} +
+ {sections.title && ( +
+ +
+ )} +
+ + {/* 4. 副标题 */} +
+
toggle("subtitle")}> + 🔡 副标题 + {sections.subtitle ? "▾" : "▸"} +
+ {sections.subtitle && ( +
+ +
+ )} +
+ + {/* 5. 人像 */} +
+
toggle("portrait")}> + 👤 人像设置 +
e.stopPropagation()}> + upd("portraitEnabled", v)} + /> +
+
+ {sections.portrait && cfg.portraitEnabled && ( +
+
+ +
+ + + {portraitImageUrl && ( + + )} + + handleFile(e.target.files?.[0], setPortraitImageUrl, "portraitImage") + } + /> +
+
+
+ + upd("portraitSize", v)} + /> +
+
+ + upd("portraitPosition", pos)} + /> +
+
+
+ 人物描边 + upd("personStrokeEnabled", v)} + /> +
+ {cfg.personStrokeEnabled && ( + <> +
+
+ {(["solid", "dashed"] as StrokeStyle[]).map((st) => ( + + ))} +
+
+
+ + upd("personStrokeColor", v)} + /> +
+
+ + upd("personStrokeWidth", v)} + /> +
+ + )} +
+
+ )} +
+ + {/* 6. 蒙版 */} +
+
toggle("mask")}> + 🌓 蒙版 +
e.stopPropagation()}> + upd("maskEnabled", v)} + /> +
+
+ {sections.mask && cfg.maskEnabled && ( +
+
+ +
+ + + { + const f = e.target.files?.[0] + if (f) upd("maskImage", f.name) + }} + /> +
+
+
+ + upd("maskColor", v)} /> +
+
+ + upd("maskOpacity", v)} + /> +
+
+ + upd("maskSize", v)} + /> +
+
+ )} +
+
+ + {/* ── Right Preview ── */} +
+
+ + + + +
+ {/* base background layer */} +
+ + {/* Background decoration */} + {cfg.backgroundEnabled && !bgImageUrl && ( +
+ )} + + {/* Portrait */} + {cfg.portraitEnabled && ( +
+ )} + + {/* Title bg */} + {cfg.title?.background?.enabled &&
} + {/* Subtitle bg */} + {cfg.subtitle?.background?.enabled &&
} + + {/* Title (draggable) */} + {cfg.title && ( +
handleTextMouseDown(e, "title")} + > + {renderTextLines(cfg.title)} +
+ )} + + {/* Subtitle (draggable) */} + {cfg.subtitle && ( +
handleTextMouseDown(e, "subtitle")} + > + {renderTextLines(cfg.subtitle)} +
+ )} + + {/* Mask overlay */} + {cfg.maskEnabled && ( +
+ )} +
+
+ +
👁️ 实时预览(9:16 竖版)
+
+
+ + ) +} + +export default CoverEditorModal diff --git a/apps/web/src/components/cover/CoverModeSelector.tsx b/apps/web/src/components/cover/CoverModeSelector.tsx new file mode 100755 index 000000000..2d3111d42 --- /dev/null +++ b/apps/web/src/components/cover/CoverModeSelector.tsx @@ -0,0 +1,32 @@ +import React from "react" +import type { CoverMode } from "./types" + +interface CoverModeSelectorProps { + mode: CoverMode + onModeChange: (mode: CoverMode) => void + modeLabels: Record + modeIcons: Record +} + +export const CoverModeSelector: React.FC = ({ + mode, + onModeChange, + modeLabels, + modeIcons, +}) => { + const modes: CoverMode[] = ["auto", "frame", "upload"] + return ( +
+ {modes.map((m) => ( + + ))} +
+ ) +} diff --git a/apps/web/src/components/cover/CoverSelector.tsx b/apps/web/src/components/cover/CoverSelector.tsx new file mode 100644 index 000000000..7dbcfda6b --- /dev/null +++ b/apps/web/src/components/cover/CoverSelector.tsx @@ -0,0 +1,315 @@ +/** + * 封面选择器 — 公共主组件 + * + * 提供三种封面来源(自动生成 / 封面模板 / 本地上传)+ 9:16 预览; + * 供智能剪辑、AI数字人及未来新功能统一调用。 + * + * 不耦合任何业务 state,回调只传 URL/templateId 等通用字段。 + */ +import React, { useCallback, useEffect, useState } from "react" +import { Modal as AntModal, Spin } from "antd" +import { LoadingOutlined } from "@ant-design/icons" +import Button from "@/components/ui/Button" +import Modal from "@/components/ui/Modal" +import { useSharedCover } from "./useSharedCover" +import CoverSettingsModal from "./CoverSettingsModal" +import CoverEditorModal from "./CoverEditorModal" +import { uploadCoverWithPreview } from "./uploadCover" +import "./cover.css" + +export interface CoverSelectorProps { + /** 当前封面 URL(预览显示) */ + value?: string + /** 当前选中的模板 ID(受控) */ + templateId?: string | null + /** 选中模板变化回调 */ + onTemplateChange?: (templateId: string, templateName: string) => void + /** 选择封面 URL 变化回调(自动生成/上传都会触发) */ + onChange?: ( + coverUrl: string, + source: "auto" | "upload" | "template", + extra?: Record, + ) => void + /** 自动生成封面:父组件负责调用后端,返回封面 URL;不传则隐藏自动生成按钮 */ + onAutoGenerate?: (templateId: string) => Promise + /** 是否可以自动生成(视频就绪等条件) */ + canGenerate?: boolean + /** 不可生成时提示文案 */ + disabledHint?: string + /** 功能开关 */ + showAutoGenerate?: boolean + showTemplate?: boolean + showUpload?: boolean + /** 封面比例,默认 "9 / 16" */ + aspectRatio?: string + /** 弹窗模式(AI数字人)/ 内联模式(智能剪辑 Step6) */ + mode?: "inline" | "modal" + /** modal 模式下的弹窗控制 */ + open?: boolean + onClose?: () => void + title?: string + /** 预览区上方的提示条内容(可传 ReactNode) */ + hint?: React.ReactNode + /** 预览区宽度(inline 模式默认 220,modal 模式 180) */ + previewWidth?: number + /** 自定义 class */ + className?: string +} + +const CoverSelector: React.FC = ({ + value, + templateId, + onTemplateChange, + onChange, + onAutoGenerate, + canGenerate = true, + disabledHint, + showAutoGenerate = true, + showTemplate = true, + showUpload = true, + aspectRatio = "9 / 16", + mode = "inline", + open = false, + onClose, + title = "选择封面", + hint, + previewWidth, + className, +}) => { + const [uploading, setUploading] = useState(false) + + const generateFn = useCallback( + async (tplId: string): Promise => { + if (!onAutoGenerate) return null + const url = await onAutoGenerate(tplId) + if (url) onChange?.(url, "auto", { templateId: tplId }) + return url ?? null + }, + [onAutoGenerate, onChange], + ) + + const shared = useSharedCover({ + canGenerate: canGenerate && showAutoGenerate && !!onAutoGenerate, + disabledHint, + initialTemplateId: templateId || "default", + generateFn, + }) + + // 受控 templateId 同步 + useEffect(() => { + if (templateId && templateId !== shared.selectedTemplateId) { + shared.handleSelectTemplate(templateId) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [templateId]) + + // 模板选中 → 通知父层 + useEffect(() => { + if (shared.selectedTemplateId && shared.selectedTemplateId !== "default") { + onTemplateChange?.(shared.selectedTemplateId, shared.selectedTemplateName) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shared.selectedTemplateId]) + + // 上传文件处理 + useEffect(() => { + shared.setOnUploadFile(async (file: File) => { + setUploading(true) + try { + const { previewUrl, finalUrl } = await uploadCoverWithPreview(file, { + onPreview: (blobUrl) => onChange?.(blobUrl, "upload"), + onUploaded: (url) => onChange?.(url, "upload"), + }) + return finalUrl || previewUrl + } catch { + return null + } finally { + setUploading(false) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onChange]) + + const previewUrl = value || "" + const _uploadInput = ( + + ) + void _uploadInput + + const actionButtons = (fullWidth = false) => ( + <> + {showAutoGenerate && ( + + )} + {showTemplate && ( + + )} + {showUpload && ( + + )} + + ) + + const hintNode = hint ?
{hint}
: null + + const previewBox = (w?: number) => ( +
+ {previewUrl ? ( + 封面预览 + ) : ( +
+ 🖼️ + {canGenerate ? "点击下方按钮生成/上传" : "视频生成后可选择封面"} +
+ )} + {aspectRatio.replace(/\s/g, "")} + {(shared.generating || uploading) && ( +
+ } /> + {uploading ? "上传中…" : "AI 选帧中…"} +
+ )} +
+ ) + + const body = + mode === "modal" ? ( +
+ {hintNode} +
+
+ {previewBox(180)} +
+ {aspectRatio.replace(/\s/g, "")} 竖版封面 +
+
+
+ {actionButtons(true)} +
+ 💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。 +
+
+
+ +
+ ) : ( +
+ {hintNode} +
{actionButtons(false)}
+ +
封面预览
+ {previewBox(previewWidth)} +
+ ) + + return ( + <> + {mode === "modal" ? ( + + + +
+ } + > + {body} + + ) : ( + body + )} + + shared.setShowCoverSettings(false)} + templates={shared.templates} + loading={shared.templatesLoading} + error={shared.templatesError} + selectedTemplateId={shared.selectedTemplateId} + onSelectTemplate={shared.handleSelectTemplate} + onEditTemplate={shared.handleEditTemplate} + onDeleteTemplate={shared.handleDeleteTemplate} + onCreateNew={shared.handleCreateTemplate} + /> + shared.setShowCoverEditor(false)} + template={shared.editingTemplate} + onSave={shared.handleSaveTemplate} + /> + +
+ +

+ AI 正在从最终成片选帧,请稍候... +

+
+
+ + ) +} + +export default CoverSelector diff --git a/apps/web/src/components/cover/CoverSettingsModal.tsx b/apps/web/src/components/cover/CoverSettingsModal.tsx new file mode 100644 index 000000000..25e30a977 --- /dev/null +++ b/apps/web/src/components/cover/CoverSettingsModal.tsx @@ -0,0 +1,195 @@ +import React, { useMemo, useState } from "react" +import type { CoverTemplate } from "./types" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" +import "@/components/cover/cover.css" + +interface CoverSettingsModalProps { + open: boolean + onClose: () => void + templates: CoverTemplate[] + loading?: boolean + error?: string | null + selectedTemplateId: string + onSelectTemplate: (id: string) => void + onEditTemplate: (template: CoverTemplate) => void + onDeleteTemplate: (id: string) => void + onCreateNew: () => void +} + +/** 模板缩略图:优先渲染 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 = ({ + open, + onClose, + templates, + loading = false, + error = null, + selectedTemplateId, + onSelectTemplate, + onEditTemplate, + onDeleteTemplate, + onCreateNew, +}) => { + return ( + + + +
+ } + > +
+ +
+ + {loading && ( +
+ 加载中... +
+ )} + + {error && !loading && ( +
{error}
+ )} + + {!loading && !error && templates.length === 0 && ( +
+ 暂无封面模板,点击右上角「创建新模板」可自定义封面样式 +
+ )} + + {!loading && !error && templates.length > 0 && ( +
+ {templates.map((tpl) => { + const isSelected = selectedTemplateId === tpl.id + return ( +
onSelectTemplate(tpl.id)} + > + +
+
+ {tpl.name} + {tpl.is_system && ✨ 系统} +
+
e.stopPropagation()}> + + {!tpl.is_system && ( + + )} +
+
+
+ ) + })} +
+ )} + +
+ 💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面 +
+ + ) +} + +export default CoverSettingsModal diff --git a/apps/web/src/components/cover/FrameCoverPicker.tsx b/apps/web/src/components/cover/FrameCoverPicker.tsx new file mode 100755 index 000000000..8f877c014 --- /dev/null +++ b/apps/web/src/components/cover/FrameCoverPicker.tsx @@ -0,0 +1,58 @@ +import React from "react" + +interface FrameCoverPickerProps { + frameTime: number + totalDuration: number + formatTime: (seconds: number) => string + onFrameTimeChange: (time: number) => void +} + +export const FrameCoverPicker: React.FC = ({ + frameTime, + totalDuration, + formatTime, + onFrameTimeChange, +}) => { + const quickRatios = [0, 0.25, 0.5, 0.75] + + return ( +
+
+
+ 🎞️ + {formatTime(frameTime)} +
+
+
+
+ 拖动选择封面帧 + {formatTime(frameTime)} +
+ onFrameTimeChange(Number(e.target.value))} + className="xx-cover-range" + /> +
+ 00:00 + {formatTime(totalDuration)} +
+
+
+ 快捷选帧: + {quickRatios.map((ratio) => { + const t = totalDuration * ratio + return ( + + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/cover/UploadCoverPicker.tsx b/apps/web/src/components/cover/UploadCoverPicker.tsx new file mode 100755 index 000000000..40312e4c0 --- /dev/null +++ b/apps/web/src/components/cover/UploadCoverPicker.tsx @@ -0,0 +1,46 @@ +import React from "react" + +interface UploadCoverPickerProps { + uploadUrl: string + onUpload: (file: File) => void +} + +export const UploadCoverPicker: React.FC = ({ uploadUrl, onUpload }) => { + const handleClick = () => { + const input = document.getElementById("cover-upload-input") + input?.click() + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + onUpload(file) + } + } + + return ( +
+
+ {uploadUrl ? ( +
+ 封面预览 +
点击更换
+
+ ) : ( +
+ 📤 + 点击上传封面图片 + 支持 JPG / PNG,建议 9:16 比例 +
+ )} + +
+
+ ) +} diff --git a/apps/web/src/components/cover/cover.css b/apps/web/src/components/cover/cover.css index 9bd4d08c0..bfd73a0c4 100644 --- a/apps/web/src/components/cover/cover.css +++ b/apps/web/src/components/cover/cover.css @@ -561,3 +561,90 @@ z-index: 0; background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); } + +/* ── CoverSelector 公共组件样式 ── */ +.cs-preview-box { + position: relative; + width: 220px; + aspect-ratio: 9 / 16; + border-radius: 8px; + overflow: hidden; + background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); + border: 1px dashed #d9d9d9; +} +.cs-preview-box.has-image { + border: none; +} +.cs-preview-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.cs-preview-placeholder { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + gap: 6px; + opacity: 0.7; +} +.cs-preview-ratio { + position: absolute; + right: 6px; + bottom: 6px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; +} +.cs-preview-loading { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + flex-direction: column; + gap: 8px; +} +.cs-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.cs-actions .btn-block { + width: 100%; +} +.cs-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #1f2937); + margin: 12px 0 8px; +} +.cs-help-tip { + font-size: 11px; + color: #8c8ca1; + line-height: 1.5; + margin-top: 4px; + padding: 6px 8px; + background: #f7f8fa; + border-radius: 6px; +} +.cs-hint { + padding: 10px 14px; + background: rgba(16, 185, 129, 0.08); + border-radius: 8px; + margin-bottom: 16px; + border: 1px solid rgba(16, 185, 129, 0.15); + font-size: 13px; + color: var(--text-secondary, #666); +} diff --git a/apps/web/src/components/cover/index.ts b/apps/web/src/components/cover/index.ts index ec1c48ffa..b0aa906bf 100644 --- a/apps/web/src/components/cover/index.ts +++ b/apps/web/src/components/cover/index.ts @@ -1,2 +1,17 @@ +/** + * 公共封面选择/编辑组件统一导出 + * + * 任何页面需要选封面、封面模板选择、封面编辑器、本地上传封面,从这里 import: + * import { CoverSelector, useSharedCover, ... } from "@/components/cover" + */ +export { default as CoverSelector } from "./CoverSelector" +export { default as CoverSettingsModal } from "./CoverSettingsModal" +export { default as CoverEditorModal } from "./CoverEditorModal" +export { CoverModeSelector } from "./CoverModeSelector" +export { FrameCoverPicker } from "./FrameCoverPicker" +export { UploadCoverPicker } from "./UploadCoverPicker" export { useSharedCover } from "./useSharedCover" +export { uploadCoverToOSS, uploadCoverWithPreview } from "./uploadCover" export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover" +export * from "./types" +import "./cover.css" diff --git a/apps/web/src/components/cover/types.ts b/apps/web/src/components/cover/types.ts new file mode 100644 index 000000000..79fd7c1a1 --- /dev/null +++ b/apps/web/src/components/cover/types.ts @@ -0,0 +1,318 @@ +/** + * 封面模板/编辑器公共类型 + * + * 抽离自 pages/generate/types/cover.ts,供智能剪辑/AI数字人/未来新功能共享。 + * 不含页面业务状态类型(CoverConfig/CoverMode/DEFAULT_COVER_CONFIG 留在 pages 侧)。 + */ + +/** 封面来源模式 */ +export type CoverMode = "auto" | "frame" | "upload" + +/** 封面配置(业务状态,抽离自 generate 模块供多页面共享) */ +export interface CoverConfig { + /** 是否启用自定义封面 */ + enabled: boolean + /** 封面来源模式 */ + mode: CoverMode + /** 抽帧时间点(秒,mode=frame 时使用) */ + frame_time: number + /** 上传的封面 URL(mode=upload 时使用) */ + upload_url: string + /** AI 智能推荐的抽帧时间(由后端分析得出) */ + ai_suggested_time: number | null + /** 封面缩略图 URL */ + thumbnail_url: string +} + +/** 默认封面配置 */ +export const DEFAULT_COVER_CONFIG: CoverConfig = { + enabled: false, + mode: "auto", + frame_time: 0, + upload_url: "", + ai_suggested_time: null, + thumbnail_url: "", +} + +/** 文字方向 */ +export type TextDirection = "horizontal" | "vertical" + +/** 文字背景形状 */ +export type TextBgShape = "rectangle" | "polygon" + +/** 描边样式 */ +export type StrokeStyle = "solid" | "dashed" + +/** 阴影层 */ +export interface ShadowLayer { + color: string + offsetX: number + offsetY: number + blur: number +} + +/** 文字位置 */ +export interface TextPosition { + x: number + y: number +} + +/** 文字背景配置 */ +export interface TextBackground { + enabled: boolean + color: string + opacity: number + shape: TextBgShape + width: number + height: number + /** 相对文字的上下偏移(百分比),背景自动跟随文字位置 */ + offsetY: number +} + +/** 文字样式配置(主标题/副标题共用) */ +export interface TextStyleConfig { + text: string + fontFamily: string + fontSize: number + fontWeight: number + direction: TextDirection + charsPerLine: number + letterSpacing: number + lineHeight: number + color: string + strokeColor: string + strokeWidth: number + shadows: ShadowLayer[] + traditionalShadow: boolean + position: TextPosition + rotation: number + background: TextBackground +} + +/** 编辑器完整配置 */ +export interface CoverEditorConfig { + // 基础设置 + blurEnabled: boolean + blurAmount: number + personStrokeEnabled: boolean + personStrokeStyle: StrokeStyle + personStrokeColor: string + personStrokeWidth: number + autoSplitEnabled: boolean + titleMaxChars: number + subtitleMaxChars: number + + // 人像设置 + portraitEnabled: boolean + portraitSize: number + portraitPosition: TextPosition + portraitImage?: string + + // 背景设置 + backgroundEnabled: boolean + backgroundSize: number + backgroundPosition: TextPosition + backgroundImage?: string + backgroundColor?: string + + // 主标题 + title: TextStyleConfig + + // 副标题 + subtitle: TextStyleConfig + + // 蒙版 + maskEnabled: boolean + maskImage: string + maskSize: number + maskPosition: TextPosition + maskColor: string + maskOpacity: number + maskShape: string +} + +/** 默认主标题配置 */ +export const DEFAULT_TITLE_CONFIG: TextStyleConfig = { + text: "主标题文字", + fontFamily: "思源黑体", + fontSize: 120, + fontWeight: 700, + direction: "horizontal", + charsPerLine: 10, + letterSpacing: 24, + lineHeight: 144, + color: "#FFD700", + strokeColor: "#000000", + strokeWidth: 3, + shadows: [], + traditionalShadow: false, + position: { x: 50, y: 30 }, + rotation: 0, + background: { + enabled: false, + color: "#FFFFFF", + opacity: 25, + shape: "polygon", + width: 30, + height: 10, + offsetY: 0, + }, +} + +/** 默认副标题配置 */ +export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = { + text: "副标题文字", + fontFamily: "思源黑体", + fontSize: 82, + fontWeight: 500, + direction: "horizontal", + charsPerLine: 17, + letterSpacing: 23, + lineHeight: 72, + color: "#FFFFFF", + strokeColor: "#000000", + strokeWidth: 1, + shadows: [], + traditionalShadow: false, + position: { x: 50, y: 70 }, + rotation: 0, + background: { + enabled: true, + color: "#000000", + opacity: 70, + shape: "rectangle", + width: 100, + height: 20, + offsetY: 8, + }, +} + +/** 默认编辑器配置 */ +export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = { + blurEnabled: false, + blurAmount: 10, + personStrokeEnabled: false, + personStrokeStyle: "solid", + personStrokeColor: "#FFFFFF", + personStrokeWidth: 8, + autoSplitEnabled: false, + titleMaxChars: 4, + subtitleMaxChars: 10, + + portraitEnabled: false, + portraitSize: 50, + portraitPosition: { x: 50, y: 70 }, + + backgroundEnabled: true, + backgroundSize: 100, + backgroundPosition: { x: 50, y: 50 }, + + title: DEFAULT_TITLE_CONFIG, + subtitle: DEFAULT_SUBTITLE_CONFIG, + + maskEnabled: false, + maskImage: "", + maskSize: 100, + maskPosition: { x: 50, y: 50 }, + maskColor: "#000000", + maskOpacity: 40, + maskShape: "矩形", +} + +/** 预置字体(已与 @/components/title/constants 字体表保持一致;自定义商业字体兜底 Google Fonts 开源中文字体) */ +// 封面编辑器预置字体:与标题样式字体列表保持一致(从 @/components/title/constants 同步), +// 并补全西文常用系统字体,保证在中英文环境下都有可用字体。 +// 注:需要配合 index.html 引入的 Google Fonts(Noto Sans SC / ZCOOL / Ma Shan Zheng 等)。 +export interface CoverFont { + name: string + family: string + tag?: "preset" | "hand" | "serif" | "sans" | "mono" +} + +/** 预置中文字体(爆款/常用) */ +export const PRESET_FONTS: CoverFont[] = [ + { + name: "优设标题黑", + family: + '"YouSheBiaoTiHei","ZCOOL QingKe HuangYou","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "阿里普惠体Bold", + family: + '"Alibaba PuHuiTi","Alibaba Sans","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "抖音美好体", + family: + '"Douyin Sans","ZCOOL KuaiLe","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "思源黑体Heavy", + family: '"Noto Sans SC","Source Han Sans SC Heavy","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "思源黑体", + family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "思源宋体", + family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif', + tag: "serif", + }, + { name: "站酷小薇体", family: '"ZCOOL XiaoWei","Noto Serif SC",serif', tag: "preset" }, + { name: "马善政毛笔", family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive', tag: "hand" }, + { name: "龙藏体", family: '"Long Cang","STXingkai",cursive', tag: "hand" }, + { name: "楷体", family: '"KaiTi","STKaiti","DFKai-SB",serif', tag: "serif" }, + { + name: "苹方", + family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif', + tag: "sans", + }, + { + name: "微软雅黑", + family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif', + tag: "sans", + }, +] + +/** 系统字体(西文 + 通用中文) */ +export const SYSTEM_FONTS: CoverFont[] = [ + { name: "Arial", family: "Arial, Helvetica, sans-serif", tag: "sans" }, + { name: "Helvetica", family: "Helvetica, Arial, sans-serif", tag: "sans" }, + { name: "Times New Roman", family: '"Times New Roman", Times, serif', tag: "serif" }, + { name: "Georgia", family: "Georgia, serif", tag: "serif" }, + { name: "Verdana", family: "Verdana, Geneva, sans-serif", tag: "sans" }, + { name: "Tahoma", family: "Tahoma, Geneva, sans-serif", tag: "sans" }, + { name: "Impact", family: 'Impact, "Arial Black", sans-serif', tag: "sans" }, + { name: "Comic Sans MS", family: '"Comic Sans MS", cursive', tag: "hand" }, + { name: "Courier New", family: '"Courier New", Courier, monospace', tag: "mono" }, + { name: "宋体", family: "SimSun, 'Noto Serif SC', serif", tag: "serif" }, + { name: "黑体", family: "SimHei, 'Noto Sans SC', sans-serif", tag: "sans" }, + { name: "仿宋", family: "FangSong, 'Noto Serif SC', serif", tag: "serif" }, + { name: "Trebuchet MS", family: '"Trebuchet MS", sans-serif', tag: "sans" }, + { name: "Lucida Console", family: '"Lucida Console", Monaco, monospace', tag: "mono" }, + { name: "Palatino", family: 'Palatino, "Palatino Linotype", serif', tag: "serif" }, + { name: "Garamond", family: "Garamond, serif", tag: "serif" }, + { name: "Calibri", family: "Calibri, sans-serif", tag: "sans" }, + { name: "Cambria", family: "Cambria, serif", tag: "serif" }, + { name: "Candara", family: "Candara, sans-serif", tag: "sans" }, + { name: "Consolas", family: "Consolas, monospace", tag: "mono" }, +] + +/** 所有字体列表 */ +export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS] + +/** 封面模板 */ +export interface CoverTemplate { + id: string + name: string + thumbnail_url: string + is_system: boolean + created_at: string + config?: CoverEditorConfig +} diff --git a/apps/web/src/components/cover/uploadCover.ts b/apps/web/src/components/cover/uploadCover.ts new file mode 100644 index 000000000..3af534eda --- /dev/null +++ b/apps/web/src/components/cover/uploadCover.ts @@ -0,0 +1,62 @@ +/** + * 封面本地上传 — OSS 直传公共工具(PR#2061 修复) + * + * 选完文件后: + * 1. 立即返回 blob URL 供调用方即时预览 + * 2. 异步上传到素材库 OSS,拿到真实 URL 后回调 onUploaded(url) + * 3. 失败时调用 onError,blob URL 仍保留作为兜底 + */ +import { message } from "antd" +import { uploadAssetDirect, getAssetLibraries } from "@/api/assets" + +export interface UploadCoverResult { + /** blob: 预览 URL(立即返回) */ + previewUrl: string + /** 真实 OSS URL(上传完成后) */ + finalUrl?: string +} + +export interface UploadCoverCallbacks { + onPreview?: (previewUrl: string) => void + onUploaded?: (finalUrl: string) => void + onError?: (err: unknown) => void +} + +/** 把本地封面图片上传到素材库(图片库)OSS,返回 Promise<最终URL> */ +export async function uploadCoverToOSS(file: File): Promise { + const libs = await getAssetLibraries() + const imageLib = libs.find((l) => l.kind === "image") || libs[0] + if (!imageLib) { + throw new Error("未找到图片素材库") + } + const result = await uploadAssetDirect({ file, library_id: imageLib.id }) + const url = result?.url || "" + if (!url) throw new Error("上传完成但未获取到URL") + return url +} + +/** + * 一站式上传:立即创建 blob URL 预览,异步上传 OSS;返回 Promise<{previewUrl, finalUrl}> + * 供封面选择器复用。 + */ +export async function uploadCoverWithPreview( + file: File, + callbacks: UploadCoverCallbacks = {}, +): Promise { + const hide = message.loading("正在上传封面...", 0) + try { + const previewUrl = URL.createObjectURL(file) + callbacks.onPreview?.(previewUrl) + const finalUrl = await uploadCoverToOSS(file) + hide() + callbacks.onUploaded?.(finalUrl) + message.success("封面上传成功") + return { previewUrl, finalUrl } + } catch (err) { + hide() + console.error("[Cover] 封面上传失败:", err) + callbacks.onError?.(err) + message.error("封面上传失败,请重试") + throw err + } +} diff --git a/apps/web/src/components/cover/useSharedCover.ts b/apps/web/src/components/cover/useSharedCover.ts index 574c5bddd..1f2332eb2 100644 --- a/apps/web/src/components/cover/useSharedCover.ts +++ b/apps/web/src/components/cover/useSharedCover.ts @@ -10,7 +10,7 @@ import type React from "react" import { useCallback, useEffect, useRef, useState } from "react" import { message } from "antd" -import type { CoverTemplate } from "@/pages/generate/types/cover" +import type { CoverTemplate } from "./types" import { fetchCoverTemplates, createCoverTemplate, diff --git a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx index 126af59dc..195e978e0 100644 --- a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx +++ b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx @@ -1,25 +1,15 @@ /** - * AI数字人 — 封面选择弹窗(#2033 共享封面组件重构) + * AI数字人 — 封面选择弹窗(重构为使用公共 CoverSelector) * - * 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器) - * + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。 - * - * 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构: - * - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url - * - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL + * 复用 @/components/cover/CoverSelector(自动生成/封面模板/本地上传+9:16预览), + * 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构。 * * 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。 */ -import React, { useCallback, useEffect, useMemo } from "react" -import { Modal as AntModal, Spin, message } from "antd" -import { LoadingOutlined } from "@ant-design/icons" -import Modal from "@/components/ui/Modal" -import Button from "@/components/ui/Button" -import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal" -import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal" -import { useSharedCover } from "@/components/cover/useSharedCover" -import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar" +import React, { useCallback } from "react" +import { CoverSelector } from "@/components/cover" import type { AiAvatarCoverConfig, RenderJob } from "../types" +import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar" interface ModalCoverSelectProps { open: boolean @@ -27,10 +17,7 @@ interface ModalCoverSelectProps { renderJob: RenderJob | null coverConfig: AiAvatarCoverConfig onCoverConfigChange: (partial: Partial) => void - /** - * 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入 - * 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。 - */ + /** 老接口保留兼容 */ onGenerateRenderSmartCover?: ( renderId: string, ) => Promise<{ cover_url: string; message?: string }> @@ -45,46 +32,33 @@ const ModalCoverSelect: React.FC = ({ coverConfig, onCoverConfigChange, onGenerateRenderSmartCover, - onUploadCover, onCoverSelected, }) => { const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id - const generateFn = useCallback( + const handleAutoGenerate = useCallback( async (templateId: string): Promise => { if (!renderJob || !isRenderCompleted) return null - try { - let coverUrl = "" - if (onGenerateRenderSmartCover) { - const res = await onGenerateRenderSmartCover(renderJob.id) - coverUrl = res.cover_url - } else { - const res = await apiGenerateSmartCover(renderJob.id, templateId) - coverUrl = res.cover_url - if (!coverUrl && res.message) { - const err = new Error(res.message) as Error & { __msgShown?: boolean } - err.__msgShown = true - message.error(res.message) - throw err - } + let coverUrl = "" + if (onGenerateRenderSmartCover) { + const res = await onGenerateRenderSmartCover(renderJob.id) + coverUrl = res.cover_url + } else { + const res = await apiGenerateSmartCover(renderJob.id, templateId) + coverUrl = res.cover_url + if (!coverUrl && res.message) { + throw new Error(res.message) } - if (coverUrl) { - onCoverConfigChange({ - mode: "auto_frame", - thumbnail_url: coverUrl, - smart_cover_url: coverUrl, - }) - onCoverSelected(coverUrl) - message.success("智能封面已生成") - } - return coverUrl || null - } catch (err) { - const anyErr = err as { __msgShown?: boolean; message?: string } - if (!anyErr?.__msgShown) { - message.error(anyErr?.message || "智能封面生成失败") - } - throw err } + if (coverUrl) { + onCoverConfigChange({ + mode: "auto_frame", + thumbnail_url: coverUrl, + smart_cover_url: coverUrl, + }) + onCoverSelected(coverUrl) + } + return coverUrl || null }, [ renderJob, @@ -95,20 +69,9 @@ const ModalCoverSelect: React.FC = ({ ], ) - const shared = useSharedCover({ - canGenerate: isRenderCompleted, - disabledHint: "请先完成视频生成再选择封面", - initialTemplateId: "default", - generateFn, - }) - - // 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件) - useEffect(() => { - shared.setOnUploadFile((file: File) => { - if (onUploadCover) { - onUploadCover(file) - } else { - const url = URL.createObjectURL(file) + const handleChange = useCallback( + (url: string, source: "auto" | "upload" | "template") => { + if (source === "upload") { onCoverConfigChange({ mode: "upload", upload_url: url, @@ -116,234 +79,30 @@ const ModalCoverSelect: React.FC = ({ }) onCoverSelected(url) } - return null - }) - }, [shared, onUploadCover, onCoverConfigChange, onCoverSelected]) - - // 打开时同步刷新模板列表 - useEffect(() => { - if (open) void shared.reloadTemplates() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) - - /** 当前预览 URL:智能封面 > 自定义上传 */ - const previewUrl = useMemo( - () => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "", - [coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url], + // auto 已在 handleAutoGenerate 内更新 + }, + [onCoverConfigChange, onCoverSelected], ) - if (!open) return null + const previewUrl = + coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "" return ( - - - -
- } - > -
- {renderJob && ( -
- 🎬 从渲染成片中智能选帧 - {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( - <> - {" "} - · 当前模板:{shared.selectedTemplateName} - - )} -
- )} - -
- {/* 左:封面预览 */} -
-
- {previewUrl ? ( - 封面预览 - ) : ( -
- 🖼️ - - {isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"} - -
- )} - {shared.generating && ( -
- } /> - AI 选帧中… -
- )} -
-
- 9:16 竖版封面 -
-
- - {/* 右:操作按钮 */} -
- - - - -
- 💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。 -
-
-
-
- - {/* 模板选择弹窗 */} - shared.setShowCoverSettings(false)} - templates={shared.templates} - loading={shared.templatesLoading} - error={shared.templatesError} - selectedTemplateId={shared.selectedTemplateId} - onSelectTemplate={shared.handleSelectTemplate} - onEditTemplate={shared.handleEditTemplate} - onDeleteTemplate={shared.handleDeleteTemplate} - onCreateNew={shared.handleCreateTemplate} - /> - - {/* 自定义编辑器弹窗 */} - shared.setShowCoverEditor(false)} - template={shared.editingTemplate} - onSave={shared.handleSaveTemplate} - /> - - {/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */} - -
- -

- AI 正在从最终成片选帧,请稍候... -

-
-
- + value={previewUrl} + onChange={handleChange} + onAutoGenerate={isRenderCompleted ? handleAutoGenerate : undefined} + canGenerate={isRenderCompleted} + disabledHint="请先完成视频生成再选择封面" + showAutoGenerate + showTemplate + showUpload + hint={renderJob ? <>🎬 从渲染成片中智能选帧 : undefined} + /> ) } diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index f20fafd6b..48381505c 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,23 +1,22 @@ /** - * Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择) - * - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传) + * Step 6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择) + * + * - 单视频:统一使用公共 CoverSelector(自动生成/封面模板/本地上传+9:16预览) * - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传 * - * 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover + * 公共组件:@/components/cover/CoverSelector(单视频 UI)+ useSharedCover(模板 CRUD) + * 批量逻辑(generateAll/批量上传)保留在本文件,使用 useBatchCovers hook。 */ -import React, { useCallback, useEffect, useMemo, useState } from "react" -import { Modal, Spin, message } from "antd" +import React, { useCallback, useMemo, useState } from "react" +import { Spin } from "antd" import { LoadingOutlined } from "@ant-design/icons" -import type { CoverConfig } from "../types/cover" +import type { CoverConfig } from "@/components/cover/types" import type { GeneratedVideo } from "@/api/template-editor" import type { TitleSettings } from "../types" import { useBatchCovers } from "../hooks/useBatchCovers" import Button from "@/components/ui/Button" -import CoverSettingsModal from "./cover-settings/CoverSettingsModal" -import CoverEditorModal from "./cover-settings/CoverEditorModal" -import { useSharedCover } from "@/components/cover/useSharedCover" +import { CoverSelector } from "@/components/cover" import { generateCover as apiGenerateCover } from "@/api/generation" -import { uploadAssetDirect, getAssetLibraries } from "@/api/assets" interface Step6CoverSettingsProps { coverSettings: CoverConfig @@ -36,39 +35,43 @@ interface Step6CoverSettingsProps { } const Step6CoverSettings: React.FC = (props) => { - const previewCount = props.previewCount || 1 + const { + coverSettings, + onCoverSettingsChange, + selectedTemplate, + titleSettings, + generatedVideos, + previewCount: _pc, + previewTitles = [], + previewCovers = [], + onPreviewCoversChange, + selectedVariantIndexes, + onTemplateChange, + currentTaskId, + } = props + const previewCount = _pc || 1 const isBatch = previewCount > 1 - const previewTitles = props.previewTitles || [] - const previewCovers = props.previewCovers || [] const cardIndexes = - isBatch && props.selectedVariantIndexes?.length - ? props.selectedVariantIndexes + isBatch && selectedVariantIndexes?.length + ? selectedVariantIndexes : Array.from({ length: previewCount }, (_, i) => i) /** 最终成片:取第一个已完成视频(单视频场景) */ const finalVideo = - props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") || - props.generatedVideos[0] + generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") || + generatedVideos[0] - /** - * 兜底任务/视频 ID:awaiting_cover 阶段后端 /results 可能还没有入库 GeneratedVideo, - * 只返回合成的 preview-{taskId} 轻量对象;此时用 currentTaskId 兜底让后端能找到任务。 - * 同时统一抽取 taskId(generation_task_id 优先)用于日志/错误提示。 - */ const effectiveTaskId = (finalVideo as { generation_task_id?: string } | undefined)?.generation_task_id || - props.currentTaskId || + currentTaskId || "" const _rawVideoId = (finalVideo as { id?: string; video_id?: string } | undefined)?.id || (finalVideo as { video_id?: string } | undefined)?.video_id || "" - // preview-{taskId} 是后端合成的临时 id,gv_repo.get 查不到 → 不传 generated_video_id, - // 让后端走 plan.config.generation_task_id / rendered_storage_key 兜底路径。 const effectiveVideoId = _rawVideoId && !_rawVideoId.startsWith("preview-") ? _rawVideoId : "" const effectiveVideoUrl = finalVideo?.file_url || finalVideo?.download_url || "" - /** 按钮可用:非批量 且 (有 finalVideo 对象或兜底 taskId) 且 视频状态已完成/等待封面/未设置 */ const isVideoReady = !finalVideo || finalVideo.status === "completed" || @@ -77,149 +80,118 @@ const Step6CoverSettings: React.FC = (props) => { const canGenerateCover = !isBatch && (!!finalVideo || !!effectiveTaskId) && isVideoReady const completedVideos = useMemo( - () => - props.generatedVideos.filter( - (v) => v.status === "completed" || v.status === "awaiting_cover", - ), - [props.generatedVideos], + () => generatedVideos.filter((v) => v.status === "completed" || v.status === "awaiting_cover"), + [generatedVideos], ) - /** - * 单视频自动生成(点击"自动生成封面"按钮):使用当前选中的模板 - * 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发 - */ - const shared = useSharedCover({ - canGenerate: canGenerateCover, - disabledHint: isBatch - ? "批量场景请在上方操作卡片" - : !finalVideo && !effectiveTaskId - ? "请先生成视频再选择封面" - : "视频尚未就绪,请稍候", - initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default - generateFn: async (tplId) => { + /** 单视频自动生成:调用后端 /generation/generate-cover */ + const handleAutoGenerate = useCallback( + async (tplId: string): Promise => { if (isBatch) return null if (!finalVideo && !effectiveTaskId) { console.warn("[Cover] generateAutoCover: no finalVideo and no taskId") return null } - // 请求体:generated_video_id 仅在后端已入库(非 preview-xxx 合成id)时传; - // video_url 兜底让后端能直接下载视频抽帧;generation_task_id 后端已从 plan.config 自动读取。 const requestBody: { generated_video_id?: string video_url?: string cover_type: "ai_frame" title_config?: Record - } = { - cover_type: "ai_frame", - } - if (effectiveVideoId) { - requestBody.generated_video_id = effectiveVideoId - } - if (effectiveVideoUrl) { - requestBody.video_url = effectiveVideoUrl - } - if (props.titleSettings?.title) { + } = { cover_type: "ai_frame" } + if (effectiveVideoId) requestBody.generated_video_id = effectiveVideoId + if (effectiveVideoUrl) requestBody.video_url = effectiveVideoUrl + if (titleSettings?.title) { requestBody.title_config = { - text: props.titleSettings.title, - font: props.titleSettings.font, - font_size: props.titleSettings.size, - font_color: props.titleSettings.color, - position: props.titleSettings.position, - bold: props.titleSettings.bold, - stroke: props.titleSettings.stroke, - shadow: props.titleSettings.shadow, + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + font_color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, } } - console.log("[Cover] auto-generate request:", { tplId, ...requestBody }) const response = await apiGenerateCover(tplId, requestBody) const url = response.cover?.image_url || response.cover?.thumbnail_url || "" if (url) { - props.onCoverSettingsChange({ - ...props.coverSettings, + onCoverSettingsChange({ + ...coverSettings, thumbnail_url: url, ai_suggested_time: response.cover?.frame_time ?? null, }) } else { console.warn("[Cover] generate returned empty url:", response) } - return url + return url || null + }, + [ + isBatch, + finalVideo, + effectiveTaskId, + effectiveVideoId, + effectiveVideoUrl, + titleSettings, + coverSettings, + onCoverSettingsChange, + ], + ) + + /** 本地上传回调(公共组件 uploadCoverWithPreview 已处理 blob 预览 + OSS 上传) */ + const handleCoverChange = useCallback( + (url: string, source: "auto" | "upload" | "template") => { + if (source === "upload") { + onCoverSettingsChange({ + ...coverSettings, + upload_url: url, + thumbnail_url: url, + mode: "upload", + }) + } + // auto 已在 handleAutoGenerate 内部更新 coverSettings + // template 切换模板不自动更新 coverSettings(等用户点"自动生成"才应用) + }, + [coverSettings, onCoverSettingsChange], + ) + + const hintNode = useMemo(() => { + return <>🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧 + }, [finalVideo?.name]) + + /** ── 批量封面(保留原逻辑) ── */ + const [localSelectedTemplate, setLocalSelectedTemplate] = useState( + selectedTemplate || "default", + ) + const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "") + const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "") + + const batchCovers = useBatchCovers({ + selectedTemplate: localSelectedTemplate === "default" ? "" : localSelectedTemplate, + generatedVideos: generatedVideos, + titles: batchTitles, + titleStyle: { + font: titleSettings?.font || "思源黑体", + size: titleSettings?.size || 28, + color: titleSettings?.color || "#ffffff", + position: titleSettings?.position || "top", + bold: titleSettings?.bold ?? true, + stroke: titleSettings?.stroke ?? true, + shadow: titleSettings?.shadow ?? false, + }, + covers: batchCoversList, + onCoversChange: (updater) => { + const prevCardView = cardIndexes.map((vi) => (previewCovers || [])[vi] || "") + const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater + const next = [...(previewCovers || [])] + cardIndexes.forEach((vi, cardPos) => { + next[vi] = nextCardView[cardPos] || "" + }) + onPreviewCoversChange?.(next) }, }) - // 选中模板变化时通知父组件(用于批量生成时透传 template_id) - const { onTemplateChange, selectedTemplate: parentSelectedTemplate } = props - // 父组件 selectedTemplate 变化时同步到子(例如从 Step1/Step4 切换到 Step6 时) - useEffect(() => { - if (parentSelectedTemplate && parentSelectedTemplate !== shared.selectedTemplateId) { - shared.handleSelectTemplate(parentSelectedTemplate) - } - }, [parentSelectedTemplate]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - if (isBatch && onTemplateChange && shared.selectedTemplateId !== parentSelectedTemplate) { - onTemplateChange(shared.selectedTemplateId) - } - }, [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 => { - 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(() => { - // 单视频:注册实际上传函数;批量场景已由 batchCovers.uploadOne 接管, - // 这里不要覆盖(批量时 input ref 绑定到 batchUploadRef,不走 shared.handleFileInputChange) - if (!isBatch) { - shared.setOnUploadFile((file) => uploadLocalCover(file)) - } - }, [shared, isBatch, uploadLocalCover]) - + // 批量:模板选择状态与 CoverSelector 同步 + // (批量使用 CoverSelector 的 showUpload/showAutoGenerate=false,仅展示模板选择) const batchUploadRef = React.useRef(null) const [batchUploadCard, setBatchUploadCard] = React.useState(null) const handleBatchUploadClick = (cardPos: number) => { @@ -235,41 +207,6 @@ const Step6CoverSettings: React.FC = (props) => { void batchCovers.uploadOne(cardPos, file) } - const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "") - const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "") - - /** - * 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板, - * 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。 - */ - const batchCovers = useBatchCovers({ - selectedTemplate: shared.selectedTemplateId, - generatedVideos: props.generatedVideos, - titles: batchTitles, - titleStyle: { - font: props.titleSettings?.font || "思源黑体", - size: props.titleSettings?.size || 28, - color: props.titleSettings?.color || "#ffffff", - position: props.titleSettings?.position || "top", - bold: props.titleSettings?.bold ?? true, - stroke: props.titleSettings?.stroke ?? true, - shadow: props.titleSettings?.shadow ?? false, - }, - covers: batchCoversList, - onCoversChange: (updater) => { - const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "") - const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater - const next = [...(props.previewCovers || [])] - cardIndexes.forEach((vi, cardPos) => { - next[vi] = nextCardView[cardPos] || "" - }) - props.onPreviewCoversChange?.(next) - }, - }) - - const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url - - /* ── 批量封面 ── */ if (isBatch) { return (
@@ -287,9 +224,9 @@ const Step6CoverSettings: React.FC = (props) => { }} > 🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题 - {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( + {localSelectedTemplate && localSelectedTemplate !== "default" && ( <> - {" · "}当前模板:{shared.selectedTemplateName} + {" · "}当前模板:{localSelectedTemplate} )}
@@ -304,17 +241,9 @@ const Step6CoverSettings: React.FC = (props) => { > ✨ 一键全部自动生成 - -
+ {/* 批量封面卡片网格 */}
{cardIndexes.map((variantIndex, cardPos) => { const url = batchCoversList[cardPos] @@ -384,7 +313,23 @@ const Step6CoverSettings: React.FC = (props) => { })}
- {/* 隐藏的文件选择 input,批量上传复用 */} + {/* 批量场景下的模板选择:嵌入一个只开 showTemplate 的 CoverSelector */} +
+ { + setLocalSelectedTemplate(id) + onTemplateChange?.(id) + }} + showAutoGenerate={false} + showUpload={false} + showTemplate + canGenerate={false} + previewWidth={0} + /> +
+ = (props) => { style={{ display: "none" }} onChange={handleBatchUploadChange} /> - - shared.setShowCoverSettings(false)} - templates={shared.templates} - loading={shared.templatesLoading} - error={shared.templatesError} - selectedTemplateId={shared.selectedTemplateId} - onSelectTemplate={shared.handleSelectTemplate} - onEditTemplate={shared.handleEditTemplate} - onDeleteTemplate={shared.handleDeleteTemplate} - onCreateNew={shared.handleCreateTemplate} - /> - - shared.setShowCoverEditor(false)} - template={shared.editingTemplate} - onSave={shared.handleSaveTemplate} - />
) } - /* ── 单视频 ── */ + /* ── 单视频:公共 CoverSelector ── */ return (

🖼️ 选择封面

- - {(finalVideo || effectiveTaskId) && ( -
- 🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧 - {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( - <> - {" "} - · 当前模板:{shared.selectedTemplateName} - - )} -
- )} - -
- - - - - -
- -
封面预览
-
- {previewUrl ? ( - 封面预览 - ) : ( -
- 🖼️ - 点击"自动生成封面"或选择模板 -
- )} -
9:16
-
- - shared.setShowCoverSettings(false)} - templates={shared.templates} - loading={shared.templatesLoading} - error={shared.templatesError} - selectedTemplateId={shared.selectedTemplateId} - onSelectTemplate={shared.handleSelectTemplate} - onEditTemplate={shared.handleEditTemplate} - onDeleteTemplate={shared.handleDeleteTemplate} - onCreateNew={shared.handleCreateTemplate} + onTemplateChange?.(id)} + onChange={handleCoverChange} + onAutoGenerate={handleAutoGenerate} + canGenerate={canGenerateCover} + disabledHint={ + !finalVideo && !effectiveTaskId ? "请先生成视频再选择封面" : "视频尚未就绪,请稍候" + } + showAutoGenerate + showTemplate + showUpload + hint={hintNode} /> - - shared.setShowCoverEditor(false)} - template={shared.editingTemplate} - onSave={shared.handleSaveTemplate} - /> - - -
- -

- AI 正在从最终成片选帧,请稍候... -

-
-
) } diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx index 21e320b6e..30118bac9 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -1,1166 +1,2 @@ -import React, { useState, useCallback, useRef, useEffect } from "react" -import { Slider, Switch, Select, InputNumber, Input } from "antd" -import type { - CoverTemplate, - CoverEditorConfig, - TextStyleConfig, - TextDirection, - StrokeStyle, -} from "../../types/cover" -import { DEFAULT_EDITOR_CONFIG, ALL_FONTS } from "../../types/cover" -import Modal from "@/components/ui/Modal" -import Button from "@/components/ui/Button" -import "@/components/cover/cover.css" - -/** 安全合并模板 config 与默认配置(深拷贝 title/subtitle/shadows/background 等嵌套对象,避免共享引用 & 字段缺失) */ -const mergeEditorConfig = (partial?: Partial | null): CoverEditorConfig => { - const def = DEFAULT_EDITOR_CONFIG - const src = partial || {} - /** 兼容老模板:老版本 background 有 posX/posY/rotation,新版改为 offsetY(相对文字位置偏移)。 - * 老模板黑底默认 posY 通常是 50(与文字对齐)或 80(副标题偏下),统一归一为 offsetY=0, - * 因为新版背景位置已自动跟随文字位置,offsetY 仅做相对微调。 */ - const normalizeBg = (bg: Record | undefined) => { - if (!bg) return {} - // 兼容老模板字段:posX/posY/rotation 在新版中已改为 offsetY(背景位置自动跟随文字) - const normalized = { ...bg } - delete (normalized as Record).posX - delete (normalized as Record).posY - delete (normalized as Record).rotation - if (normalized.offsetY == null) normalized.offsetY = 0 - return normalized - } - const mergeText = ( - base: TextStyleConfig, - patch?: Partial | null, - ): TextStyleConfig => ({ - ...base, - ...(patch || {}), - position: { ...base.position, ...(patch?.position || {}) }, - background: { - ...base.background, - ...normalizeBg(patch?.background as Record | undefined), - }, - shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows], - }) - return { - ...def, - ...src, - title: mergeText(def.title, src.title), - subtitle: mergeText(def.subtitle, src.subtitle), - portraitPosition: { ...def.portraitPosition, ...(src.portraitPosition || {}) }, - backgroundPosition: { ...def.backgroundPosition, ...(src.backgroundPosition || {}) }, - maskPosition: { ...def.maskPosition, ...(src.maskPosition || {}) }, - } -} - -/* ── Props ── */ -interface CoverEditorModalProps { - open: boolean - onClose: () => void - template: CoverTemplate | null - onSave: (template: CoverTemplate) => void -} - -/* ── Section expand/collapse keys ── */ -type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask" - -/* ── Color picker sub-component ── */ -const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({ - value, - onChange, -}) => ( -
- onChange(e.target.value)} /> - onChange(e.target.value)} - maxLength={7} - className="xx-ce-color-hex" - /> -
-) - -/* ── Position pair sub-component ── */ -const PositionPair: React.FC<{ - x: number - y: number - onChange: (pos: { x: number; y: number }) => void -}> = ({ x, y, onChange }) => ( -
- onChange({ x: v ?? 0, y })} - style={{ width: "100%" }} - /> - onChange({ x, y: v ?? 0 })} - style={{ width: "100%" }} - /> -
-) - -/* ── Font Select options(合并预设+系统,用圆点颜色区分 tag) ── */ -const fontDotClass = (tag?: string) => { - if (tag === "preset") return "xx-ce-font-dot xx-ce-font-dot--preset" - if (tag === "hand") return "xx-ce-font-dot xx-ce-font-dot--hand" - if (tag === "serif") return "xx-ce-font-dot xx-ce-font-dot--serif" - if (tag === "mono") return "xx-ce-font-dot xx-ce-font-dot--mono" - return "xx-ce-font-dot xx-ce-font-dot--system" -} -const fontOptions = ALL_FONTS.map((f) => ({ - label: ( - - - {f.name} - - ), - value: f.name, -})) - -/* ── Find font family string from name ── */ -const getFontFamily = (name: string): string => { - const found = ALL_FONTS.find((f) => f.name === name) - return found ? found.family : "sans-serif" -} - -/* ── Text style panel (shared between title & subtitle) ── */ -const TextStylePanel: React.FC<{ - config: TextStyleConfig | undefined | null - onChange: (c: TextStyleConfig) => void - placeholder: string -}> = ({ config, onChange, placeholder }) => { - if (!config) return null - const upd = (key: K, val: TextStyleConfig[K]) => - onChange({ ...config, [key]: val }) - return ( -
- {/* 文字内容 - 改为可输入 */} -
- - upd("text", e.target.value)} - placeholder={placeholder} - autoSize={{ minRows: 1, maxRows: 3 }} - size="small" - maxLength={50} - showCount - /> -
- - {/* 字体 */} -
- - setName(e.target.value)} - placeholder="输入模板名称(如:我的爆款封面)" - /> -
- - -
-
- - {/* ── Layout ── */} -
- {/* ── Left Panel ── */} -
- {/* 1. 基础设置 */} -
-
toggle("basic")}> - 🔧 基础设置 - {sections.basic ? "▾" : "▸"} -
- {sections.basic && ( -
-
-
- 文字自动换行 - upd("autoSplitEnabled", v)} - /> -
- {cfg.autoSplitEnabled && ( - <> -
- - upd("titleMaxChars", v)} - /> -
-
- - upd("subtitleMaxChars", v)} - /> -
- - )} -
- -
-
- 背景模糊 - upd("blurEnabled", v)} - /> -
- {cfg.blurEnabled && ( -
- - upd("blurAmount", v)} - /> -
- )} -
-
- )} -
- - {/* 2. 背景 */} -
-
toggle("background")}> - 🎨 背景设置 -
e.stopPropagation()}> - upd("backgroundEnabled", v)} - /> -
-
- {sections.background && cfg.backgroundEnabled && ( -
-
- -
- - - {bgImageUrl && ( - - )} - - handleFile(e.target.files?.[0], setBgImageUrl, "backgroundImage") - } - /> -
-
-
- - upd("backgroundColor", v)} - /> -
-
- - upd("backgroundSize", v)} - /> -
-
- - upd("backgroundPosition", pos)} - /> -
-
- )} -
- - {/* 3. 主标题 */} -
-
toggle("title")}> - 🔠 主标题 - {sections.title ? "▾" : "▸"} -
- {sections.title && ( -
- -
- )} -
- - {/* 4. 副标题 */} -
-
toggle("subtitle")}> - 🔡 副标题 - {sections.subtitle ? "▾" : "▸"} -
- {sections.subtitle && ( -
- -
- )} -
- - {/* 5. 人像 */} -
-
toggle("portrait")}> - 👤 人像设置 -
e.stopPropagation()}> - upd("portraitEnabled", v)} - /> -
-
- {sections.portrait && cfg.portraitEnabled && ( -
-
- -
- - - {portraitImageUrl && ( - - )} - - handleFile(e.target.files?.[0], setPortraitImageUrl, "portraitImage") - } - /> -
-
-
- - upd("portraitSize", v)} - /> -
-
- - upd("portraitPosition", pos)} - /> -
-
-
- 人物描边 - upd("personStrokeEnabled", v)} - /> -
- {cfg.personStrokeEnabled && ( - <> -
-
- {(["solid", "dashed"] as StrokeStyle[]).map((st) => ( - - ))} -
-
-
- - upd("personStrokeColor", v)} - /> -
-
- - upd("personStrokeWidth", v)} - /> -
- - )} -
-
- )} -
- - {/* 6. 蒙版 */} -
-
toggle("mask")}> - 🌓 蒙版 -
e.stopPropagation()}> - upd("maskEnabled", v)} - /> -
-
- {sections.mask && cfg.maskEnabled && ( -
-
- -
- - - { - const f = e.target.files?.[0] - if (f) upd("maskImage", f.name) - }} - /> -
-
-
- - upd("maskColor", v)} /> -
-
- - upd("maskOpacity", v)} - /> -
-
- - upd("maskSize", v)} - /> -
-
- )} -
-
- - {/* ── Right Preview ── */} -
-
- - - - -
- {/* base background layer */} -
- - {/* Background decoration */} - {cfg.backgroundEnabled && !bgImageUrl && ( -
- )} - - {/* Portrait */} - {cfg.portraitEnabled && ( -
- )} - - {/* Title bg */} - {cfg.title?.background?.enabled &&
} - {/* Subtitle bg */} - {cfg.subtitle?.background?.enabled &&
} - - {/* Title (draggable) */} - {cfg.title && ( -
handleTextMouseDown(e, "title")} - > - {renderTextLines(cfg.title)} -
- )} - - {/* Subtitle (draggable) */} - {cfg.subtitle && ( -
handleTextMouseDown(e, "subtitle")} - > - {renderTextLines(cfg.subtitle)} -
- )} - - {/* Mask overlay */} - {cfg.maskEnabled && ( -
- )} -
-
- -
👁️ 实时预览(9:16 竖版)
-
-
- - ) -} - -export default CoverEditorModal +// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容) +export { default } from "@/components/cover/CoverEditorModal" diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverModeSelector.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverModeSelector.tsx index df50a20c8..a873b8be4 100755 --- a/apps/web/src/pages/generate/components/cover-settings/CoverModeSelector.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverModeSelector.tsx @@ -1,32 +1,2 @@ -import React from "react" -import type { CoverMode } from "../../types/cover" - -interface CoverModeSelectorProps { - mode: CoverMode - onModeChange: (mode: CoverMode) => void - modeLabels: Record - modeIcons: Record -} - -export const CoverModeSelector: React.FC = ({ - mode, - onModeChange, - modeLabels, - modeIcons, -}) => { - const modes: CoverMode[] = ["auto", "frame", "upload"] - return ( -
- {modes.map((m) => ( - - ))} -
- ) -} +// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容) +export { CoverModeSelector } from "@/components/cover" 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 786a5ed5b..8208597c2 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -1,195 +1,2 @@ -import React, { useMemo, useState } from "react" -import type { CoverTemplate } from "../../types/cover" -import Modal from "@/components/ui/Modal" -import Button from "@/components/ui/Button" -import "@/components/cover/cover.css" - -interface CoverSettingsModalProps { - open: boolean - onClose: () => void - templates: CoverTemplate[] - loading?: boolean - error?: string | null - selectedTemplateId: string - onSelectTemplate: (id: string) => void - onEditTemplate: (template: CoverTemplate) => void - onDeleteTemplate: (id: string) => void - onCreateNew: () => void -} - -/** 模板缩略图:优先渲染 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 = ({ - open, - onClose, - templates, - loading = false, - error = null, - selectedTemplateId, - onSelectTemplate, - onEditTemplate, - onDeleteTemplate, - onCreateNew, -}) => { - return ( - - - -
- } - > -
- -
- - {loading && ( -
- 加载中... -
- )} - - {error && !loading && ( -
{error}
- )} - - {!loading && !error && templates.length === 0 && ( -
- 暂无封面模板,点击右上角「创建新模板」可自定义封面样式 -
- )} - - {!loading && !error && templates.length > 0 && ( -
- {templates.map((tpl) => { - const isSelected = selectedTemplateId === tpl.id - return ( -
onSelectTemplate(tpl.id)} - > - -
-
- {tpl.name} - {tpl.is_system && ✨ 系统} -
-
e.stopPropagation()}> - - {!tpl.is_system && ( - - )} -
-
-
- ) - })} -
- )} - -
- 💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面 -
- - ) -} - -export default CoverSettingsModal +// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容) +export { default } from "@/components/cover/CoverSettingsModal" diff --git a/apps/web/src/pages/generate/components/cover-settings/FrameCoverPicker.tsx b/apps/web/src/pages/generate/components/cover-settings/FrameCoverPicker.tsx index 8f877c014..0ab3e3d8a 100755 --- a/apps/web/src/pages/generate/components/cover-settings/FrameCoverPicker.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/FrameCoverPicker.tsx @@ -1,58 +1,2 @@ -import React from "react" - -interface FrameCoverPickerProps { - frameTime: number - totalDuration: number - formatTime: (seconds: number) => string - onFrameTimeChange: (time: number) => void -} - -export const FrameCoverPicker: React.FC = ({ - frameTime, - totalDuration, - formatTime, - onFrameTimeChange, -}) => { - const quickRatios = [0, 0.25, 0.5, 0.75] - - return ( -
-
-
- 🎞️ - {formatTime(frameTime)} -
-
-
-
- 拖动选择封面帧 - {formatTime(frameTime)} -
- onFrameTimeChange(Number(e.target.value))} - className="xx-cover-range" - /> -
- 00:00 - {formatTime(totalDuration)} -
-
-
- 快捷选帧: - {quickRatios.map((ratio) => { - const t = totalDuration * ratio - return ( - - ) - })} -
-
- ) -} +// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容) +export { FrameCoverPicker } from "@/components/cover" diff --git a/apps/web/src/pages/generate/components/cover-settings/UploadCoverPicker.tsx b/apps/web/src/pages/generate/components/cover-settings/UploadCoverPicker.tsx index 40312e4c0..0062e9ccd 100755 --- a/apps/web/src/pages/generate/components/cover-settings/UploadCoverPicker.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/UploadCoverPicker.tsx @@ -1,46 +1,2 @@ -import React from "react" - -interface UploadCoverPickerProps { - uploadUrl: string - onUpload: (file: File) => void -} - -export const UploadCoverPicker: React.FC = ({ uploadUrl, onUpload }) => { - const handleClick = () => { - const input = document.getElementById("cover-upload-input") - input?.click() - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - onUpload(file) - } - } - - return ( -
-
- {uploadUrl ? ( -
- 封面预览 -
点击更换
-
- ) : ( -
- 📤 - 点击上传封面图片 - 支持 JPG / PNG,建议 9:16 比例 -
- )} - -
-
- ) -} +// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容) +export { UploadCoverPicker } from "@/components/cover" diff --git a/apps/web/src/pages/generate/types/cover.ts b/apps/web/src/pages/generate/types/cover.ts index c52846c85..f9a7cb945 100644 --- a/apps/web/src/pages/generate/types/cover.ts +++ b/apps/web/src/pages/generate/types/cover.ts @@ -32,285 +32,28 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = { thumbnail_url: "", } -/** 文字方向 */ -export type TextDirection = "horizontal" | "vertical" - -/** 文字背景形状 */ -export type TextBgShape = "rectangle" | "polygon" - -/** 描边样式 */ -export type StrokeStyle = "solid" | "dashed" - -/** 阴影层 */ -export interface ShadowLayer { - color: string - offsetX: number - offsetY: number - blur: number -} - -/** 文字位置 */ -export interface TextPosition { - x: number - y: number -} - -/** 文字背景配置 */ -export interface TextBackground { - enabled: boolean - color: string - opacity: number - shape: TextBgShape - width: number - height: number - /** 相对文字的上下偏移(百分比),背景自动跟随文字位置 */ - offsetY: number -} - -/** 文字样式配置(主标题/副标题共用) */ -export interface TextStyleConfig { - text: string - fontFamily: string - fontSize: number - fontWeight: number - direction: TextDirection - charsPerLine: number - letterSpacing: number - lineHeight: number - color: string - strokeColor: string - strokeWidth: number - shadows: ShadowLayer[] - traditionalShadow: boolean - position: TextPosition - rotation: number - background: TextBackground -} - -/** 编辑器完整配置 */ -export interface CoverEditorConfig { - // 基础设置 - blurEnabled: boolean - blurAmount: number - personStrokeEnabled: boolean - personStrokeStyle: StrokeStyle - personStrokeColor: string - personStrokeWidth: number - autoSplitEnabled: boolean - titleMaxChars: number - subtitleMaxChars: number - - // 人像设置 - portraitEnabled: boolean - portraitSize: number - portraitPosition: TextPosition - portraitImage?: string - - // 背景设置 - backgroundEnabled: boolean - backgroundSize: number - backgroundPosition: TextPosition - backgroundImage?: string - backgroundColor?: string - - // 主标题 - title: TextStyleConfig - - // 副标题 - subtitle: TextStyleConfig - - // 蒙版 - maskEnabled: boolean - maskImage: string - maskSize: number - maskPosition: TextPosition - maskColor: string - maskOpacity: number - maskShape: string -} - -/** 默认主标题配置 */ -export const DEFAULT_TITLE_CONFIG: TextStyleConfig = { - text: "主标题文字", - fontFamily: "思源黑体", - fontSize: 120, - fontWeight: 700, - direction: "horizontal", - charsPerLine: 10, - letterSpacing: 24, - lineHeight: 144, - color: "#FFD700", - strokeColor: "#000000", - strokeWidth: 3, - shadows: [], - traditionalShadow: false, - position: { x: 50, y: 30 }, - rotation: 0, - background: { - enabled: false, - color: "#FFFFFF", - opacity: 25, - shape: "polygon", - width: 30, - height: 10, - offsetY: 0, - }, -} - -/** 默认副标题配置 */ -export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = { - text: "副标题文字", - fontFamily: "思源黑体", - fontSize: 82, - fontWeight: 500, - direction: "horizontal", - charsPerLine: 17, - letterSpacing: 23, - lineHeight: 72, - color: "#FFFFFF", - strokeColor: "#000000", - strokeWidth: 1, - shadows: [], - traditionalShadow: false, - position: { x: 50, y: 70 }, - rotation: 0, - background: { - enabled: true, - color: "#000000", - opacity: 70, - shape: "rectangle", - width: 100, - height: 20, - offsetY: 8, - }, -} - -/** 默认编辑器配置 */ -export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = { - blurEnabled: false, - blurAmount: 10, - personStrokeEnabled: false, - personStrokeStyle: "solid", - personStrokeColor: "#FFFFFF", - personStrokeWidth: 8, - autoSplitEnabled: false, - titleMaxChars: 4, - subtitleMaxChars: 10, - - portraitEnabled: false, - portraitSize: 50, - portraitPosition: { x: 50, y: 70 }, - - backgroundEnabled: true, - backgroundSize: 100, - backgroundPosition: { x: 50, y: 50 }, - - title: DEFAULT_TITLE_CONFIG, - subtitle: DEFAULT_SUBTITLE_CONFIG, - - maskEnabled: false, - maskImage: "", - maskSize: 100, - maskPosition: { x: 50, y: 50 }, - maskColor: "#000000", - maskOpacity: 40, - maskShape: "矩形", -} - -/** 预置字体(已与 @/components/title/constants 字体表保持一致;自定义商业字体兜底 Google Fonts 开源中文字体) */ -// 封面编辑器预置字体:与标题样式字体列表保持一致(从 @/components/title/constants 同步), -// 并补全西文常用系统字体,保证在中英文环境下都有可用字体。 -// 注:需要配合 index.html 引入的 Google Fonts(Noto Sans SC / ZCOOL / Ma Shan Zheng 等)。 -export interface CoverFont { - name: string - family: string - tag?: "preset" | "hand" | "serif" | "sans" | "mono" -} - -/** 预置中文字体(爆款/常用) */ -export const PRESET_FONTS: CoverFont[] = [ - { - name: "优设标题黑", - family: - '"YouSheBiaoTiHei","ZCOOL QingKe HuangYou","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', - tag: "preset", - }, - { - name: "阿里普惠体Bold", - family: - '"Alibaba PuHuiTi","Alibaba Sans","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', - tag: "preset", - }, - { - name: "抖音美好体", - family: - '"Douyin Sans","ZCOOL KuaiLe","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', - tag: "preset", - }, - { - name: "思源黑体Heavy", - family: '"Noto Sans SC","Source Han Sans SC Heavy","PingFang SC","Microsoft YaHei",sans-serif', - tag: "preset", - }, - { - name: "思源黑体", - family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif', - tag: "preset", - }, - { - name: "思源宋体", - family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif', - tag: "serif", - }, - { name: "站酷小薇体", family: '"ZCOOL XiaoWei","Noto Serif SC",serif', tag: "preset" }, - { name: "马善政毛笔", family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive', tag: "hand" }, - { name: "龙藏体", family: '"Long Cang","STXingkai",cursive', tag: "hand" }, - { name: "楷体", family: '"KaiTi","STKaiti","DFKai-SB",serif', tag: "serif" }, - { - name: "苹方", - family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif', - tag: "sans", - }, - { - name: "微软雅黑", - family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif', - tag: "sans", - }, -] - -/** 系统字体(西文 + 通用中文) */ -export const SYSTEM_FONTS: CoverFont[] = [ - { name: "Arial", family: "Arial, Helvetica, sans-serif", tag: "sans" }, - { name: "Helvetica", family: "Helvetica, Arial, sans-serif", tag: "sans" }, - { name: "Times New Roman", family: '"Times New Roman", Times, serif', tag: "serif" }, - { name: "Georgia", family: "Georgia, serif", tag: "serif" }, - { name: "Verdana", family: "Verdana, Geneva, sans-serif", tag: "sans" }, - { name: "Tahoma", family: "Tahoma, Geneva, sans-serif", tag: "sans" }, - { name: "Impact", family: 'Impact, "Arial Black", sans-serif', tag: "sans" }, - { name: "Comic Sans MS", family: '"Comic Sans MS", cursive', tag: "hand" }, - { name: "Courier New", family: '"Courier New", Courier, monospace', tag: "mono" }, - { name: "宋体", family: "SimSun, 'Noto Serif SC', serif", tag: "serif" }, - { name: "黑体", family: "SimHei, 'Noto Sans SC', sans-serif", tag: "sans" }, - { name: "仿宋", family: "FangSong, 'Noto Serif SC', serif", tag: "serif" }, - { name: "Trebuchet MS", family: '"Trebuchet MS", sans-serif', tag: "sans" }, - { name: "Lucida Console", family: '"Lucida Console", Monaco, monospace', tag: "mono" }, - { name: "Palatino", family: 'Palatino, "Palatino Linotype", serif', tag: "serif" }, - { name: "Garamond", family: "Garamond, serif", tag: "serif" }, - { name: "Calibri", family: "Calibri, sans-serif", tag: "sans" }, - { name: "Cambria", family: "Cambria, serif", tag: "serif" }, - { name: "Candara", family: "Candara, sans-serif", tag: "sans" }, - { name: "Consolas", family: "Consolas, monospace", tag: "mono" }, -] - -/** 所有字体列表 */ -export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS] - -/** 封面模板 */ -export interface CoverTemplate { - id: string - name: string - thumbnail_url: string - is_system: boolean - created_at: string - config?: CoverEditorConfig -} +/* ── 以下类型已抽离到 @/components/cover/types,统一重导出 ── */ +export type { + TextDirection, + TextBgShape, + StrokeStyle, + ShadowLayer, + TextPosition, + TextBackground, + TextStyleConfig, + CoverEditorConfig, + CoverFont, + CoverTemplate, +} from "@/components/cover/types" +export { + DEFAULT_TITLE_CONFIG, + DEFAULT_SUBTITLE_CONFIG, + DEFAULT_EDITOR_CONFIG, + PRESET_FONTS, + SYSTEM_FONTS, + ALL_FONTS, +} from "@/components/cover/types" +export type { + CoverMode as CoverModeShared, + CoverConfig as CoverConfigShared, +} from "@/components/cover/types"