diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 5fad2865f..810e0ed51 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -31,7 +31,6 @@ import type { import { getMediaAssets, getEditPlanGenerations, - generateCover, getEditPlan, createEditPlan, updateEditPlan, @@ -96,7 +95,7 @@ import PipConfigPanel from "./components/PipConfigPanel" import FilterPanel from "./components/FilterPanel" import GreenScreenPanel from "./components/GreenScreenPanel" import StickerPanel from "./components/StickerPanel" -import CoverSelector from "./components/CoverSelector" + import SaveModal from "./components/SaveModal" import GenerationHistoryModal from "./components/GenerationHistoryModal" import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm" @@ -111,13 +110,6 @@ const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [ { key: "voice_pip", label: "口播+混剪", icon: "🎭" }, ] -const COVER_SCHEMES = [ - { key: "ai_frame", label: "AI选帧" }, - { key: "manual", label: "手动选" }, - { key: "upload", label: "上传" }, - { key: "ai_reselect", label: "AI重选" }, -] - const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"] /* ──────────── 组件 ──────────── */ @@ -146,13 +138,6 @@ const EditingPlanner: React.FC = () => { } = useUndoRedo([]) const [selectedClipId, setSelectedClipId] = useState(null) - /* ── AI 操作状态 ── */ - - const [aiCoverLoading, setAiCoverLoading] = useState(false) - - /* ── 封面方案 ── */ - const [currentCoverScheme, setCurrentCoverScheme] = useState("ai_frame") - /* ── 左栏筛选 ── */ const [currentFilter, setCurrentFilter] = useState("全部") const [searchQuery, setSearchQuery] = useState("") @@ -223,11 +208,10 @@ const EditingPlanner: React.FC = () => { }) const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false) - /* ── 封面 ── */ - const [coverSettings, setCoverSettings] = useState({ + /* ── 封面配置(只读,从模板/计划继承) ── */ + const [coverConfig, setCoverConfig] = useState({ ...DEFAULT_COVER_CONFIG, }) - const [coverDrawerOpen, setCoverDrawerOpen] = useState(false) /* ── 右侧栏 Tab ── */ const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") @@ -448,7 +432,7 @@ const EditingPlanner: React.FC = () => { } // 还原封面配置 if (cfg.cover_config) { - setCoverSettings((prev) => ({ + setCoverConfig((prev) => ({ ...prev, enabled: cfg.cover_config!.enabled ?? prev.enabled, mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, @@ -788,34 +772,6 @@ const EditingPlanner: React.FC = () => { setStickerSettings(config) }, []) - /* ── 封面配置变更 ── */ - const handleCoverChange = useCallback((config: CoverConfig) => { - setCoverSettings(config) - }, []) - - /* AI 封面生成 */ - const handleAiGenerateCover = async (coverType: "ai_frame" | "ai_regenerate") => { - if (!loadedTemplateId) return - const assetIds = selectedAssetIds - if (assetIds.length === 0) { - message.warning("请先在素材库中选择素材") - return - } - setAiCoverLoading(true) - try { - await generateCover(loadedTemplateId, { - asset_ids: assetIds, - cover_type: coverType, - }) - setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect") - message.success("AI 封面生成成功") - } catch { - message.error("AI 封面生成失败") - } finally { - setAiCoverLoading(false) - } - } - /** 构建剪辑计划 config(编辑器状态 → API config) */ const buildPlanConfig = (): EditPlanConfig => ({ title_config: titleConfig, @@ -865,7 +821,7 @@ const EditingPlanner: React.FC = () => { filter_config: { ...filterSettings }, green_screen_config: { ...chromaKeySettings }, sticker_config: { ...stickerSettings }, - cover_config: { ...coverSettings }, + cover_config: { ...coverConfig }, }) /* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */ @@ -935,7 +891,7 @@ const EditingPlanner: React.FC = () => { filter_config: { ...filterSettings }, green_screen_config: { ...chromaKeySettings }, sticker_config: { ...stickerSettings }, - cover_config: { ...coverSettings }, + cover_config: { ...coverConfig }, } if (loadedTemplateId) { await updateEditingTemplate(loadedTemplateId, payload) @@ -1275,10 +1231,8 @@ const EditingPlanner: React.FC = () => { clips={clips} selectedClipId={selectedClipId} isPlaying={isPlaying} - currentCoverScheme={currentCoverScheme} - coverSchemes={COVER_SCHEMES} - aiCoverLoading={aiCoverLoading} titleConfig={titleConfig} + coverConfig={coverConfig} subtitleSettings={{ enabled: subtitleSettings.enabled, position: subtitleSettings.position, @@ -1287,9 +1241,7 @@ const EditingPlanner: React.FC = () => { animation: subtitleSettings.animation, }} onClipSelect={handleClipSelect} - onCoverSchemeChange={setCurrentCoverScheme} onPlayPause={() => setIsPlaying(!isPlaying)} - onAiGenerateCover={handleAiGenerateCover} /> {/* 下半部:水平时间线 */} @@ -1362,7 +1314,6 @@ const EditingPlanner: React.FC = () => { onOpenFilterDrawer={() => setFilterDrawerOpen(true)} onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)} onOpenStickerDrawer={() => setStickerDrawerOpen(true)} - onOpenCoverDrawer={() => setCoverDrawerOpen(true)} /> )} @@ -1732,15 +1683,6 @@ const EditingPlanner: React.FC = () => { onChange={handleStickerChange} totalDuration={totalDuration} /> - - {/* ═══ 封面选择器 ═══ */} - setCoverDrawerOpen(false)} - config={coverSettings} - onChange={handleCoverChange} - totalDuration={totalDuration} - /> ) } diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index c92a5842b..621cc7703 100755 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -72,7 +72,6 @@ interface ClipPropertiesPanelProps { /** 打开贴纸面板 Drawer */ onOpenStickerDrawer?: () => void /** 打开封面选择器 Drawer */ - onOpenCoverDrawer?: () => void } const POSITION_OPTIONS = [ @@ -122,7 +121,6 @@ const ClipPropertiesPanel: React.FC = ({ onOpenFilterDrawer, onOpenGreenScreenDrawer, onOpenStickerDrawer, - onOpenCoverDrawer, }) => { const navigate = useNavigate() @@ -374,21 +372,6 @@ const ClipPropertiesPanel: React.FC = ({ )} - {/* ═══ 封面 ═══ */} -
-
- 🖼️ - 封面 -
- {onOpenCoverDrawer && ( - - )} -
- {/* ═══ 片段详情(选中时显示) ═══ */} {selectedClip && (
diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index 3ff6c0e30..9d86531c6 100755 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -1,16 +1,12 @@ /** * 预览区 — V8 原型 1:1 还原 - * 手机模型预览(150x267) + 封面预览(150x267) 并排 - * 封面右侧竖排4个方案按钮 + * 手机模型预览 + 封面预览 并排 + * 封面为只读展示(从模板/计划继承) */ import React from "react" import type { ClipData, ClipType } from "../types" import type { TitleConfig } from "@/api/editPlans" - -interface CoverScheme { - key: string - label: string -} +import type { CoverConfig } from "../types" interface SubtitleSettings { enabled: boolean @@ -24,15 +20,11 @@ interface PreviewPlayerProps { clips: ClipData[] selectedClipId: string | null isPlaying: boolean - currentCoverScheme: string - coverSchemes: CoverScheme[] - aiCoverLoading: boolean titleConfig?: TitleConfig + coverConfig?: CoverConfig subtitleSettings?: SubtitleSettings onClipSelect: (clipId: string) => void - onCoverSchemeChange: (scheme: string) => void onPlayPause: () => void - onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void } const CLIP_TYPE_ICONS: Record = { @@ -45,18 +37,20 @@ const CLIP_TYPE_LABELS: Record = { pip: "混剪", } +const COVER_MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧封面", + upload: "上传封面", +} + const PreviewPlayer: React.FC = ({ clips, selectedClipId, isPlaying, - currentCoverScheme, - coverSchemes, - aiCoverLoading, titleConfig, + coverConfig, subtitleSettings, - onCoverSchemeChange, onPlayPause, - onAiGenerateCover, }) => { const selectedClip = clips.find((c) => c.id === selectedClipId) const displayClip = selectedClip || clips[0] @@ -139,50 +133,24 @@ const PreviewPlayer: React.FC = ({
- {/* 封面预览 */} + {/* 封面预览(只读) */}
- {displayClip ? ( + {coverConfig?.thumbnail_url || coverConfig?.upload_url ? ( + 封面预览 + ) : displayClip ? ( {CLIP_TYPE_ICONS[displayClip.type] || "🎬"} ) : ( 暂无封面 )}
- {coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"} + {coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
- {/* AI 封面操作按钮 */} -
- - -
-
- - {/* 封面方案按钮(竖排4个) */} -
- {coverSchemes.map((scheme) => ( - - ))}
) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index b036651c7..e8aa3a76e 100755 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -32,6 +32,7 @@ import { getGenerationTaskResults, } from "@/api/editPlans" import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans" +import type { CoverConfig } from "../editing-planner/types" import { getEditingTemplates } from "@/api/editingPlanner" import { getTitles } from "@/api/titles" import apiClient from "@/api/client" @@ -75,9 +76,11 @@ const VOICE_GENDER_ICON: Record = { const STEPS = [ { key: 1, label: "选择模板" }, { key: 2, label: "选择素材" }, - { key: 3, label: "选择标题" }, - { key: 4, label: "选择配音" }, - { key: 5, label: "确认生成" }, + { key: 3, label: "生成预览" }, + { key: 4, label: "选择标题" }, + { key: 5, label: "选择配音" }, + { key: 6, label: "选择封面" }, + { key: 7, label: "确认生成" }, ] /* ── 标题设置常量 ── */ @@ -197,6 +200,28 @@ const TITLE_PRESETS = [ }, ] +/* ── 封面设置常量 ── */ +const COVER_MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧选封面", + upload: "上传封面", +} + +const COVER_MODE_ICONS: Record = { + auto: "🤖", + frame: "🎞️", + upload: "📤", +} + +const DEFAULT_COVER_SETTINGS: CoverConfig = { + enabled: true, + mode: "auto", + frame_time: 0, + upload_url: "", + ai_suggested_time: null, + thumbnail_url: "", +} + function getActivePreset(settings: TitleSettings): string | null { for (const p of TITLE_PRESETS) { if ( @@ -244,6 +269,9 @@ const GeneratePage: React.FC = () => { /* ── 标题设置 ── */ const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) + + /* ── 封面设置 ── */ + const [coverSettings, setCoverSettings] = useState(DEFAULT_COVER_SETTINGS) const { data: userTitles = [] } = useQuery({ queryKey: ["titles"], queryFn: () => getTitles(), @@ -263,6 +291,17 @@ const GeneratePage: React.FC = () => { color: tpl.title_config!.font_color || prev.color, })) } + if (tpl?.cover_config) { + setCoverSettings((prev) => ({ + ...prev, + enabled: tpl.cover_config!.enabled ?? prev.enabled, + mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode, + frame_time: tpl.cover_config!.frame_time ?? prev.frame_time, + upload_url: tpl.cover_config!.upload_url || prev.upload_url, + ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, + thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url, + })) + } }, [selectedTemplate, userTemplates]) /* ── 配音 ── */ @@ -360,6 +399,18 @@ const GeneratePage: React.FC = () => { color: cfg.title_config!.font_color || prev.color, })) } + if (cfg?.cover_config) { + const cc = cfg.cover_config as CoverConfig + setCoverSettings((prev) => ({ + ...prev, + enabled: cc.enabled ?? prev.enabled, + mode: cc.mode || prev.mode, + frame_time: cc.frame_time ?? prev.frame_time, + upload_url: cc.upload_url || prev.upload_url, + ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time, + thumbnail_url: cc.thumbnail_url || prev.thumbnail_url, + })) + } if (cfg?.asset_ids) { setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string")) } @@ -658,6 +709,7 @@ const GeneratePage: React.FC = () => { font_color: titleSettings.color, font_size: titleSettings.size, }, + cover_config: coverSettings, ...voiceConfig, ratio: videoRatio, style, @@ -856,6 +908,7 @@ const GeneratePage: React.FC = () => { selectedTemplate, generateCount, materialMode, + coverSettings, ]) /* ── 下载视频 ── */ @@ -904,11 +957,11 @@ const GeneratePage: React.FC = () => { message.warning("请至少选择一个素材") return } - if (currentStep === 3 && !titleSettings.title.trim()) { + if (currentStep === 4 && !titleSettings.title.trim()) { message.warning("请选择或输入标题") return } - if (currentStep < 5) { + if (currentStep < 7) { setCurrentStep((s) => s + 1) } }, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode]) @@ -1167,8 +1220,45 @@ const GeneratePage: React.FC = () => { ) - /** 步骤 3:选择标题 + 标题样式设置 */ + /** 步骤 3:生成预览 */ const renderStep3 = () => ( +
+

🎬 生成预览

+
+ + 素材已选好,AI 将为您智能匹配剪辑方案 +
+
+
剪辑计划预览
+
+
+ 模板 + {getTemplateName()} +
+
+ 素材数量 + + {materialMode === "auto" ? "AI自动匹配" : `${selectedMaterials.length} 个素材`} + +
+
+ 预计时长 + {duration} 秒 +
+
+ 视频比例 + {videoRatio} +
+
+
+ 💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题 +
+
+
+ ) + + /** 步骤 4:选择标题 + 标题样式设置 */ + const renderStep4 = () => (

📝 选择标题

@@ -1359,7 +1449,7 @@ const GeneratePage: React.FC = () => { ) /** 步骤 4:选择配音 */ - const renderStep4 = () => ( + const renderStep5 = () => (

🎙️ 选择配音

@@ -1734,8 +1824,198 @@ const GeneratePage: React.FC = () => {
) - /** 步骤 5:确认生成 */ - const renderStep5 = () => ( + /** 步骤 5:选择封面 */ + const renderStep6 = () => { + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + const ms = Math.floor((seconds % 1) * 10) + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` + } + + const totalDuration = duration || 30 + + return ( +
+

🖼️ 选择封面

+ + {/* 启用开关 */} +
+ 启用自定义封面 + +
+ + {coverSettings.enabled && ( + <> + {/* 模式选择 */} +
封面来源
+
+ {(["auto", "frame", "upload"] as const).map((m) => ( + + ))} +
+ + {/* 智能封面 */} + {coverSettings.mode === "auto" && ( +
+
+ AI 将分析视频内容,自动选择最具吸引力的画面作为封面。 +
+
+ 🤖 + AI 智能选帧 +
+
+ )} + + {/* 抽帧选封面 */} + {coverSettings.mode === "frame" && ( +
+
+
+ 🎞️ + + {formatTime(coverSettings.frame_time)} + +
+
+
+
+ 拖动选择封面帧 + + {formatTime(coverSettings.frame_time)} + +
+ + setCoverSettings((prev) => ({ + ...prev, + frame_time: Number(e.target.value), + })) + } + className="xx-cover-range" + /> +
+ 00:00 + {formatTime(totalDuration)} +
+
+
+ 快捷选帧: + {[0, 0.25, 0.5, 0.75].map((ratio) => { + const t = totalDuration * ratio + return ( + + ) + })} +
+
+ )} + + {/* 上传封面 */} + {coverSettings.mode === "upload" && ( +
+
{ + const input = document.getElementById("cover-upload-input") + input?.click() + }} + > + {coverSettings.upload_url ? ( +
+ 封面预览 +
点击更换
+
+ ) : ( +
+ 📤 + 点击上传封面图片 + 支持 JPG / PNG,建议 16:9 比例 +
+ )} + { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (ev) => { + const url = ev.target?.result as string + setCoverSettings((prev) => ({ + ...prev, + upload_url: url, + thumbnail_url: url, + mode: "upload", + })) + } + reader.readAsDataURL(file) + } + }} + /> +
+
+ )} + + {/* 封面预览 */} +
封面预览
+
+ {coverSettings.upload_url ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {coverSettings.mode === "auto" + ? "AI 智能选择" + : coverSettings.mode === "frame" + ? `帧 ${formatTime(coverSettings.frame_time)}` + : "未上传封面"} + +
+ )} +
16:9
+
+ + )} +
+ ) + } + + /** 步骤 6:确认生成 */ + const renderStep7 = () => (

✨ 确认生成

@@ -1757,6 +2037,12 @@ const GeneratePage: React.FC = () => { 配音 {getVoiceName()}
+
+ 封面 + + {coverSettings.enabled ? COVER_MODE_LABELS[coverSettings.mode] || "智能封面" : "不使用"} + +
生成数量 @@ -1872,6 +2158,10 @@ const GeneratePage: React.FC = () => { return renderStep4() case 5: return renderStep5() + case 6: + return renderStep6() + case 7: + return renderStep7() default: return null } @@ -1948,7 +2238,7 @@ const GeneratePage: React.FC = () => { - {currentStep < 5 ? ( + {currentStep < 7 ? ( diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 86f1ef5da..88db68607 100755 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -1337,3 +1337,415 @@ border-color: var(--primary-color); color: #fff; } +/* ================================================================ + 封面设置 + ================================================================ */ + +.xx-cover-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + margin-bottom: 16px; +} + +.xx-cover-header-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.xx-switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; +} + +.xx-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.xx-switch-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: 0.2s; + border-radius: 22px; +} + +.xx-switch-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.2s; + border-radius: 50%; +} + +.xx-switch input:checked + .xx-switch-slider { + background-color: var(--primary-color); +} + +.xx-switch input:checked + .xx-switch-slider:before { + transform: translateX(18px); +} + +.xx-section-title { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin: 16px 0 10px; +} + +.xx-cover-mode-tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.xx-cover-mode-tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px; + border: 1.5px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-primary); + cursor: pointer; + transition: all 0.15s; +} + +.xx-cover-mode-tab:hover { + border-color: var(--primary-300); +} + +.xx-cover-mode-tab.active { + border-color: var(--primary-color); + background: var(--primary-50); +} + +.xx-cover-mode-icon { + font-size: 20px; +} + +.xx-cover-mode-label { + font-size: 12px; + color: var(--text-primary); + font-weight: 500; +} + +.xx-cover-auto { + padding: 20px; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + text-align: center; +} + +.xx-cover-auto-desc { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; + line-height: 1.6; +} + +.xx-cover-auto-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--primary-50); + border: 1px solid var(--primary-200); + border-radius: 20px; + font-size: 13px; + font-weight: 500; + color: var(--primary-color); +} + +.xx-cover-frame { + margin-bottom: 16px; +} + +.xx-cover-frame-preview { + margin-bottom: 16px; +} + +.xx-cover-frame-placeholder { + aspect-ratio: 16 / 9; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #fff; +} + +.xx-cover-frame-icon { + font-size: 32px; +} + +.xx-cover-frame-time { + font-size: 18px; + font-weight: 600; + font-family: monospace; +} + +.xx-cover-frame-slider { + margin-bottom: 12px; +} + +.xx-cover-frame-slider-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.xx-cover-frame-value { + font-weight: 600; + color: var(--text-primary); + font-family: monospace; +} + +.xx-cover-range { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border-color); + border-radius: 2px; + outline: none; +} + +.xx-cover-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--primary-color); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.xx-cover-frame-range { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-tertiary); + margin-top: 4px; +} + +.xx-cover-frame-quick { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.xx-cover-quick-label { + font-size: 12px; + color: var(--text-secondary); +} + +.xx-cover-quick-btn { + padding: 4px 10px; + font-size: 12px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + font-family: monospace; +} + +.xx-cover-quick-btn:hover { + border-color: var(--primary-color); + color: var(--primary-color); +} + +.xx-cover-upload { + margin-bottom: 16px; +} + +.xx-cover-upload-area { + aspect-ratio: 16 / 9; + border: 2px dashed var(--border-color); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s; + position: relative; + overflow: hidden; +} + +.xx-cover-upload-area:hover { + border-color: var(--primary-color); + background: var(--primary-50); +} + +.xx-cover-upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +.xx-cover-upload-text { + font-size: 14px; + font-weight: 500; +} + +.xx-cover-upload-hint { + font-size: 12px; + opacity: 0.7; +} + +.xx-cover-upload-preview { + position: relative; + width: 100%; + height: 100%; +} + +.xx-cover-upload-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.xx-cover-upload-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 12px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); + color: #fff; + font-size: 13px; + text-align: center; +} + +.xx-cover-preview-box { + position: relative; + aspect-ratio: 16 / 9; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-color); +} + +.xx-cover-preview-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.xx-cover-preview-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-tertiary); + font-size: 13px; +} + +.xx-cover-preview-ratio { + position: absolute; + bottom: 8px; + right: 8px; + padding: 2px 8px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; + border-radius: 4px; + font-family: monospace; +} + +/* ================================================================ + 生成预览步骤 + ================================================================ */ + +.xx-preview-tip { + display: flex; + align-items: center; + padding: 12px 16px; + background: rgba(82, 196, 26, 0.08); + border: 1px solid rgba(82, 196, 26, 0.2); + border-radius: var(--radius-md); + margin-bottom: 20px; + font-size: 13px; + color: var(--text-primary); +} + +.xx-preview-plan-card { + background: var(--bg-tertiary); + border-radius: var(--radius-lg); + padding: 20px; + border: 1px solid var(--border-color); +} + +.xx-preview-plan-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.xx-preview-plan-info { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.xx-preview-plan-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.xx-preview-plan-label { + font-size: 13px; + color: var(--text-secondary); +} + +.xx-preview-plan-value { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.xx-preview-plan-hint { + font-size: 12px; + color: var(--text-tertiary); + padding: 10px 12px; + background: var(--bg-primary); + border-radius: var(--radius-sm); + line-height: 1.5; +}