445267a3b2
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 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) 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 / 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
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / Build Production Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m25s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m52s
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 / ACR Image Cleanup (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) Failing after 2m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m39s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 41s
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 / Integration Tests (push) Successful in 4m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m51s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m32s
CI/CD Pipeline / Validate - Style (push) Successful in 5m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
AI Code Review / AI Code Review (pull_request) Successful in 7m5s
CI/CD Pipeline / Validate - Security (push) Successful in 17m30s
CI/CD Pipeline / Unit Tests (push) Successful in 17m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
885 lines
28 KiB
TypeScript
885 lines
28 KiB
TypeScript
/**
|
|
* 标题样式设置面板(v3 重构)
|
|
*
|
|
* 三种模式(mode):
|
|
* - 「默认」(不传):顶部实时预览 + 爆款预设网格 + 参数 Tab(基础/描边/阴影/背景/排版/封面)
|
|
* - 「模板网格」(enableTemplates=true, paramsOnly=false):
|
|
* 仅显示顶部标题栏「标题模板」+ 我的模板/系统模板两个分组的大卡片网格,
|
|
* 卡片用图片背景 + 真实标题渲染(Canvas 叠加透明渲染),底部始终可见
|
|
* 编辑/复制/导出/删除 4 个按钮,选中卡片紫边紫影。
|
|
* - 「仅参数」(paramsOnly=true):仅渲染参数 Tab(给编辑器右侧用,不显示预览/预设/模板网格)
|
|
*
|
|
* 历史:#2001 6 Tab 参数面板;#2003 初版内联模板网格;v3 按 sketch 重写大卡片 + 图片背景
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import { Tabs, Button, message, Popconfirm } from "antd"
|
|
import {
|
|
PlusOutlined,
|
|
EditOutlined,
|
|
CopyOutlined,
|
|
DeleteOutlined,
|
|
ExportOutlined,
|
|
CheckOutlined,
|
|
} from "@ant-design/icons"
|
|
import type { TitleSettings } from "../../types"
|
|
import {
|
|
FONT_OPTIONS as NEW_FONT_OPTIONS,
|
|
TITLE_PRESETS,
|
|
TITLE_COLOR_PALETTE,
|
|
STROKE_COLOR_PALETTE,
|
|
BG_COLOR_PALETTE,
|
|
} from "@/components/title/constants"
|
|
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,
|
|
camelToTitleStyleConfig,
|
|
} from "@/components/title/utils"
|
|
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../types"
|
|
import type { TitleTemplate } from "@/components/title/template-types"
|
|
import type { TitleStyleConfig } from "@/components/title/types"
|
|
import "./TitleStylePanel.css"
|
|
import "@/components/title/TitleTemplate.css"
|
|
|
|
interface PositionOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
interface LegacyPreset {
|
|
key: string
|
|
label: string
|
|
emoji?: string
|
|
style?: Record<string, unknown>
|
|
previewStyle?: React.CSSProperties
|
|
}
|
|
|
|
interface TitleStylePanelProps {
|
|
settings: TitleSettings
|
|
onUpdatePosition: (position: string) => void
|
|
onUpdateFont: (font: string) => void
|
|
onUpdateSize: (size: number) => void
|
|
onToggleBold: () => void
|
|
onToggleItalic: () => void
|
|
onToggleStroke: () => void
|
|
onToggleShadow: () => void
|
|
onApplyPreset: (presetKey: string) => void
|
|
activePreset: string | null
|
|
titlePresets: LegacyPreset[]
|
|
POSITION_OPTIONS: PositionOption[]
|
|
FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }>
|
|
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
|
|
showCoverToggle?: boolean
|
|
previewWidth?: number
|
|
/** 启用「标题模板」模式:显示大卡片网格,隐藏参数 Tab 和旧预览 */
|
|
enableTemplates?: boolean
|
|
/** 当前选中模板 id */
|
|
selectedTemplateId?: string | null
|
|
/** 应用模板回调(父组件负责合并到 state) */
|
|
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
|
|
/** 仅渲染参数 Tab(编辑器右侧用,不显示预览/预设/模板网格) */
|
|
paramsOnly?: boolean
|
|
/** 编辑器模式下,模板编辑器组件不在这里渲染(由父组件承载) */
|
|
hideEditor?: boolean
|
|
}
|
|
|
|
/* ── Slider 行 ── */
|
|
const SliderRow: React.FC<{
|
|
label: string
|
|
value: number
|
|
min: number
|
|
max: number
|
|
step?: number
|
|
unit?: string
|
|
onChange: (v: number) => void
|
|
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
|
|
<div className="ts-form-field">
|
|
<div className="ts-field-label-row">
|
|
<label>{label}</label>
|
|
<span className="ts-field-value">
|
|
{value}
|
|
{unit}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
className="ts-slider"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
/* ── 色板 ── */
|
|
const ColorPicker: React.FC<{
|
|
label?: string
|
|
value: string
|
|
palette: string[]
|
|
onChange: (c: string) => void
|
|
}> = ({ label, value, palette, onChange }) => {
|
|
const [customOpen, setCustomOpen] = useState(false)
|
|
return (
|
|
<div className="ts-form-field">
|
|
{label && <label>{label}</label>}
|
|
<div className="ts-color-row">
|
|
{palette.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
|
|
style={{ background: c }}
|
|
onClick={() => onChange(c)}
|
|
title={c}
|
|
/>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="ts-color-swatch ts-color-custom"
|
|
onClick={() => setCustomOpen((v) => !v)}
|
|
title="自定义颜色"
|
|
>
|
|
+
|
|
</button>
|
|
<input
|
|
type="color"
|
|
className="ts-color-native"
|
|
value={value.startsWith("rgba") ? "#000000" : value}
|
|
onChange={(e) => {
|
|
onChange(e.target.value)
|
|
setCustomOpen(false)
|
|
}}
|
|
style={{
|
|
opacity: customOpen ? 1 : 0,
|
|
position: customOpen ? "static" : "absolute",
|
|
pointerEvents: customOpen ? "auto" : "none",
|
|
width: customOpen ? 28 : 0,
|
|
height: customOpen ? 28 : 0,
|
|
border: "none",
|
|
padding: 0,
|
|
cursor: "pointer",
|
|
background: "transparent",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
|
|
当前:<code style={{ fontSize: 11 }}>{value}</code>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */
|
|
const FillPreview: React.FC<{
|
|
settings: TitleSettings
|
|
sampleText: string
|
|
portrait?: boolean
|
|
}> = ({ settings, sampleText, portrait }) => {
|
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
const [w, setW] = useState(0)
|
|
useEffect(() => {
|
|
const el = wrapRef.current
|
|
if (!el) return
|
|
const update = () => setW(Math.floor(el.clientWidth))
|
|
update()
|
|
const ro = new ResizeObserver(update)
|
|
ro.observe(el)
|
|
return () => ro.disconnect()
|
|
}, [])
|
|
return (
|
|
<div ref={wrapRef} className="tt-fill-canvas-wrap">
|
|
{w > 0 && (
|
|
<TitleMiniPreview
|
|
settings={settings}
|
|
width={w}
|
|
sampleText={sampleText}
|
|
transparent
|
|
portrait={portrait}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── 模板卡片预览背景图池:按 index 轮换 ── */
|
|
const PREVIEW_BG_IMAGES = [
|
|
"/title-templates/portrait1.jpg",
|
|
"/title-templates/portrait2.jpg",
|
|
"/title-templates/scene1.jpg",
|
|
]
|
|
|
|
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
|
settings,
|
|
onUpdatePosition,
|
|
onUpdateFont,
|
|
onUpdateSize,
|
|
onToggleBold,
|
|
onToggleItalic,
|
|
onToggleStroke,
|
|
onToggleShadow,
|
|
onApplyPreset,
|
|
activePreset,
|
|
titlePresets: _titlePresets,
|
|
POSITION_OPTIONS,
|
|
showCoverToggle = false,
|
|
previewWidth = 220,
|
|
enableTemplates = false,
|
|
selectedTemplateId,
|
|
onApplyTemplate,
|
|
onUpdateStyle,
|
|
paramsOnly = false,
|
|
hideEditor = false,
|
|
}) => {
|
|
const upd = (patch: Partial<TitleSettings>) => {
|
|
onUpdateStyle?.(patch)
|
|
}
|
|
|
|
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
|
|
|
|
/* ── 模板相关 hook + 编辑器 state ── */
|
|
const {
|
|
templates,
|
|
createTemplate,
|
|
duplicateTemplate,
|
|
updateTemplate,
|
|
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 = useCallback(() => {
|
|
// 新建模板:若已有选中模板,以其样式为起点;否则以当前 settings 为起点
|
|
// 注意:这里只构造「草稿」对象(不写入 localStorage),保存时再由 handleEditorSave 真正 createTemplate
|
|
let base: TitleSettings = settings
|
|
if (selectedTemplateId) {
|
|
const sel = templates.find((t) => t.id === selectedTemplateId)
|
|
if (sel) {
|
|
base = {
|
|
...DEFAULT_TITLE_SETTINGS_FULL,
|
|
...titleStyleConfigToCamel(sel.style),
|
|
title: settings.title,
|
|
aiAutoSelect: settings.aiAutoSelect,
|
|
coverTitle: settings.coverTitle,
|
|
}
|
|
}
|
|
}
|
|
const draft: TitleTemplate = {
|
|
id: "", // 空 id 表示新建草稿
|
|
name: "我的标题模板",
|
|
emoji: "✨",
|
|
isBuiltin: false,
|
|
style: camelToTitleStyleConfig({
|
|
...base,
|
|
position: base.position === "custom" ? "bottom" : base.position,
|
|
} as TitleSettings),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
setEditingTemplate(draft)
|
|
setEditorOpen(true)
|
|
}, [settings, selectedTemplateId, templates])
|
|
|
|
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
|
|
setEditingTemplate(tpl)
|
|
setEditorOpen(true)
|
|
}, [])
|
|
|
|
const handleDuplicate = useCallback(
|
|
(t: TitleTemplate) => {
|
|
const dup = duplicateTemplate(t.id)
|
|
if (dup) message.success(`已复制:${dup.name}`)
|
|
},
|
|
[duplicateTemplate],
|
|
)
|
|
const handleDelete = useCallback(
|
|
(t: TitleTemplate) => {
|
|
deleteTemplate(t.id)
|
|
message.success("已删除模板")
|
|
},
|
|
[deleteTemplate],
|
|
)
|
|
const handleExport = useCallback(
|
|
(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)
|
|
},
|
|
[exportTemplate],
|
|
)
|
|
|
|
const handleSelectTemplate = useCallback(
|
|
(tpl: TitleTemplate) => {
|
|
const full: TitleSettings = {
|
|
...DEFAULT_TITLE_SETTINGS_FULL,
|
|
...titleStyleConfigToCamel(tpl.style),
|
|
title: settings.title,
|
|
aiAutoSelect: settings.aiAutoSelect,
|
|
coverTitle: settings.coverTitle,
|
|
}
|
|
onApplyTemplate?.(full, tpl)
|
|
},
|
|
[onApplyTemplate, settings],
|
|
)
|
|
|
|
const handleEditorSave = useCallback(
|
|
(data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => {
|
|
if (!editingTemplate) return
|
|
let saved: TitleTemplate
|
|
if (editingTemplate.isBuiltin || !editingTemplate.id) {
|
|
// 系统模板编辑 → 创建副本;新草稿(空 id)→ 新建
|
|
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
|
|
} else {
|
|
// 已有自定义模板 → 更新
|
|
updateTemplate(editingTemplate.id, {
|
|
name: data.name,
|
|
emoji: data.emoji,
|
|
style: data.style,
|
|
})
|
|
saved = {
|
|
...editingTemplate,
|
|
name: data.name,
|
|
emoji: data.emoji,
|
|
style: data.style,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
setEditorOpen(false)
|
|
setEditingTemplate(null)
|
|
message.success(`已保存:${data.name}`)
|
|
handleSelectTemplate(saved)
|
|
},
|
|
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
|
|
)
|
|
|
|
/* ── 渲染模板大卡片(v3:图片背景 + 透明 Canvas 叠字 + 底栏始终可见操作按钮) ── */
|
|
const renderTemplateCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => {
|
|
const isSelected = selectedTemplateId === t.id
|
|
const bgIdx = idx % PREVIEW_BG_IMAGES.length
|
|
const bgImg = PREVIEW_BG_IMAGES[bgIdx]
|
|
// 卡片用的预览 settings:字号按 3:4 竖版比例缩放
|
|
const preview = templateToPreviewSettings(t, 42)
|
|
return (
|
|
<div
|
|
key={t.id}
|
|
className={`ttv3-card${isSelected ? " selected" : ""}`}
|
|
onClick={() => handleSelectTemplate(t)}
|
|
>
|
|
<div className="ttv3-preview">
|
|
<img className="ttv3-bg" src={bgImg} alt="" />
|
|
<div className="ttv3-vignette" />
|
|
<FillPreview settings={preview} sampleText="预览标题文字" portrait />
|
|
<span className={`ttv3-badge ttv3-badge--${section}`}>
|
|
{section === "sys" ? "系统" : "我的"}
|
|
</span>
|
|
<span className={`ttv3-check${isSelected ? " on" : ""}`}>
|
|
{isSelected && <CheckOutlined />}
|
|
</span>
|
|
</div>
|
|
<div className="ttv3-footer">
|
|
<div className="ttv3-name-row">
|
|
<span className="ttv3-emoji">{t.emoji || "✨"}</span>
|
|
<span className="ttv3-name" title={t.name}>
|
|
{t.name}
|
|
</span>
|
|
<span className={`ttv3-tag ttv3-tag--${section}`}>
|
|
{section === "sys" ? "系统" : "我的"}
|
|
</span>
|
|
</div>
|
|
<div className="ttv3-actions" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
className="ttv3-act ttv3-act--primary"
|
|
disabled={t.isBuiltin}
|
|
onClick={() => handleRequestEdit(t)}
|
|
title={t.isBuiltin ? "系统模板不可编辑,点击复制后可编辑" : "编辑"}
|
|
>
|
|
<EditOutlined /> 编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ttv3-act"
|
|
onClick={() => handleDuplicate(t)}
|
|
title="复制"
|
|
>
|
|
<CopyOutlined /> 复制
|
|
</button>
|
|
<button type="button" className="ttv3-act" onClick={() => handleExport(t)} title="导出">
|
|
<ExportOutlined /> 导出
|
|
</button>
|
|
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
|
|
<button
|
|
type="button"
|
|
className="ttv3-act ttv3-act--danger"
|
|
disabled={t.isBuiltin}
|
|
title={t.isBuiltin ? "系统模板不可删除" : "删除"}
|
|
>
|
|
<DeleteOutlined /> 删除
|
|
</button>
|
|
</Popconfirm>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── 旧爆款预设网格(paramsOnly 或 enableTemplates 时不显示) ── */
|
|
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>
|
|
)
|
|
|
|
/* ── 参数 Tab 面板(编辑器右侧复用) ── */
|
|
const renderParamsTabs = () => (
|
|
<Tabs
|
|
size="small"
|
|
defaultActiveKey="basic"
|
|
items={[
|
|
{
|
|
key: "basic",
|
|
label: "基础",
|
|
children: (
|
|
<>
|
|
<div className="ts-row-2">
|
|
<div className="ts-form-field ts-half">
|
|
<label>位置</label>
|
|
<select
|
|
className="ts-select"
|
|
value={settings.position}
|
|
onChange={(e) => onUpdatePosition(e.target.value)}
|
|
>
|
|
{POSITION_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="ts-form-field ts-half">
|
|
<label>字体</label>
|
|
<select
|
|
className="ts-select"
|
|
value={settings.font}
|
|
onChange={(e) => onUpdateFont(e.target.value)}
|
|
>
|
|
{NEW_FONT_OPTIONS.map((f) => (
|
|
<option key={f.value} value={f.value}>
|
|
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
|
|
{f.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<SliderRow
|
|
label="字号"
|
|
value={settings.size}
|
|
min={16}
|
|
max={120}
|
|
onChange={onUpdateSize}
|
|
/>
|
|
|
|
<div className="ts-form-field">
|
|
<label>样式</label>
|
|
<div className="ts-style-btns">
|
|
<button
|
|
type="button"
|
|
className={`ts-style-btn${settings.bold ? " active" : ""}`}
|
|
onClick={onToggleBold}
|
|
>
|
|
<b>B</b>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`ts-style-btn${settings.italic ? " active" : ""}`}
|
|
onClick={onToggleItalic}
|
|
>
|
|
<i>I</i>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
|
|
onClick={() => {
|
|
onToggleStroke()
|
|
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2)
|
|
upd({ strokeWidth: 4 })
|
|
}}
|
|
title="描边"
|
|
>
|
|
S
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
|
|
onClick={() => {
|
|
onToggleShadow()
|
|
if (!settings.shadow) {
|
|
upd({
|
|
shadowOffsetX: 2,
|
|
shadowOffsetY: 2,
|
|
shadowBlur: 4,
|
|
shadowColor: "rgba(0,0,0,0.8)",
|
|
})
|
|
}
|
|
}}
|
|
title="阴影"
|
|
>
|
|
☁
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ColorPicker
|
|
label="字色"
|
|
value={settings.color}
|
|
palette={TITLE_COLOR_PALETTE}
|
|
onChange={(c) => upd({ color: c })}
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: "stroke",
|
|
label: "描边",
|
|
children: (
|
|
<>
|
|
<div className="ts-toggle-row">
|
|
<label>
|
|
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
|
|
启用描边
|
|
</label>
|
|
</div>
|
|
{settings.stroke && (
|
|
<>
|
|
<SliderRow
|
|
label="描边宽度"
|
|
value={settings.strokeWidth ?? 4}
|
|
min={0}
|
|
max={20}
|
|
onChange={(v) => upd({ strokeWidth: v })}
|
|
/>
|
|
<ColorPicker
|
|
label="描边颜色"
|
|
value={settings.strokeColor ?? "#000000"}
|
|
palette={STROKE_COLOR_PALETTE}
|
|
onChange={(c) => upd({ strokeColor: c })}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: "shadow",
|
|
label: "阴影",
|
|
children: (
|
|
<>
|
|
<div className="ts-toggle-row">
|
|
<label>
|
|
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
|
|
启用阴影
|
|
</label>
|
|
</div>
|
|
{settings.shadow && (
|
|
<>
|
|
<SliderRow
|
|
label="X偏移"
|
|
value={settings.shadowOffsetX ?? 2}
|
|
min={-20}
|
|
max={20}
|
|
onChange={(v) => upd({ shadowOffsetX: v })}
|
|
/>
|
|
<SliderRow
|
|
label="Y偏移"
|
|
value={settings.shadowOffsetY ?? 2}
|
|
min={-20}
|
|
max={20}
|
|
onChange={(v) => upd({ shadowOffsetY: v })}
|
|
/>
|
|
<SliderRow
|
|
label="模糊半径"
|
|
value={settings.shadowBlur ?? 4}
|
|
min={0}
|
|
max={30}
|
|
onChange={(v) => upd({ shadowBlur: v })}
|
|
/>
|
|
<div className="ts-form-field">
|
|
<label>阴影颜色</label>
|
|
<input
|
|
type="text"
|
|
className="ts-input"
|
|
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
|
|
onChange={(e) => upd({ shadowColor: e.target.value })}
|
|
placeholder="rgba(0,0,0,0.8)"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: "bg",
|
|
label: "背景",
|
|
children: (
|
|
<>
|
|
<div className="ts-toggle-row">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={settings.bgEnabled}
|
|
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
|
|
/>
|
|
启用背景色块
|
|
</label>
|
|
</div>
|
|
{settings.bgEnabled && (
|
|
<>
|
|
<ColorPicker
|
|
label="背景颜色(含透明度)"
|
|
value={settings.bgColor}
|
|
palette={BG_COLOR_PALETTE}
|
|
onChange={(c) => upd({ bgColor: c })}
|
|
/>
|
|
<SliderRow
|
|
label="内边距"
|
|
value={settings.bgPadding}
|
|
min={0}
|
|
max={40}
|
|
onChange={(v) => upd({ bgPadding: v })}
|
|
/>
|
|
<SliderRow
|
|
label="圆角"
|
|
value={settings.bgRadius}
|
|
min={0}
|
|
max={30}
|
|
onChange={(v) => upd({ bgRadius: v })}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: "layout",
|
|
label: "排版",
|
|
children: (
|
|
<>
|
|
<SliderRow
|
|
label="每行最大字符数"
|
|
value={settings.maxCharsPerLine ?? 0}
|
|
min={0}
|
|
max={20}
|
|
unit=""
|
|
onChange={(v) => upd({ maxCharsPerLine: v })}
|
|
/>
|
|
<div
|
|
className="ts-form-field"
|
|
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
|
|
>
|
|
0 = 不自动换行(按 / 手动分行)
|
|
</div>
|
|
<SliderRow
|
|
label="行距倍数"
|
|
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
|
|
min={1}
|
|
max={2}
|
|
step={0.05}
|
|
unit=""
|
|
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
|
|
/>
|
|
<SliderRow
|
|
label="顶部边距"
|
|
value={settings.marginTop ?? 24}
|
|
min={0}
|
|
max={200}
|
|
onChange={(v) => upd({ marginTop: v })}
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
...(showCoverToggle
|
|
? [
|
|
{
|
|
key: "cover",
|
|
label: "封面",
|
|
children: (
|
|
<>
|
|
<div className="ts-toggle-row">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={coverOpen}
|
|
onChange={(e) => {
|
|
setCoverOpen(e.target.checked)
|
|
if (!e.target.checked) {
|
|
upd({ coverTitle: null })
|
|
} else {
|
|
upd({
|
|
coverTitle: {
|
|
font: settings.font,
|
|
size: Math.round(settings.size * 0.9),
|
|
color: settings.color,
|
|
bold: settings.bold,
|
|
},
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
封面使用独立标题样式
|
|
</label>
|
|
</div>
|
|
{coverOpen && settings.coverTitle && (
|
|
<div style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6 }}>
|
|
封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。
|
|
</div>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
]}
|
|
/>
|
|
)
|
|
|
|
/* ── 模板模式:只显示网格 ── */
|
|
if (enableTemplates && !paramsOnly) {
|
|
return (
|
|
<div className="xx-title-style-section ttv3-panel">
|
|
<div className="ttv3-header">
|
|
<span className="ttv3-title">标题模板</span>
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
icon={<PlusOutlined />}
|
|
onClick={handleRequestCreate}
|
|
className="ttv3-new-btn"
|
|
>
|
|
新建模板
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="ttv3-section">
|
|
<div className="ttv3-section-label">我的模板</div>
|
|
{grouped.custom.length === 0 ? (
|
|
<div className="ttv3-empty">
|
|
<div className="ttv3-empty-icon">✨</div>
|
|
<div className="ttv3-empty-text">还没有自定义模板,点右上角「新建模板」创建</div>
|
|
</div>
|
|
) : (
|
|
<div className="ttv3-grid">
|
|
{grouped.custom.map((t, i) => renderTemplateCard(t, i, "mine"))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ttv3-section">
|
|
<div className="ttv3-section-label">系统模板</div>
|
|
<div className="ttv3-grid">
|
|
{grouped.builtin.map((t, i) => renderTemplateCard(t, i, "sys"))}
|
|
</div>
|
|
</div>
|
|
|
|
{!hideEditor && editorOpen && editingTemplate && (
|
|
<TitleTemplateEditor
|
|
open={editorOpen}
|
|
template={editingTemplate}
|
|
onClose={() => {
|
|
setEditorOpen(false)
|
|
setEditingTemplate(null)
|
|
}}
|
|
onSave={handleEditorSave}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── paramsOnly:仅参数 Tab ── */
|
|
if (paramsOnly) {
|
|
return <div className="xx-title-style-section ttv3-params-only">{renderParamsTabs()}</div>
|
|
}
|
|
|
|
/* ── 默认模式:顶部预览 + 预设 + 参数 Tab ── */
|
|
return (
|
|
<div className="xx-title-style-section ts-panel">
|
|
{/* 实时迷你预览 */}
|
|
<div className="ts-preview-wrap">
|
|
<TitleMiniPreview
|
|
settings={settings}
|
|
width={previewWidth}
|
|
sampleText={settings.title || "预览标题文字"}
|
|
/>
|
|
</div>
|
|
|
|
{/* 爆款预设 */}
|
|
<div className="ts-form-field">
|
|
<div className="ts-field-label-row">
|
|
<label>爆款预设</label>
|
|
{activePreset && <span className="ts-field-value">已选</span>}
|
|
</div>
|
|
{renderPresetGrid()}
|
|
</div>
|
|
|
|
{renderParamsTabs()}
|
|
|
|
{!hideEditor && editorOpen && editingTemplate && (
|
|
<TitleTemplateEditor
|
|
open={editorOpen}
|
|
template={editingTemplate}
|
|
onClose={() => {
|
|
setEditorOpen(false)
|
|
setEditingTemplate(null)
|
|
}}
|
|
onSave={handleEditorSave}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TitleStylePanel
|