refactor(shared): 抽取封面选择为公共组件CoverSelector,智能剪辑/AI数字人复用
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m27s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 4m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m58s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 10m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m35s

- 新增公共目录 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
This commit is contained in:
Coze Agent
2026-09-27 21:35:34 +08:00
parent 4ae09f2b1d
commit f741a0e76e
20 changed files with 2528 additions and 2379 deletions
+1 -1
View File
@@ -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[]
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import React from "react"
import type { CoverMode } from "./types"
interface CoverModeSelectorProps {
mode: CoverMode
onModeChange: (mode: CoverMode) => void
modeLabels: Record<CoverMode, string>
modeIcons: Record<CoverMode, string>
}
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
mode,
onModeChange,
modeLabels,
modeIcons,
}) => {
const modes: CoverMode[] = ["auto", "frame", "upload"]
return (
<div className="xx-cover-mode-tabs">
{modes.map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
onClick={() => onModeChange(m)}
>
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
</button>
))}
</div>
)
}
@@ -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<string, unknown>,
) => void
/** 自动生成封面:父组件负责调用后端,返回封面 URL;不传则隐藏自动生成按钮 */
onAutoGenerate?: (templateId: string) => Promise<string | null | undefined>
/** 是否可以自动生成(视频就绪等条件) */
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<CoverSelectorProps> = ({
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<string | null> => {
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 = (
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
)
void _uploadInput
const actionButtons = (fullWidth = false) => (
<>
{showAutoGenerate && (
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!canGenerate || shared.generating}
loading={shared.generating}
style={fullWidth ? { width: "100%" } : undefined}
>
✨ 自动生成封面
</Button>
)}
{showTemplate && (
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={fullWidth ? { width: "100%" } : undefined}
>
⚙️ 封面模板
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
? `:${shared.selectedTemplateName}`
: ""}
</Button>
)}
{showUpload && (
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={uploading}
loading={uploading}
style={fullWidth ? { width: "100%" } : undefined}
>
📷 本地上传
</Button>
)}
</>
)
const hintNode = hint ? <div className="cs-hint">{hint}</div> : null
const previewBox = (w?: number) => (
<div
className={`cs-preview-box${previewUrl ? " has-image" : ""}`}
style={{
width: w ?? (mode === "modal" ? 180 : 220),
aspectRatio,
...(mode === "modal" ? { margin: "0 auto" } : {}),
}}
>
{previewUrl ? (
<img src={previewUrl} alt="封面预览" className="cs-preview-img" />
) : (
<div className="cs-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼️</span>
<span>{canGenerate ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}</span>
</div>
)}
<span className="cs-preview-ratio">{aspectRatio.replace(/\s/g, "")}</span>
{(shared.generating || uploading) && (
<div className="cs-preview-loading">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>{uploading ? "上传中…" : "AI 选帧中…"}</span>
</div>
)}
</div>
)
const body =
mode === "modal" ? (
<div style={{ padding: "8px 0" }} className={className}>
{hintNode}
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<div style={{ width: 180, flexShrink: 0 }}>
{previewBox(180)}
<div style={{ marginTop: 6, textAlign: "center", fontSize: 11, color: "#8c8ca1" }}>
{aspectRatio.replace(/\s/g, "")} 竖版封面
</div>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
{actionButtons(true)}
<div className="cs-help-tip">
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
</div>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
</div>
) : (
<div className={className}>
{hintNode}
<div className="cs-actions">{actionButtons(false)}</div>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div className="cs-section-title">封面预览</div>
{previewBox(previewWidth)}
</div>
)
return (
<>
{mode === "modal" ? (
<Modal
open={open}
onCancel={onClose}
title={title}
width={560}
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确定
</Button>
</div>
}
>
{body}
</Modal>
) : (
body
)}
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => 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}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
<AntModal
open={shared.generating && mode === "inline"}
closable={false}
footer={null}
centered
>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</>
)
}
export default CoverSelector
@@ -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 (
<div
className="xx-cover-template-thumb"
style={{
background: url ? "#000" : placeholderBg,
position: "relative",
overflow: "hidden",
}}
>
{isSelected && <span className="xx-cover-template-check">✓</span>}
{url ? (
<img
src={url}
alt={tpl.name}
onError={() => setErrored(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<span style={{ fontSize: 28, opacity: 0.5 }}>🖼️</span>
)}
</div>
)
}
const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
open,
onClose,
templates,
loading = false,
error = null,
selectedTemplateId,
onSelectTemplate,
onEditTemplate,
onDeleteTemplate,
onCreateNew,
}) => {
return (
<Modal
open={open}
onCancel={onClose}
width={800}
title="封面设置"
centered
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确认应用
</Button>
</div>
}
>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
</Button>
</div>
{loading && (
<div style={{ textAlign: "center", padding: "40px 0", color: "var(--text-secondary)" }}>
加载中...
</div>
)}
{error && !loading && (
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && templates.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-secondary)",
fontSize: 13,
}}
>
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
</div>
)}
{!loading && !error && templates.length > 0 && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<TemplateThumb tpl={tpl} isSelected={isSelected} />
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "rgba(124,58,237,0.06)",
borderRadius: 6,
fontSize: 12,
color: "#6d28d9",
}}
>
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
</div>
</Modal>
)
}
export default CoverSettingsModal
+58
View File
@@ -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<FrameCoverPickerProps> = ({
frameTime,
totalDuration,
formatTime,
onFrameTimeChange,
}) => {
const quickRatios = [0, 0.25, 0.5, 0.75]
return (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={frameTime}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{quickRatios.map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import React from "react"
interface UploadCoverPickerProps {
uploadUrl: string
onUpload: (file: File) => void
}
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
const handleClick = () => {
const input = document.getElementById("cover-upload-input")
input?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
onUpload(file)
}
}
return (
<div className="xx-cover-upload">
<div className="xx-cover-upload-area" onClick={handleClick}>
{uploadUrl ? (
<div className="xx-cover-upload-preview">
<img src={uploadUrl} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 9:16 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
}
+87
View File
@@ -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);
}
+15
View File
@@ -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"
+318
View File
@@ -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
}
@@ -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<string> {
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<UploadCoverResult> {
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
}
}
@@ -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,
@@ -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<AiAvatarCoverConfig>) => void
/**
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
*/
/** 老接口保留兼容 */
onGenerateRenderSmartCover?: (
renderId: string,
) => Promise<{ cover_url: string; message?: string }>
@@ -45,46 +32,33 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
coverConfig,
onCoverConfigChange,
onGenerateRenderSmartCover,
onUploadCover,
onCoverSelected,
}) => {
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
const generateFn = useCallback(
const handleAutoGenerate = useCallback(
async (templateId: string): Promise<string | null> => {
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<ModalCoverSelectProps> = ({
],
)
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<ModalCoverSelectProps> = ({
})
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 (
<Modal
<CoverSelector
mode="modal"
open={open}
onCancel={onClose}
onClose={onClose}
title="选择封面"
width={560}
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确定
</Button>
</div>
}
>
<div style={{ padding: "8px 0" }}>
{renderJob && (
<div
style={{
padding: "8px 12px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 从渲染成片中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
}}
>
{/* 左:封面预览 */}
<div
style={{
width: 180,
flexShrink: 0,
}}
>
<div
className="xx-ce-canvas"
style={{
position: "relative",
width: "100%",
aspectRatio: "9 / 16",
borderRadius: 8,
overflow: "hidden",
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
border: previewUrl ? "none" : "1px dashed #d9d9d9",
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="封面预览"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
gap: 6,
opacity: 0.7,
}}
>
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
</span>
</div>
)}
{shared.generating && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
flexDirection: "column",
gap: 8,
}}
>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>AI 选帧中…</span>
</div>
)}
</div>
<div
style={{
marginTop: 6,
textAlign: "center",
fontSize: 11,
color: "#8c8ca1",
}}
>
9:16 竖版封面
</div>
</div>
{/* 右:操作按钮 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!isRenderCompleted || shared.generating}
loading={shared.generating}
style={{ width: "100%" }}
>
✨ 自动生成封面
</Button>
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={{ width: "100%" }}
>
⚙️ 封面模板
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={!isRenderCompleted || shared.generating}
style={{ width: "100%" }}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div
style={{
fontSize: 11,
color: "#8c8ca1",
lineHeight: 1.5,
marginTop: 4,
padding: "6px 8px",
background: "#f7f8fa",
borderRadius: 6,
}}
>
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
</div>
</div>
{/* 模板选择弹窗 */}
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => 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}
/>
{/* 自定义编辑器弹窗 */}
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</Modal>
value={previewUrl}
onChange={handleChange}
onAutoGenerate={isRenderCompleted ? handleAutoGenerate : undefined}
canGenerate={isRenderCompleted}
disabledHint="请先完成视频生成再选择封面"
showAutoGenerate
showTemplate
showUpload
hint={renderJob ? <>🎬 从渲染成片中智能选帧</> : undefined}
/>
)
}
@@ -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<Step6CoverSettingsProps> = (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<Step6CoverSettingsProps> = (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<string | null> => {
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<string, unknown>
} = {
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<string>(
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<string | null> => {
const hide = message.loading("正在上传封面...", 0)
setUploadingLocalCover(true)
try {
// 立即创建 blob URL 用于即时预览,同时异步上传 OSS
const previewUrl = URL.createObjectURL(file)
onCoverSettingsChange({
...curCoverSettings,
upload_url: previewUrl,
thumbnail_url: previewUrl,
mode: "upload",
})
// 查找图片素材库(复用批量封面的逻辑)
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
hide()
message.error("未找到素材库,请先创建图片素材库")
return previewUrl
}
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
const realUrl = result?.url || ""
if (!realUrl) {
hide()
message.warning("上传完成但未获取到URL,将使用本地预览")
return previewUrl
}
hide()
// 替换 blob URL 为真实 OSS URL(blob 用于预览过渡,finalize 时必须用真实 URL)
onCoverSettingsChange({
...curCoverSettings,
upload_url: realUrl,
thumbnail_url: realUrl,
mode: "upload",
})
message.success("封面上传成功")
return realUrl
} catch (err) {
hide()
console.error("[Step6] 封面上传失败:", err)
message.error("封面上传失败,请重试")
return null
} finally {
setUploadingLocalCover(false)
}
},
[curCoverSettings, onCoverSettingsChange],
)
useEffect(() => {
// 单视频:注册实际上传函数;批量场景已由 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<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
const handleBatchUploadClick = (cardPos: number) => {
@@ -235,41 +207,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (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 (
<div className="xx-form-section">
@@ -287,9 +224,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
{localSelectedTemplate && localSelectedTemplate !== "default" && (
<>
{" · "}当前模板:<strong>{shared.selectedTemplateName}</strong>
{" · "}当前模板:<strong>{localSelectedTemplate}</strong>
</>
)}
</div>
@@ -304,17 +241,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
>
✨ 一键全部自动生成
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
? `:${shared.selectedTemplateName}`
: ""}
</Button>
<Button buttonType="ghost" onClick={shared.handleCreateTemplate}>
➕ 新建模板
</Button>
</div>
{/* 批量封面卡片网格 */}
<div className="xx-cover-grid">
{cardIndexes.map((variantIndex, cardPos) => {
const url = batchCoversList[cardPos]
@@ -384,7 +313,23 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
})}
</div>
{/* 隐藏的文件选择 input,批量上传复用 */}
{/* 批量场景下的模板选择:嵌入一个只开 showTemplate 的 CoverSelector */}
<div style={{ marginTop: 16 }}>
<CoverSelector
mode="inline"
templateId={localSelectedTemplate}
onTemplateChange={(id) => {
setLocalSelectedTemplate(id)
onTemplateChange?.(id)
}}
showAutoGenerate={false}
showUpload={false}
showTemplate
canGenerate={false}
previewWidth={0}
/>
</div>
<input
ref={batchUploadRef}
type="file"
@@ -392,135 +337,30 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => 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}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
</div>
)
}
/* ── 单视频 ── */
/* ── 单视频:公共 CoverSelector ── */
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{(finalVideo || effectiveTaskId) && (
<div
style={{
padding: "10px 14px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(16, 185, 129, 0.15)",
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div className="xx-cover-actions">
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!canGenerateCover || shared.generating}
loading={shared.generating}
title={!canGenerateCover ? "请先完成视频生成" : ""}
>
✨ 自动生成封面
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={uploadingLocalCover}
loading={uploadingLocalCover}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<input
ref={batchUploadRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
</div>
<div className="xx-section-title">封面预览</div>
<div className="xx-cover-preview-box">
{previewUrl ? (
<img src={previewUrl} alt="封面预览" className="xx-cover-preview-img" />
) : (
<div className="xx-cover-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼️</span>
<span>点击"自动生成封面"或选择模板</span>
</div>
)}
<div className="xx-cover-preview-ratio">9:16</div>
</div>
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => 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}
<CoverSelector
mode="inline"
value={coverSettings.thumbnail_url || coverSettings.upload_url}
templateId={selectedTemplate || null}
onTemplateChange={(id) => onTemplateChange?.(id)}
onChange={handleCoverChange}
onAutoGenerate={handleAutoGenerate}
canGenerate={canGenerateCover}
disabledHint={
!finalVideo && !effectiveTaskId ? "请先生成视频再选择封面" : "视频尚未就绪,请稍候"
}
showAutoGenerate
showTemplate
showUpload
hint={hintNode}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
<Modal open={shared.generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</Modal>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,2 @@
import React from "react"
import type { CoverMode } from "../../types/cover"
interface CoverModeSelectorProps {
mode: CoverMode
onModeChange: (mode: CoverMode) => void
modeLabels: Record<CoverMode, string>
modeIcons: Record<CoverMode, string>
}
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
mode,
onModeChange,
modeLabels,
modeIcons,
}) => {
const modes: CoverMode[] = ["auto", "frame", "upload"]
return (
<div className="xx-cover-mode-tabs">
{modes.map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
onClick={() => onModeChange(m)}
>
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
</button>
))}
</div>
)
}
// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容)
export { CoverModeSelector } from "@/components/cover"
@@ -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 (
<div
className="xx-cover-template-thumb"
style={{
background: url ? "#000" : placeholderBg,
position: "relative",
overflow: "hidden",
}}
>
{isSelected && <span className="xx-cover-template-check">✓</span>}
{url ? (
<img
src={url}
alt={tpl.name}
onError={() => setErrored(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<span style={{ fontSize: 28, opacity: 0.5 }}>🖼️</span>
)}
</div>
)
}
const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
open,
onClose,
templates,
loading = false,
error = null,
selectedTemplateId,
onSelectTemplate,
onEditTemplate,
onDeleteTemplate,
onCreateNew,
}) => {
return (
<Modal
open={open}
onCancel={onClose}
width={800}
title="封面设置"
centered
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确认应用
</Button>
</div>
}
>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
</Button>
</div>
{loading && (
<div style={{ textAlign: "center", padding: "40px 0", color: "var(--text-secondary)" }}>
加载中...
</div>
)}
{error && !loading && (
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && templates.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-secondary)",
fontSize: 13,
}}
>
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
</div>
)}
{!loading && !error && templates.length > 0 && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<TemplateThumb tpl={tpl} isSelected={isSelected} />
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "rgba(124,58,237,0.06)",
borderRadius: 6,
fontSize: 12,
color: "#6d28d9",
}}
>
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
</div>
</Modal>
)
}
export default CoverSettingsModal
// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容)
export { default } from "@/components/cover/CoverSettingsModal"
@@ -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<FrameCoverPickerProps> = ({
frameTime,
totalDuration,
formatTime,
onFrameTimeChange,
}) => {
const quickRatios = [0, 0.25, 0.5, 0.75]
return (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={frameTime}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{quickRatios.map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
}
// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容)
export { FrameCoverPicker } from "@/components/cover"
@@ -1,46 +1,2 @@
import React from "react"
interface UploadCoverPickerProps {
uploadUrl: string
onUpload: (file: File) => void
}
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
const handleClick = () => {
const input = document.getElementById("cover-upload-input")
input?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
onUpload(file)
}
}
return (
<div className="xx-cover-upload">
<div className="xx-cover-upload-area" onClick={handleClick}>
{uploadUrl ? (
<div className="xx-cover-upload-preview">
<img src={uploadUrl} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 9:16 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
}
// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容)
export { UploadCoverPicker } from "@/components/cover"
+25 -282
View File
@@ -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"