Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6097dacbd3 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user