diff --git a/apps/web/public/title-templates/portrait1.jpg b/apps/web/public/title-templates/portrait1.jpg new file mode 100644 index 000000000..10ff584b5 Binary files /dev/null and b/apps/web/public/title-templates/portrait1.jpg differ diff --git a/apps/web/public/title-templates/portrait2.jpg b/apps/web/public/title-templates/portrait2.jpg new file mode 100644 index 000000000..07838a010 Binary files /dev/null and b/apps/web/public/title-templates/portrait2.jpg differ diff --git a/apps/web/public/title-templates/scene1.jpg b/apps/web/public/title-templates/scene1.jpg new file mode 100644 index 000000000..cba869f7b Binary files /dev/null and b/apps/web/public/title-templates/scene1.jpg differ diff --git a/apps/web/src/components/title/TitleTemplate.css b/apps/web/src/components/title/TitleTemplate.css index 4d1522a4d..6cf273837 100644 --- a/apps/web/src/components/title/TitleTemplate.css +++ b/apps/web/src/components/title/TitleTemplate.css @@ -1,383 +1,391 @@ /* ============================================================ - 标题模板系统(#2003):选择器 + 编辑器 + 保存弹窗 + 标题模板系统 v3(按 sketch 重构) + - 大卡片网格(图片背景 + 透明 Canvas 叠字 + 始终可见操作按钮) + - 编辑器弹窗(左竖屏预览 + 右参数 Tab) ============================================================ */ -/* ── Modal 头部 ── */ -.tt-modal-title { - display: flex; - align-items: center; - justify-content: space-between; - padding-right: 32px; -} -.tt-modal .ant-modal-body { - padding: 16px 20px; - max-height: 70vh; - overflow-y: auto; -} - -/* ── 分组 ── */ -.tt-section { - margin-bottom: 20px; -} -.tt-section:last-child { - margin-bottom: 0; -} -.tt-section-title { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary, #6b7280); - margin: 0 0 10px; -} - -/* ── 空状态 ── */ -.tt-empty { - padding: 32px 16px; - text-align: center; - color: var(--text-secondary, #9ca3af); - background: var(--bg-secondary, #f9fafb); - border: 1px dashed var(--border-color, #e5e7eb); - border-radius: 8px; - font-size: 13px; -} -.tt-empty-emoji { - font-size: 32px; - margin-bottom: 8px; -} - -/* ── 卡片网格(4列) ── */ -.tt-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; -} -@media (max-width: 768px) { - .tt-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -.tt-card { - border: 2px solid var(--border-color, #e5e7eb); - border-radius: 10px; - background: #fff; - cursor: pointer; - transition: all 0.18s ease; - overflow: hidden; - position: relative; -} -.tt-card:hover { - border-color: var(--primary-color, #7c3aed); - transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(124, 58, 237, 0.12); -} -.tt-card.active { - border-color: var(--primary-color, #7c3aed); - background: #faf5ff; - box-shadow: 0 0 0 1px var(--primary-color, #7c3aed); -} - -/* ── 预览区 ── */ -.tt-card-preview { - position: relative; - height: 80px; - background: #0f172a; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; -} -.tt-card-preview canvas { - max-width: 100%; - max-height: 100%; -} - -/* ── 标签 ── */ -.tt-tag { - position: absolute; - top: 6px; - left: 6px; - padding: 1px 6px; - border-radius: 4px; - font-size: 10px; - font-weight: 600; - line-height: 16px; -} -.tt-tag.sys { - background: rgba(124, 58, 237, 0.85); - color: #fff; -} -.tt-tag.mine { - background: rgba(16, 185, 129, 0.9); - color: #fff; -} -.tt-check { - position: absolute; - top: 6px; - right: 6px; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--primary-color, #7c3aed); - color: #fff; - font-size: 11px; - display: flex; - align-items: center; - justify-content: center; -} - -/* ── 卡片操作按钮(hover 显示) ── */ -.tt-card-actions { - position: absolute; - top: 6px; - right: 6px; - display: none; - gap: 4px; - align-items: center; -} -.tt-card.active .tt-check + .tt-card-actions { - top: 30px; -} -.tt-card:hover .tt-card-actions { - display: flex; -} -.tt-ico-btn { - width: 24px; - height: 24px; - border-radius: 4px; - border: none; - background: rgba(0, 0, 0, 0.55); - color: #fff; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 12px; - padding: 0; - transition: 0.15s; -} -.tt-ico-btn:hover { - background: var(--primary-color, #7c3aed); -} -.tt-ico-btn.danger:hover { - background: #ef4444; -} - -/* ── 卡片底部 meta ── */ -.tt-card-meta { - display: flex; - align-items: center; - gap: 4px; - padding: 6px 8px; - font-size: 12px; - color: var(--text-primary, #1f2937); -} -.tt-card-emoji { - font-size: 13px; - flex-shrink: 0; -} -.tt-card-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} - -/* ── 编辑器(左右布局) ── */ -.tt-editor { - display: grid; - grid-template-columns: 360px 1fr; - gap: 16px; - min-height: 480px; -} -.tt-editor-preview { - background: #0f172a; - border-radius: 10px; - padding: 16px; - display: flex; - align-items: center; - justify-content: center; - min-height: 400px; -} -.tt-editor-panel { - overflow-y: auto; - max-height: 65vh; - padding-right: 4px; -} - -@media (max-width: 768px) { - .tt-editor { - grid-template-columns: 1fr; - } -} - -/* ============================================================ - #2003 内联网格(直接嵌在 TitleStylePanel 预览下方) - ============================================================ */ - -.tt-inline-wrap { +/* ── 面板容器(模板模式) ── */ +.ttv3-panel { display: flex; flex-direction: column; gap: 14px; } - -/* section header (label + 新建按钮) */ -.tt-section-header { +.ttv3-header { display: flex; - align-items: center; justify-content: space-between; + align-items: center; +} +.ttv3-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary, #1f2937); +} +.ttv3-new-btn.ant-btn { + background: linear-gradient(135deg, #6c5ce7, #a29bfe); + border: none; + border-radius: 20px; + font-size: 12px; + height: 28px; + padding: 0 14px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(108, 92, 231, 0.25); +} +.ttv3-new-btn.ant-btn:hover { + background: linear-gradient(135deg, #5b4cdb, #8c83f5) !important; + color: #fff !important; +} +.ttv3-section-label { + font-size: 12px; + color: #888; + font-weight: 500; margin-bottom: 8px; } -.tt-section-header .tt-section-label, -.tt-section-header .tt-section-title { - font-size: 12px; - font-weight: 600; - color: var(--text-secondary, #6b7280); - margin: 0; -} -.tt-new-btn.ant-btn { - border-radius: 6px; - font-size: 12px; - height: 26px; - padding: 0 10px; +.ttv3-section { + margin-bottom: 6px; } -/* 内联网格:自适应列数(窄面板 2 列、宽面板 3-4 列) */ -.tt-grid--inline { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; +/* ── 空状态 ── */ +.ttv3-empty { + background: #f8f8fc; + border-radius: 12px; + padding: 28px 16px; + text-align: center; + color: #aaa; } -@media (min-width: 1200px) { - .tt-grid--inline { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } +.ttv3-empty-icon { + font-size: 28px; + margin-bottom: 6px; } -@media (min-width: 1600px) { - .tt-grid--inline { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } +.ttv3-empty-text { + font-size: 12px; } -/* 内联卡片 */ -.tt-card--inline { - display: flex; - flex-direction: column; - border: 2px solid var(--border-color, #e5e7eb); - border-radius: 8px; +/* ── 卡片网格:minmax(180px,1fr) 自适应 ── */ +.ttv3-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +/* ── 卡片:3:4 竖版,圆角 14px ── */ +.ttv3-card { background: #fff; + border-radius: 14px; + border: 3px solid #e8e8ed; cursor: pointer; - transition: all 0.18s ease; + transition: all 0.15s ease; overflow: hidden; padding: 0; - font: inherit; + font-family: inherit; text-align: left; - position: relative; } -.tt-card--inline:hover { - border-color: var(--primary-color, #7c3aed); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(124, 58, 237, 0.12); +.ttv3-card:hover { + border-color: #c5c0f0; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); } -.tt-card--inline.active { - border-color: var(--primary-color, #7c3aed); - background: #faf5ff; - box-shadow: 0 0 0 1px var(--primary-color, #7c3aed); +.ttv3-card.selected { + border-color: #6c5ce7; + box-shadow: 0 4px 16px rgba(108, 92, 231, 0.25); } -/* 大预览区(上半):深色背景+canvas */ -.tt-card-preview--lg { +/* ── 卡片预览区(3:4) ── */ +.ttv3-preview { + width: 100%; + aspect-ratio: 3/4; position: relative; - height: 90px; - background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); + overflow: hidden; + border-radius: 11px 11px 0 0; +} +.ttv3-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +/* 暗色渐变遮罩:顶部15%半透明黑 + 中部透明 + 底部45%黑 */ +.ttv3-vignette { + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.45) 0%, + rgba(0, 0, 0, 0.15) 15%, + transparent 30%, + transparent 55%, + rgba(0, 0, 0, 0.6) 100% + ); + z-index: 1; + pointer-events: none; +} +/* 透明 Canvas 标题填充整个预览区 */ +.ttv3-preview .tt-fill-canvas-wrap { + position: absolute; + inset: 0; + z-index: 2; +} +.ttv3-preview .tt-fill-canvas-wrap canvas { + width: 100% !important; + height: 100% !important; + border-radius: 0; + display: block; +} + +/* ── 左上角角标(系统/我的) ── */ +.ttv3-badge { + position: absolute; + top: 8px; + left: 8px; + font-size: 10px; + padding: 2px 8px; + border-radius: 8px; + font-weight: 500; + color: #fff; + z-index: 3; + backdrop-filter: blur(4px); +} +.ttv3-badge--sys { + background: rgba(108, 92, 231, 0.88); +} +.ttv3-badge--mine { + background: rgba(0, 184, 148, 0.88); +} + +/* ── 右上角勾选圆圈 ── */ +.ttv3-check { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.7); + z-index: 3; display: flex; align-items: center; justify-content: center; - overflow: hidden; + font-size: 12px; + color: transparent; + transition: all 0.15s; } -.tt-card-preview--lg canvas { - max-width: 100%; - max-height: 100%; +.ttv3-check.on { + background: #6c5ce7; + border-color: #fff; + color: #fff; } -/* 选中圆点(单选 radio 样式) */ -.tt-radio-dot { - position: absolute; - top: 8px; - right: 8px; - width: 16px; - height: 16px; - border-radius: 50%; - border: 2px solid var(--primary-color, #7c3aed); - background: #fff; +/* ── 卡片底栏(名称 + 操作按钮) ── */ +.ttv3-footer { + padding: 10px 10px 12px; } -.tt-radio-dot::after { - content: ""; - position: absolute; - inset: 2px; - border-radius: 50%; - background: var(--primary-color, #7c3aed); -} - -/* 标签位置微调(内联卡片) */ -.tt-card--inline .tt-tag { - top: 8px; - left: 8px; -} - -/* hover/选中时操作按钮位置(避开 radio-dot) */ -.tt-card--inline .tt-card-actions { - top: 8px; - right: 8px; - display: none; - gap: 4px; -} -.tt-card--inline:hover .tt-card-actions { - display: flex; -} -/* 选中态 hover 时 actions 下移以避开 radio-dot */ -.tt-card--inline.active:hover .tt-card-actions { - top: 30px; -} -.tt-card--inline.active .tt-radio-dot { - display: block; -} -.tt-card--inline.active:hover .tt-radio-dot { - display: none; -} - -/* 底部 meta(下半):emoji + name */ -.tt-card-meta--lg { +.ttv3-name-row { display: flex; align-items: center; gap: 6px; - padding: 6px 8px; - font-size: 12px; - color: var(--text-primary, #1f2937); - background: #fff; - border-top: 1px solid var(--border-light, #f3f4f6); -} -.tt-card--inline.active .tt-card-meta--lg { - background: #faf5ff; -} -.tt-card-meta--lg .tt-card-emoji { + margin-bottom: 8px; font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + color: #1f2937; +} +.ttv3-emoji { + font-size: 14px; flex-shrink: 0; } -.tt-card-meta--lg .tt-card-name { - flex: 1; +.ttv3-name { overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + flex: 1; + min-width: 0; +} +.ttv3-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 6px; font-weight: 500; + flex-shrink: 0; +} +.ttv3-tag--sys { + background: #f0ecff; + color: #6c5ce7; +} +.ttv3-tag--mine { + background: #e6f9f4; + color: #00b894; } -/* 空状态(紧凑型) */ -.tt-empty--sm { - padding: 16px 12px; +/* ── 操作按钮:始终可见,等宽排列 ── */ +.ttv3-actions { + display: flex; + gap: 5px; +} +.ttv3-act { + flex: 1; + padding: 5px 0; + border: 1px solid #e8e8ed; + background: #fff; + border-radius: 7px; + font-size: 11px; + cursor: pointer; + color: #666; + font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + transition: all 0.15s; + font-family: inherit; +} +.ttv3-act:hover:not(:disabled) { + background: #f5f5fa; + border-color: #d5d3e8; +} +.ttv3-act--primary { + background: #6c5ce7; + color: #fff; + border-color: #6c5ce7; +} +.ttv3-act--primary:hover:not(:disabled) { + background: #5b4cdb; + border-color: #5b4cdb; +} +.ttv3-act--danger { + color: #e74c3c; +} +.ttv3-act--danger:hover:not(:disabled) { + background: #fef2f2; +} +.ttv3-act:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* ── FillPreview 公共容器 ── */ +.tt-fill-canvas-wrap { + position: relative; +} + +/* ── params-only(编辑器右侧)去掉多余 margin ── */ +.ttv3-params-only { + padding: 0; +} +.ttv3-params-only .ant-tabs { + margin: 0; +} + +/* ============================================================ + 编辑器 Modal(v3) + ============================================================ */ +.ttv3-modal .ant-modal-content { + border-radius: 16px; + overflow: hidden; + padding: 0; +} +.ttv3-modal .ant-modal-header { + padding: 16px 20px; + margin: 0; + border-bottom: 1px solid #f0f0f0; +} +.ttv3-modal .ant-modal-body { + padding: 0; + max-height: 75vh; +} +.ttv3-modal .ant-modal-footer { + padding: 14px 20px; + margin: 0; + border-top: 1px solid #f0f0f0; +} + +/* 编辑器两栏布局 */ +.ttv3-editor { + display: flex; + min-height: 500px; +} +.ttv3-editor-left { + width: 300px; + padding: 20px; + background: #f8f8fc; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + border-right: 1px solid #f0f0f0; +} +.ttv3-editor-canvas { + width: 200px; + aspect-ratio: 9/16; + border-radius: 12px; + overflow: hidden; + position: relative; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} +.ttv3-editor-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} +.ttv3-editor-canvas-inner { + position: absolute; + inset: 0; + z-index: 2; +} +.ttv3-editor-canvas-inner canvas { + width: 100% !important; + height: 100% !important; + border-radius: 0; + display: block; +} +.ttv3-editor-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} +.ttv3-form-row { + display: flex; + align-items: center; + gap: 10px; +} +.ttv3-form-row label { font-size: 12px; - gap: 6px; - border-radius: 6px; + font-weight: 600; + color: #333; + white-space: nowrap; + min-width: 44px; } -.tt-empty--sm .tt-empty-emoji { - font-size: 22px; - margin-bottom: 0; +.ttv3-form-row--grow { + flex: 1; +} +.ttv3-form-row--grow .ant-input { + flex: 1; +} + +.ttv3-editor-right { + flex: 1; + padding: 16px 20px; + overflow-y: auto; + min-width: 0; +} + +/* 侧栏较窄时(380px 侧栏):强制 2 列,卡片稍微紧凑 */ +@media (max-width: 540px) { + .ttv3-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + .ttv3-act { + font-size: 10px; + padding: 4px 0; + } + .ttv3-act .anticon { + display: none; + } } diff --git a/apps/web/src/components/title/TitleTemplateEditor.tsx b/apps/web/src/components/title/TitleTemplateEditor.tsx index 0e8e258c8..6b3b95fbe 100644 --- a/apps/web/src/components/title/TitleTemplateEditor.tsx +++ b/apps/web/src/components/title/TitleTemplateEditor.tsx @@ -1,15 +1,18 @@ /** - * 标题模板编辑器(#2003) + * 标题模板编辑器(v3 重构) * - * 左侧大预览(400×225,16:9),右侧复用 TitleStylePanel 进行参数调整。 - * 编辑完成后点"保存"弹出 SaveTemplateModal(名称必填),保存后回调 onSaved。 + * - Modal 弹窗 860px 宽 + * - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框 + * - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式 + * - 底部:取消 / 保存模板 按钮 + * - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理) */ -import React, { useState } from "react" +import React, { useEffect, useMemo, useState } from "react" import { Modal, Button, Input, message } from "antd" import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel" import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview" import { POSITION_OPTIONS } from "../../pages/generate/constants" -import { FONT_OPTIONS, TITLE_PRESETS as SYSTEM_TITLE_PRESETS } from "./constants" +import { FONT_OPTIONS } from "./constants" import type { TitleSettings } from "../../pages/generate/types" import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types" import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils" @@ -23,34 +26,32 @@ interface Props { onSaved: (template: TitleTemplate) => void } +/** 编辑器预览用的背景图(复用卡片池第一张) */ +const EDITOR_BG = "/title-templates/portrait1.jpg" + const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSaved }) => { const { updateTemplate, createTemplate } = useTitleTemplates() - // 编辑态:完整 TitleSettings(camelCase) const [settings, setSettings] = useState(() => ({ ...DEFAULT_TITLE_SETTINGS_FULL, ...titleStyleConfigToCamel(template.style || {}), - title: "标题预览", + title: "预览标题文字", })) const [formName, setFormName] = useState(template.name || "") - const [formDesc, setFormDesc] = useState(template.description || "") const [formEmoji, setFormEmoji] = useState(template.emoji || "✨") const [saving, setSaving] = useState(false) - // 每次 open 重置 - React.useEffect(() => { + useEffect(() => { if (open) { setSettings({ ...DEFAULT_TITLE_SETTINGS_FULL, ...titleStyleConfigToCamel(template.style || {}), - title: "标题预览", + title: "预览标题文字", }) setFormName(template.name || "") - setFormDesc(template.description || "") setFormEmoji(template.emoji || "✨") } }, [open, template]) - /** 把单个 updater 包装成 setSettings patch */ const upd = (patch: Partial) => setSettings((s) => ({ ...s, ...patch })) const handleSave = () => { @@ -63,15 +64,13 @@ const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSaved try { const snake = camelToTitleStyleConfig(settings) if (template.isBuiltin) { - // 内置模板保存时创建一个副本 - const t = createTemplate({ name, description: formDesc, emoji: formEmoji, style: snake }) + const t = createTemplate({ name, emoji: formEmoji, style: snake }) onSaved(t) } else { - updateTemplate(template.id, { name, description: formDesc, emoji: formEmoji, style: snake }) + updateTemplate(template.id, { name, emoji: formEmoji, style: snake }) onSaved({ ...template, name, - description: formDesc, emoji: formEmoji, style: snake, updatedAt: new Date().toISOString(), @@ -82,14 +81,20 @@ const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSaved } } + // 编辑器内的预览用 settings:字号适配竖屏 + const previewSettings = useMemo(() => { + // 竖屏宽度 200px,按比例缩放字号,让预览看起来协调 + return { ...settings, size: Math.round(settings.size * 0.55) } + }, [settings]) + return ( +
} destroyOnClose - className="tt-modal tt-editor-modal" + className="ttv3-modal" + styles={{ body: { padding: 0 } }} > - {/* 模板名称/描述表单 */} -
-
- - setFormEmoji(e.target.value)} - /> +
+ {/* 左侧:竖屏预览 + 名称 */} +
+
+ +
+
+ +
+
+
+
+ + setFormEmoji(e.target.value)} + /> +
+
+ + setFormName(e.target.value)} + /> +
+
-
- - setFormName(e.target.value)} - /> -
-
- - setFormDesc(e.target.value)} - /> -
-
- -
- {/* 左侧实时预览 */} -
- -
- {/* 右侧编辑器 — 复用 TitleStylePanel 的细粒度能力 */} -
+ {/* 右侧:参数 Tab */} +
upd({ position: p, posX: null, posY: null })} @@ -160,23 +161,15 @@ const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSaved }) } onToggleShadow={() => upd({ shadow: !settings.shadow })} - onApplyPreset={(key) => { - // 在编辑器中点击系统预设:把 preset 作为编辑起点 - const pp = SYSTEM_TITLE_PRESETS.find((x) => x.key === key) - if (pp) { - setSettings((cs) => ({ - ...cs, - ...titleStyleConfigToCamel(pp.style), - lineOverrides: [], - title: "标题预览", - })) - } + onApplyPreset={() => { + /* 编辑器内不使用系统预设快捷键 */ }} onUpdateStyle={(patch) => upd(patch)} activePreset={null} titlePresets={[]} POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} + paramsOnly />
diff --git a/apps/web/src/components/title/TitleTemplateSelector.tsx b/apps/web/src/components/title/TitleTemplateSelector.tsx deleted file mode 100644 index 64107a298..000000000 --- a/apps/web/src/components/title/TitleTemplateSelector.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/** - * 标题模板选择器(#2003) - * - * 参考「IP智能体设置 → 字幕设置 → 字幕模板」交互: - * - Modal 打开后展示模板卡片网格(4 列),每张卡片含 Canvas 预览 + 名称 + 标签 - * - 系统模板(sys:):只能「复制为我的」「应用」 - * - 自定义模板(usr:):支持编辑/复制/导出/删除 - * - 右上角「+ 新建模板」按钮进入编辑器 - * - * 受控使用:visible/onCancel/onSelect - */ -import React, { useMemo, useState } from "react" -import { Modal, Button, message, Popconfirm, Tooltip } from "antd" -import { - PlusOutlined, - EditOutlined, - CopyOutlined, - DeleteOutlined, - ExportOutlined, - CheckOutlined, -} from "@ant-design/icons" -import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview" -import type { TitleSettings } from "../../pages/generate/types" -import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types" -import { titleStyleConfigToCamel } from "./utils" -import { useTitleTemplates } from "./useTitleTemplates" -import type { TitleTemplate } from "./template-types" -import TitleTemplateEditor from "./TitleTemplateEditor" -import "./TitleTemplate.css" - -interface Props { - open: boolean - /** 当前选中模板 id(可选) */ - selectedTemplateId?: string | null - onClose: () => void - /** 选择/应用模板:返回 camelCase TitleSettings 给调用方 */ - onSelect: (settings: TitleSettings, template: TitleTemplate) => void -} - -/** 把模板 style 渲染为完整 TitleSettings(带默认值),用于预览 */ -function templateToSettings(t: TitleTemplate): TitleSettings { - return { ...DEFAULT_TITLE_SETTINGS_FULL, ...titleStyleConfigToCamel(t.style) } -} - -const TitleTemplateSelector: React.FC = ({ - open, - selectedTemplateId, - onClose, - onSelect, -}) => { - const { templates, duplicateTemplate, deleteTemplate, exportTemplate, createTemplate } = - useTitleTemplates() - const [editingTemplate, setEditingTemplate] = useState(null) - const [editorOpen, setEditorOpen] = useState(false) - - const grouped = useMemo(() => { - return { - builtin: templates.filter((t) => t.isBuiltin), - custom: templates.filter((t) => !t.isBuiltin), - } - }, [templates]) - - const handleCreate = () => { - // 基于当前默认样式创建空白模板进入编辑 - const t = createTemplate({ - name: "我的标题模板", - style: { - font: DEFAULT_TITLE_SETTINGS_FULL.font, - size: 56, - color: "#ffffff", - bold: true, - italic: false, - stroke: true, - stroke_width: 4, - stroke_color: "#000000", - shadow: false, - bg_enabled: false, - line_height: 1.2, - max_chars_per_line: 10, - position: "bottom", - margin_top: 32, - line_overrides: [], - cover_title_config: null, - }, - }) - setEditingTemplate(t) - setEditorOpen(true) - } - - const handleEdit = (t: TitleTemplate) => { - setEditingTemplate(t) - setEditorOpen(true) - } - - const handleDuplicate = (t: TitleTemplate) => { - const dup = duplicateTemplate(t.id) - if (dup) message.success(`已复制:${dup.name}`) - } - - const handleDelete = (t: TitleTemplate) => { - deleteTemplate(t.id) - message.success("已删除模板") - } - - const handleExport = (t: TitleTemplate) => { - const json = exportTemplate(t.id) - if (!json) return - const blob = new Blob([json], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${t.name}.title-template.json` - a.click() - URL.revokeObjectURL(url) - } - - const handleApply = (t: TitleTemplate) => { - const settings = templateToSettings(t) - onSelect(settings, t) - } - - const renderCard = (t: TitleTemplate) => { - const isSelected = selectedTemplateId === t.id - const settings = templateToSettings(t) - return ( -
handleApply(t)} - > -
- - - {t.isBuiltin ? "系统" : "我的"} - - {isSelected && ( - - - - )} -
e.stopPropagation()}> - {!t.isBuiltin && ( - - - - )} - - - - - - - {!t.isBuiltin && ( - handleDelete(t)}> - - - - - )} -
-
-
- {t.emoji || "✨"} - - {t.name} - -
-
- ) - } - - return ( - <> - - 🎨 选择标题模板 - -
- } - open={open && !editorOpen} - onCancel={onClose} - footer={null} - width={880} - className="tt-modal" - destroyOnClose - > -
-
我的模板
- {grouped.custom.length === 0 ? ( -
-
✨
-
还没有自定义模板,点击右上角「新建模板」创建第一个吧
-
- ) : ( -
{grouped.custom.map(renderCard)}
- )} -
- -
-
系统模板
-
{grouped.builtin.map(renderCard)}
-
- - - {editorOpen && editingTemplate && ( - { - setEditorOpen(false) - setEditingTemplate(null) - }} - onSaved={(t) => { - setEditorOpen(false) - setEditingTemplate(null) - message.success(`已保存:${t.name}`) - // 保存后自动应用 - handleApply(t) - }} - /> - )} - - ) -} - -export default TitleTemplateSelector diff --git a/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx index 9611c0743..f2e189f91 100644 --- a/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx +++ b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx @@ -18,10 +18,14 @@ interface Props { settings: TitleSettings width?: number sampleText?: string - /** 背景(预览用,默认深色渐变模拟视频底) */ + /** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */ background?: string - /** 高度(可选,默认 width/2) */ + /** 高度(可选,默认按 portrait 选比例) */ height?: number + /** 透明背景(卡片/编辑器预览叠加在图片上时使用) */ + transparent?: boolean + /** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */ + portrait?: boolean } /** 按 maxCharsPerLine 自动换行 */ @@ -56,9 +60,11 @@ const TitleMiniPreview: React.FC = ({ sampleText, background = "linear-gradient(135deg,#1f2937,#111827)", height, + transparent = false, + portrait = false, }) => { const canvasRef = useRef(null) - const h = height ?? Math.round(width / 1.8) + const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8)) const text = (sampleText || settings.title || "预览标题").trim() || "预览标题" useEffect(() => { @@ -74,9 +80,11 @@ const TitleMiniPreview: React.FC = ({ ctx.scale(dpr, dpr) ctx.clearRect(0, 0, width, h) - // 背景 - ctx.fillStyle = "#111827" - ctx.fillRect(0, 0, width, h) + // 背景(transparent 时跳过,用于叠加在图片上) + if (!transparent) { + ctx.fillStyle = "#111827" + ctx.fillRect(0, 0, width, h) + } // 分辨率缩放:以 360 宽为基准(对应 720p 的一半) const scale = width / 360 @@ -121,7 +129,8 @@ const TitleMiniPreview: React.FC = ({ startY = h / 2 - totalH / 2 + size / 2 } else { // bottom - startY = h - totalH - r(16) + size / 2 + const botMargin = portrait ? r(24) : r(16) + startY = h - totalH - botMargin + size / 2 } let centerX = width / 2 if (settings.position === "custom" && settings.posX != null) { @@ -190,7 +199,7 @@ const TitleMiniPreview: React.FC = ({ borderRadius: 6, display: "block", maxWidth: "100%", - background, + background: transparent ? "transparent" : background, }} /> ) diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 0542d4c56..43d7b4de0 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -1,20 +1,25 @@ /** - * 标题样式设置面板(#2001 升级 / #2003 模板内联改造) + * 标题样式设置面板(v3 重构) * - * - #2001:描边/阴影/背景/排版/封面 6个Tab + Canvas 迷你预览 + 4款爆款字体 - * - #2003:标题模板直接内嵌在预览下方(卡片网格始终可见),不再弹窗选择 - * 支持新建/编辑/复制/导出/删除,编辑仍用 Modal 编辑器(左预览+右参数) + * 三种模式(mode): + * - 「默认」(不传):顶部实时预览 + 爆款预设网格 + 参数 Tab(基础/描边/阴影/背景/排版/封面) + * - 「模板网格」(enableTemplates=true, paramsOnly=false): + * 仅显示顶部标题栏「标题模板」+ 我的模板/系统模板两个分组的大卡片网格, + * 卡片用图片背景 + 真实标题渲染(Canvas 叠加透明渲染),底部始终可见 + * 编辑/复制/导出/删除 4 个按钮,选中卡片紫边紫影。 + * - 「仅参数」(paramsOnly=true):仅渲染参数 Tab(给编辑器右侧用,不显示预览/预设/模板网格) * - * 向后兼容:旧 props 全部保留;enableTemplates=true 时渲染内联网格,false 保留旧爆款预设。 + * 历史:#2001 6 Tab 参数面板;#2003 初版内联模板网格;v3 按 sketch 重写大卡片 + 图片背景 */ -import React, { useMemo, useState } from "react" -import { Tabs, Button, message, Popconfirm, Tooltip } from "antd" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Tabs, Button, message, Popconfirm } from "antd" import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, ExportOutlined, + CheckOutlined, } from "@ant-design/icons" import type { TitleSettings } from "../../types" import { @@ -65,12 +70,16 @@ interface TitleStylePanelProps { onUpdateStyle?: (patch: Partial) => void showCoverToggle?: boolean previewWidth?: number - /** 是否启用「标题模板」内联网格(替代旧爆款预设) */ + /** 启用「标题模板」模式:显示大卡片网格,隐藏参数 Tab 和旧预览 */ enableTemplates?: boolean /** 当前选中模板 id */ selectedTemplateId?: string | null /** 应用模板回调(父组件负责合并到 state) */ onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void + /** 仅渲染参数 Tab(编辑器右侧用,不显示预览/预设/模板网格) */ + paramsOnly?: boolean + /** 编辑器模式下,模板编辑器组件不在这里渲染(由父组件承载) */ + hideEditor?: boolean } /* ── Slider 行 ── */ @@ -154,6 +163,45 @@ const ColorPicker: React.FC<{ ) } +/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */ +const FillPreview: React.FC<{ + settings: TitleSettings + sampleText: string + portrait?: boolean +}> = ({ settings, sampleText, portrait }) => { + const wrapRef = useRef(null) + const [w, setW] = useState(0) + useEffect(() => { + const el = wrapRef.current + if (!el) return + const update = () => setW(Math.floor(el.clientWidth)) + update() + const ro = new ResizeObserver(update) + ro.observe(el) + return () => ro.disconnect() + }, []) + return ( +
+ {w > 0 && ( + + )} +
+ ) +} + +/* ── 模板卡片预览背景图池:按 index 轮换 ── */ +const PREVIEW_BG_IMAGES = [ + "/title-templates/portrait1.jpg", + "/title-templates/portrait2.jpg", + "/title-templates/scene1.jpg", +] + const TitleStylePanel: React.FC = ({ settings, onUpdatePosition, @@ -173,6 +221,8 @@ const TitleStylePanel: React.FC = ({ selectedTemplateId, onApplyTemplate, onUpdateStyle, + paramsOnly = false, + hideEditor = false, }) => { const upd = (patch: Partial) => { onUpdateStyle?.(patch) @@ -194,142 +244,186 @@ const TitleStylePanel: React.FC = ({ [templates], ) - const handleRequestCreate = () => { + const handleRequestCreate = useCallback(() => { + // 新建模板:若已有选中模板,以其样式为起点;否则以当前 settings 为起点 + let base: TitleSettings = settings + if (selectedTemplateId) { + const sel = templates.find((t) => t.id === selectedTemplateId) + if (sel) { + base = { + ...DEFAULT_TITLE_SETTINGS_FULL, + ...titleStyleConfigToCamel(sel.style), + title: settings.title, + aiAutoSelect: settings.aiAutoSelect, + coverTitle: settings.coverTitle, + } + } + } const t = createTemplate({ name: "我的标题模板", style: { - font: settings.font, - size: settings.size, - color: settings.color, - bold: settings.bold, - italic: settings.italic, - stroke: settings.stroke, - stroke_width: settings.strokeWidth ?? 4, - stroke_color: settings.strokeColor ?? "#000000", - shadow: settings.shadow, - shadow_offset_x: settings.shadowOffsetX ?? 2, - shadow_offset_y: settings.shadowOffsetY ?? 2, - shadow_blur: settings.shadowBlur ?? 4, - shadow_color: settings.shadowColor ?? "rgba(0,0,0,0.8)", - bg_enabled: settings.bgEnabled, - bg_color: settings.bgColor, - bg_padding: settings.bgPadding, - bg_radius: settings.bgRadius, - line_height: settings.lineHeight, - margin_top: settings.marginTop, - max_chars_per_line: settings.maxCharsPerLine, - position: (settings.position === "custom" ? "bottom" : settings.position) as + font: base.font, + size: base.size, + color: base.color, + bold: base.bold, + italic: base.italic, + stroke: base.stroke, + stroke_width: base.strokeWidth ?? 4, + stroke_color: base.strokeColor ?? "#000000", + shadow: base.shadow, + shadow_offset_x: base.shadowOffsetX ?? 2, + shadow_offset_y: base.shadowOffsetY ?? 2, + shadow_blur: base.shadowBlur ?? 4, + shadow_color: base.shadowColor ?? "rgba(0,0,0,0.8)", + bg_enabled: base.bgEnabled, + bg_color: base.bgColor, + bg_padding: base.bgPadding, + bg_radius: base.bgRadius, + line_height: base.lineHeight, + margin_top: base.marginTop, + max_chars_per_line: base.maxCharsPerLine, + position: (base.position === "custom" ? "bottom" : base.position) as "top" | "center" | "bottom" | "custom", - pos_x: settings.posX ?? undefined, - pos_y: settings.posY ?? undefined, + pos_x: base.posX ?? undefined, + pos_y: base.posY ?? undefined, line_overrides: [], cover_title_config: null, }, }) setEditingTemplate(t) setEditorOpen(true) - } + }, [createTemplate, settings, selectedTemplateId, templates]) - const handleRequestEdit = (tpl: TitleTemplate) => { + const handleRequestEdit = useCallback((tpl: TitleTemplate) => { setEditingTemplate(tpl) setEditorOpen(true) - } + }, []) - const handleDuplicate = (t: TitleTemplate) => { - const dup = duplicateTemplate(t.id) - if (dup) message.success(`已复制:${dup.name}`) - } - const handleDelete = (t: TitleTemplate) => { - deleteTemplate(t.id) - message.success("已删除模板") - } - const handleExport = (t: TitleTemplate) => { - const json = exportTemplate(t.id) - if (!json) return - const blob = new Blob([json], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${t.name}.title-template.json` - a.click() - URL.revokeObjectURL(url) - } + const handleDuplicate = useCallback( + (t: TitleTemplate) => { + const dup = duplicateTemplate(t.id) + if (dup) message.success(`已复制:${dup.name}`) + }, + [duplicateTemplate], + ) + const handleDelete = useCallback( + (t: TitleTemplate) => { + deleteTemplate(t.id) + message.success("已删除模板") + }, + [deleteTemplate], + ) + const handleExport = useCallback( + (t: TitleTemplate) => { + const json = exportTemplate(t.id) + if (!json) return + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${t.name}.title-template.json` + a.click() + URL.revokeObjectURL(url) + }, + [exportTemplate], + ) - const handleSelectTemplate = (_preview: TitleSettings, tpl: TitleTemplate) => { - // _preview 是归一化小预览,应用时用完整 DEFAULT + 原始 style 保证真实尺寸 - const full: TitleSettings = { - ...DEFAULT_TITLE_SETTINGS_FULL, - ...titleStyleConfigToCamel(tpl.style), - title: settings.title, - aiAutoSelect: settings.aiAutoSelect, - coverTitle: settings.coverTitle, - } - onApplyTemplate?.(full, tpl) - } + const handleSelectTemplate = useCallback( + (tpl: TitleTemplate) => { + const full: TitleSettings = { + ...DEFAULT_TITLE_SETTINGS_FULL, + ...titleStyleConfigToCamel(tpl.style), + title: settings.title, + aiAutoSelect: settings.aiAutoSelect, + coverTitle: settings.coverTitle, + } + onApplyTemplate?.(full, tpl) + }, + [onApplyTemplate, settings], + ) - const handleEditorSaved = (tpl: TitleTemplate) => { - setEditorOpen(false) - setEditingTemplate(null) - message.success(`已保存:${tpl.name}`) - handleSelectTemplate(DEFAULT_TITLE_SETTINGS_FULL, tpl) - } + const handleEditorSaved = useCallback( + (tpl: TitleTemplate) => { + setEditorOpen(false) + setEditingTemplate(null) + message.success(`已保存:${tpl.name}`) + handleSelectTemplate(tpl) + }, + [handleSelectTemplate], + ) - const renderTemplateCard = (t: TitleTemplate) => { + /* ── 渲染模板大卡片(v3:图片背景 + 透明 Canvas 叠字 + 底栏始终可见操作按钮) ── */ + const renderTemplateCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => { const isSelected = selectedTemplateId === t.id - const preview = templateToPreviewSettings(t, 48) + const bgIdx = idx % PREVIEW_BG_IMAGES.length + const bgImg = PREVIEW_BG_IMAGES[bgIdx] + // 卡片用的预览 settings:字号按 3:4 竖版比例缩放 + const preview = templateToPreviewSettings(t, 42) return ( - - - )} - -
+
+
+ {t.emoji || "✨"} + + {t.name} + + + {section === "sys" ? "系统" : "我的"} + +
+
e.stopPropagation()}> + + + + handleDelete(t)}> + - - - - - {!t.isBuiltin && ( - handleDelete(t)}> - - - - - )} +
-
- {t.emoji || "✨"} - - {t.name} - -
- +
) } - /* ── 旧爆款预设网格(enableTemplates=false 时显示,作为编辑器内快速起点) ── */ + /* ── 旧爆款预设网格(paramsOnly 或 enableTemplates 时不显示) ── */ const renderPresetGrid = () => (
{TITLE_PRESETS.map((p) => { @@ -356,6 +450,380 @@ const TitleStylePanel: React.FC = ({
) + /* ── 参数 Tab 面板(编辑器右侧复用) ── */ + const renderParamsTabs = () => ( + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + + + +
+
+ + upd({ color: c })} + /> + + ), + }, + { + key: "stroke", + label: "描边", + children: ( + <> +
+ +
+ {settings.stroke && ( + <> + upd({ strokeWidth: v })} + /> + upd({ strokeColor: c })} + /> + + )} + + ), + }, + { + key: "shadow", + label: "阴影", + children: ( + <> +
+ +
+ {settings.shadow && ( + <> + upd({ shadowOffsetX: v })} + /> + upd({ shadowOffsetY: v })} + /> + upd({ shadowBlur: v })} + /> +
+ + upd({ shadowColor: e.target.value })} + placeholder="rgba(0,0,0,0.8)" + /> +
+ + )} + + ), + }, + { + key: "bg", + label: "背景", + children: ( + <> +
+ +
+ {settings.bgEnabled && ( + <> + upd({ bgColor: c })} + /> + upd({ bgPadding: v })} + /> + upd({ bgRadius: v })} + /> + + )} + + ), + }, + { + key: "layout", + label: "排版", + children: ( + <> + upd({ maxCharsPerLine: v })} + /> +
+ 0 = 不自动换行(按 / 手动分行) +
+ upd({ lineHeight: Number(v.toFixed(2)) })} + /> + upd({ marginTop: v })} + /> + + ), + }, + ...(showCoverToggle + ? [ + { + key: "cover", + label: "封面", + children: ( + <> +
+ +
+ {coverOpen && settings.coverTitle && ( +
+ 封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。 +
+ )} + + ), + }, + ] + : []), + ]} + /> + ) + + /* ── 模板模式:只显示网格 ── */ + if (enableTemplates && !paramsOnly) { + return ( +
+
+ 标题模板 + +
+ +
+
我的模板
+ {grouped.custom.length === 0 ? ( +
+
✨
+
还没有自定义模板,点右上角「新建模板」创建
+
+ ) : ( +
+ {grouped.custom.map((t, i) => renderTemplateCard(t, i, "mine"))} +
+ )} +
+ +
+
系统模板
+
+ {grouped.builtin.map((t, i) => renderTemplateCard(t, i, "sys"))} +
+
+ + {!hideEditor && editorOpen && editingTemplate && ( + { + setEditorOpen(false) + setEditingTemplate(null) + }} + onSaved={handleEditorSaved} + /> + )} +
+ ) + } + + /* ── paramsOnly:仅参数 Tab ── */ + if (paramsOnly) { + return
{renderParamsTabs()}
+ } + + /* ── 默认模式:顶部预览 + 预设 + 参数 Tab ── */ return (
{/* 实时迷你预览 */} @@ -367,368 +835,18 @@ const TitleStylePanel: React.FC = ({ />
- {/* 标题模板(内联)/ 爆款预设(旧) */} - {enableTemplates ? ( -
-
-
-
- 我的模板 - -
- {grouped.custom.length === 0 ? ( -
- ✨ - 还没有自定义模板,点击右上角「新建模板」 -
- ) : ( -
- {grouped.custom.map(renderTemplateCard)} -
- )} -
- -
-
- 系统模板 -
-
- {grouped.builtin.map(renderTemplateCard)} -
-
-
+ {/* 爆款预设 */} +
+
+ + {activePreset && 已选}
- ) : ( -
-
- - {activePreset && 已选} -
- {renderPresetGrid()} -
- )} + {renderPresetGrid()} +
- -
-
- - -
-
- - -
-
+ {renderParamsTabs()} - - -
- -
- - - - -
-
- - upd({ color: c })} - /> - - ), - }, - { - key: "stroke", - label: "描边", - children: ( - <> -
- -
- {settings.stroke && ( - <> - upd({ strokeWidth: v })} - /> - upd({ strokeColor: c })} - /> - - )} - - ), - }, - { - key: "shadow", - label: "阴影", - children: ( - <> -
- -
- {settings.shadow && ( - <> - upd({ shadowOffsetX: v })} - /> - upd({ shadowOffsetY: v })} - /> - upd({ shadowBlur: v })} - /> -
- - upd({ shadowColor: e.target.value })} - placeholder="rgba(0,0,0,0.8)" - /> -
- - )} - - ), - }, - { - key: "bg", - label: "背景", - children: ( - <> -
- -
- {settings.bgEnabled && ( - <> - upd({ bgColor: c })} - /> - upd({ bgPadding: v })} - /> - upd({ bgRadius: v })} - /> - - )} - - ), - }, - { - key: "layout", - label: "排版", - children: ( - <> - upd({ maxCharsPerLine: v })} - /> -
- 0 = 不自动换行(按 / 手动分行) -
- upd({ lineHeight: Number(v.toFixed(2)) })} - /> - upd({ marginTop: v })} - /> - - ), - }, - ...(showCoverToggle - ? [ - { - key: "cover", - label: "封面", - children: ( - <> -
- -
- {coverOpen && settings.coverTitle && ( -
- 封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。 -
- )} - - ), - }, - ] - : []), - ]} - /> - - {editorOpen && editingTemplate && ( + {!hideEditor && editorOpen && editingTemplate && (