diff --git a/apps/web/src/pages/editing-planner/types/cover.ts b/apps/web/src/pages/editing-planner/types/cover.ts index c3a02e887..7e63beca5 100644 --- a/apps/web/src/pages/editing-planner/types/cover.ts +++ b/apps/web/src/pages/editing-planner/types/cover.ts @@ -30,3 +30,20 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = { ai_suggested_time: null, thumbnail_url: "", } + +/** 封面模板 */ +export interface CoverTemplate { + id: string + name: string + thumbnail_url: string + is_system: boolean + created_at: string + config?: { + background_enabled?: boolean + background_color?: string + portrait_enabled?: boolean + title_text?: string + subtitle_text?: string + mask_enabled?: boolean + } +} diff --git a/apps/web/src/pages/editing-planner/types/index.ts b/apps/web/src/pages/editing-planner/types/index.ts index 072ad49e1..ee0f695da 100644 --- a/apps/web/src/pages/editing-planner/types/index.ts +++ b/apps/web/src/pages/editing-planner/types/index.ts @@ -72,7 +72,7 @@ export { } from "./sticker" /* 封面 */ -export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover" +export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG, type CoverTemplate } from "./cover" /* 片段数据 */ export { type ClipType, type ClipData } from "./clip" diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index c0a4e9fef..9ce4dd3f0 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useRef } from "react" +import React from "react" import type { CoverConfig } from "../../editing-planner/types" import { useStep6Cover } from "../hooks/useStep6Cover" -import { CoverModeSelector } from "./cover-settings/CoverModeSelector" -import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker" -import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker" +import Button from "@/components/ui/Button" +import CoverSettingsModal from "./cover-settings/CoverSettingsModal" +import CoverEditorModal from "./cover-settings/CoverEditorModal" interface Step6CoverSettingsProps { coverSettings: CoverConfig @@ -18,15 +18,18 @@ interface Step6CoverSettingsProps { const Step6CoverSettings: React.FC = (props) => { const { coverSettings, - formatTime, - toggleEnabled, - setMode, - setFrameTime, - handleUpload, generateAutoCover, - totalDuration, - COVER_MODE_LABELS, - COVER_MODE_ICONS, + showCoverSettings, + setShowCoverSettings, + showCoverEditor, + setShowCoverEditor, + selectedTemplateId, + selectedTemplateName, + editingTemplate, + coverTemplates, + handleSelectTemplate, + handleEditTemplate, + handleSaveTemplate, } = useStep6Cover({ coverSettings: props.coverSettings, onCoverSettingsChange: props.onCoverSettingsChange, @@ -35,32 +38,9 @@ const Step6CoverSettings: React.FC = (props) => { selectedTemplate: props.selectedTemplate, }) - // 进入 auto 模式时自动触发智能封面生成 - const autoTriggeredRef = useRef(false) - useEffect(() => { - // 切换模式、禁用封面或素材变更时重置触发标记 - if (coverSettings.mode !== "auto" || !coverSettings.enabled) { - autoTriggeredRef.current = false - return - } - // 有素材且未生成过封面时自动触发 - if ( - coverSettings.mode === "auto" && - !coverSettings.thumbnail_url && - !autoTriggeredRef.current && - props.assetIds && - props.assetIds.length > 0 - ) { - autoTriggeredRef.current = true - generateAutoCover() - } - }, [ - coverSettings.enabled, - coverSettings.mode, - coverSettings.thumbnail_url, - generateAutoCover, - props.assetIds, - ]) + const handleAutoGenerate = () => { + generateAutoCover() + } // 预览图:优先 thumbnail_url,其次 upload_url const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url @@ -69,70 +49,49 @@ const Step6CoverSettings: React.FC = (props) => {

🖼️ 选择封面

-
- 启用自定义封面 - +
+ +
- {coverSettings.enabled && ( - <> -
封面来源
- +
已选模板: {selectedTemplateName}
- {coverSettings.mode === "auto" && ( -
-
- 🤖 - AI 智能选帧 -
-
- )} - - {coverSettings.mode === "frame" && ( - - )} - - {coverSettings.mode === "upload" && ( - - )} - -
封面预览
-
- {previewUrl ? ( - 封面预览 - ) : ( -
- 🖼️ - - {coverSettings.mode === "auto" - ? "AI 正在选择..." - : coverSettings.mode === "frame" - ? `帧 ${formatTime(coverSettings.frame_time)}` - : "未上传封面"} - -
- )} -
9:16
+
封面预览
+
+ {previewUrl ? ( + 封面预览 + ) : ( +
+ 🖼️ + 点击"自动生成封面"或选择模板
- - )} + )} +
9:16
+
+ + setShowCoverSettings(false)} + templates={coverTemplates} + selectedTemplateId={selectedTemplateId} + onSelectTemplate={handleSelectTemplate} + onEditTemplate={handleEditTemplate} + onCreateNew={() => { + setShowCoverSettings(false) + setShowCoverEditor(true) + }} + /> + + setShowCoverEditor(false)} + template={editingTemplate} + onSave={handleSaveTemplate} + />
) } diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx new file mode 100644 index 000000000..b0ed69175 --- /dev/null +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -0,0 +1,125 @@ +import React, { useState } from "react" +import type { CoverTemplate } from "../../../editing-planner/types" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" + +interface CoverEditorModalProps { + open: boolean + onClose: () => void + template: CoverTemplate | null + onSave: (template: CoverTemplate) => void +} + +interface SectionState { + basic: boolean + portrait: boolean + background: boolean + title: boolean + subtitle: boolean + mask: boolean +} + +const CoverEditorModal: React.FC = ({ open, onClose, template, onSave }) => { + const [name, setName] = useState(template?.name || "") + const [sections, setSections] = useState({ + basic: true, + portrait: false, + background: false, + title: true, + subtitle: true, + mask: false, + }) + + const toggleSection = (key: keyof SectionState) => { + setSections((prev) => ({ ...prev, [key]: !prev[key] })) + } + + const handleSave = () => { + if (!template) return + onSave({ ...template, name }) + onClose() + } + + return ( + +
+ setName(e.target.value)} + placeholder="输入模板名称" + /> +
+ + +
+
+ +
+ {/* 左侧折叠面板 */} +
+ {[ + { key: "basic" as const, label: "基础设置" }, + { key: "portrait" as const, label: "人像设置" }, + { key: "background" as const, label: "背景设置", toggle: true }, + { key: "title" as const, label: "主标题" }, + { key: "subtitle" as const, label: "副标题" }, + { key: "mask" as const, label: "蒙版", toggle: true }, + ].map((item) => ( +
+
toggleSection(item.key)} + > + {item.label} + {sections[item.key] ? "▾" : "▸"} +
+ {sections[item.key] && ( +
+ {item.toggle ? ( + + ) : ( + 暂无配置项 + )} +
+ )} +
+ ))} +
+ + {/* 右侧画布预览 */} +
+
+ {/* 人像占位 */} +
+ {/* 四角拖拽手柄 */} + + + + +
+ {/* 文字占位 */} +
主标题文字
+
副标题文字
+
+
+
+
+ ) +} + +export default CoverEditorModal diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx new file mode 100644 index 000000000..d0c168b50 --- /dev/null +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -0,0 +1,81 @@ +import React from "react" +import type { CoverTemplate } from "../../../editing-planner/types" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" + +interface CoverSettingsModalProps { + open: boolean + onClose: () => void + templates: CoverTemplate[] + selectedTemplateId: string + onSelectTemplate: (id: string) => void + onEditTemplate: (template: CoverTemplate) => void + onCreateNew: () => void +} + +const GRADIENT_MAP: Record = { + default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)", + "bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)", + "elegant-black": "linear-gradient(135deg, #374151, #111827)", + "gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)", + "gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)", + "warm-orange": "linear-gradient(135deg, #f97316, #ea580c)", + "fresh-green": "linear-gradient(135deg, #22c55e, #15803d)", + "tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)", +} + +const CoverSettingsModal: React.FC = ({ + open, + onClose, + templates, + selectedTemplateId, + onSelectTemplate, + onEditTemplate, + onCreateNew, +}) => { + return ( + +
+ + + +
+ +
+ {templates.map((tpl) => ( +
onSelectTemplate(tpl.id)} + > +
+ 🖼️ +
+
+
+ {tpl.name} + {tpl.is_system && ✨ 系统模板} +
+
{tpl.created_at}
+
e.stopPropagation()}> + + +
+
+
+ ))} +
+
+ ) +} + +export default CoverSettingsModal diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index 478523f38..703c3244d 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -2,7 +2,7 @@ * 智能剪辑页面 — 常量定义 */ -import type { CoverConfig } from "../editing-planner/types" +import type { CoverConfig, CoverTemplate } from "../editing-planner/types" /* ── 克隆声音状态配置 ── */ export const CLONE_STATUS_CONFIG: Record = { @@ -198,3 +198,40 @@ export const DEFAULT_COVER_SETTINGS: CoverConfig = { ai_suggested_time: null, thumbnail_url: "", } + +/* ── Mock 封面模板数据 ── */ + +export const MOCK_COVER_TEMPLATES: CoverTemplate[] = [ + { id: "default", name: "默认模板", thumbnail_url: "", is_system: true, created_at: "2026-08-01" }, + { id: "bold-red", name: "大胆红", thumbnail_url: "", is_system: true, created_at: "2026-08-01" }, + { + id: "elegant-black", + name: "优雅黑", + thumbnail_url: "", + is_system: true, + created_at: "2026-08-01", + }, + { + id: "gradient-blue", + name: "渐变蓝", + thumbnail_url: "", + is_system: true, + created_at: "2026-08-01", + }, + { + id: "gradient-purple", + name: "渐变紫", + thumbnail_url: "", + is_system: true, + created_at: "2026-08-01", + }, + { id: "warm-orange", name: "暖橙", thumbnail_url: "", is_system: true, created_at: "2026-08-01" }, + { + id: "fresh-green", + name: "清新绿", + thumbnail_url: "", + is_system: true, + created_at: "2026-08-01", + }, + { id: "tech-blue", name: "科技蓝", thumbnail_url: "", is_system: true, created_at: "2026-08-01" }, +] diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 7bc13a098..2e85d49dd 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -2888,3 +2888,196 @@ .ant-modal-close { color: #fff !important; } + +/* ── 封面设置区域改造样式 ── */ + +/* 封面操作按钮区 */ +.xx-cover-actions { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +/* 已选模板文字 */ +.xx-cover-selected-template { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; +} + +/* 封面设置弹窗 - 工具栏 */ +.xx-cover-modal-toolbar { + display: flex; + gap: 8px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +/* 封面模板网格 */ +.xx-cover-template-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +/* 封面模板卡片 */ +.xx-cover-template-card { + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: border-color 0.2s; +} +.xx-cover-template-card:hover { + border-color: var(--primary-color); +} +.xx-cover-template-card.selected { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +/* 卡片缩略图 */ +.xx-cover-template-thumb { + aspect-ratio: 9/16; + background: linear-gradient(135deg, #f0f0f0, #e0e0e0); + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + color: #ccc; +} + +/* 卡片信息区 */ +.xx-cover-template-info { + padding: 8px; +} +.xx-cover-template-name { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} +.xx-cover-template-badge { + font-size: 11px; + color: #7c3aed; + background: rgba(124, 58, 237, 0.1); + padding: 1px 6px; + border-radius: 4px; + white-space: nowrap; +} +.xx-cover-template-date { + font-size: 11px; + color: var(--text-tertiary); + margin-bottom: 6px; +} +.xx-cover-template-actions { + display: flex; + gap: 4px; +} + +/* 封面编辑器 */ +.xx-cover-editor-layout { + display: flex; + gap: 20px; + min-height: 500px; +} +.xx-cover-editor-left { + width: 280px; + flex-shrink: 0; +} +.xx-cover-editor-right { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: var(--radius-md); +} +.xx-cover-editor-canvas { + width: 225px; + height: 400px; + background: #ddd; + position: relative; + border-radius: 8px; + overflow: hidden; +} +.xx-cover-editor-portrait { + position: absolute; + top: 20%; + left: 15%; + width: 70%; + height: 45%; + background: #a8d4f0; + border: 2px solid #333; +} +.xx-cover-editor-handle { + position: absolute; + width: 8px; + height: 8px; + background: #333; + border: 1px solid white; +} + +/* 编辑器折叠面板 */ +.xx-cover-editor-section { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + margin-bottom: 8px; +} +.xx-cover-editor-section-header { + padding: 10px 12px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-tertiary); +} +.xx-cover-editor-section-body { + padding: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +/* 编辑器顶部 */ +.xx-cover-editor-header { + margin-bottom: 16px; +} +.xx-cover-editor-name-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 14px; + margin-bottom: 12px; +} +.xx-cover-editor-header-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* 编辑器画布内文字占位 */ +.xx-cover-editor-title-placeholder { + position: absolute; + top: 72%; + left: 50%; + transform: translateX(-50%); + font-size: 16px; + font-weight: 700; + color: #fff; + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6); + white-space: nowrap; +} +.xx-cover-editor-subtitle-placeholder { + position: absolute; + top: 82%; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; +} diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 4dd3781cb..c73ca0ab0 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -2,9 +2,9 @@ * Step 6 封面设置 Hook * 封装封面设置的交互逻辑 */ -import { useCallback, useRef } from "react" -import type { CoverConfig } from "../../editing-planner/types" -import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants" +import { useCallback, useRef, useState } from "react" +import type { CoverConfig, CoverTemplate } from "../../editing-planner/types" +import { MOCK_COVER_TEMPLATES } from "../constants" import { generateCover } from "@/api/template-editor" interface UseStep6CoverProps { @@ -26,50 +26,12 @@ export function useStep6Cover({ }: UseStep6CoverProps) { const generatingRef = useRef(false) - const formatTime = useCallback((seconds: number) => { - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - const ms = Math.floor((seconds % 1) * 10) - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` - }, []) - - const toggleEnabled = useCallback( - (enabled: boolean) => { - onCoverSettingsChange({ ...coverSettings, enabled }) - }, - [coverSettings, onCoverSettingsChange], - ) - - const setMode = useCallback( - (mode: CoverConfig["mode"]) => { - onCoverSettingsChange({ ...coverSettings, mode }) - }, - [coverSettings, onCoverSettingsChange], - ) - - const setFrameTime = useCallback( - (frameTime: number) => { - onCoverSettingsChange({ ...coverSettings, frame_time: frameTime }) - }, - [coverSettings, onCoverSettingsChange], - ) - - const handleUpload = useCallback( - (file: File) => { - const reader = new FileReader() - reader.onload = (ev) => { - const url = ev.target?.result as string - onCoverSettingsChange({ - ...coverSettings, - upload_url: url, - thumbnail_url: url, - mode: "upload", - }) - } - reader.readAsDataURL(file) - }, - [coverSettings, onCoverSettingsChange], - ) + // ── 封面设置弹窗状态 ── + const [showCoverSettings, setShowCoverSettings] = useState(false) + const [showCoverEditor, setShowCoverEditor] = useState(false) + const [selectedTemplateId, setSelectedTemplateId] = useState("default") + const [editingTemplate, setEditingTemplate] = useState(null) + const [coverTemplates, setCoverTemplates] = useState(MOCK_COVER_TEMPLATES) /** 调用后端智能封面 API,生成封面并更新预览 */ const generateAutoCover = useCallback(async () => { @@ -95,20 +57,42 @@ export function useStep6Cover({ } }, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange]) + // ── 模板操作方法 ── + const handleSelectTemplate = useCallback((id: string) => { + setSelectedTemplateId(id) + }, []) + + const handleEditTemplate = useCallback((tpl: CoverTemplate) => { + setEditingTemplate(tpl) + setShowCoverEditor(true) + }, []) + + const handleSaveTemplate = useCallback((tpl: CoverTemplate) => { + setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? tpl : t))) + setShowCoverEditor(false) + }, []) + + const selectedTemplateName = + coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认" + const totalDuration = duration || 30 return { coverSettings, - formatTime, - toggleEnabled, - setMode, - setFrameTime, - handleUpload, generateAutoCover, totalDuration, - COVER_MODE_LABELS, - COVER_MODE_ICONS, - DEFAULT_COVER_SETTINGS, + showCoverSettings, + setShowCoverSettings, + showCoverEditor, + setShowCoverEditor, + selectedTemplateId, + setSelectedTemplateId, + editingTemplate, + coverTemplates, + selectedTemplateName, + handleSelectTemplate, + handleEditTemplate, + handleSaveTemplate, } }