diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index ca96d6d22..5fad2865f 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -26,6 +26,7 @@ import type { GeneratedVideo, MediaAsset, TransitionEffect, + TitleConfig, } from "@/api/editPlans" import { getMediaAssets, @@ -61,7 +62,6 @@ import type { ChromaKeyConfig, StickerConfig, CoverConfig, - TitleSettings, } from "./types" import { DEFAULT_TRANSITION, @@ -157,18 +157,14 @@ const EditingPlanner: React.FC = () => { const [currentFilter, setCurrentFilter] = useState("全部") const [searchQuery, setSearchQuery] = useState("") - /* ── 标题/字幕/BGM 设置 ── */ - const [titleSettings, setTitleSettings] = useState({ - aiAutoSelect: false, - title: "", - position: "top", - font: "思源黑体", - size: 24, - bold: false, - italic: false, - stroke: true, - shadow: true, - color: "#ffffff", + /* ── 标题配置(只读,从模板/计划继承) ── */ + const [titleConfig, setTitleConfig] = useState({ + ai_auto_select: false, + content: "", + position: "bottom", + font_preset: "思源黑体", + font_color: "#ffffff", + font_size: 28, }) const [subtitleSettings, setSubtitleSettings] = useState({ @@ -368,15 +364,14 @@ const EditingPlanner: React.FC = () => { })) resetClips(mapped) - setTitleSettings((prev) => ({ - ...prev, - aiAutoSelect: tpl.title_config.ai_auto_select, - title: tpl.title_config.content, + setTitleConfig({ + ai_auto_select: tpl.title_config.ai_auto_select, + content: tpl.title_config.content, position: tpl.title_config.position, - font: tpl.title_config.font_preset, - size: tpl.title_config.font_size, - color: tpl.title_config.font_color || "#ffffff", - })) + font_preset: tpl.title_config.font_preset, + font_size: tpl.title_config.font_size, + font_color: tpl.title_config.font_color || "#ffffff", + }) setSubtitleSettings((prev) => ({ ...prev, enabled: tpl.subtitle_config.enabled, @@ -423,15 +418,14 @@ const EditingPlanner: React.FC = () => { // 还原 config 中的编辑器状态 const cfg = plan.config if (cfg.title_config) { - setTitleSettings((prev) => ({ - ...prev, - aiAutoSelect: cfg.title_config!.ai_auto_select, - title: cfg.title_config!.content, + setTitleConfig({ + ai_auto_select: cfg.title_config!.ai_auto_select, + content: cfg.title_config!.content, position: cfg.title_config!.position, - font: cfg.title_config!.font_preset, - size: cfg.title_config!.font_size, - color: cfg.title_config!.font_color || "#ffffff", - })) + font_preset: cfg.title_config!.font_preset, + font_size: cfg.title_config!.font_size, + font_color: cfg.title_config!.font_color || "#ffffff", + }) } if (cfg.subtitle_config) { setSubtitleSettings((prev) => ({ @@ -824,14 +818,7 @@ const EditingPlanner: React.FC = () => { /** 构建剪辑计划 config(编辑器状态 → API config) */ const buildPlanConfig = (): EditPlanConfig => ({ - title_config: { - ai_auto_select: titleSettings.aiAutoSelect, - content: titleSettings.title, - position: titleSettings.position, - font_preset: titleSettings.font, - font_color: titleSettings.color, - font_size: titleSettings.size, - }, + title_config: titleConfig, subtitle_config: { enabled: subtitleSettings.enabled, position: subtitleSettings.position, @@ -901,14 +888,7 @@ const EditingPlanner: React.FC = () => { .split(",") .map((t) => t.trim()) .filter(Boolean), - title_config: { - ai_auto_select: titleSettings.aiAutoSelect, - content: titleSettings.title, - font_preset: titleSettings.font, - font_color: titleSettings.color, - font_size: titleSettings.size, - position: titleSettings.position, - }, + title_config: titleConfig, subtitle_config: { enabled: subtitleSettings.enabled, position: subtitleSettings.position, @@ -1298,7 +1278,7 @@ const EditingPlanner: React.FC = () => { currentCoverScheme={currentCoverScheme} coverSchemes={COVER_SCHEMES} aiCoverLoading={aiCoverLoading} - titleSettings={titleSettings} + titleConfig={titleConfig} subtitleSettings={{ enabled: subtitleSettings.enabled, position: subtitleSettings.position, @@ -1355,15 +1335,11 @@ const EditingPlanner: React.FC = () => {
- setTitleSettings((prev) => ({ ...prev, ...partial })) - } onSubtitleSettingsChange={(partial) => setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig) } diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index 65cfa5e78..c92a5842b 100755 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -1,11 +1,11 @@ /** * 右栏设置面板 — V8 原型 1:1 还原 - * 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情 + * 字幕设置 + BGM设置 + 片段详情 */ import React, { useRef, useState, useCallback } from "react" import { useNavigate } from "react-router-dom" import type { TemplateMode } from "@/api/editingPlanner" -import type { ClipData, ClipType, TitleSettings } from "../types" +import type { ClipData, ClipType } from "../types" import { TRANSITION_OPTIONS } from "@/api/editPlans" import type { AssetItem } from "@/api/assets" @@ -33,13 +33,11 @@ interface BgmSettings { interface ClipPropertiesPanelProps { selectedClip: ClipData | null - titleSettings: TitleSettings subtitleSettings: SubtitleSettings bgmSettings: BgmSettings clipsCount: number totalDuration: number currentMode: TemplateMode - onTitleSettingsChange: (partial: Partial) => void onSubtitleSettingsChange: (partial: Partial) => void onBgmSettingsChange: (partial: Partial) => void onClipUpdate: (clipId: string, data: Partial) => void @@ -92,174 +90,6 @@ const ANIMATION_OPTIONS = [ { value: "typewriter", label: "打字机" }, ] -/** - * 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号) - * 不绑定字体,用户可自由搭配任意字体 - * 预览统一用系统字体展示效果 - */ -const TITLE_PRESETS = [ - { - key: "classic_white", - label: "经典白字", - style: { - size: 28, - color: "#ffffff", - bold: true, - italic: false, - stroke: true, - shadow: false, - }, - previewStyle: { - fontWeight: 700, - color: "#ffffff", - WebkitTextStroke: "1px #000000", - fontSize: "20px", - }, - }, - { - key: "black_gold", - label: "黑金质感", - style: { - size: 32, - color: "#d4a843", - bold: true, - italic: false, - stroke: false, - shadow: true, - }, - previewStyle: { - fontWeight: 700, - color: "#d4a843", - textShadow: "1px 1px 3px rgba(0,0,0,0.8)", - fontSize: "20px", - }, - }, - { - key: "fresh_minimal", - label: "清新简约", - style: { - size: 24, - color: "#333333", - bold: false, - italic: false, - stroke: false, - shadow: false, - }, - previewStyle: { - fontWeight: 400, - color: "#333333", - fontSize: "18px", - }, - }, - { - key: "variety_show", - label: "综艺花字", - style: { - size: 36, - color: "#ff4081", - bold: true, - italic: false, - stroke: true, - shadow: true, - }, - previewStyle: { - fontWeight: 900, - color: "#ff4081", - WebkitTextStroke: "1.5px #ffffff", - textShadow: "2px 2px 4px rgba(0,0,0,0.5)", - fontSize: "22px", - }, - }, - { - key: "business", - label: "商务极简", - style: { - size: 24, - color: "#1a1a1a", - bold: false, - italic: false, - stroke: false, - shadow: false, - }, - previewStyle: { - fontWeight: 400, - color: "#1a1a1a", - fontSize: "17px", - }, - }, - { - key: "retro_film", - label: "复古胶片", - style: { - size: 28, - color: "#e8d5b7", - bold: false, - italic: false, - stroke: false, - shadow: true, - }, - previewStyle: { - fontWeight: 400, - color: "#e8d5b7", - textShadow: "2px 2px 6px rgba(0,0,0,0.7)", - fontSize: "18px", - }, - }, - { - key: "neon_glow", - label: "霓虹发光", - style: { - size: 32, - color: "#00e5ff", - bold: true, - italic: false, - stroke: false, - shadow: true, - }, - previewStyle: { - fontWeight: 700, - color: "#00e5ff", - textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", - fontSize: "20px", - }, - }, - { - key: "handwriting", - label: "手写字", - style: { - size: 28, - color: "#333333", - bold: false, - italic: false, - stroke: false, - shadow: true, - }, - previewStyle: { - fontWeight: 400, - color: "#333333", - textShadow: "1px 1px 2px rgba(0,0,0,0.3)", - fontSize: "20px", - }, - }, -] - -/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */ -function getActivePreset(settings: TitleSettings): string | null { - for (const p of TITLE_PRESETS) { - if ( - settings.size === p.style.size && - 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 CLIP_TYPE_ICONS: Record = { voice: "🎙️", pip: "🖼️" } const CLIP_TYPE_LABELS: Record = { @@ -269,13 +99,11 @@ const CLIP_TYPE_LABELS: Record = { const ClipPropertiesPanel: React.FC = ({ selectedClip, - titleSettings, subtitleSettings, bgmSettings, clipsCount, totalDuration, currentMode, - onTitleSettingsChange, onSubtitleSettingsChange, onBgmSettingsChange: _onBgmSettingsChange, onClipUpdate, @@ -333,142 +161,6 @@ const ClipPropertiesPanel: React.FC = ({ } return (
- {/* ═══ 标题设置 ═══ */} -
-
- 📝 - 标题设置 -
- -
- AI 自动选择 -
- onTitleSettingsChange({ - aiAutoSelect: !titleSettings.aiAutoSelect, - }) - } - > -
-
-
- - {!titleSettings.aiAutoSelect && ( - <> -
- - -
- -
- - -
- -
- -
- onTitleSettingsChange({ size: Number(e.target.value) })} - /> - {titleSettings.size}px -
-
- -
- -
- {TITLE_PRESETS.map((p) => { - const isActive = getActivePreset(titleSettings) === p.key - return ( - - ) - })} -
-
- -
- -
- - - - -
-
- - )} -
- {/* ═══ 字幕设置 ═══ */}
diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index 9ae424cf6..3ff6c0e30 100755 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -4,7 +4,8 @@ * 封面右侧竖排4个方案按钮 */ import React from "react" -import type { ClipData, ClipType, TitleSettings } from "../types" +import type { ClipData, ClipType } from "../types" +import type { TitleConfig } from "@/api/editPlans" interface CoverScheme { key: string @@ -26,7 +27,7 @@ interface PreviewPlayerProps { currentCoverScheme: string coverSchemes: CoverScheme[] aiCoverLoading: boolean - titleSettings?: TitleSettings + titleConfig?: TitleConfig subtitleSettings?: SubtitleSettings onClipSelect: (clipId: string) => void onCoverSchemeChange: (scheme: string) => void @@ -51,7 +52,7 @@ const PreviewPlayer: React.FC = ({ currentCoverScheme, coverSchemes, aiCoverLoading, - titleSettings, + titleConfig, subtitleSettings, onCoverSchemeChange, onPlayPause, @@ -90,27 +91,28 @@ const PreviewPlayer: React.FC = ({ )} {/* 标题实时预览 */} - {titleSettings && !titleSettings.aiAutoSelect && titleSettings.title && ( + {titleConfig && !titleConfig.ai_auto_select && titleConfig.content && (
- {titleSettings.title} + {titleConfig.content}
)} diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index ff3baa14e..5b10bb11f 100755 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -31,7 +31,7 @@ import { updateEditPlan, getGenerationTaskResults, } from "@/api/editPlans" -import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans" +import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans" import { getEditingTemplates } from "@/api/editingPlanner" import { getTitles } from "@/api/titles" import apiClient from "@/api/client" @@ -80,7 +80,138 @@ const STEPS = [ { key: 5, label: "确认生成" }, ] -/* ── 标题选项从 API 加载,不再硬编码 ── */ +/* ── 标题设置常量 ── */ +const POSITION_OPTIONS = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, +] + +const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"] + +interface TitleSettings { + aiAutoSelect: boolean + title: string + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string +} + +const DEFAULT_TITLE_SETTINGS: TitleSettings = { + aiAutoSelect: false, + title: "", + position: "bottom", + font: "思源黑体", + size: 28, + bold: true, + italic: false, + stroke: true, + shadow: false, + color: "#ffffff", +} + +const TITLE_PRESETS = [ + { + key: "classic_white", + label: "经典白字", + style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false }, + previewStyle: { + fontWeight: 700, + color: "#ffffff", + WebkitTextStroke: "1px #000000", + fontSize: "20px", + }, + }, + { + key: "black_gold", + label: "黑金质感", + style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 700, + color: "#d4a843", + textShadow: "1px 1px 3px rgba(0,0,0,0.8)", + fontSize: "20px", + }, + }, + { + key: "fresh_minimal", + label: "清新简约", + style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false }, + previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" }, + }, + { + key: "variety_show", + label: "综艺花字", + style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true }, + previewStyle: { + fontWeight: 900, + color: "#ff4081", + WebkitTextStroke: "1.5px #ffffff", + textShadow: "2px 2px 4px rgba(0,0,0,0.5)", + fontSize: "22px", + }, + }, + { + key: "business", + label: "商务极简", + style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false }, + previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" }, + }, + { + key: "retro_film", + label: "复古胶片", + style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 400, + color: "#e8d5b7", + textShadow: "2px 2px 6px rgba(0,0,0,0.7)", + fontSize: "18px", + }, + }, + { + key: "neon_glow", + label: "霓虹发光", + style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 700, + color: "#00e5ff", + textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", + fontSize: "20px", + }, + }, + { + key: "handwriting", + label: "手写字", + style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 400, + color: "#333333", + textShadow: "1px 1px 2px rgba(0,0,0,0.3)", + fontSize: "20px", + }, + }, +] + +function getActivePreset(settings: TitleSettings): string | null { + for (const p of TITLE_PRESETS) { + if ( + settings.size === p.style.size && + 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 +} /* ================================================================ 组件 @@ -111,8 +242,8 @@ const GeneratePage: React.FC = () => { /* 素材选择模式:手动选择 / 自动匹配 */ const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") - /* ── 标题 ── */ - const [title, setTitle] = useState("") + /* ── 标题设置 ── */ + const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) const { data: userTitles = [] } = useQuery({ queryKey: ["titles"], queryFn: () => getTitles(), @@ -121,8 +252,16 @@ const GeneratePage: React.FC = () => { /* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */ useEffect(() => { const tpl = userTemplates.find((t) => t.id === selectedTemplate) - if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) { - setTitle(tpl.title_config.content) + if (tpl?.title_config) { + setTitleSettings((prev) => ({ + ...prev, + aiAutoSelect: tpl.title_config!.ai_auto_select, + title: tpl.title_config!.content || prev.title, + position: tpl.title_config!.position || prev.position, + font: tpl.title_config!.font_preset || prev.font, + size: tpl.title_config!.font_size || prev.size, + color: tpl.title_config!.font_color || prev.color, + })) } }, [selectedTemplate, userTemplates]) @@ -184,8 +323,17 @@ const GeneratePage: React.FC = () => { segments?: Array<{ media_asset_id?: string; material_type?: string }> } - if (config.title_config?.content) { - setTitle(config.title_config.content) + if (config.title_config) { + const tc = config.title_config as TitleConfig + setTitleSettings((prev) => ({ + ...prev, + title: tc.content || "", + aiAutoSelect: tc.ai_auto_select || false, + position: tc.position || prev.position, + font: tc.font_preset || prev.font, + size: tc.font_size || prev.size, + color: tc.font_color || prev.color, + })) } if (config.segments && config.segments.length > 0) { const assetIds = config.segments @@ -206,8 +354,19 @@ const GeneratePage: React.FC = () => { const loadPlanConfig = async () => { try { const plan = await getEditPlan(editPlanId) - if (plan.name) setTitle(plan.name) + if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name })) const cfg = plan.config + if (cfg?.title_config) { + setTitleSettings((prev) => ({ + ...prev, + aiAutoSelect: cfg.title_config!.ai_auto_select, + title: cfg.title_config!.content || prev.title, + position: cfg.title_config!.position || prev.position, + font: cfg.title_config!.font_preset || prev.font, + size: cfg.title_config!.font_size || prev.size, + color: cfg.title_config!.font_color || prev.color, + })) + } if (cfg?.asset_ids) { setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string")) } @@ -454,12 +613,12 @@ const GeneratePage: React.FC = () => { const handleGenerate = useCallback(async () => { console.log("[handleGenerate] 开始生成, 参数:", { - title, + titleSettings, selectedTemplate, selectedMaterials, voiceMode, }) - if (!title.trim()) { + if (!titleSettings.title.trim()) { message.warning("请先选择或输入标题") return } @@ -504,9 +663,17 @@ const GeneratePage: React.FC = () => { const plan = await createEditPlan({ template_id: selectedTemplate, - name: title.trim(), + name: titleSettings.title.trim(), config: { asset_ids: selectedMaterials, + title_config: { + ai_auto_select: titleSettings.aiAutoSelect, + content: titleSettings.title, + position: titleSettings.position, + font_preset: titleSettings.font, + font_color: titleSettings.color, + font_size: titleSettings.size, + }, ...voiceConfig, ratio: videoRatio, style, @@ -689,7 +856,7 @@ const GeneratePage: React.FC = () => { message.error(finalMsg) } }, [ - title, + titleSettings, selectedMaterials, selectedVoice, voiceMode, @@ -755,14 +922,14 @@ const GeneratePage: React.FC = () => { message.warning("请至少选择一个素材") return } - if (currentStep === 3 && !title.trim()) { + if (currentStep === 3 && !titleSettings.title.trim()) { message.warning("请选择或输入标题") return } if (currentStep < 5) { setCurrentStep((s) => s + 1) } - }, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode]) + }, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode]) const goPrev = useCallback(() => { if (currentStep > 1) { @@ -1022,48 +1189,193 @@ const GeneratePage: React.FC = () => {
) - /** 步骤 3:选择标题 */ + /** 步骤 3:选择标题 + 标题样式设置 */ const renderStep3 = () => (

📝 选择标题

-
- - setTitle(e.target.value)} - maxLength={50} - /> -
- {userTitles.length === 0 && ( -

- 标题库为空,请前往「标题管理」添加标题,或手动输入 -

+ + {!titleSettings.aiAutoSelect && ( + <> + {/* 标题内容选择 */} +
+ + setTitleSettings((prev) => ({ ...prev, title: e.target.value }))} + maxLength={50} + /> +
+ + {/* 标题样式设置区 */} +
+

标题样式

+ + {/* 位置 + 字体 一行 */} +
+
+ + +
+
+ + +
+
+ + {/* 字号滑块 */} +
+
+ + {titleSettings.size}px +
+ + setTitleSettings((prev) => ({ + ...prev, + size: Number(e.target.value), + })) + } + /> +
+ + {/* 预设样式 */} +
+ +
+ {TITLE_PRESETS.map((p) => { + const isActive = getActivePreset(titleSettings) === p.key + return ( + + ) + })} +
+
+ + {/* 样式按钮:粗体/斜体/描边/阴影 */} +
+ +
+ + + + +
+
+
+ )}
) @@ -1594,7 +1906,7 @@ const GeneratePage: React.FC = () => {
标题 - {title || "未选择"} + {titleSettings.title || "未选择"}
配音 @@ -1845,7 +2157,9 @@ const GeneratePage: React.FC = () => {
{/* 字幕预览 */} -
{title || "3秒抓住注意力,30秒讲清卖点"}
+
+ {titleSettings.title || "3秒抓住注意力,30秒讲清卖点"} +
{/* 时间线标题 */}
剪辑计划预览
diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css old mode 100644 new mode 100755 index f07f221c1..86f1ef5da --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -1136,3 +1136,204 @@ border: 1px solid var(--border-light, #f1f5f9); border-radius: 20px; } + +/* ============================================================ + 标题设置(选择标题步骤) + ============================================================ */ +.xx-title-ai-toggle { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + margin-bottom: 18px; +} + +.xx-toggle-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.xx-switch { + width: 44px; + height: 24px; + background: var(--border-color); + border-radius: 12px; + position: relative; + cursor: pointer; + transition: background 0.2s; +} + +.xx-switch.active { + background: var(--primary-color); +} + +.xx-switch-knob { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} + +.xx-switch.active .xx-switch-knob { + transform: translateX(20px); +} + +.xx-title-style-section { + margin-top: 22px; + padding-top: 20px; + border-top: 1px solid var(--border-light); +} + +.xx-section-subtitle { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 16px; +} + +.xx-title-style-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-bottom: 14px; +} + +.xx-half-field { + margin-bottom: 0; +} + +.xx-field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.xx-field-label-row label { + margin-bottom: 0; +} + +.xx-field-value { + font-size: 13px; + font-weight: 600; + color: var(--primary-color); +} + +.xx-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--border-color); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.xx-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--primary-color); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3); +} + +.xx-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--primary-color); + border-radius: 50%; + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3); +} + +/* 标题预设卡片网格 */ +.xx-title-presets-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.xx-title-preset-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 14px 8px; + background: var(--bg-secondary); + border: 2px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; + text-align: center; +} + +.xx-title-preset-card:hover { + border-color: var(--primary-200); + background: var(--bg-primary); +} + +.xx-title-preset-card.active { + border-color: var(--primary-color); + background: var(--primary-50); +} + +.xx-title-preset-preview-text { + line-height: 1.4; + margin-bottom: 6px; + user-select: none; +} + +.xx-title-preset-card-label { + font-size: 11px; + color: var(--text-secondary); +} + +.xx-title-preset-card.active .xx-title-preset-card-label { + color: var(--primary-color); + font-weight: 500; +} + +/* 样式按钮组 */ +.xx-style-btns { + display: flex; + gap: 8px; +} + +.xx-style-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + cursor: pointer; + font-size: 15px; + color: var(--text-secondary); + transition: all 0.15s; +} + +.xx-style-btn:hover { + border-color: var(--primary-300); + color: var(--primary-color); +} + +.xx-style-btn.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +}