From 7e123de28b284fcf9be6c68597b1b3b79fd5e077 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Wed, 23 Sep 2026 09:50:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=A0=87=E9=A2=98=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=86=85=E5=B5=8C=E9=9D=A2=E6=9D=BF=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=BC=B9=E7=AA=97=E9=80=89=E6=8B=A9=EF=BC=8C?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=8D=A1=E7=89=87=E7=BD=91=E6=A0=BC=E5=B9=B3?= =?UTF-8?q?=E9=93=BA=E5=9C=A8=E9=A2=84=E8=A7=88=E4=B8=8B=E6=96=B9=EF=BC=88?= =?UTF-8?q?#2003=20follow-up=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TitleStylePanel: 移除「选择模板」按钮+弹窗入口,模板卡片网格直接内嵌在预览下方始终可见 - 模板卡片放大(上半90px深色Canvas预览+下半emoji+name),hover显示编辑/复制/导出/删除,选中紫色高亮边框+单选圆点 - 「新建模板」按钮放在「我的模板」分组右上角,点击用 Modal 编辑器(左预览+右参数) - 新建模板时自动带入当前样式作为起点 - GeneratePage/Step4/PanelTitleConfig: 移除 TitleTemplateSelector 弹窗逻辑,改走 onApplyTemplate 回调 - 面板宽度自适应:窄侧栏2列、宽面板1200px+3列、1600px+4列 - 系统模板(sys:)不可删除编辑,自定义模板(usr:)支持CRUD+导入导出(localStorage持久化) - 修复activePreset/activeTemplate状态不同步问题 --- .../src/components/title/TitleTemplate.css | 172 +++++++++ apps/web/src/components/title/utils.ts | 27 ++ .../ai-avatar/components/PanelTitleConfig.tsx | 81 ++--- apps/web/src/pages/generate/GeneratePage.tsx | 25 +- .../components/GenerateStepContent.tsx | 13 +- .../components/Step4TitleSettings.tsx | 13 +- .../components/title/TitleStylePanel.tsx | 343 ++++++++++++++---- 7 files changed, 523 insertions(+), 151 deletions(-) diff --git a/apps/web/src/components/title/TitleTemplate.css b/apps/web/src/components/title/TitleTemplate.css index 602fc2fd2..4d1522a4d 100644 --- a/apps/web/src/components/title/TitleTemplate.css +++ b/apps/web/src/components/title/TitleTemplate.css @@ -209,3 +209,175 @@ grid-template-columns: 1fr; } } + +/* ============================================================ + #2003 内联网格(直接嵌在 TitleStylePanel 预览下方) + ============================================================ */ + +.tt-inline-wrap { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* section header (label + 新建按钮) */ +.tt-section-header { + display: flex; + align-items: center; + justify-content: space-between; + 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; +} + +/* 内联网格:自适应列数(窄面板 2 列、宽面板 3-4 列) */ +.tt-grid--inline { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +@media (min-width: 1200px) { + .tt-grid--inline { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (min-width: 1600px) { + .tt-grid--inline { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +/* 内联卡片 */ +.tt-card--inline { + display: flex; + flex-direction: column; + border: 2px solid var(--border-color, #e5e7eb); + border-radius: 8px; + background: #fff; + cursor: pointer; + transition: all 0.18s ease; + overflow: hidden; + padding: 0; + font: 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); +} +.tt-card--inline.active { + border-color: var(--primary-color, #7c3aed); + background: #faf5ff; + box-shadow: 0 0 0 1px var(--primary-color, #7c3aed); +} + +/* 大预览区(上半):深色背景+canvas */ +.tt-card-preview--lg { + position: relative; + height: 90px; + background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.tt-card-preview--lg canvas { + max-width: 100%; + max-height: 100%; +} + +/* 选中圆点(单选 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; +} +.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 { + 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 { + font-size: 13px; + flex-shrink: 0; +} +.tt-card-meta--lg .tt-card-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +/* 空状态(紧凑型) */ +.tt-empty--sm { + padding: 16px 12px; + font-size: 12px; + gap: 6px; + border-radius: 6px; +} +.tt-empty--sm .tt-empty-emoji { + font-size: 22px; + margin-bottom: 0; +} diff --git a/apps/web/src/components/title/utils.ts b/apps/web/src/components/title/utils.ts index a56dac90d..8a9817da8 100644 --- a/apps/web/src/components/title/utils.ts +++ b/apps/web/src/components/title/utils.ts @@ -7,6 +7,8 @@ import type { TitleStyleConfig } from "./types" import type { TitleSettings } from "../../pages/generate/types" import { TITLE_PRESETS } from "./constants" +import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types" +import type { TitleTemplate } from "./template-types" /** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */ export function titleStyleConfigToCamel(s: Partial): Partial { @@ -96,3 +98,28 @@ export function buildPresetPreviewSettings( lineOverrides: [], } } + +/** + * 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。 + * 与模板选择器中保持一致,抽出共用。 + */ +export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings { + const base: TitleSettings = { + ...DEFAULT_TITLE_SETTINGS_FULL, + ...titleStyleConfigToCamel(t.style), + } + // 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放 + const origSize = t.style.size ?? base.size + if (origSize && origSize !== fixedSize) { + const ratio = fixedSize / origSize + base.size = fixedSize + if (base.strokeWidth != null) + base.strokeWidth = Math.max(1, Math.round(base.strokeWidth * ratio)) + if (base.shadowOffsetX != null) base.shadowOffsetX = Math.round(base.shadowOffsetX * ratio) + if (base.shadowOffsetY != null) base.shadowOffsetY = Math.round(base.shadowOffsetY * ratio) + if (base.shadowBlur != null) base.shadowBlur = Math.round(base.shadowBlur * ratio) + if (base.bgPadding != null) base.bgPadding = Math.round(base.bgPadding * ratio) + } + base.lineOverrides = [] + return base +} diff --git a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx index 4d21cff2e..c9168ab41 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx @@ -10,7 +10,6 @@ import React, { useMemo, useState, useEffect } from "react" import { Input } from "antd" import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel" -import TitleTemplateSelector from "@/components/title/TitleTemplateSelector" import type { TitleTemplate } from "@/components/title/template-types" import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete" @@ -32,8 +31,7 @@ interface PanelTitleConfigProps { const PanelTitleConfig: React.FC = ({ titleConfig, onUpdate }) => { /** TitleStylePanel 内部高亮的预设 key(面板本地状态) */ const [activePreset, setActivePreset] = useState(null) - const [templateModalOpen, setTemplateModalOpen] = useState(false) - const [activeTemplate, setActiveTemplate] = useState(null) + const [selectedTemplateId, setSelectedTemplateId] = useState(null) /** 标题库选项(#1894:从文案库 scripts[].title 取候选) */ const [titleOptions, setTitleOptions] = useState([]) @@ -334,6 +332,40 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda onUpdate(snake) } + /** 应用模板(来自内联网格 onApplyTemplate):camelCase TitleSettings → snake_case AiAvatarTitleConfig */ + const handleApplyTemplate = (settings: TitleSettings, tpl: TitleTemplate) => { + setActivePreset(null) + setSelectedTemplateId(tpl.id) + onUpdate({ + title: titleConfig.title, + font: settings.font, + size: settings.size, + color: settings.color, + bold: settings.bold, + italic: settings.italic, + position: settings.position, + stroke: settings.stroke, + stroke_width: settings.strokeWidth, + stroke_color: settings.strokeColor, + shadow: settings.shadow, + shadow_offset_x: settings.shadowOffsetX, + shadow_offset_y: settings.shadowOffsetY, + shadow_blur: settings.shadowBlur, + shadow_color: settings.shadowColor, + 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, + line_overrides: [], + pos_x: settings.posX ?? undefined, + pos_y: settings.posY ?? undefined, + cover_title_config: null, + }) + } + return (
{/* 主标题输入 — TextArea 多行 + 标题库选择 */} @@ -381,8 +413,8 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda showCoverToggle previewWidth={280} enableTemplates - onOpenTemplates={() => setTemplateModalOpen(true)} - activeTemplateLabel={activeTemplate?.name ?? null} + selectedTemplateId={selectedTemplateId} + onApplyTemplate={handleApplyTemplate} activePreset={activePreset} titlePresets={ TITLE_PRESETS as unknown as React.ComponentProps["titlePresets"] @@ -391,45 +423,6 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda FONT_OPTIONS={FONT_OPTIONS} /> - setTemplateModalOpen(false)} - onSelect={(settings, tpl) => { - setActiveTemplate(tpl) - setActivePreset(null) - setTemplateModalOpen(false) - // 把 camelCase settings 转回 snake_case 并 onUpdate - onUpdate({ - title: titleConfig.title, - font: settings.font, - size: settings.size, - color: settings.color, - bold: settings.bold, - italic: settings.italic, - position: settings.position, - stroke: settings.stroke, - stroke_width: settings.strokeWidth, - stroke_color: settings.strokeColor, - shadow: settings.shadow, - shadow_offset_x: settings.shadowOffsetX, - shadow_offset_y: settings.shadowOffsetY, - shadow_blur: settings.shadowBlur, - shadow_color: settings.shadowColor, - 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, - line_overrides: [], - pos_x: settings.posX ?? undefined, - pos_y: settings.posY ?? undefined, - }) - }} - /> - {/* 自动生成字幕 */}
) } diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index e9a159da1..12661187d 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -16,6 +16,7 @@ import Step6CoverSettings from "../components/Step6CoverSettings" import BatchGenerationGrid from "./BatchGenerationGrid" import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling" import type { GeneratedVideo } from "@/api/template-editor" +import type { TitleTemplate } from "@/components/title/template-types" export interface GenerateStepContentProps { currentStep: number @@ -60,8 +61,8 @@ export interface GenerateStepContentProps { style: Record }> enableTemplates?: boolean - onOpenTemplates?: () => void - activeTemplateLabel?: string | null + selectedTemplateId?: string | null + onApplyTemplate?: (settings: import("../types").TitleSettings, template: TitleTemplate) => void /* ── 封面 ── */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void @@ -130,8 +131,8 @@ export const GenerateStepContent: React.FC = (props) = onApplyPreset, onUpdateStyle, enableTemplates, - onOpenTemplates, - activeTemplateLabel, + selectedTemplateId, + onApplyTemplate, activePreset, titlePresets, coverSettings, @@ -208,8 +209,8 @@ export const GenerateStepContent: React.FC = (props) = activePreset={activePreset} titlePresets={titlePresets} enableTemplates={enableTemplates} - onOpenTemplates={onOpenTemplates} - activeTemplateLabel={activeTemplateLabel} + selectedTemplateId={selectedTemplateId} + onApplyTemplate={onApplyTemplate} previewCount={previewCount} previewTitles={previewTitles} onPreviewTitlesChange={onPreviewTitlesChange} diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index a0955e52c..8cdcc5f90 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -19,6 +19,7 @@ import AiTitleGenerator from "./title/AiTitleGenerator" import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete" import TitleStylePanel from "./title/TitleStylePanel" import { AI_TITLE_TEMPLATES } from "../constants" +import type { TitleTemplate } from "@/components/title/template-types" interface Step4TitleSettingsProps { titleSettings: TitleSettings @@ -50,8 +51,8 @@ interface Step4TitleSettingsProps { previewTitles?: string[] onPreviewTitlesChange?: (titles: string[]) => void enableTemplates?: boolean - onOpenTemplates?: () => void - activeTemplateLabel?: string | null + selectedTemplateId?: string | null + onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void } /** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */ @@ -102,8 +103,8 @@ const Step4TitleSettings: React.FC = (props) => { previewTitles, onPreviewTitlesChange, enableTemplates, - onOpenTemplates, - activeTemplateLabel, + selectedTemplateId, + onApplyTemplate, } = props const isBatch = previewCount > 1 @@ -307,8 +308,8 @@ const Step4TitleSettings: React.FC = (props) => { POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} enableTemplates={enableTemplates} - onOpenTemplates={onOpenTemplates} - activeTemplateLabel={activeTemplateLabel} + selectedTemplateId={selectedTemplateId} + onApplyTemplate={onApplyTemplate} />
) diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 39db747a3..0542d4c56 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -1,16 +1,21 @@ /** - * 标题样式设置面板(#2001 升级) + * 标题样式设置面板(#2001 升级 / #2003 模板内联改造) * - * P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 / - * 4款爆款字体 / 抖音爆款黄预设 - * P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 / - * 封面独立标题配置入口 + * - #2001:描边/阴影/背景/排版/封面 6个Tab + Canvas 迷你预览 + 4款爆款字体 + * - #2003:标题模板直接内嵌在预览下方(卡片网格始终可见),不再弹窗选择 + * 支持新建/编辑/复制/导出/删除,编辑仍用 Modal 编辑器(左预览+右参数) * - * 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/ - * onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。 + * 向后兼容:旧 props 全部保留;enableTemplates=true 时渲染内联网格,false 保留旧爆款预设。 */ -import React, { useState } from "react" -import { Tabs } from "antd" +import React, { useMemo, useState } from "react" +import { Tabs, Button, message, Popconfirm, Tooltip } from "antd" +import { + PlusOutlined, + EditOutlined, + CopyOutlined, + DeleteOutlined, + ExportOutlined, +} from "@ant-design/icons" import type { TitleSettings } from "../../types" import { FONT_OPTIONS as NEW_FONT_OPTIONS, @@ -22,7 +27,13 @@ import { import { buildPresetPreviewSettings } from "@/components/title/utils" import TitleMiniPreview from "./TitleMiniPreview" +import TitleTemplateEditor from "@/components/title/TitleTemplateEditor" +import { useTitleTemplates } from "@/components/title/useTitleTemplates" +import { titleStyleConfigToCamel, templateToPreviewSettings } from "@/components/title/utils" +import { DEFAULT_TITLE_SETTINGS_FULL } from "../../types" +import type { TitleTemplate } from "@/components/title/template-types" import "./TitleStylePanel.css" +import "@/components/title/TitleTemplate.css" interface PositionOption { value: string @@ -39,7 +50,6 @@ interface LegacyPreset { interface TitleStylePanelProps { settings: TitleSettings - /* 旧 props(兼容) */ onUpdatePosition: (position: string) => void onUpdateFont: (font: string) => void onUpdateSize: (size: number) => void @@ -52,21 +62,18 @@ interface TitleStylePanelProps { titlePresets: LegacyPreset[] POSITION_OPTIONS: PositionOption[] FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }> - /* 新增:统一字段更新 */ onUpdateStyle?: (patch: Partial) => void - /* 是否显示封面独立标题切换 */ showCoverToggle?: boolean - /** 画布预览宽度(默认 200) */ previewWidth?: number - /** 是否启用「标题模板」入口(显示选择模板按钮,隐藏旧预设网格) */ + /** 是否启用「标题模板」内联网格(替代旧爆款预设) */ enableTemplates?: boolean - /** 打开模板选择器回调 */ - onOpenTemplates?: () => void - /** 当前选中模板名称 */ - activeTemplateLabel?: string | null + /** 当前选中模板 id */ + selectedTemplateId?: string | null + /** 应用模板回调(父组件负责合并到 state) */ + onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void } -/* ── 通用 Slider + Label 行 ── */ +/* ── Slider 行 ── */ const SliderRow: React.FC<{ label: string value: number @@ -96,7 +103,7 @@ const SliderRow: React.FC<{ ) -/* ── 色板 + 自定义颜色选择 ── */ +/* ── 色板 ── */ const ColorPicker: React.FC<{ label?: string value: string @@ -147,40 +154,6 @@ const ColorPicker: React.FC<{ ) } -/* ── 预设网格(含爆款黄,10 个 + 迷你 Canvas 缩略) ── */ -const PresetGrid: React.FC<{ - activePreset: string | null - onApply: (key: string) => void - settings: TitleSettings -}> = ({ activePreset, onApply, settings }) => { - return ( -
- {TITLE_PRESETS.map((p) => { - const isActive = activePreset === p.key - // 归一化预览:固定字号 48(按 360 基准缩放后 ~13px),所有卡片"标题"两字视觉大小一致,描边/阴影按比例缩放 - const previewStyle = buildPresetPreviewSettings(settings, p.key, 56) - return ( - - ) - })} -
- ) -} - const TitleStylePanel: React.FC = ({ settings, onUpdatePosition, @@ -197,17 +170,192 @@ const TitleStylePanel: React.FC = ({ showCoverToggle = false, previewWidth = 220, enableTemplates = false, - onOpenTemplates, - activeTemplateLabel, + selectedTemplateId, + onApplyTemplate, onUpdateStyle, }) => { const upd = (patch: Partial) => { onUpdateStyle?.(patch) } - /* 封面独立标题切换 */ const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle) + /* ── 模板相关 hook + 编辑器 state ── */ + const { templates, createTemplate, duplicateTemplate, deleteTemplate, exportTemplate } = + useTitleTemplates() + const [editingTemplate, setEditingTemplate] = useState(null) + const [editorOpen, setEditorOpen] = useState(false) + + const grouped = useMemo( + () => ({ + builtin: templates.filter((t) => t.isBuiltin), + custom: templates.filter((t) => !t.isBuiltin), + }), + [templates], + ) + + const handleRequestCreate = () => { + 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 + "top" | "center" | "bottom" | "custom", + pos_x: settings.posX ?? undefined, + pos_y: settings.posY ?? undefined, + line_overrides: [], + cover_title_config: null, + }, + }) + setEditingTemplate(t) + setEditorOpen(true) + } + + const handleRequestEdit = (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 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 handleEditorSaved = (tpl: TitleTemplate) => { + setEditorOpen(false) + setEditingTemplate(null) + message.success(`已保存:${tpl.name}`) + handleSelectTemplate(DEFAULT_TITLE_SETTINGS_FULL, tpl) + } + + const renderTemplateCard = (t: TitleTemplate) => { + const isSelected = selectedTemplateId === t.id + const preview = templateToPreviewSettings(t, 48) + return ( + + + )} + + + + + + + {!t.isBuiltin && ( + handleDelete(t)}> + + + + + )} + + +
+ {t.emoji || "✨"} + + {t.name} + +
+ + ) + } + + /* ── 旧爆款预设网格(enableTemplates=false 时显示,作为编辑器内快速起点) ── */ + const renderPresetGrid = () => ( +
+ {TITLE_PRESETS.map((p) => { + const isActive = activePreset === p.key + const previewStyle = buildPresetPreviewSettings(settings, p.key, 56) + return ( + + ) + })} +
+ ) + return (
{/* 实时迷你预览 */} @@ -219,22 +367,54 @@ const TitleStylePanel: React.FC = ({ />
- {/* 标题模板 / 爆款预设 */} -
-
- - {enableTemplates ? ( - - ) : ( - activePreset && 已选 - )} + {/* 标题模板(内联)/ 爆款预设(旧) */} + {enableTemplates ? ( +
+
+
+
+ 我的模板 + +
+ {grouped.custom.length === 0 ? ( +
+ ✨ + 还没有自定义模板,点击右上角「新建模板」 +
+ ) : ( +
+ {grouped.custom.map(renderTemplateCard)} +
+ )} +
+ +
+
+ 系统模板 +
+
+ {grouped.builtin.map(renderTemplateCard)} +
+
+
- {!enableTemplates && ( - - )} -
+ ) : ( +
+
+ + {activePreset && 已选} +
+ {renderPresetGrid()} +
+ )} = ({ label: "基础", children: ( <> - {/* 位置 + 字体 */}
@@ -286,7 +465,6 @@ const TitleStylePanel: React.FC = ({ onChange={onUpdateSize} /> - {/* 样式按钮 */}
@@ -309,10 +487,8 @@ const TitleStylePanel: React.FC = ({ className={`ts-style-btn${settings.stroke ? " active" : ""}`} onClick={() => { onToggleStroke() - // 如果之前 strokeWidth 为 0,启用时给个默认值 - if (!settings.stroke && (settings.strokeWidth ?? 0) < 2) { + if (!settings.stroke && (settings.strokeWidth ?? 0) < 2) upd({ strokeWidth: 4 }) - } }} title="描边" > @@ -339,7 +515,6 @@ const TitleStylePanel: React.FC = ({
- {/* 字色 */} = ({ : []), ]} /> + + {editorOpen && editingTemplate && ( + { + setEditorOpen(false) + setEditingTemplate(null) + }} + onSaved={handleEditorSaved} + /> + )}
) } -- 2.54.0