Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 6097dacbd3 feat(web): 标题模板内嵌面板——移除弹窗、卡片放大 (#2016)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m8s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m43s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m57s
CI/CD Pipeline / Validate - Style (push) Successful in 6m7s
CI/CD Pipeline / Integration Tests (push) Successful in 6m46s
AI Code Review / AI Code Review (pull_request) Successful in 7m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 10m49s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m2s
CI/CD Pipeline / Unit Tests (push) Successful in 13m21s
CI/CD Pipeline / Validate - Security (push) Successful in 17m21s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
feat(web): 标题模板内嵌面板——移除弹窗、卡片放大(#2016)

- TitleStylePanel 内联网格替代弹窗选择
- 卡片放大(90px 预览区 + 底部 meta + 紫色 radio dot + hover 操作)
- 新建/编辑保留 Modal 编辑器,新建时带入当前样式
- 智能剪辑+AI数字人双入口同步改造
- 响应式 2-4 列自适应
2026-09-23 10:31:50 +08:00
7 changed files with 523 additions and 151 deletions
@@ -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;
}
+27
View File
@@ -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<TitleStyleConfig>): Partial<TitleSettings> {
@@ -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
}
@@ -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<PanelTitleConfigProps> = ({ titleConfig, onUpdate }) => {
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
const [activePreset, setActivePreset] = useState<string | null>(null)
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [activeTemplate, setActiveTemplate] = useState<TitleTemplate | null>(null)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
/** 标题库选项(#1894:从文案库 scripts[].title 取候选) */
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
@@ -334,6 +332,40 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ 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 (
<div className="aa-title-config">
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
@@ -381,8 +413,8 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ 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<typeof TitleStylePanel>["titlePresets"]
@@ -391,45 +423,6 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
FONT_OPTIONS={FONT_OPTIONS}
/>
<TitleTemplateSelector
open={templateModalOpen}
selectedTemplateId={activeTemplate?.id}
onClose={() => 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,
})
}}
/>
{/* 自动生成字幕 */}
<div className="aa-subtitle-toggle">
<label className="aa-checkbox-row">
+8 -17
View File
@@ -29,8 +29,6 @@ import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import TitleTemplateSelector from "@/components/title/TitleTemplateSelector"
import type { TitleTemplate } from "@/components/title/template-types"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import { usePointsStore } from "@/store/pointsStore"
@@ -44,6 +42,9 @@ const GeneratePage: React.FC = () => {
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 标题模板(#2003)当前选中模板 id ── */
const [selectedTitleTemplateId, setSelectedTitleTemplateId] = useState<string | null>(null)
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
useEffect(() => {
@@ -151,8 +152,6 @@ const GeneratePage: React.FC = () => {
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
const [scriptModalOpen, setScriptModalOpen] = useState(false)
const [ttsModalOpen, setTtsModalOpen] = useState(false)
const [titleTemplateModalOpen, setTitleTemplateModalOpen] = useState(false)
const [activeTitleTemplate, setActiveTitleTemplate] = useState<TitleTemplate | null>(null)
/* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({
@@ -648,8 +647,11 @@ const GeneratePage: React.FC = () => {
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
enableTemplates
onOpenTemplates={() => setTitleTemplateModalOpen(true)}
activeTemplateLabel={activeTitleTemplate?.name ?? null}
selectedTemplateId={selectedTitleTemplateId}
onApplyTemplate={(settings, tpl) => {
styleUpdaters.applyTemplate(settings)
setSelectedTitleTemplateId(tpl.id)
}}
bgm={bgm}
bgmConfig={bgmConfig}
coverSettings={coverSettings}
@@ -814,17 +816,6 @@ const GeneratePage: React.FC = () => {
onCancel={() => setTtsModalOpen(false)}
onSynthesized={handleTtsSynthesized}
/>
{/* 标题模板选择器 */}
<TitleTemplateSelector
open={titleTemplateModalOpen}
selectedTemplateId={activeTitleTemplate?.id}
onClose={() => setTitleTemplateModalOpen(false)}
onSelect={(settings, tpl) => {
styleUpdaters.applyTemplate(settings)
setActiveTitleTemplate(tpl)
setTitleTemplateModalOpen(false)
}}
/>
</div>
)
}
@@ -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<string, unknown>
}>
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<GenerateStepContentProps> = (props) =
onApplyPreset,
onUpdateStyle,
enableTemplates,
onOpenTemplates,
activeTemplateLabel,
selectedTemplateId,
onApplyTemplate,
activePreset,
titlePresets,
coverSettings,
@@ -208,8 +209,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
activePreset={activePreset}
titlePresets={titlePresets}
enableTemplates={enableTemplates}
onOpenTemplates={onOpenTemplates}
activeTemplateLabel={activeTemplateLabel}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
@@ -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<Step4TitleSettingsProps> = (props) => {
previewTitles,
onPreviewTitlesChange,
enableTemplates,
onOpenTemplates,
activeTemplateLabel,
selectedTemplateId,
onApplyTemplate,
} = props
const isBatch = previewCount > 1
@@ -307,8 +308,8 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
enableTemplates={enableTemplates}
onOpenTemplates={onOpenTemplates}
activeTemplateLabel={activeTemplateLabel}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
/>
</div>
)
@@ -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<TitleSettings>) => 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<{
</div>
)
/* ── 色板 + 自定义颜色选择 ── */
/* ── 色板 ── */
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 (
<div className="ts-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = activePreset === p.key
// 归一化预览:固定字号 48(按 360 基准缩放后 ~13px),所有卡片"标题"两字视觉大小一致,描边/阴影按比例缩放
const previewStyle = buildPresetPreviewSettings(settings, p.key, 56)
return (
<button
key={p.key}
type="button"
className={`ts-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)}
title={p.label}
>
<div className="ts-preset-preview">
<TitleMiniPreview settings={previewStyle} width={120} sampleText="标题" />
</div>
<div className="ts-preset-meta">
<span className="ts-preset-emoji">{p.emoji}</span>
<span className="ts-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
)
}
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
settings,
onUpdatePosition,
@@ -197,17 +170,192 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
showCoverToggle = false,
previewWidth = 220,
enableTemplates = false,
onOpenTemplates,
activeTemplateLabel,
selectedTemplateId,
onApplyTemplate,
onUpdateStyle,
}) => {
const upd = (patch: Partial<TitleSettings>) => {
onUpdateStyle?.(patch)
}
/* 封面独立标题切换 */
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
/* ── 模板相关 hook + 编辑器 state ── */
const { templates, createTemplate, duplicateTemplate, deleteTemplate, exportTemplate } =
useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(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 (
<button
type="button"
key={t.id}
className={`tt-card tt-card--inline${isSelected ? " active" : ""}`}
onClick={() => handleSelectTemplate(preview, t)}
>
<div className="tt-card-preview tt-card-preview--lg">
<TitleMiniPreview settings={preview} width={220} sampleText="预览标题文字" />
<span className={`tt-tag${t.isBuiltin ? " sys" : " mine"}`}>
{t.isBuiltin ? "系统" : "我的"}
</span>
{isSelected && <span className="tt-radio-dot" />}
<div className="tt-card-actions" onClick={(e) => e.stopPropagation()}>
{!t.isBuiltin && (
<Tooltip title="编辑">
<button type="button" className="tt-ico-btn" onClick={() => handleRequestEdit(t)}>
<EditOutlined />
</button>
</Tooltip>
)}
<Tooltip title="复制">
<button type="button" className="tt-ico-btn" onClick={() => handleDuplicate(t)}>
<CopyOutlined />
</button>
</Tooltip>
<Tooltip title="导出">
<button type="button" className="tt-ico-btn" onClick={() => handleExport(t)}>
<ExportOutlined />
</button>
</Tooltip>
{!t.isBuiltin && (
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
<Tooltip title="删除">
<button type="button" className="tt-ico-btn danger">
<DeleteOutlined />
</button>
</Tooltip>
</Popconfirm>
)}
</div>
</div>
<div className="tt-card-meta tt-card-meta--lg">
<span className="tt-card-emoji">{t.emoji || "✨"}</span>
<span className="tt-card-name" title={t.name}>
{t.name}
</span>
</div>
</button>
)
}
/* ── 旧爆款预设网格(enableTemplates=false 时显示,作为编辑器内快速起点) ── */
const renderPresetGrid = () => (
<div className="ts-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = activePreset === p.key
const previewStyle = buildPresetPreviewSettings(settings, p.key, 56)
return (
<button
key={p.key}
type="button"
className={`ts-preset-card${isActive ? " active" : ""}`}
onClick={() => onApplyPreset(p.key)}
title={p.label}
>
<div className="ts-preset-preview">
<TitleMiniPreview settings={previewStyle} width={120} sampleText="标题" />
</div>
<div className="ts-preset-meta">
<span className="ts-preset-emoji">{p.emoji}</span>
<span className="ts-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
)
return (
<div className="xx-title-style-section ts-panel">
{/* 实时迷你预览 */}
@@ -219,22 +367,54 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
/>
</div>
{/* 标题模板 / 爆款预设 */}
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{enableTemplates ? "标题模板" : "爆款预设"}</label>
{enableTemplates ? (
<button type="button" className="ts-template-btn" onClick={onOpenTemplates}>
{activeTemplateLabel ? `当前:${activeTemplateLabel} · ` : ""}
</button>
) : (
activePreset && <span className="ts-field-value"></span>
)}
{/* 标题模板(内联)/ 爆款预设(旧) */}
{enableTemplates ? (
<div className="ts-form-field">
<div className="tt-inline-wrap">
<div className="tt-section">
<div className="tt-section-header">
<span className="tt-section-label"></span>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleRequestCreate}
className="tt-new-btn"
>
</Button>
</div>
{grouped.custom.length === 0 ? (
<div className="tt-empty tt-empty--sm">
<span className="tt-empty-emoji"></span>
<span></span>
</div>
) : (
<div className="tt-grid tt-grid--inline">
{grouped.custom.map(renderTemplateCard)}
</div>
)}
</div>
<div className="tt-section">
<div className="tt-section-header">
<span className="tt-section-label"></span>
</div>
<div className="tt-grid tt-grid--inline">
{grouped.builtin.map(renderTemplateCard)}
</div>
</div>
</div>
</div>
{!enableTemplates && (
<PresetGrid activePreset={activePreset} onApply={onApplyPreset} settings={settings} />
)}
</div>
) : (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label></label>
{activePreset && <span className="ts-field-value"></span>}
</div>
{renderPresetGrid()}
</div>
)}
<Tabs
size="small"
@@ -245,7 +425,6 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
label: "基础",
children: (
<>
{/* 位置 + 字体 */}
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label></label>
@@ -286,7 +465,6 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
onChange={onUpdateSize}
/>
{/* 样式按钮 */}
<div className="ts-form-field">
<label></label>
<div className="ts-style-btns">
@@ -309,10 +487,8 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
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<TitleStylePanelProps> = ({
</div>
</div>
{/* 字色 */}
<ColorPicker
label="字色"
value={settings.color}
@@ -552,6 +727,18 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
: []),
]}
/>
{editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSaved={handleEditorSaved}
/>
)}
</div>
)
}