From 566a728dda72e1bb3b366350078948b5ab56d34d Mon Sep 17 00:00:00 2001 From: saas-frontend Date: Tue, 22 Sep 2026 22:40:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=A0=87=E9=A2=98=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=B3=BB=E7=BB=9F=20+=20=E4=BF=AE=E5=A4=8D3=E4=B8=AA?= =?UTF-8?q?=E9=A2=84=E8=AE=BEbug=20(#2003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 修复: - useTitleStyleUpdaters.applyPreset:snake_case preset.style 错误 spread 到 camelCase TitleSettings 导致选预设后改字体/描边/阴影/背景等细粒度参数不生效,改用 @/components/title/utils 的 titleStyleConfigToCamel 做显式键名转换 - toggleStroke:开启描边时若 strokeWidth<2 自动给默认值4,修滑块不可调 - PresetGrid 预览:原 width=100 按 preset 自身 size(44~80) 渲染,导致卡片中 "标题"两字视觉大小不一、小的看不清。新增 buildPresetPreviewSettings 归一化 到固定56号+描边/阴影/背景按比例缩放,canvas宽度改为120 标题模板系统(参考字幕模板交互): - 新建 @/components/title/template-types.ts TitleTemplate 类型(sys:/usr:双结构) - 新建 useTitleTemplates hook:localStorage 持久化 + 内置10个预设系统模板 + 自定义模板 CRUD(新建/复制/更新/删除/导出JSON/导入JSON) - 新建 TitleTemplateSelector 弹窗:4列卡片网格,卡片含Canvas预览+系统/我的标签+ 选中勾+hover显示编辑/复制/导出/删除图标;顶部+新建模板按钮 - 新建 TitleTemplateEditor 编辑器:左侧320×180实时预览,右侧复用 TitleStylePanel 细粒度调节,内联表单填emoji/名称(必填)/描述(可选),保存自动写localStorage - TitleStylePanel 新增 enableTemplates/onOpenTemplates/activeTemplateLabel 三个 props,开启时旧PresetGrid隐藏,改为"标题模板"标签行+"选择模板"按钮 - generate 页入口:GeneratePage 增加 titleTemplateModalOpen 状态+TitleTemplateSelector, 通过 GenerateStepContent/Step4TitleSettings 透传到 TitleStylePanel,选择模板后 用新增的 applyTemplate 方法覆盖样式字段(保留title/aiAutoSelect/coverTitle) - ai-avatar 入口:PanelTitleConfig 同样接入模板弹窗,选中后把 camelCase settings 转回 snake_case 调 onUpdate 共 13 文件:6 新建 + 7 修改。TitleStylePanel 作为公共组件改完后,智能剪辑 和 AI数字人两个页面的字体/标题配置都通过弹窗选模板。 --- .../src/components/title/TitleTemplate.css | 211 ++++++++++++++++ .../components/title/TitleTemplateEditor.tsx | 187 ++++++++++++++ .../title/TitleTemplateSelector.tsx | 237 ++++++++++++++++++ .../src/components/title/template-types.ts | 28 +++ .../src/components/title/useTitleTemplates.ts | 213 ++++++++++++++++ apps/web/src/components/title/utils.ts | 98 ++++++++ .../ai-avatar/components/PanelTitleConfig.tsx | 47 ++++ apps/web/src/pages/generate/GeneratePage.tsx | 18 ++ .../components/GenerateStepContent.tsx | 9 + .../components/Step4TitleSettings.tsx | 9 + .../components/title/TitleStylePanel.css | 14 ++ .../components/title/TitleStylePanel.tsx | 33 ++- .../useStep4Title/useTitleStyleUpdaters.ts | 83 +++++- 13 files changed, 1170 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/title/TitleTemplate.css create mode 100644 apps/web/src/components/title/TitleTemplateEditor.tsx create mode 100644 apps/web/src/components/title/TitleTemplateSelector.tsx create mode 100644 apps/web/src/components/title/template-types.ts create mode 100644 apps/web/src/components/title/useTitleTemplates.ts create mode 100644 apps/web/src/components/title/utils.ts diff --git a/apps/web/src/components/title/TitleTemplate.css b/apps/web/src/components/title/TitleTemplate.css new file mode 100644 index 000000000..602fc2fd2 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplate.css @@ -0,0 +1,211 @@ +/* ============================================================ + 标题模板系统(#2003):选择器 + 编辑器 + 保存弹窗 + ============================================================ */ + +/* ── 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; + } +} diff --git a/apps/web/src/components/title/TitleTemplateEditor.tsx b/apps/web/src/components/title/TitleTemplateEditor.tsx new file mode 100644 index 000000000..0e8e258c8 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplateEditor.tsx @@ -0,0 +1,187 @@ +/** + * 标题模板编辑器(#2003) + * + * 左侧大预览(400×225,16:9),右侧复用 TitleStylePanel 进行参数调整。 + * 编辑完成后点"保存"弹出 SaveTemplateModal(名称必填),保存后回调 onSaved。 + */ +import React, { 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 type { TitleSettings } from "../../pages/generate/types" +import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types" +import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils" +import { useTitleTemplates } from "./useTitleTemplates" +import type { TitleTemplate } from "./template-types" + +interface Props { + open: boolean + template: TitleTemplate + onClose: () => void + onSaved: (template: TitleTemplate) => void +} + +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: "标题预览", + })) + 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(() => { + if (open) { + setSettings({ + ...DEFAULT_TITLE_SETTINGS_FULL, + ...titleStyleConfigToCamel(template.style || {}), + 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 = () => { + const name = formName.trim() + if (!name) { + message.warning("请填写模板名称") + return + } + setSaving(true) + try { + const snake = camelToTitleStyleConfig(settings) + if (template.isBuiltin) { + // 内置模板保存时创建一个副本 + const t = createTemplate({ name, description: formDesc, emoji: formEmoji, style: snake }) + onSaved(t) + } else { + updateTemplate(template.id, { name, description: formDesc, emoji: formEmoji, style: snake }) + onSaved({ + ...template, + name, + description: formDesc, + emoji: formEmoji, + style: snake, + updatedAt: new Date().toISOString(), + }) + } + } finally { + setSaving(false) + } + } + + return ( + + + + + } + destroyOnClose + className="tt-modal tt-editor-modal" + > + {/* 模板名称/描述表单 */} +
+
+ + setFormEmoji(e.target.value)} + /> +
+
+ + setFormName(e.target.value)} + /> +
+
+ + setFormDesc(e.target.value)} + /> +
+
+ +
+ {/* 左侧实时预览 */} +
+ +
+ {/* 右侧编辑器 — 复用 TitleStylePanel 的细粒度能力 */} +
+ upd({ position: p, posX: null, posY: null })} + onUpdateFont={(f) => upd({ font: f })} + onUpdateSize={(v) => upd({ size: v })} + onToggleBold={() => upd({ bold: !settings.bold })} + onToggleItalic={() => upd({ italic: !settings.italic })} + onToggleStroke={() => + upd({ + stroke: !settings.stroke, + strokeWidth: + !settings.stroke && (settings.strokeWidth ?? 0) < 2 ? 4 : settings.strokeWidth, + }) + } + 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: "标题预览", + })) + } + }} + onUpdateStyle={(patch) => upd(patch)} + activePreset={null} + titlePresets={[]} + POSITION_OPTIONS={POSITION_OPTIONS} + FONT_OPTIONS={FONT_OPTIONS} + /> +
+
+
+ ) +} + +export default TitleTemplateEditor diff --git a/apps/web/src/components/title/TitleTemplateSelector.tsx b/apps/web/src/components/title/TitleTemplateSelector.tsx new file mode 100644 index 000000000..64107a298 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplateSelector.tsx @@ -0,0 +1,237 @@ +/** + * 标题模板选择器(#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/components/title/template-types.ts b/apps/web/src/components/title/template-types.ts new file mode 100644 index 000000000..51c3e8459 --- /dev/null +++ b/apps/web/src/components/title/template-types.ts @@ -0,0 +1,28 @@ +/** + * 标题模板系统类型(#2003) + * + * 设计: + * - 内置系统模板(从 TITLE_PRESETS 自动派生,不可编辑/删除,可"复制为我的") + * - 用户自定义模板(保存在 localStorage,可编辑/复制/导出/删除) + * - 模板存完整 TitleStyleConfig(snake_case),与后端契约一致 + */ +import type { TitleStyleConfig } from "./types" + +export interface TitleTemplate { + /** 唯一 ID:系统模板 `sys:`,用户模板 `usr:` */ + id: string + /** 模板名称(必填,保存时校验) */ + name: string + /** 模板描述(可选) */ + description?: string + /** 是否为系统内置(不可删除/编辑源) */ + isBuiltin: boolean + /** emoji(展示用,可选) */ + emoji?: string + /** 创建时间(ISO 字符串,系统模板=固定值) */ + createdAt: string + /** 更新时间 */ + updatedAt: string + /** 完整样式配置(snake_case,与后端 title_config 对齐) */ + style: Partial +} diff --git a/apps/web/src/components/title/useTitleTemplates.ts b/apps/web/src/components/title/useTitleTemplates.ts new file mode 100644 index 000000000..c8a26acac --- /dev/null +++ b/apps/web/src/components/title/useTitleTemplates.ts @@ -0,0 +1,213 @@ +/** + * 标题模板 CRUD Hook(#2003) + * + * - 内存态 + localStorage 持久化 + * - 系统模板(来自 TITLE_PRESETS)始终前置、不可删除/编辑源 + * - 用户模板 CRUD:新增/复制/更新/删除/导出/导入 + */ +import { useCallback, useEffect, useMemo, useState } from "react" +import { TITLE_PRESETS } from "./constants" +import type { TitleStyleConfig } from "./types" +import type { TitleTemplate } from "./template-types" + +const STORAGE_KEY = "xiaoxia.title.templates.v1" + +function uid(): string { + return "usr:" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36) +} + +/** 把 TITLE_PRESETS 转为内置 TitleTemplate 列表 */ +function buildBuiltinTemplates(): TitleTemplate[] { + const now = "2026-09-22T00:00:00+08:00" + return TITLE_PRESETS.map((p) => ({ + id: `sys:${p.key}`, + name: p.label, + description: "系统内置模板", + isBuiltin: true, + emoji: p.emoji, + createdAt: now, + updatedAt: now, + style: { ...p.style }, + })) +} + +function loadUserTemplates(): TitleTemplate[] { + if (typeof window === "undefined") return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((t) => t && !t.isBuiltin && t.id.startsWith("usr:")) + } catch { + return [] + } +} + +function saveUserTemplates(list: TitleTemplate[]) { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list)) + } catch { + /* ignore quota */ + } +} + +export interface UseTitleTemplatesResult { + templates: TitleTemplate[] + builtin: TitleTemplate[] + custom: TitleTemplate[] + /** 新建自定义模板:传入样式(snake_case),返回新建模板 id */ + createTemplate: (input: { + name: string + description?: string + emoji?: string + style: Partial + }) => TitleTemplate + /** 复制模板(内置模板也可复制,自动加"副本"后缀) */ + duplicateTemplate: (id: string) => TitleTemplate | null + /** 更新自定义模板(系统模板不可改) */ + updateTemplate: (id: string, patch: Partial>) => void + /** 删除自定义模板 */ + deleteTemplate: (id: string) => void + /** 导出单个模板为 JSON 字符串 */ + exportTemplate: (id: string) => string | null + /** 导入 JSON 字符串作为新模板,返回新建模板 */ + importTemplate: (json: string) => TitleTemplate | null + /** 根据 id 查询模板 */ + getById: (id: string) => TitleTemplate | undefined +} + +export function useTitleTemplates(): UseTitleTemplatesResult { + const [custom, setCustom] = useState(() => loadUserTemplates()) + + // 跨 tab 同步 + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) setCustom(loadUserTemplates()) + } + window.addEventListener("storage", onStorage) + return () => window.removeEventListener("storage", onStorage) + }, []) + + const builtin = useMemo(() => buildBuiltinTemplates(), []) + const templates = useMemo(() => [...builtin, ...custom], [builtin, custom]) + + const persist = useCallback((next: TitleTemplate[]) => { + setCustom(next) + saveUserTemplates(next) + }, []) + + const getById = useCallback((id: string) => templates.find((t) => t.id === id), [templates]) + + const createTemplate = useCallback( + ({ name, description, emoji, style }) => { + const now = new Date().toISOString() + const t: TitleTemplate = { + id: uid(), + name: name.trim() || "未命名模板", + description: description?.trim() || undefined, + emoji: emoji || "✨", + isBuiltin: false, + createdAt: now, + updatedAt: now, + style: { ...style }, + } + persist([...custom, t]) + return t + }, + [custom, persist], + ) + + const duplicateTemplate = useCallback( + (id) => { + const src = templates.find((t) => t.id === id) + if (!src) return null + const now = new Date().toISOString() + const t: TitleTemplate = { + id: uid(), + name: `${src.name} 副本`, + description: src.description, + emoji: src.emoji, + isBuiltin: false, + createdAt: now, + updatedAt: now, + style: { ...src.style }, + } + persist([...custom, t]) + return t + }, + [templates, custom, persist], + ) + + const updateTemplate = useCallback( + (id, patch) => { + if (id.startsWith("sys:")) return + const next = custom.map((t) => + t.id === id + ? { ...t, ...patch, id: t.id, isBuiltin: false, updatedAt: new Date().toISOString() } + : t, + ) + persist(next) + }, + [custom, persist], + ) + + const deleteTemplate = useCallback( + (id) => { + if (id.startsWith("sys:")) return + persist(custom.filter((t) => t.id !== id)) + }, + [custom, persist], + ) + + const exportTemplate = useCallback( + (id) => { + const t = templates.find((x) => x.id === id) + if (!t) return null + return JSON.stringify( + { + name: t.name, + description: t.description, + emoji: t.emoji, + style: t.style, + exportedAt: new Date().toISOString(), + }, + null, + 2, + ) + }, + [templates], + ) + + const importTemplate = useCallback( + (json) => { + try { + const data = JSON.parse(json) + if (!data || typeof data !== "object" || !data.style) return null + return createTemplate({ + name: data.name || "导入模板", + description: data.description, + emoji: data.emoji || "✨", + style: data.style, + }) + } catch { + return null + } + }, + [createTemplate], + ) + + return { + templates, + builtin, + custom, + createTemplate, + duplicateTemplate, + updateTemplate, + deleteTemplate, + exportTemplate, + importTemplate, + getById, + } +} diff --git a/apps/web/src/components/title/utils.ts b/apps/web/src/components/title/utils.ts new file mode 100644 index 000000000..a56dac90d --- /dev/null +++ b/apps/web/src/components/title/utils.ts @@ -0,0 +1,98 @@ +/** + * 标题样式工具(#2001 / 模板系统 #2003) + * + * - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转 + * - preset 归一化预览(修复"标题"两字大小不一) + */ +import type { TitleStyleConfig } from "./types" +import type { TitleSettings } from "../../pages/generate/types" +import { TITLE_PRESETS } from "./constants" + +/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */ +export function titleStyleConfigToCamel(s: Partial): Partial { + const out: Partial = {} + if (s.font != null) out.font = s.font + if (s.size != null) out.size = s.size + if (s.color != null) out.color = s.color + if (s.bold != null) out.bold = s.bold + if (s.italic != null) out.italic = s.italic + if (s.position != null) out.position = s.position as TitleSettings["position"] + if (s.pos_x != null) out.posX = s.pos_x + if (s.pos_y != null) out.posY = s.pos_y + if (s.line_height != null) out.lineHeight = s.line_height + if (s.margin_top != null) out.marginTop = s.margin_top + if (s.max_chars_per_line != null) out.maxCharsPerLine = s.max_chars_per_line + if (s.stroke != null) out.stroke = s.stroke + if (s.stroke_width != null) out.strokeWidth = s.stroke_width + if (s.stroke_color != null) out.strokeColor = s.stroke_color + if (s.shadow != null) out.shadow = s.shadow + if (s.shadow_offset_x != null) out.shadowOffsetX = s.shadow_offset_x + if (s.shadow_offset_y != null) out.shadowOffsetY = s.shadow_offset_y + if (s.shadow_blur != null) out.shadowBlur = s.shadow_blur + if (s.shadow_color != null) out.shadowColor = s.shadow_color + if (s.bg_enabled != null) out.bgEnabled = s.bg_enabled + if (s.bg_color != null) out.bgColor = s.bg_color + if (s.bg_padding != null) out.bgPadding = s.bg_padding + if (s.bg_radius != null) out.bgRadius = s.bg_radius + if (s.line_overrides != null) out.lineOverrides = s.line_overrides + return out +} + +/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */ +export function camelToTitleStyleConfig(p: Partial): Partial { + const out: Partial = {} + if (p.font != null) out.font = p.font + if (p.size != null) out.size = p.size + if (p.color != null) out.color = p.color + if (p.bold != null) out.bold = p.bold + if (p.italic != null) out.italic = p.italic + if (p.position != null) out.position = p.position as TitleStyleConfig["position"] + if (p.posX != null) out.pos_x = p.posX + if (p.posY != null) out.pos_y = p.posY + if (p.lineHeight != null) out.line_height = p.lineHeight + if (p.marginTop != null) out.margin_top = p.marginTop + if (p.maxCharsPerLine != null) out.max_chars_per_line = p.maxCharsPerLine + if (p.stroke != null) out.stroke = p.stroke + if (p.strokeWidth != null) out.stroke_width = p.strokeWidth + if (p.strokeColor != null) out.stroke_color = p.strokeColor + if (p.shadow != null) out.shadow = p.shadow + if (p.shadowOffsetX != null) out.shadow_offset_x = p.shadowOffsetX + if (p.shadowOffsetY != null) out.shadow_offset_y = p.shadowOffsetY + if (p.shadowBlur != null) out.shadow_blur = p.shadowBlur + if (p.shadowColor != null) out.shadow_color = p.shadowColor + if (p.bgEnabled != null) out.bg_enabled = p.bgEnabled + if (p.bgColor != null) out.bg_color = p.bgColor + if (p.bgPadding != null) out.bg_padding = p.bgPadding + if (p.bgRadius != null) out.bg_radius = p.bgRadius + if (p.lineOverrides != null) out.line_overrides = p.lineOverrides + return out +} + +/** + * 把 preset style(snake_case)归一化为固定字号的 TitleSettings, + * 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。 + * 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。 + */ +export function buildPresetPreviewSettings( + base: TitleSettings, + presetKey: string, + fixedSize = 56, +): TitleSettings { + const preset = TITLE_PRESETS.find((p) => p.key === presetKey) + if (!preset) return base + const origSize = preset.style.size ?? fixedSize + const ratio = fixedSize / origSize + const scale = (v: number | undefined, fallback: number): number => + v != null ? Math.round(v * ratio) : fallback + return { + ...base, + ...titleStyleConfigToCamel(preset.style), + size: fixedSize, + strokeWidth: scale(preset.style.stroke_width, base.strokeWidth) ?? base.strokeWidth, + shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX) ?? base.shadowOffsetX, + shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY) ?? base.shadowOffsetY, + shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur) ?? base.shadowBlur, + bgPadding: scale(preset.style.bg_padding, base.bgPadding) ?? base.bgPadding, + lineOverrides: [], + } +} diff --git a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx index 9a6ff5bda..4d21cff2e 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx @@ -10,6 +10,9 @@ 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" import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete" import type { TitleSettings } from "@/pages/generate/types" @@ -29,6 +32,8 @@ 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) /** 标题库选项(#1894:从文案库 scripts[].title 取候选) */ const [titleOptions, setTitleOptions] = useState([]) @@ -375,6 +380,9 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda onUpdateStyle={handleUpdateStyle} showCoverToggle previewWidth={280} + enableTemplates + onOpenTemplates={() => setTemplateModalOpen(true)} + activeTemplateLabel={activeTemplate?.name ?? null} activePreset={activePreset} titlePresets={ TITLE_PRESETS as unknown as React.ComponentProps["titlePresets"] @@ -383,6 +391,45 @@ 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 abe451b89..e9a159da1 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -59,6 +59,9 @@ export interface GenerateStepContentProps { emoji?: string style: Record }> + enableTemplates?: boolean + onOpenTemplates?: () => void + activeTemplateLabel?: string | null /* ── 封面 ── */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void @@ -126,6 +129,9 @@ export const GenerateStepContent: React.FC = (props) = onToggleShadow, onApplyPreset, onUpdateStyle, + enableTemplates, + onOpenTemplates, + activeTemplateLabel, activePreset, titlePresets, coverSettings, @@ -201,6 +207,9 @@ export const GenerateStepContent: React.FC = (props) = onUpdateStyle={onUpdateStyle} activePreset={activePreset} titlePresets={titlePresets} + enableTemplates={enableTemplates} + onOpenTemplates={onOpenTemplates} + activeTemplateLabel={activeTemplateLabel} 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 b50d46c42..a0955e52c 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -49,6 +49,9 @@ interface Step4TitleSettingsProps { /** 每个变体的标题文字(长度=previewCount) */ previewTitles?: string[] onPreviewTitlesChange?: (titles: string[]) => void + enableTemplates?: boolean + onOpenTemplates?: () => void + activeTemplateLabel?: string | null } /** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */ @@ -98,6 +101,9 @@ const Step4TitleSettings: React.FC = (props) => { previewCount = 1, previewTitles, onPreviewTitlesChange, + enableTemplates, + onOpenTemplates, + activeTemplateLabel, } = props const isBatch = previewCount > 1 @@ -300,6 +306,9 @@ const Step4TitleSettings: React.FC = (props) => { titlePresets={titlePresets} POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} + enableTemplates={enableTemplates} + onOpenTemplates={onOpenTemplates} + activeTemplateLabel={activeTemplateLabel} /> ) diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.css b/apps/web/src/pages/generate/components/title/TitleStylePanel.css index 89940060d..58d88efcc 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.css +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.css @@ -442,3 +442,17 @@ font-size: 12px !important; padding: 6px 8px !important; } + +/* 标题模板入口按钮(#2003) */ +.ts-template-btn { + border: none; + background: transparent; + color: var(--primary-color, #7c3aed); + font-size: 12px; + cursor: pointer; + padding: 2px 0; + font-weight: 500; +} +.ts-template-btn:hover { + text-decoration: underline; +} diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 2e9afd531..39db747a3 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -19,6 +19,7 @@ import { STROKE_COLOR_PALETTE, BG_COLOR_PALETTE, } from "@/components/title/constants" +import { buildPresetPreviewSettings } from "@/components/title/utils" import TitleMiniPreview from "./TitleMiniPreview" import "./TitleStylePanel.css" @@ -57,6 +58,12 @@ interface TitleStylePanelProps { showCoverToggle?: boolean /** 画布预览宽度(默认 200) */ previewWidth?: number + /** 是否启用「标题模板」入口(显示选择模板按钮,隐藏旧预设网格) */ + enableTemplates?: boolean + /** 打开模板选择器回调 */ + onOpenTemplates?: () => void + /** 当前选中模板名称 */ + activeTemplateLabel?: string | null } /* ── 通用 Slider + Label 行 ── */ @@ -150,8 +157,8 @@ const PresetGrid: React.FC<{
{TITLE_PRESETS.map((p) => { const isActive = activePreset === p.key - // 合并当前 style 与 preset.style 用于预览(仅预览时覆盖) - const previewStyle: TitleSettings = { ...settings, ...(p.style as Partial) } + // 归一化预览:固定字号 48(按 360 基准缩放后 ~13px),所有卡片"标题"两字视觉大小一致,描边/阴影按比例缩放 + const previewStyle = buildPresetPreviewSettings(settings, p.key, 56) return ( + ) : ( + activePreset && 已选 + )} +
+ {!enableTemplates && ( + + )} { + // 新预设匹配:font/size/color/bold/stroke/shadow/bg 全部对齐才算命中 + for (const p of NEW_TITLE_PRESETS) { + const camel = titleStyleConfigToCamel(p.style) + if ( + (camel.font ?? null) === (settings.font ?? null) && + (camel.color ?? null) === (settings.color ?? null) && + (camel.bold ?? null) === (settings.bold ?? null) && + (camel.italic ?? null) === (settings.italic ?? null) && + (camel.stroke ?? null) === (settings.stroke ?? null) && + (camel.shadow ?? null) === (settings.shadow ?? null) && + (camel.bgEnabled ?? null) === (settings.bgEnabled ?? null) + ) { + return p.key + } + } + // fallback 旧预设(legacy) for (const p of TITLE_PRESETS) { if ( settings.color === p.style.color && @@ -91,24 +108,31 @@ export function useTitleStyleUpdaters({ }, [titleSettings, onTitleSettingsChange]) const toggleStroke = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke }) + onTitleSettingsChange({ + ...titleSettings, + stroke: !titleSettings.stroke, + // 开启描边时若宽度过小给个默认值(让滑块可见可调) + strokeWidth: + !titleSettings.stroke && (titleSettings.strokeWidth ?? 0) < 2 + ? 4 + : titleSettings.strokeWidth, + }) }, [titleSettings, onTitleSettingsChange]) const toggleShadow = useCallback(() => { onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow }) }, [titleSettings, onTitleSettingsChange]) - /** 应用预设(支持新预设细粒度字段) */ + /** 应用预设:正确把 snake_case 的 preset.style 转为 camelCase 再 spread */ const applyPreset = useCallback( (presetKey: string) => { - // 优先匹配新预设(10个爆款预设),fallback 旧预设 const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey) const oldPreset = TITLE_PRESETS.find((p) => p.key === presetKey) if (newPreset) { onTitleSettingsChange({ ...titleSettings, - ...(newPreset.style as Partial), - // 清除逐行覆盖 + ...titleStyleConfigToCamel(newPreset.style), + // 封面独立标题保持不变(不清空,避免破坏封面定制) lineOverrides: [], }) return @@ -116,11 +140,47 @@ export function useTitleStyleUpdaters({ if (!oldPreset) return onTitleSettingsChange({ ...titleSettings, - color: oldPreset.style.color, - bold: oldPreset.style.bold, - italic: oldPreset.style.italic, - stroke: oldPreset.style.stroke, - shadow: oldPreset.style.shadow, + color: oldPreset.style.color as string, + bold: oldPreset.style.bold as boolean, + italic: oldPreset.style.italic as boolean, + stroke: oldPreset.style.stroke as boolean, + shadow: oldPreset.style.shadow as boolean, + }) + }, + [titleSettings, onTitleSettingsChange], + ) + + /** 应用模板:用模板(camelCase TitleSettings)覆盖样式字段,保留 title/aiAutoSelect */ + const applyTemplate = useCallback( + (tpl: TitleSettings) => { + onTitleSettingsChange({ + ...titleSettings, + // 覆盖样式字段 + position: tpl.position, + posX: tpl.posX, + posY: tpl.posY, + font: tpl.font, + size: tpl.size, + bold: tpl.bold, + italic: tpl.italic, + stroke: tpl.stroke, + shadow: tpl.shadow, + color: tpl.color, + lineHeight: tpl.lineHeight, + marginTop: tpl.marginTop, + maxCharsPerLine: tpl.maxCharsPerLine, + strokeWidth: tpl.strokeWidth, + strokeColor: tpl.strokeColor, + shadowOffsetX: tpl.shadowOffsetX, + shadowOffsetY: tpl.shadowOffsetY, + shadowBlur: tpl.shadowBlur, + shadowColor: tpl.shadowColor, + bgEnabled: tpl.bgEnabled, + bgColor: tpl.bgColor, + bgPadding: tpl.bgPadding, + bgRadius: tpl.bgRadius, + lineOverrides: [], + // coverTitle 保留用户当前值,不强制覆盖 }) }, [titleSettings, onTitleSettingsChange], @@ -149,6 +209,7 @@ export function useTitleStyleUpdaters({ toggleStroke, toggleShadow, applyPreset, + applyTemplate, updateStyle, } } -- 2.54.0