From 05b5de2ea8734931d6230966ed64bddee91d8082 Mon Sep 17 00:00:00 2001 From: saas-backend Date: Tue, 22 Sep 2026 22:25:58 +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=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=20=E2=80=94=20?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E5=8C=BA=E5=9F=9F=E5=8D=87=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=8D=A1=E7=89=87=E7=BD=91=E6=A0=BC=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E5=BB=BA/=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E5=A4=8D=E5=88=B6/=E5=88=A0=E9=99=A4=20+=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug修复: - Bug1:预设选中后手动改参数自动脱离预设(useRef→useState管理activePreset) - Bug2:预设卡片预览字号统一(canvas固定120px宽度渲染) - Bug3:描边参数始终可手动调整(预设只给初始值不锁定) 功能升级: - 预设区域改为4列模板卡片网格(预览+名称+系统/自定义标签) - 新增「新建模板」按钮,编辑模式实时预览+参数面板 - 支持编辑/复制/删除用户自定义模板 - 模板数据持久化到localStorage(key: xiaoxia-title-templates) 影响范围:TitleStylePanel公共组件,智能剪辑和AI数字人两个页面自动生效 --- .../ai-avatar/components/PanelTitleConfig.tsx | 89 +++-- apps/web/src/pages/generate/GeneratePage.tsx | 9 + .../components/GenerateStepContent.tsx | 28 ++ .../components/Step4TitleSettings.tsx | 28 ++ .../components/title/TitleStylePanel.css | 187 +++++++++ .../components/title/TitleStylePanel.tsx | 377 ++++++++++++++++-- .../generate/hooks/useStep4Title/index.ts | 2 + .../useStep4Title/useTitleStyleUpdaters.ts | 72 ++-- .../hooks/useStep4Title/useTitleTemplates.ts | 82 ++++ 9 files changed, 770 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/pages/generate/hooks/useStep4Title/useTitleTemplates.ts diff --git a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx index 9a6ff5bda..9255910a9 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx @@ -15,6 +15,7 @@ import type { TitleOption } from "@/pages/generate/components/title/TitleLibrary import type { TitleSettings } from "@/pages/generate/types" import { POSITION_OPTIONS } from "@/pages/generate/constants" import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants" +import { useTitleTemplates } from "@/pages/generate/hooks/useStep4Title/useTitleTemplates" import type { AiAvatarTitleConfig } from "../types" // #1894: 标题数据源切换到文案库,取 script.title 作为候选 import { getScripts } from "@/api/scripts" @@ -30,6 +31,9 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda /** TitleStylePanel 内部高亮的预设 key(面板本地状态) */ const [activePreset, setActivePreset] = useState(null) + /** 模板管理 */ + const { allTemplates, saveTemplate, updateTemplate, deleteTemplate, duplicateTemplate } = useTitleTemplates() + /** 标题库选项(#1894:从文案库 scripts[].title 取候选) */ const [titleOptions, setTitleOptions] = useState([]) useEffect(() => { @@ -218,33 +222,63 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda } }, [titleConfig]) - /** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */ + /** 应用预设/模板:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */ const handleApplyPreset = (presetKey: string) => { + // 先找系统预设 const preset = TITLE_PRESETS.find((p) => p.key === presetKey) - if (!preset) return - setActivePreset(presetKey) - const st = preset.style || {} - onUpdate({ - font: st.font, - size: st.size, - color: st.color, - bold: st.bold, - italic: st.italic, - stroke: st.stroke, - stroke_width: st.stroke_width, - stroke_color: st.stroke_color, - shadow: st.shadow, - shadow_offset_x: st.shadow_offset_x, - shadow_offset_y: st.shadow_offset_y, - shadow_blur: st.shadow_blur, - shadow_color: st.shadow_color, - bg_enabled: st.bg_enabled, - bg_color: st.bg_color, - bg_padding: st.bg_padding, - bg_radius: st.bg_radius, - line_overrides: [], - cover_title_config: null, - }) + if (preset) { + setActivePreset(presetKey) + const st = preset.style || {} + onUpdate({ + font: st.font, + size: st.size, + color: st.color, + bold: st.bold, + italic: st.italic, + stroke: st.stroke, + stroke_width: st.stroke_width, + stroke_color: st.stroke_color, + shadow: st.shadow, + shadow_offset_x: st.shadow_offset_x, + shadow_offset_y: st.shadow_offset_y, + shadow_blur: st.shadow_blur, + shadow_color: st.shadow_color, + bg_enabled: st.bg_enabled, + bg_color: st.bg_color, + bg_padding: st.bg_padding, + bg_radius: st.bg_radius, + line_overrides: [], + cover_title_config: null, + }) + return + } + // 再找用户模板 + const tmpl = allTemplates.find((t) => t.key === presetKey) + if (tmpl && !tmpl.isSystem) { + setActivePreset(presetKey) + const st = tmpl.style || {} + onUpdate({ + font: st.font, + size: st.size, + color: st.color, + bold: st.bold, + italic: st.italic, + stroke: st.stroke, + stroke_width: (st as any).stroke_width, + stroke_color: (st as any).stroke_color, + shadow: st.shadow, + shadow_offset_x: (st as any).shadow_offset_x, + shadow_offset_y: (st as any).shadow_offset_y, + shadow_blur: (st as any).shadow_blur, + shadow_color: (st as any).shadow_color, + bg_enabled: (st as any).bg_enabled, + bg_color: (st as any).bg_color, + bg_padding: (st as any).bg_padding, + bg_radius: (st as any).bg_radius, + line_overrides: [], + cover_title_config: null, + }) + } } /** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → snake_case) */ @@ -381,6 +415,11 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda } POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} + templates={allTemplates} + onSaveTemplate={(label, style, emoji) => saveTemplate(label, style as any, emoji)} + onUpdateTemplate={(key, patch) => updateTemplate(key, patch as any)} + onDeleteTemplate={deleteTemplate} + onDuplicateTemplate={duplicateTemplate} /> {/* 自动生成字幕 */} diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index decfc4106..565e05bb8 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -29,6 +29,7 @@ import { usePreviewAssets } from "./hooks/usePreviewAssets" import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans" import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" +import { useTitleTemplates } from "./hooks/useStep4Title/useTitleTemplates" import { getAssetsByKind } from "@/api/assets" import { previewTts } from "@/api/tts" import { usePointsStore } from "@/store/pointsStore" @@ -156,6 +157,9 @@ const GeneratePage: React.FC = () => { onTitleSettingsChange: setTitleSettings, }) + /* ── 模板管理 ─ */ + const { allTemplates, saveTemplate, updateTemplate, deleteTemplate, duplicateTemplate } = useTitleTemplates() + /* ── 配音素材库(TTS 试听)── */ const { data: voiceMaterials = [] } = useQuery({ queryKey: ["assets", "voice"], @@ -643,6 +647,11 @@ const GeneratePage: React.FC = () => { onUpdateStyle={styleUpdaters.updateStyle} activePreset={styleUpdaters.activePreset} titlePresets={styleUpdaters.titlePresets} + templates={allTemplates} + onSaveTemplate={(label, style, emoji) => saveTemplate(label, style as any, emoji)} + onUpdateTemplate={(key, patch) => updateTemplate(key, patch as any)} + onDeleteTemplate={deleteTemplate} + onDuplicateTemplate={duplicateTemplate} bgm={bgm} bgmConfig={bgmConfig} coverSettings={coverSettings} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index abe451b89..9ed8635c1 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -59,6 +59,20 @@ export interface GenerateStepContentProps { emoji?: string style: Record }> + /* ── 模板管理 ── */ + templates?: Array<{ + key: string + label: string + emoji?: string + isSystem: boolean + style: Record + }> + onSaveTemplate?: (label: string, style: Record, emoji?: string) => void + onUpdateTemplate?: (key: string, patch: { label?: string; style?: Record; emoji?: string }) => void + onDeleteTemplate?: (key: string) => void + onDuplicateTemplate?: (key: string) => void + selectedTemplateKey?: string | null + onSelectTemplate?: (key: string | null) => void /* ── 封面 ── */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void @@ -128,6 +142,13 @@ export const GenerateStepContent: React.FC = (props) = onUpdateStyle, activePreset, titlePresets, + templates, + onSaveTemplate, + onUpdateTemplate, + onDeleteTemplate, + onDuplicateTemplate, + selectedTemplateKey, + onSelectTemplate, coverSettings, onCoverSettingsChange, onServerClipsChange, @@ -201,6 +222,13 @@ export const GenerateStepContent: React.FC = (props) = onUpdateStyle={onUpdateStyle} activePreset={activePreset} titlePresets={titlePresets} + templates={templates} + onSaveTemplate={onSaveTemplate} + onUpdateTemplate={onUpdateTemplate} + onDeleteTemplate={onDeleteTemplate} + onDuplicateTemplate={onDuplicateTemplate} + selectedTemplateKey={selectedTemplateKey} + onSelectTemplate={onSelectTemplate} 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..e96ec8834 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -43,6 +43,20 @@ interface Step4TitleSettingsProps { style?: Record previewStyle?: React.CSSProperties }> + /* ── 模板管理 ── */ + templates?: Array<{ + key: string + label: string + emoji?: string + isSystem: boolean + style: Record + }> + onSaveTemplate?: (label: string, style: Record, emoji?: string) => void + onUpdateTemplate?: (key: string, patch: { label?: string; style?: Record; emoji?: string }) => void + onDeleteTemplate?: (key: string) => void + onDuplicateTemplate?: (key: string) => void + selectedTemplateKey?: string | null + onSelectTemplate?: (key: string | null) => void /* ── 批量生成(#1677)── */ /** 生成数量 */ previewCount?: number @@ -95,6 +109,13 @@ const Step4TitleSettings: React.FC = (props) => { onUpdateStyle, activePreset, titlePresets, + templates, + onSaveTemplate, + onUpdateTemplate, + onDeleteTemplate, + onDuplicateTemplate, + selectedTemplateKey, + onSelectTemplate, previewCount = 1, previewTitles, onPreviewTitlesChange, @@ -300,6 +321,13 @@ const Step4TitleSettings: React.FC = (props) => { titlePresets={titlePresets} POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} + templates={templates} + onSaveTemplate={onSaveTemplate} + onUpdateTemplate={onUpdateTemplate} + onDeleteTemplate={onDeleteTemplate} + onDuplicateTemplate={onDuplicateTemplate} + selectedTemplateKey={selectedTemplateKey} + onSelectTemplate={onSelectTemplate} /> ) diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.css b/apps/web/src/pages/generate/components/title/TitleStylePanel.css index 89940060d..14eac42dd 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.css +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.css @@ -442,3 +442,190 @@ font-size: 12px !important; padding: 6px 8px !important; } + +/* ============================================================ + #模板管理系统 — 模板卡片网格样式 + ============================================================ */ + +/* 模板头部(标签 + 新建按钮) */ +.ts-template-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.ts-template-header label { + margin-bottom: 0; +} +.ts-new-template-btn { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + color: #7c3aed; + background: #faf5ff; + border: 1px solid #ddd6fe; + border-radius: 6px; + cursor: pointer; + transition: 0.15s; +} +.ts-new-template-btn:hover { + background: #ede9fe; + border-color: #7c3aed; +} + +/* 模板网格:4列 */ +.ts-template-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +/* 模板卡片 */ +.ts-template-card { + position: relative; + border: 2px solid #e5e7eb; + border-radius: 8px; + background: #fff; + overflow: hidden; + transition: 0.15s; +} +.ts-template-card:hover { + border-color: #c4b5fd; + box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1); +} +.ts-template-card.active { + border-color: #7c3aed; + background: #faf5ff; + box-shadow: 0 0 0 1px #7c3aed; +} + +/* 预览按钮 */ +.ts-template-preview-btn { + display: block; + width: 100%; + padding: 0; + border: none; + background: none; + cursor: pointer; +} + +/* 预览 canvas 容器 */ +.ts-template-canvas-wrap { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: #0f172a; + overflow: hidden; +} +.ts-template-canvas-wrap canvas { + max-width: 100%; + max-height: 100%; +} + +/* 标签行 */ +.ts-template-label-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + gap: 4px; +} +.ts-template-name { + font-size: 10px; + color: #4b5563; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.ts-template-tag { + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + flex-shrink: 0; +} +.ts-template-tag.system { + background: #f3f4f6; + color: #6b7280; +} +.ts-template-tag.user { + background: #ede9fe; + color: #7c3aed; +} + +/* 操作按钮 */ +.ts-template-actions { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + gap: 2px; + padding: 3px 4px; + background: rgba(255, 255, 255, 0.95); + border-top: 1px solid #e5e7eb; + z-index: 2; +} +.ts-template-action-btn { + padding: 2px 6px; + font-size: 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + background: #fff; + cursor: pointer; + color: #374151; + transition: 0.1s; +} +.ts-template-action-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.ts-template-action-delete:hover { + border-color: #ef4444; + color: #ef4444; +} + +/* 编辑模式横幅 */ +.ts-editing-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-bottom: 10px; + background: #ede9fe; + border: 1px solid #c4b5fd; + border-radius: 8px; + font-size: 13px; + color: #5b21b6; +} +.ts-editing-save-btn { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + color: #fff; + background: #7c3aed; + border: none; + border-radius: 6px; + cursor: pointer; + margin-left: auto; + transition: 0.15s; +} +.ts-editing-save-btn:hover { + background: #6d28d9; +} +.ts-editing-cancel-btn { + padding: 4px 12px; + font-size: 12px; + color: #6b7280; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 6px; + cursor: pointer; + transition: 0.15s; +} +.ts-editing-cancel-btn:hover { + border-color: #9ca3af; + color: #374151; +} diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 2e9afd531..b8cbbc5d8 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -1,17 +1,21 @@ /** - * 标题样式设置面板(#2001 升级) + * 标题样式设置面板(#2001 升级 + #模板管理系统) * * P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 / - * 4款爆款字体 / 抖音爆款黄预设 - * P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 / - * 封面独立标题配置入口 + * 模板卡片网格 / 新建模板 / 编辑模板 * * 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/ * onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。 + * + * Bug修复(#模板管理系统): + * - Bug1:预设选中后手动改参数自动脱离预设 + * - Bug2:预设卡片预览字号统一(canvas 固定宽度渲染) + * - Bug3:描边参数始终可手动调整(预设只给初始值) */ -import React, { useState } from "react" -import { Tabs } from "antd" +import React, { useState, useMemo } from "react" +import { Tabs, Modal, Input, Button } from "antd" import type { TitleSettings } from "../../types" +import type { TitleStyleConfig } from "@/components/title/types" import { FONT_OPTIONS as NEW_FONT_OPTIONS, TITLE_PRESETS, @@ -19,10 +23,12 @@ import { STROKE_COLOR_PALETTE, BG_COLOR_PALETTE, } from "@/components/title/constants" +import type { TitleTemplate } from "../../hooks/useStep4Title/useTitleTemplates" import TitleMiniPreview from "./TitleMiniPreview" import "./TitleStylePanel.css" +/* ── 类型定义 ── */ interface PositionOption { value: string label: string @@ -57,9 +63,27 @@ interface TitleStylePanelProps { showCoverToggle?: boolean /** 画布预览宽度(默认 200) */ previewWidth?: number + /* ── 模板管理新增 ── */ + /** 所有模板列表(系统 + 用户) */ + templates?: TitleTemplate[] + /** 保存模板 */ + onSaveTemplate?: (label: string, style: Partial, emoji?: string) => TitleTemplate | void + /** 更新模板 */ + onUpdateTemplate?: (key: string, patch: { label?: string; style?: Partial; emoji?: string }) => void + /** 删除模板 */ + onDeleteTemplate?: (key: string) => void + /** 复制模板 */ + onDuplicateTemplate?: (key: string) => void + /** 当前选中的模板 key(编辑模式) */ + selectedTemplateKey?: string | null + /** 选中模板 */ + onSelectTemplate?: (key: string | null) => void } -/* ── 通用 Slider + Label 行 ── */ +/* ── 统一预览字号:所有预设卡片用相同 canvas 宽度渲染 ─ */ +const PREVIEW_CANVAS_WIDTH = 120 + +/* ── SliderRow ── */ const SliderRow: React.FC<{ label: string value: number @@ -89,7 +113,7 @@ const SliderRow: React.FC<{ ) -/* ── 色板 + 自定义颜色选择 ── */ +/* ── ColorPicker ── */ const ColorPicker: React.FC<{ label?: string value: string @@ -140,40 +164,172 @@ const ColorPicker: React.FC<{ ) } -/* ── 预设网格(含爆款黄,10 个 + 迷你 Canvas 缩略) ── */ -const PresetGrid: React.FC<{ - activePreset: string | null - onApply: (key: string) => void - settings: TitleSettings -}> = ({ activePreset, onApply, settings }) => { +/* ── 保存模板弹窗 ── */ +const SaveTemplateDialog: React.FC<{ + open: boolean + isEdit: boolean + currentLabel?: string + onCancel: () => void + onSave: (label: string, emoji: string) => void +}> = ({ open, isEdit, currentLabel = "", onCancel, onSave }) => { + const [label, setLabel] = useState(currentLabel) + const [emoji, setEmoji] = useState("") + + React.useEffect(() => { + if (open) { + setLabel(currentLabel) + setEmoji("") + } + }, [open, currentLabel]) + + const handleSave = () => { + if (!label.trim()) return + onSave(label.trim(), emoji.trim()) + } + return ( -
- {TITLE_PRESETS.map((p) => { - const isActive = activePreset === p.key - // 合并当前 style 与 preset.style 用于预览(仅预览时覆盖) - const previewStyle: TitleSettings = { ...settings, ...(p.style as Partial) } + +
+ + setLabel(e.target.value)} + maxLength={20} + /> +
+
+ + setEmoji(e.target.value)} + maxLength={4} + /> +
+
+

保存内容包括:

+
    +
  • 字体、字号、字色、加粗、斜体
  • +
  • 描边开关、描边宽度、描边颜色
  • +
  • 阴影开关及参数
  • +
  • 背景色块及参数
  • +
  • 排版参数(行距、行字符数等)
  • +
+
+
+ ) +} + +/* ── 模板卡片网格(替代旧 PresetGrid) ─ */ +const TemplateGrid: React.FC<{ + templates: TitleTemplate[] + activeKey: string | null + onApply: (key: string) => void + onEdit: (key: string) => void + onCopy: (key: string) => void + onDelete: (key: string) => void + currentSettings: TitleSettings + hoveredKey: string | null + onHover: (key: string | null) => void +}> = ({ templates, activeKey, onApply, onEdit, onCopy, onDelete, currentSettings, hoveredKey, onHover }) => { + return ( +
+ {templates.map((t) => { + const isActive = activeKey === t.key + const isHovered = hoveredKey === t.key + // 预览用:合并当前设置与模板样式 + const previewSettings: TitleSettings = { + ...currentSettings, + ...(t.style as Partial), + } return ( - + {/* 标签行 */} +
+ {t.emoji} {t.label} + + {t.isSystem ? "系统" : "自定义"} +
-
- {p.emoji} - {p.label} -
- + {/* 操作按钮(hover 显示,系统预设不显示删除) */} + {isHovered && ( +
+ + + {!t.isSystem && ( + + )} +
+ )} +
) })}
) } +/* ── 主面板 ── */ const TitleStylePanel: React.FC = ({ settings, onUpdatePosition, @@ -190,16 +346,130 @@ const TitleStylePanel: React.FC = ({ showCoverToggle = false, previewWidth = 220, onUpdateStyle, + /* 模板管理 */ + templates = [], + onSaveTemplate, + onUpdateTemplate, + onDeleteTemplate, + onDuplicateTemplate, + selectedTemplateKey, + onSelectTemplate, }) => { const upd = (patch: Partial) => { onUpdateStyle?.(patch) } - /* 封面独立标题切换 */ const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle) + /* ── 模板管理状态 ── */ + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [saveDialogEdit, setSaveDialogEdit] = useState(false) + const [saveDialogKey, setSaveDialogKey] = useState(null) + const [hoveredTemplateKey, setHoveredTemplateKey] = useState(null) + + // 编辑模板:选择某个模板进入编辑模式,修改参数后保存 + const [editingTemplateKey, setEditingTemplateKey] = useState(null) + + const handleNewTemplate = () => { + setSaveDialogEdit(false) + setSaveDialogKey(null) + setSaveDialogOpen(true) + } + + const handleEditTemplate = (key: string) => { + setEditingTemplateKey(key) + setSaveDialogEdit(true) + setSaveDialogKey(key) + // 应用模板样式到当前设置 + const tmpl = templates.find((t) => t.key === key) + if (tmpl) { + onApplyPreset(key) + } + } + + const handleSaveDialog = (label: string, emoji: string) => { + // 将当前设置转为 TitleStyleConfig 格式 + const style: Partial = { + font: settings.font, + size: settings.size, + color: settings.color, + bold: settings.bold, + italic: settings.italic, + 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, + max_chars_per_line: settings.maxCharsPerLine, + } + + if (saveDialogEdit && saveDialogKey && onUpdateTemplate) { + onUpdateTemplate(saveDialogKey, { label, style, emoji: emoji || undefined }) + } else if (onSaveTemplate) { + onSaveTemplate(label, style, emoji || undefined) + } + setSaveDialogOpen(false) + setEditingTemplateKey(null) + setSaveDialogKey(null) + } + + const handleDeleteTemplate = (key: string) => { + if (onDeleteTemplate) { + onDeleteTemplate(key) + } + } + + const handleCopyTemplate = (key: string) => { + if (onDuplicateTemplate) { + onDuplicateTemplate(key) + } + } + + const handleApplyTemplate = (key: string) => { + onApplyPreset(key) + onSelectTemplate?.(key) + setEditingTemplateKey(null) + } + + // 编辑模式提示 + const isEditing = editingTemplateKey !== null + const editingTemplate = editingTemplateKey ? templates.find((t) => t.key === editingTemplateKey) : null + return (
+ {/* 编辑模式提示 */} + {isEditing && editingTemplate && ( +
+ 正在编辑:{editingTemplate.label} + + +
+ )} + {/* 实时迷你预览 */}
= ({ />
- {/* 预设样式(10个,含抖音爆款黄) */} + {/* 模板管理区域 */}
- - +
+ + +
+ 0 ? templates : []} + activeKey={activePreset} + onApply={handleApplyTemplate} + onEdit={handleEditTemplate} + onCopy={handleCopyTemplate} + onDelete={handleDeleteTemplate} + currentSettings={settings} + hoveredKey={hoveredTemplateKey} + onHover={setHoveredTemplateKey} + />
+ {/* 基础参数面板 */} = ({ label: "基础", children: ( <> - {/* 位置 + 字体 */}
@@ -249,7 +534,7 @@ const TitleStylePanel: React.FC = ({ > {NEW_FONT_OPTIONS.map((f) => ( ))} @@ -265,7 +550,6 @@ const TitleStylePanel: React.FC = ({ onChange={onUpdateSize} /> - {/* 样式按钮 */}
@@ -288,7 +572,6 @@ const TitleStylePanel: React.FC = ({ className={`ts-style-btn${settings.stroke ? " active" : ""}`} onClick={() => { onToggleStroke() - // 如果之前 strokeWidth 为 0,启用时给个默认值 if (!settings.stroke && (settings.strokeWidth ?? 0) < 2) { upd({ strokeWidth: 4 }) } @@ -318,7 +601,6 @@ const TitleStylePanel: React.FC = ({
- {/* 字色 */} = ({ : []), ]} /> + + {/* 保存模板弹窗 */} + t.key === saveDialogKey)?.label : ""} + onCancel={() => { + setSaveDialogOpen(false) + setSaveDialogEdit(false) + setSaveDialogKey(null) + }} + onSave={handleSaveDialog} + />
) } diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts index ba7baa2f6..390deee7c 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts @@ -114,6 +114,7 @@ export function useStep4Title({ aiTitleResults, hasGeneratedTitles, activePreset: styleUpdaters.activePreset, + setActivePreset: styleUpdaters.setActivePreset, titlePresets: styleUpdaters.titlePresets, // AI 标题操作 handleGenerateAiTitles, @@ -132,6 +133,7 @@ export function useStep4Title({ toggleStroke: styleUpdaters.toggleStroke, toggleShadow: styleUpdaters.toggleShadow, applyPreset: styleUpdaters.applyPreset, + updateStyle: styleUpdaters.updateStyle, } } diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts index aa77f976a..058e1b257 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react" +import { useCallback, useState } from "react" import { TITLE_PRESETS } from "../../constants" import { TITLE_PRESETS as NEW_TITLE_PRESETS } from "@/components/title/constants" import type { TitleSettings } from "../../types" @@ -11,28 +11,15 @@ interface UseTitleStyleUpdatersOptions { /** * 标题样式更新 Hook * 封装标题文字、位置、字体、样式等所有设置更新函数 + * + * Bug修复:手动修改参数时自动脱离预设(#模板管理系统) */ export function useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange, }: UseTitleStyleUpdatersOptions) { - /** 匹配预设:只用 color/bold/italic/stroke/shadow,不再匹配 size */ - const getActivePreset = (settings: TitleSettings): string | null => { - for (const p of TITLE_PRESETS) { - if ( - settings.color === p.style.color && - settings.bold === p.style.bold && - settings.italic === p.style.italic && - settings.stroke === p.style.stroke && - settings.shadow === p.style.shadow - ) { - return p.key - } - } - return null - } - - const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings]) + /** 当前选中的预设 key(手动修改参数时置 null) */ + const [activePreset, setActivePreset] = useState(null) const updateTitle = useCallback( (title: string) => { @@ -47,7 +34,6 @@ export function useTitleStyleUpdaters({ const updatePosition = useCallback( (position: string) => { - // 切回三档预设时清掉自定义坐标 onTitleSettingsChange({ ...titleSettings, position, posX: null, posY: null }) }, [titleSettings, onTitleSettingsChange], @@ -61,8 +47,18 @@ export function useTitleStyleUpdaters({ [titleSettings, onTitleSettingsChange], ) + /** 通用手动更新:自动脱离预设 */ + const updateStyle = useCallback( + (patch: Partial) => { + setActivePreset(null) + onTitleSettingsChange({ ...titleSettings, ...patch }) + }, + [titleSettings, onTitleSettingsChange], + ) + const updateFont = useCallback( (font: string) => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, font }) }, [titleSettings, onTitleSettingsChange], @@ -70,6 +66,7 @@ export function useTitleStyleUpdaters({ const updateSize = useCallback( (size: number) => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, size }) }, [titleSettings, onTitleSettingsChange], @@ -77,65 +74,64 @@ export function useTitleStyleUpdaters({ const updateColor = useCallback( (color: string) => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, color }) }, [titleSettings, onTitleSettingsChange], ) const toggleBold = useCallback(() => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold }) }, [titleSettings, onTitleSettingsChange]) const toggleItalic = useCallback(() => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic }) }, [titleSettings, onTitleSettingsChange]) const toggleStroke = useCallback(() => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke }) }, [titleSettings, onTitleSettingsChange]) const toggleShadow = useCallback(() => { + setActivePreset(null) onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow }) }, [titleSettings, onTitleSettingsChange]) /** 应用预设(支持新预设细粒度字段) */ 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), - // 清除逐行覆盖 lineOverrides: [], }) + setActivePreset(presetKey) return } - 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, - }) - }, - [titleSettings, onTitleSettingsChange], - ) - - /** 通用字段更新(patch) */ - const updateStyle = useCallback( - (patch: Partial) => { - onTitleSettingsChange({ ...titleSettings, ...patch }) + if (oldPreset) { + onTitleSettingsChange({ + ...titleSettings, + color: oldPreset.style.color, + bold: oldPreset.style.bold, + italic: oldPreset.style.italic, + stroke: oldPreset.style.stroke, + shadow: oldPreset.style.shadow, + }) + setActivePreset(presetKey) + } }, [titleSettings, onTitleSettingsChange], ) return { activePreset, + setActivePreset, titlePresets: NEW_TITLE_PRESETS, updateTitle, toggleAiAutoSelect, diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleTemplates.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleTemplates.ts new file mode 100644 index 000000000..b18f835e3 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleTemplates.ts @@ -0,0 +1,82 @@ +/** + * 标题模板管理 Hook + * - 系统预设从 TITLE_PRESETS 加载(始终可用) + * - 用户自定义模板存 localStorage + * - 支持创建、编辑、复制、删除 + */ +import { useState, useCallback, useEffect } from "react" +import { TITLE_PRESETS } from "@/components/title/constants" +import type { TitleStyleConfig } from "@/components/title/types" + +const STORAGE_KEY = "xiaoxia-title-templates" + +export interface TitleTemplate { + key: string + label: string + emoji?: string + isSystem: boolean + style: Partial +} + +/** 生成唯一模板 key */ +function genTemplateKey(label: string): string { + const slug = label + .toLowerCase() + .replace(/[^\w\u4e00-\u9fff]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + return `user_${slug}_${Date.now().toString(36)}` +} + +export function useTitleTemplates() { + const [userTemplates, setUserTemplates] = useState(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } + }) + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(userTemplates)) + } catch { + // ignore + } + }, [userTemplates]) + + const allTemplates: TitleTemplate[] = [ + ...TITLE_PRESETS.map((p) => ({ ...p, isSystem: true })), + ...userTemplates, + ] + + const saveTemplate = useCallback((label: string, style: Partial, emoji?: string) => { + const t: TitleTemplate = { key: genTemplateKey(label), label, emoji: emoji || "", isSystem: false, style } + setUserTemplates((prev) => [...prev, t]) + return t + }, []) + + const updateTemplate = useCallback((key: string, patch: { label?: string; style?: Partial; emoji?: string }) => { + setUserTemplates((prev) => prev.map((t) => (t.key === key ? { ...t, ...patch } : t))) + }, []) + + const deleteTemplate = useCallback((key: string) => { + setUserTemplates((prev) => prev.filter((t) => t.key !== key)) + }, []) + + const duplicateTemplate = useCallback((key: string) => { + setUserTemplates((prev) => { + const src = prev.find((t) => t.key === key) + if (!src) return prev + const dup: TitleTemplate = { + ...src, + key: genTemplateKey(src.label + "_copy"), + label: src.label + " 副本", + } + return [...prev, dup] + }) + }, []) + + return { allTemplates, saveTemplate, updateTemplate, deleteTemplate, duplicateTemplate } +} -- 2.54.0