From ffd533b552a72c26737aefc7aa030ff002266bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sat, 4 Jul 2026 20:06:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20V8=20=E5=8E=9F=E5=9E=8B=201:1=20?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=E5=89=AA=E8=BE=91=E8=AE=A1=E5=88=92=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) - 顶栏:logo + 模板名 + 保存/生成按钮 - 模式栏:4种剪辑模式(画中画/人物口播/一镜到底/口播+画中画)下划线选中态 - 左栏(220px):纯模板列表 + chip分类筛选 - 中栏:手机模型预览(150x267) + 封面预览 + 水平轨道时间线(100x100卡片) - 右栏(260px):标题/字幕/BGM设置 + 片段详情 - 底栏:片段数/总时长/当前模式 - 暗色主题 #0a0a14/#6c5ce7/#13131f - HTML5拖拽排序 + 封面4方案切换 - TypeScript 零错误 --- .../pages/editing-planner/EditingPlanner.css | 1853 ++++++++--------- .../pages/editing-planner/EditingPlanner.tsx | 1044 +++++----- .../components/ClipPropertiesPanel.tsx | 566 +++-- .../editing-planner/components/MediaPanel.tsx | 280 +-- .../components/PreviewPlayer.tsx | 471 +---- .../components/TimelinePanel.tsx | 416 ++-- 6 files changed, 2074 insertions(+), 2556 deletions(-) diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index 5f5b6b1ab..6891b53a6 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -1,1183 +1,1092 @@ /** - * 剪辑计划编辑器 - V21 设计系统样式 - * 三栏布局:素材面板 / 时间线 / 片段属性 - * 统一使用 CSS 变量,支持深色/浅色主题 + * 剪辑计划编辑器 — V8 原型 1:1 还原样式 + * 颜色:背景 #0a0a14、主色 #6c5ce7、卡片 #13131f + * 布局:4行(顶栏42 + 模式栏48 + 三栏主体flex + 底栏40) + * 面板宽度:左220 / 右260 */ -@import "../../styles/global.css"; -/* ============================================================ - 页面容器 - ============================================================ */ -.ep-page { +/* ═══════════════════════════════════════ + 根容器 — 全屏四行布局 + ═══════════════════════════════════════ */ + +.ep-v8-root { display: flex; flex-direction: column; - height: 100%; - background: var(--bg-secondary); + height: 100vh; + background: #0a0a14; + color: #e0e0e0; + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; + overflow: hidden; } -/* ── 顶部工具栏 ─────────────────────────────────────────────── */ -.ep-toolbar { +/* ═══════════════════════════════════════ + 第1行:顶栏 42px + ═══════════════════════════════════════ */ + +.ep-top-bar { + height: 42px; + min-height: 42px; + background: #161625; + border-bottom: 1px solid #222; display: flex; align-items: center; justify-content: space-between; - padding: var(--space-md) var(--space-lg); - background: var(--bg-primary); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; - gap: var(--space-md); - flex-wrap: wrap; + padding: 0 16px; } -.ep-toolbar-left { +.ep-top-bar-left { display: flex; align-items: center; - gap: var(--space-md); + gap: 8px; } -.ep-toolbar-center { - display: flex; - align-items: center; - gap: var(--space-sm); +.ep-logo { + font-size: 18px; } -.ep-toolbar-template-name { - font-size: var(--font-size-sm); +.ep-app-title { + font-size: 14px; font-weight: 600; - color: var(--text-primary); - padding: 4px 12px; - background: var(--bg-secondary); - border-radius: var(--radius-md); + color: #fff; } -.ep-toolbar-plan-badge { +.ep-divider { + color: #333; + margin: 0 4px; +} + +.ep-template-name { + color: #888; font-size: 12px; - font-weight: 600; - color: var(--success-color); - background: var(--success-soft); - border: 1px solid var(--success-border); - padding: 2px 8px; - border-radius: 999px; } -.ep-toolbar-right { +.ep-top-bar-right { display: flex; align-items: center; - gap: var(--space-sm); + gap: 8px; } -/* ── 模式切换 — 圆角胶囊样式 ────────────────────────────────── */ -.ep-mode-switch { - display: flex; - gap: 6px; - padding: 4px; - background: var(--bg-secondary); - border-radius: 999px; - border: 1px solid var(--border-color); -} - -.ep-mode-btn { - padding: 6px 16px; - border-radius: 999px; - border: 1px solid transparent; - background: transparent; - color: var(--text-secondary); - font-size: var(--font-size-sm); +/* 按钮 */ +.ep-btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 12px; font-weight: 500; + border: none; cursor: pointer; - transition: var(--transition-all); - white-space: nowrap; - user-select: none; + transition: all 0.2s; display: flex; align-items: center; gap: 4px; - line-height: 1.4; +} + +.ep-btn-primary { + background: #6c5ce7; + color: #fff; +} + +.ep-btn-primary:hover { + background: #7c6cf7; +} + +.ep-btn-secondary { + background: #1e1e2e; + color: #ccc; + border: 1px solid #2a2a4a; +} + +.ep-btn-secondary:hover { + background: #2a2a3a; + border-color: #6c5ce7; +} + +.ep-btn-sm { + padding: 4px 10px; + font-size: 11px; +} + +.ep-btn-danger { + background: #ff4757; + color: #fff; +} + +.ep-btn-danger:hover { + background: #ff6b81; +} + +/* ═══════════════════════════════════════ + 第2行:模式栏 48px + ═══════════════════════════════════════ */ + +.ep-mode-bar { + height: 48px; + min-height: 48px; + background: #13131f; + border-bottom: 1px solid #222; + display: flex; + align-items: center; + padding: 0 16px; + gap: 4px; +} + +.ep-mode-btn { + position: relative; + height: 36px; + padding: 0 16px; + background: transparent; + border: none; + color: #888; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + border-radius: 6px; + transition: all 0.2s; } .ep-mode-btn:hover { - color: var(--primary-color); - background: var(--primary-soft); + color: #ccc; + background: rgba(108, 92, 231, 0.1); } .ep-mode-btn.active { - background: var(--primary-soft); - border-color: var(--primary-color); - color: var(--primary-color); - font-weight: 600; + color: #6c5ce7; + background: rgba(108, 92, 231, 0.15); } -/* ============================================================ - 三栏主体布局 - ============================================================ */ -.ep-body { - display: flex; +.ep-mode-btn.active::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 60%; + height: 2px; + background: #6c5ce7; + border-radius: 1px; + transition: all 0.3s ease; +} + +.ep-mode-icon { + font-size: 14px; +} + +/* ═══════════════════════════════════════ + 第3行:三栏主体 + ═══════════════════════════════════════ */ + +.ep-main-body { flex: 1; + display: flex; overflow: hidden; min-height: 0; } -/* ── 左侧素材面板 ───────────────────────────────────────────── */ -.ep-left { - width: 280px; - flex-shrink: 0; +/* ─── 左栏 220px ─── */ + +.ep-left-panel { + width: 220px; + min-width: 220px; + background: #0e0e1a; + border-right: 1px solid #222; display: flex; flex-direction: column; - background: var(--bg-primary); - border-right: 1px solid var(--border-color); overflow: hidden; } -/* Tab 切换 */ -.ep-left-tabs { - display: flex; - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; +/* 搜索 */ +.ep-search-wrap { + padding: 8px; } -.ep-left-tab { - flex: 1; - padding: 10px 12px; - border: none; - background: transparent; - color: var(--text-secondary); - font-size: var(--font-size-sm); - font-weight: 500; - cursor: pointer; - transition: var(--transition-all); - text-align: center; - position: relative; +.ep-search-input { + width: 100%; + height: 30px; + background: #13131f; + border: 1px solid #2a2a4a; + border-radius: 6px; + padding: 0 10px 0 30px; + color: #e0e0e0; + font-size: 12px; + outline: none; + transition: border-color 0.2s; } -.ep-left-tab:hover { - color: var(--text-primary); - background: var(--bg-secondary); +.ep-search-input:focus { + border-color: #6c5ce7; } -.ep-left-tab.active { - color: var(--primary-color); - font-weight: 600; -} - -.ep-left-tab.active::after { - content: ""; +.ep-search-icon { position: absolute; - bottom: 0; - left: 16px; - right: 16px; - height: 2px; - background: var(--primary-color); - border-radius: 2px 2px 0 0; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: #555; + font-size: 12px; + pointer-events: none; } -.ep-left-header { - padding: var(--space-md); - border-bottom: 1px solid var(--border-color); +/* Chip 筛选 */ +.ep-filter-chips { display: flex; - flex-direction: column; - gap: var(--space-sm); - flex-shrink: 0; + gap: 6px; + padding: 0 8px 8px; + flex-wrap: wrap; } -.ep-left-list { +.ep-filter-chip { + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + background: #13131f; + color: #888; + border: 1px solid #2a2a4a; + cursor: pointer; + transition: all 0.2s; +} + +.ep-filter-chip:hover { + border-color: #6c5ce7; + color: #ccc; +} + +.ep-filter-chip.active { + background: #6c5ce7; + color: #fff; + border-color: #6c5ce7; +} + +/* 模板列表 */ +.ep-template-list { flex: 1; overflow-y: auto; - padding: var(--space-sm); + padding: 0 8px 8px; } -.ep-left-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-2xl) var(--space-md); - text-align: center; - color: var(--text-secondary); +.ep-template-list::-webkit-scrollbar { + width: 4px; } -.ep-left-empty-icon { - font-size: 40px; - margin-bottom: var(--space-sm); - opacity: 0.5; +.ep-template-list::-webkit-scrollbar-thumb { + background: #333; + border-radius: 2px; } -.ep-left-empty p { - margin: 0; - font-size: var(--font-size-sm); -} - -/* 模板卡片 */ .ep-template-card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 12px; - margin-bottom: var(--space-sm); + background: #13131f; + border: 1px solid #222; + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; cursor: pointer; - transition: var(--transition-all); + transition: all 0.2s; } .ep-template-card:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); + border-color: #6c5ce7; + background: #1a1a2a; } -.ep-template-card.selected { - border-color: var(--primary-color); - background: var(--primary-soft); +.ep-template-card.active { + border-color: #6c5ce7; + background: rgba(108, 92, 231, 0.1); +} + +.ep-template-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; } .ep-template-card-name { - font-size: 14px; + font-size: 12px; font-weight: 600; - color: var(--text-primary); - margin: 0 0 6px; + color: #fff; +} + +.ep-template-card-mode { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(108, 92, 231, 0.2); + color: #6c5ce7; +} + +.ep-template-card-meta { + display: flex; + gap: 8px; + font-size: 10px; + color: #666; } .ep-template-card-tags { display: flex; gap: 4px; + margin-top: 6px; flex-wrap: wrap; } -.ep-template-card-meta { - font-size: 12px; - color: var(--text-secondary); - margin-top: 6px; +.ep-template-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: #1a1a2a; + color: #888; } -/* 素材卡片(可拖拽) */ -.ep-asset-card { +/* Loading / Empty */ +.ep-loading, +.ep-empty { display: flex; + flex-direction: column; align-items: center; - gap: var(--space-sm); - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 10px 12px; - margin-bottom: var(--space-sm); - cursor: grab; - transition: var(--transition-all); - user-select: none; + justify-content: center; + padding: 32px 16px; + color: #555; + font-size: 12px; + gap: 8px; } -.ep-asset-card:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); +/* ─── 中栏 flex-1 ─── */ + +.ep-center-col { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; } -.ep-asset-card:active { - cursor: grabbing; - opacity: 0.7; -} - -.ep-asset-card-icon { - font-size: 24px; - flex-shrink: 0; - width: 36px; - height: 36px; +/* 上半部:预览 + 封面 (320px) */ +.ep-preview-area { + height: 320px; + min-height: 280px; display: flex; align-items: center; justify-content: center; - background: var(--bg-secondary); - border-radius: var(--radius-sm); + gap: 16px; + padding: 16px; + background: #0a0a14; + border-bottom: 1px solid #222; } -.ep-asset-card-info { - flex: 1; - min-width: 0; -} - -.ep-asset-card-name { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; +/* 手机模型预览 */ +.ep-phone-preview { + width: 150px; + height: 267px; + background: #000; + border-radius: 16px; + border: 2px solid #333; + position: relative; overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; } -.ep-asset-card-meta { - font-size: 11px; - color: var(--text-secondary); - margin-top: 2px; +.ep-phone-status-bar { + height: 20px; + background: #111; display: flex; align-items: center; - gap: 4px; - flex-wrap: wrap; + justify-content: space-between; + padding: 0 10px; + font-size: 9px; + color: #666; } -.ep-asset-card-tags { - display: inline-flex; - gap: 3px; -} - -.ep-asset-card-tag { - padding: 1px 4px; - background: var(--bg-secondary); - border-radius: 3px; - font-size: 10px; -} - -.ep-asset-card-drag-hint { - color: var(--text-secondary); - font-size: 16px; - opacity: 0.4; - flex-shrink: 0; - transition: var(--transition-all); -} - -.ep-asset-card:hover .ep-asset-card-drag-hint { - opacity: 0.8; -} - -.ep-left-footer { - padding: var(--space-sm) var(--space-md); - border-top: 1px solid var(--border-color); - flex-shrink: 0; -} - -.ep-new-template-btn { - width: 100%; - padding: 8px; - border: 1px dashed var(--border-color); - border-radius: var(--radius-md); - background: transparent; - color: var(--text-secondary); - font-size: var(--font-size-sm); - cursor: pointer; - transition: var(--transition-all); -} - -.ep-new-template-btn:hover { - border-color: var(--primary-color); - color: var(--primary-color); - background: var(--primary-soft); -} - -/* 素材 Tab — AssetSelector 容器 */ -.ep-left-media { +.ep-phone-content { flex: 1; - overflow-y: auto; - min-height: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1a1a2e, #0a0a14); + height: calc(100% - 20px); + position: relative; } -.ep-left-media-footer { - padding: var(--space-sm) var(--space-md); - border-top: 1px solid var(--border-color); +.ep-phone-play-btn { + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(108, 92, 231, 0.8); + border: none; + color: #fff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.ep-phone-play-btn:hover { + background: #6c5ce7; + transform: scale(1.1); +} + +.ep-phone-progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: #222; +} + +.ep-phone-progress-fill { + height: 100%; + background: #6c5ce7; + transition: width 0.3s; +} + +.ep-phone-clip-label { + position: absolute; + bottom: 8px; + left: 0; + right: 0; + text-align: center; + font-size: 9px; + color: #888; +} + +/* 封面预览 */ +.ep-cover-preview { + width: 150px; + height: 267px; + background: #13131f; + border-radius: 8px; + border: 1px solid #2a2a4a; + position: relative; + overflow: hidden; flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; } -/* ── 中间时间线 ──────────────────────────────────────────────── */ -.ep-center { +.ep-cover-image { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #1a1a2e, #13131f); + display: flex; + align-items: center; + justify-content: center; + color: #555; + font-size: 11px; +} + +.ep-cover-label { + position: absolute; + bottom: 8px; + left: 0; + right: 0; + text-align: center; + font-size: 9px; + color: #888; +} + +/* 封面方案按钮(竖排4个) */ +.ep-cover-tags { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ep-cover-tag { + padding: 6px 12px; + border-radius: 6px; + font-size: 11px; + background: #13131f; + color: #888; + border: 1px solid #2a2a4a; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.ep-cover-tag:hover { + border-color: #6c5ce7; + color: #ccc; +} + +.ep-cover-tag.active { + background: #6c5ce7; + color: #fff; + border-color: #6c5ce7; +} + +/* ─── 下半部:水平时间线 ─── */ + +.ep-timeline-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; - min-width: 0; - background: var(--bg-primary); + background: #0e0e1a; } -/* 可视化时长条 */ -.ep-timeline-bar { - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; -} - -.ep-timeline-bar-label { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--text-primary); - margin-bottom: 8px; - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.ep-timeline-bar-duration { - font-weight: 400; - color: var(--text-secondary); - font-size: 12px; -} - -.ep-timeline-bar-track { - display: flex; - height: 28px; - background: var(--bg-secondary); - border-radius: var(--radius-sm); - overflow: hidden; - gap: 2px; -} - -.ep-timeline-bar-segment { - height: 100%; - border-radius: 3px; - transition: var(--transition-all); - min-width: 8px; - position: relative; -} - -.ep-timeline-bar-segment:hover { - opacity: 0.85; - transform: scaleY(1.1); -} - -.ep-timeline-bar-empty { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - font-size: 12px; - opacity: 0.6; -} - -/* 时间线头部 */ .ep-timeline-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-md) var(--space-lg); - flex-shrink: 0; + padding: 6px 12px; + border-bottom: 1px solid #1a1a2a; } -.ep-timeline-header h3 { - margin: 0; - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); -} - -/* 片段列表 */ -.ep-timeline-list { - flex: 1; - overflow-y: auto; - padding: 0 var(--space-lg) var(--space-lg); - display: flex; - flex-direction: column; - gap: var(--space-sm); -} - -/* 空状态拖放区 */ -.ep-timeline-empty-drop { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-2xl); - border: 2px dashed var(--border-color); - border-radius: var(--radius-lg); - text-align: center; - color: var(--text-secondary); - transition: var(--transition-all); - min-height: 120px; -} - -.ep-timeline-empty-drop.active { - border-color: var(--primary-color); - background: var(--primary-soft); - color: var(--primary-color); -} - -.ep-timeline-empty-drop .ep-timeline-empty-icon { - font-size: 40px; - margin-bottom: var(--space-sm); - opacity: 0.5; -} - -.ep-timeline-empty-drop p { - margin: 0 0 4px; - font-size: var(--font-size-sm); - font-weight: 500; -} - -.ep-timeline-empty-drop span { +.ep-timeline-title { font-size: 12px; - opacity: 0.7; + font-weight: 600; + color: #fff; } -/* 拖放指示器 */ -.ep-timeline-drop-indicator { - height: 3px; - background: var(--primary-color); - border-radius: 2px; - margin: -2px 0; - animation: ep-drop-pulse 0.6s ease infinite alternate; +.ep-timeline-actions { + display: flex; + gap: 4px; } -@keyframes ep-drop-pulse { - from { - opacity: 0.6; - } - to { - opacity: 1; - } +.ep-timeline-action-btn { + padding: 3px 8px; + background: #13131f; + border: 1px solid #2a2a4a; + border-radius: 4px; + color: #888; + font-size: 11px; + cursor: pointer; + transition: all 0.2s; } -/* 片段卡片 */ -.ep-clip-card { +.ep-timeline-action-btn:hover { + border-color: #6c5ce7; + color: #ccc; +} + +/* 时间标尺 */ +.ep-time-ruler { + height: 20px; + background: #13131f; + border-bottom: 1px solid #1a1a2a; display: flex; align-items: center; - gap: var(--space-sm); - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 10px 12px; + padding: 0 12px; + overflow: hidden; +} + +.ep-time-ruler-inner { + display: flex; + align-items: center; + height: 100%; + position: relative; +} + +.ep-time-mark { + position: absolute; + font-size: 9px; + color: #555; + top: 2px; +} + +.ep-time-mark::after { + content: ""; + position: absolute; + bottom: -4px; + left: 0; + width: 1px; + height: 6px; + background: #333; +} + +/* 水平片段轨道 */ +.ep-clip-track { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + overflow-x: auto; + overflow-y: hidden; +} + +.ep-clip-track::-webkit-scrollbar { + height: 4px; +} + +.ep-clip-track::-webkit-scrollbar-thumb { + background: #333; + border-radius: 2px; +} + +/* 水平片段卡片 */ +.ep-clip-card { + width: 100px; + min-width: 100px; + height: 100px; + background: #13131f; + border: 1px solid #222; + border-radius: 8px; cursor: pointer; - transition: var(--transition-all); - user-select: none; + transition: all 0.2s; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; } .ep-clip-card:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); + border-color: #444; + transform: translateY(-2px); } .ep-clip-card.selected { - border-color: var(--primary-color); - background: var(--primary-soft); - box-shadow: 0 0 0 1px var(--primary-color); + border-color: #6c5ce7; + box-shadow: 0 0 8px rgba(108, 92, 231, 0.3); } .ep-clip-card.drag-over { - border-color: var(--primary-color); - border-style: dashed; - background: var(--primary-soft); + border-color: #6c5ce7; + background: rgba(108, 92, 231, 0.1); } -.ep-clip-card[draggable="true"]:active { - opacity: 0.6; -} - -.ep-clip-drag { - cursor: grab; - color: var(--text-secondary); - font-size: 16px; - user-select: none; - flex-shrink: 0; +.ep-clip-card.dragging { opacity: 0.5; - transition: var(--transition-all); } -.ep-clip-card:hover .ep-clip-drag { - opacity: 1; -} - -.ep-clip-drag:active { - cursor: grabbing; -} - -.ep-clip-index { - width: 26px; - height: 26px; - border-radius: var(--radius-sm); - color: #fff; - font-size: 12px; - font-weight: 700; +.ep-clip-thumbnail { + height: 56px; + background: linear-gradient(135deg, #1a1a2e, #0e0e1a); display: flex; align-items: center; justify-content: center; + font-size: 20px; flex-shrink: 0; } .ep-clip-info { + padding: 4px 6px; flex: 1; - min-width: 0; + display: flex; + flex-direction: column; + justify-content: space-between; } -.ep-clip-info-top { +.ep-clip-name { + font-size: 10px; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ep-clip-duration { + font-size: 9px; + color: #666; +} + +.ep-clip-remove { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(255, 71, 87, 0.8); + border: none; + color: #fff; + font-size: 10px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + line-height: 1; +} + +.ep-clip-card:hover .ep-clip-remove { + display: flex; +} + +/* 空轨道 */ +.ep-track-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #444; + font-size: 12px; + min-height: 100px; +} + +/* 一镜到底提示 */ +.ep-one-take-hint { + padding: 4px 12px; + background: rgba(255, 165, 2, 0.1); + border-bottom: 1px solid rgba(255, 165, 2, 0.2); + color: #ffa502; + font-size: 11px; + text-align: center; +} + +/* ─── 右栏 260px ─── */ + +.ep-right-panel { + width: 260px; + min-width: 260px; + background: #0e0e1a; + border-left: 1px solid #222; + overflow-y: auto; + padding: 12px; +} + +.ep-right-panel::-webkit-scrollbar { + width: 4px; +} + +.ep-right-panel::-webkit-scrollbar-thumb { + background: #333; + border-radius: 2px; +} + +/* 设置区块 */ +.ep-settings-section { + margin-bottom: 16px; +} + +.ep-section-title { + font-size: 12px; + font-weight: 600; + color: #fff; + margin-bottom: 10px; display: flex; align-items: center; gap: 6px; +} + +.ep-section-icon { + font-size: 14px; +} + +/* 表单字段 */ +.ep-field { + margin-bottom: 8px; +} + +.ep-field-label { + display: block; + font-size: 11px; + color: #888; margin-bottom: 4px; } -.ep-clip-type-icon { - font-size: 14px; - flex-shrink: 0; -} - -.ep-clip-script { - font-size: 13px; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -} - -.ep-clip-script-empty { - color: var(--text-secondary); - font-style: italic; - opacity: 0.6; -} - -.ep-clip-info-bottom { - display: flex; - align-items: center; - gap: 6px; -} - -.ep-clip-duration-bar { - flex: 1; - height: 4px; - background: var(--bg-secondary); - border-radius: 2px; - overflow: hidden; - max-width: 100px; -} - -.ep-clip-duration-fill { - height: 100%; - border-radius: 2px; - transition: width 0.2s ease; -} - -.ep-clip-duration-text { +.ep-form-input, +.ep-form-select { + width: 100%; + height: 28px; + background: #13131f; + border: 1px solid #2a2a4a; + border-radius: 4px; + padding: 0 8px; + color: #e0e0e0; font-size: 11px; - color: var(--text-secondary); - font-weight: 600; - white-space: nowrap; + outline: none; + transition: border-color 0.2s; } -.ep-clip-asset-badge { - font-size: 12px; - opacity: 0.7; +.ep-form-input:focus, +.ep-form-select:focus { + border-color: #6c5ce7; } -.ep-clip-transition-badge { - font-size: 10px; - color: var(--primary-color); - background: var(--primary-soft); - padding: 1px 6px; - border-radius: 999px; - white-space: nowrap; -} - -.ep-clip-actions { - flex-shrink: 0; -} - -.ep-clip-action-btn { - width: 24px; - height: 24px; - border: none; - background: transparent; - color: var(--text-secondary); - font-size: 14px; +.ep-form-select { cursor: pointer; - border-radius: var(--radius-sm); +} + +/* Toggle 开关 */ +.ep-toggle-row { display: flex; align-items: center; - justify-content: center; - transition: var(--transition-all); - opacity: 0; + justify-content: space-between; + margin-bottom: 8px; } -.ep-clip-card:hover .ep-clip-action-btn { - opacity: 1; +.ep-toggle-label { + font-size: 11px; + color: #888; } -.ep-clip-action-btn:hover { - background: var(--error-soft); - color: var(--error-color); +.ep-toggle { + width: 36px; + height: 20px; + background: #2a2a4a; + border-radius: 10px; + position: relative; + cursor: pointer; + transition: background 0.2s; } -/* ============================================================ - 右侧片段属性面板 - ============================================================ */ -.ep-right { - width: 300px; - flex-shrink: 0; +.ep-toggle.active { + background: #6c5ce7; +} + +.ep-toggle-knob { + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + position: absolute; + top: 2px; + left: 2px; + transition: left 0.2s; +} + +.ep-toggle.active .ep-toggle-knob { + left: 18px; +} + +/* 滑块 */ +.ep-slider-row { display: flex; - flex-direction: column; - background: var(--bg-primary); - border-left: 1px solid var(--border-color); - overflow-y: auto; -} - -.ep-right-section { - padding: var(--space-md); - border-bottom: 1px solid var(--border-color); -} - -.ep-right-section:last-child { - border-bottom: none; -} - -.ep-right-section h3 { - margin: 0 0 var(--space-md); - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); -} - -/* 空状态 */ -.ep-clip-props-empty { - display: flex; - flex-direction: column; align-items: center; - justify-content: center; - padding: var(--space-2xl); - text-align: center; - color: var(--text-secondary); + gap: 8px; +} + +.ep-slider { flex: 1; + -webkit-appearance: none; + height: 4px; + background: #2a2a4a; + border-radius: 2px; + outline: none; } -.ep-clip-props-empty-icon { - font-size: 48px; - margin-bottom: var(--space-md); - opacity: 0.4; +.ep-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: #6c5ce7; + border-radius: 50%; + cursor: pointer; } -.ep-clip-props-empty h3 { - margin: 0 0 4px; - font-size: var(--font-size-base); - color: var(--text-primary); +.ep-slider-value { + font-size: 11px; + color: #888; + min-width: 30px; + text-align: right; } -.ep-clip-props-empty p { - margin: 0; - font-size: var(--font-size-sm); -} - -.ep-clip-props-summary { - margin-top: var(--space-lg); +/* 样式按钮组 */ +.ep-style-btns { display: flex; - gap: var(--space-lg); -} - -.ep-clip-props-summary-item { - display: flex; - flex-direction: column; - align-items: center; gap: 4px; } -.ep-clip-props-summary-label { - font-size: 12px; - color: var(--text-secondary); -} - -.ep-clip-props-summary-value { - font-size: 20px; - font-weight: 700; - color: var(--text-primary); -} - -/* 片段信息头 */ -.ep-clip-props-header { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.ep-clip-props-header-icon { - font-size: 28px; -} - -.ep-clip-props-header h3 { - margin: 0 0 2px; - font-size: var(--font-size-base); -} - -.ep-clip-props-header-type { - font-size: 12px; - color: var(--text-secondary); -} - -/* 字段 */ -.ep-clip-props-field { - margin-bottom: var(--space-sm); -} - -.ep-clip-props-field:last-child { - margin-bottom: 0; -} - -.ep-clip-props-field-hint { - font-size: 11px; - color: var(--text-secondary); - text-align: right; - margin-top: 4px; -} - -.ep-clip-props-label { - display: block; - font-size: var(--font-size-sm); - color: var(--text-secondary); - margin-bottom: 6px; -} - -/* 文案文本框 */ -.ep-clip-props-textarea { - width: 100%; - padding: 8px 12px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); - color: var(--text-primary); - font-size: var(--font-size-sm); - font-family: inherit; - resize: vertical; - transition: var(--transition-all); - line-height: 1.5; -} - -.ep-clip-props-textarea:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 2px var(--primary-soft); -} - -.ep-clip-props-textarea::placeholder { - color: var(--text-secondary); - opacity: 0.6; -} - -/* 时长控制 */ -.ep-clip-props-duration-control { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.ep-clip-props-range { - flex: 1; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: var(--bg-secondary); - border-radius: 2px; - outline: none; -} - -.ep-clip-props-range::-webkit-slider-thumb { - -webkit-appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--primary-color); - cursor: pointer; - border: 2px solid var(--bg-primary); - box-shadow: var(--shadow-sm); -} - -.ep-clip-props-range::-moz-range-thumb { - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--primary-color); - cursor: pointer; - border: 2px solid var(--bg-primary); - box-shadow: var(--shadow-sm); -} - -.ep-clip-props-duration-value { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - min-width: 36px; - text-align: right; -} - -/* 转场效果网格 */ -.ep-clip-props-transition-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; -} - -.ep-clip-props-transition-btn { - padding: 6px 8px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-primary); - color: var(--text-secondary); +.ep-style-btn { + width: 28px; + height: 28px; + background: #13131f; + border: 1px solid #2a2a4a; + border-radius: 4px; + color: #888; font-size: 12px; cursor: pointer; - transition: var(--transition-all); - text-align: center; -} - -.ep-clip-props-transition-btn:hover { - border-color: var(--primary-color); - color: var(--primary-color); -} - -.ep-clip-props-transition-btn.active { - background: var(--primary-soft); - border-color: var(--primary-color); - color: var(--primary-color); - font-weight: 600; -} - -/* 关联素材 */ -.ep-clip-props-asset-linked { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: 8px 12px; - background: var(--bg-secondary); - border-radius: var(--radius-md); - border: 1px solid var(--border-color); -} - -.ep-clip-props-asset-icon { - font-size: 20px; -} - -.ep-clip-props-asset-name { - flex: 1; - font-size: 13px; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ep-clip-props-asset-unlink { - width: 20px; - height: 20px; - border: none; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; + transition: all 0.2s; +} + +.ep-style-btn:hover { + border-color: #6c5ce7; + color: #ccc; +} + +.ep-style-btn.active { + background: rgba(108, 92, 231, 0.2); + border-color: #6c5ce7; + color: #6c5ce7; +} + +/* AI推荐按钮 */ +.ep-ai-recommend-btn { + width: 100%; + padding: 8px; + background: linear-gradient(135deg, #6c5ce7, #a29bfe); + border: none; + border-radius: 6px; + color: #fff; font-size: 12px; - transition: var(--transition-all); + cursor: pointer; + transition: all 0.2s; + margin-top: 8px; } -.ep-clip-props-asset-unlink:hover { - background: var(--error-soft); - color: var(--error-color); +.ep-ai-recommend-btn:hover { + opacity: 0.9; + transform: translateY(-1px); } -.ep-clip-props-asset-empty { +/* 片段详情卡片 */ +.ep-clip-detail { + background: #13131f; + border: 1px solid #2a2a4a; + border-radius: 8px; padding: 12px; +} + +.ep-clip-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.ep-clip-detail-name { + font-size: 12px; + font-weight: 600; + color: #fff; +} + +.ep-clip-detail-type { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(108, 92, 231, 0.2); + color: #6c5ce7; +} + +.ep-clip-detail-field { + margin-bottom: 8px; +} + +.ep-clip-detail-label { + font-size: 10px; + color: #666; + margin-bottom: 3px; +} + +.ep-clip-detail-value { + font-size: 11px; + color: #ccc; +} + +.ep-clip-detail-row { + display: flex; + gap: 8px; + align-items: center; +} + +.ep-duration-input { + width: 60px; + height: 24px; + background: #0a0a14; + border: 1px solid #2a2a4a; + border-radius: 4px; + padding: 0 6px; + color: #e0e0e0; + font-size: 11px; + outline: none; text-align: center; - color: var(--text-secondary); - font-size: var(--font-size-sm); - background: var(--bg-secondary); - border-radius: var(--radius-md); - border: 1px dashed var(--border-color); } -.ep-clip-props-asset-empty p { - margin: 0; +.ep-duration-input:focus { + border-color: #6c5ce7; } -/* ============================================================ - 保存 / 生成弹窗 - ============================================================ */ +/* ═══════════════════════════════════════ + 第4行:底栏 40px + ═══════════════════════════════════════ */ + +.ep-status-bar { + height: 40px; + min-height: 40px; + background: #161625; + border-top: 1px solid #222; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + font-size: 11px; + color: #888; +} + +.ep-status-left, +.ep-status-right { + display: flex; + align-items: center; + gap: 8px; +} + +.ep-status-sep { + color: #333; +} + +/* ═══════════════════════════════════════ + 弹窗补充样式 + ═══════════════════════════════════════ */ + .ep-modal-field { - margin-bottom: var(--space-md); -} - -.ep-modal-field:last-child { - margin-bottom: 0; + margin-bottom: 12px; } .ep-modal-field label { display: block; - margin-bottom: 6px; - font-size: var(--font-size-sm); - font-weight: 500; - color: var(--text-secondary); + font-size: 12px; + color: #888; + margin-bottom: 4px; } .ep-modal-info { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: 10px 14px; - background: var(--bg-secondary); - border-radius: var(--radius-md); - font-size: var(--font-size-sm); - color: var(--text-secondary); - margin-bottom: var(--space-md); + padding: 8px 12px; + background: #13131f; + border-radius: 6px; + font-size: 12px; + color: #888; + margin-top: 8px; } -.ep-modal-info strong { - color: var(--text-primary); -} - -/* ============================================================ - 简易 Toast 提示 - ============================================================ */ -.ep-toast { - position: fixed; - top: 24px; - left: 50%; - transform: translateX(-50%); - padding: 10px 24px; - border-radius: var(--radius-md); - font-size: var(--font-size-sm); - font-weight: 500; - z-index: 1000; - animation: ep-toast-in 0.3s ease; - pointer-events: none; - box-shadow: var(--shadow-md); -} - -.ep-toast--success { - background: var(--success-soft); - color: var(--success-color); - border: 1px solid var(--success-border); -} - -.ep-toast--error { - background: var(--error-soft); - color: var(--error-color); - border: 1px solid var(--error-border); -} - -.ep-toast--warning { - background: var(--warning-soft); - color: var(--warning-color); - border: 1px solid var(--warning-border); -} - -@keyframes ep-toast-in { - from { - opacity: 0; - transform: translateX(-50%) translateY(-12px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } -} - -/* ============================================================ - 响应式 - ============================================================ */ -@media (max-width: 1200px) { - .ep-right { - width: 260px; - } - - .ep-left { - width: 240px; - } -} - -@media (max-width: 900px) { - .ep-body { - flex-direction: column; - } - - .ep-left, - .ep-right { - width: 100%; - max-height: 220px; - border-right: none; - border-left: none; - border-bottom: 1px solid var(--border-color); - } - - .ep-right { - border-bottom: none; - border-top: 1px solid var(--border-color); - } - - .ep-toolbar { - flex-wrap: wrap; - gap: var(--space-sm); - } - - .ep-mode-switch { - order: 3; - width: 100%; - justify-content: center; - } -} - -@media (max-width: 768px) { - .ep-left, - .ep-right { - max-height: 180px; - } - - .ep-timeline-bar { - padding: var(--space-sm) var(--space-md); - } - - .ep-timeline-header { - padding: var(--space-sm) var(--space-md); - } - - .ep-timeline-list { - padding: 0 var(--space-md) var(--space-md); - } - - .ep-clip-props-transition-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 480px) { - .ep-toolbar { - padding: var(--space-sm) var(--space-md); - } - - .ep-mode-btn { - padding: 4px 12px; - font-size: var(--font-size-xs); - } - - .ep-clip-card { - padding: 8px 10px; - gap: 6px; - } - - .ep-asset-card { - padding: 8px 10px; - } -} - -/* ============================================================ - 生成进度弹窗 — 任务 2.17 - ============================================================ */ - -/* ── setup 阶段 ─────────────────────────────────────────────── */ - +/* 生成弹窗 */ .ep-gen-setup { - display: flex; - flex-direction: column; - gap: var(--space-md); + padding: 8px 0; } .ep-gen-field-label { - font-size: var(--font-size-sm); - font-weight: 500; - color: var(--text-primary); - margin-bottom: -4px; + display: block; + font-size: 12px; + color: #888; + margin-bottom: 6px; +} + +.ep-gen-duration-input { + width: 100%; + height: 32px; + background: #13131f; + border: 1px solid #2a2a4a; + border-radius: 6px; + padding: 0 10px; + color: #e0e0e0; + font-size: 13px; + outline: none; + margin-bottom: 12px; +} + +.ep-gen-duration-input:focus { + border-color: #6c5ce7; } .ep-gen-estimate { - font-size: var(--font-size-sm); - color: var(--text-secondary); + font-size: 12px; + color: #888; padding: 8px 12px; - background: var(--bg-secondary); - border-radius: var(--radius-md); + background: #13131f; + border-radius: 6px; } -.ep-gen-estimate strong { - color: var(--primary-color); - font-weight: 600; -} - -/* ── progress 阶段 ──────────────────────────────────────────── */ - .ep-gen-progress { display: flex; flex-direction: column; align-items: center; - gap: var(--space-md); - padding: var(--space-lg) 0; + padding: 16px 0; + gap: 16px; } .ep-gen-progress-ring-wrap { @@ -1194,7 +1103,7 @@ .ep-gen-progress-ring-bg { fill: none; - stroke: var(--border-color); + stroke: #2a2a4a; stroke-width: 8; } @@ -1202,9 +1111,7 @@ fill: none; stroke-width: 8; stroke-linecap: round; - transition: - stroke-dashoffset 0.5s ease, - stroke 0.3s ease; + transition: stroke-dashoffset 0.5s ease, stroke 0.3s; } .ep-gen-progress-pct { @@ -1214,20 +1121,17 @@ transform: translate(-50%, -50%); font-size: 24px; font-weight: 700; - font-family: var(--font-mono, monospace); } .ep-gen-step-text { - font-size: var(--font-size-base); - color: var(--text-primary); - font-weight: 500; - text-align: center; + font-size: 14px; + color: #ccc; } .ep-gen-progress-bar { width: 100%; height: 6px; - background: var(--bg-secondary); + background: #2a2a4a; border-radius: 3px; overflow: hidden; } @@ -1235,58 +1139,43 @@ .ep-gen-progress-bar-fill { height: 100%; border-radius: 3px; - transition: - width 0.5s ease, - background-color 0.3s ease; + transition: width 0.5s ease; } .ep-gen-task-id { - font-size: var(--font-size-xs); - color: var(--text-muted, #9ca3af); - font-family: var(--font-mono, monospace); + font-size: 11px; + color: #555; } -/* ── 结果阶段(completed / failed)──────────────────────────── */ - .ep-gen-result { display: flex; flex-direction: column; align-items: center; - gap: var(--space-md); - padding: var(--space-lg) 0; - text-align: center; + padding: 24px 0; + gap: 12px; } .ep-gen-result-icon { font-size: 48px; - line-height: 1; } .ep-gen-result-title { - font-size: var(--font-size-lg); + font-size: 18px; font-weight: 600; - color: var(--text-primary); + color: #fff; } .ep-gen-result-msg { - font-size: var(--font-size-sm); - color: var(--text-secondary); - max-width: 320px; - line-height: 1.5; + font-size: 12px; + color: #888; } .ep-gen-result-msg--error { - color: var(--error-color, #ef4444); -} - -.ep-gen-result-count { - font-size: var(--font-size-sm); - color: var(--success-color, #10b981); - font-weight: 500; + color: #ff4757; } .ep-gen-result-actions { display: flex; - gap: var(--space-sm); - margin-top: var(--space-sm); + gap: 8px; + margin-top: 8px; } diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index c9df24ec2..39e71dd14 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -1,638 +1,565 @@ /** - * 剪辑计划编辑器 — V21 设计系统(完整版) - * 任务 2.14:三栏布局 — 素材面板 / 时间线 / 片段属性 - * 支持拖拽排序、素材关联、转场编辑、剪辑计划 CRUD + * 剪辑计划编辑器 — V8 原型 1:1 还原 + * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) */ -import React, { useState, useEffect, useCallback } from "react"; -import "./EditingPlanner.css"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui"; +import React, { useState, useCallback, useEffect } from "react"; import { useSearchParams } from "react-router-dom"; -import { - getEditingTemplates, - getTemplateCategories, - createEditingTemplate, - updateEditingTemplate, - MODE_LABELS, - type EditingTemplate, - type TemplateMode, - type SaveTemplatePayload, +// 简易消息工具(UI 库未导出 message) +const msg = { + success: (s: string) => console.log(`✅ ${s}`), + error: (s: string) => console.error(`❌ ${s}`), + warning: (s: string) => console.warn(`⚠️ ${s}`), + info: (s: string) => console.info(`ℹ️ ${s}`), +}; +import type { + EditingTemplate, + TemplateCategory, + TemplateMode, + SaveTemplatePayload, } from "@/api/editingPlanner"; import { - createEditPlan, - updateEditPlan, - type EditPlanClip, - type MediaAsset, -} from "@/api/editPlans"; -import { - createGenerationTask, - getTask, - retryTask, - type TaskItem, -} from "@/api/tasks"; + getEditingTemplates, + getEditingTemplate, + updateEditingTemplate, + getTemplateCategories, + generateFromTemplate, + MODE_LABELS, +} from "@/api/editingPlanner"; +import type { EditPlanClip, MediaAsset } from "@/api/editPlans"; +import { getMediaAssets, MATERIAL_TYPE_LABELS } from "@/api/editPlans"; +import type { TaskItem } from "@/api/tasks"; +import { createGenerationTask, getTask, retryTask } from "@/api/tasks"; -/* ── 子组件 ── */ import MediaPanel from "./components/MediaPanel"; +import PreviewPlayer from "./components/PreviewPlayer"; import TimelinePanel from "./components/TimelinePanel"; import ClipPropertiesPanel from "./components/ClipPropertiesPanel"; -import PreviewPlayer from "./components/PreviewPlayer"; import SaveModal from "./components/SaveModal"; -import GenerationProgressModal, { - type GenPhase, -} from "./components/GenerationProgressModal"; +import GenerationProgressModal from "./components/GenerationProgressModal"; +import type { GenPhase } from "./components/GenerationProgressModal"; +import "./EditingPlanner.css"; + +/* ──────────── 类型 ──────────── */ + +interface ClipData extends EditPlanClip { + name: string; + thumbnail?: string; + assetName?: string; +} /* ──────────── 常量 ──────────── */ -let _clipId = 0; -const newClipId = () => `clip-new-${++_clipId}`; +const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [ + { key: "pip", label: "画中画", icon: "🖼️" }, + { key: "voice_over", label: "人物口播", icon: "🎙️" }, + { key: "one_take", label: "一镜到底", icon: "🎥" }, + { key: "voice_pip", label: "口播+画中画", icon: "🎭" }, +]; -let _segId = 0; -const newSegId = () => `seg-new-${++_segId}`; +const COVER_SCHEMES = [ + { key: "ai_frame", label: "AI选帧" }, + { key: "manual", label: "手动选" }, + { key: "upload", label: "上传" }, + { key: "ai_reselect", label: "AI重选" }, +]; -/* ──────────── 简易 Toast ──────────── */ -type ToastType = "success" | "error" | "warning"; - -const useToast = () => { - const [toast, setToast] = useState<{ - message: string; - type: ToastType; - } | null>(null); - - const show = useCallback((message: string, type: ToastType = "success") => { - setToast({ message, type }); - setTimeout(() => setToast(null), 2500); - }, []); - - const ToastNode = toast ? ( -
{toast.message}
- ) : null; - - return { show, ToastNode }; -}; +const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]; /* ──────────── 组件 ──────────── */ const EditingPlanner: React.FC = () => { - const queryClient = useQueryClient(); const [searchParams] = useSearchParams(); - const { show: showToast, ToastNode } = useToast(); + const urlTemplateId = searchParams.get("templateId") || ""; - /* ── URL 参数 ── */ - const urlTemplateId = searchParams.get("template"); - const urlGenerate = searchParams.get("generate"); - - /* ── 数据查询:模板 ── */ - const { data: templates = [], isLoading: tplLoading } = useQuery< - EditingTemplate[] - >({ - queryKey: ["editing-templates"], - queryFn: () => getEditingTemplates(), - }); - - const { data: categories = [] } = useQuery({ - queryKey: ["template-categories"], - queryFn: getTemplateCategories, - }); - - /* ── 编辑器状态 ── */ + /* ── 模板列表 ── */ + const [templates, setTemplates] = useState([]); + const [categories, setCategories] = useState([]); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [loadedTemplateId, setLoadedTemplateId] = useState( + urlTemplateId || null, + ); const [currentMode, setCurrentMode] = useState("pip"); - const [clips, setClips] = useState([ - { - id: newClipId(), - template_segment_id: newSegId(), - material_type: "video", - script_text: "", - duration: 10, - transition: { type: "none", duration: 0 }, - order: 0, - }, - ]); - const [selectedClipId, setSelectedClipId] = useState(null); - const [loadedTemplateId, setLoadedTemplateId] = useState(null); - const [editPlanId, setEditPlanId] = useState(null); - /* ── UI 状态 ── */ + /* ── 片段 ── */ + const [clips, setClips] = useState([]); + const [selectedClipId, setSelectedClipId] = useState(null); + + /* ── 封面方案 ── */ + const [currentCoverScheme, setCurrentCoverScheme] = + useState("ai_frame"); + + /* ── 左栏筛选 ── */ + const [currentFilter, setCurrentFilter] = useState("全部"); + const [searchQuery, setSearchQuery] = useState(""); + + /* ── 标题/字幕/BGM 设置 ── */ + const [titleSettings, setTitleSettings] = useState({ + aiAutoSelect: true, + title: "", + position: "top", + font: "思源黑体", + size: 24, + bold: false, + italic: false, + stroke: true, + shadow: true, + }); + + const [subtitleSettings, setSubtitleSettings] = useState({ + enabled: true, + position: "bottom", + font: "思源黑体", + size: 16, + animation: "none", + }); + + const [bgmSettings, setBgmSettings] = useState({ + music: "none", + }); + + /* ── 保存弹窗 ── */ const [saveModalOpen, setSaveModalOpen] = useState(false); - const [generateModalOpen, setGenerateModalOpen] = useState(false); - const [genPhase, setGenPhase] = useState("setup"); - const [taskId, setTaskId] = useState(null); const [draftName, setDraftName] = useState(""); const [draftCategory, setDraftCategory] = useState(""); const [draftTags, setDraftTags] = useState(""); + const [saveLoading, setSaveLoading] = useState(false); + + /* ── 生成弹窗 ── */ + const [genModalOpen, setGenModalOpen] = useState(false); + const [genPhase, setGenPhase] = useState("setup"); + const [genTask, setGenTask] = useState(null); + const [genSubmitting, setGenSubmitting] = useState(false); const [voiceoverDuration, setVoiceoverDuration] = useState( null, ); - /* ── 自动加载 URL 指定的模板 ── */ - useEffect(() => { - if (urlTemplateId && templates.length > 0 && !loadedTemplateId) { - const tpl = templates.find((t) => t.id === urlTemplateId); - if (tpl) { - loadTemplate(tpl); - if (urlGenerate === "1") { - setGenerateModalOpen(true); - } - } + /* ── 素材库 ── */ + const [mediaAssets, setMediaAssets] = useState([]); + + /* ── 播放 ── */ + const [isPlaying, setIsPlaying] = useState(false); + + /* ──────────── 加载 ──────────── */ + + const loadTemplates = useCallback(async () => { + setLoadingTemplates(true); + try { + const [tpls, cats, assets] = await Promise.all([ + getEditingTemplates(), + getTemplateCategories(), + getMediaAssets(), + ]); + setTemplates(tpls); + setCategories(cats); + setMediaAssets(assets); + } catch { + msg.error("加载模板失败"); + } finally { + setLoadingTemplates(false); } - }, [urlTemplateId, templates, loadedTemplateId, urlGenerate]); + }, []); - /* ── Mutations:模板 ── */ - const createMutation = useMutation({ - mutationFn: createEditingTemplate, - onSuccess: () => { - showToast("模板已保存", "success"); - queryClient.invalidateQueries({ queryKey: ["editing-templates"] }); - setSaveModalOpen(false); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - showToast("保存失败", "error"); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: SaveTemplatePayload }) => - updateEditingTemplate(id, data), - onSuccess: () => { - showToast("模板已更新", "success"); - queryClient.invalidateQueries({ queryKey: ["editing-templates"] }); - setSaveModalOpen(false); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - showToast("保存失败", "error"); - }, - }); - - /* ── Mutations:剪辑计划 ── */ - const savePlanMutation = useMutation({ - mutationFn: createEditPlan, - onSuccess: (plan) => { - showToast("剪辑计划已保存", "success"); - setEditPlanId(plan.id); - setSaveModalOpen(false); - }, - onError: () => showToast("保存失败", "error"), - }); - - const updatePlanMutation = useMutation({ - mutationFn: ({ - id, - data, - }: { - id: string; - data: { - name?: string; - config?: Record; - total_duration?: number; - }; - }) => updateEditPlan(id, data), - onSuccess: () => { - showToast("剪辑计划已更新", "success"); - }, - onError: () => showToast("更新失败", "error"), - }); - - /* ── 任务轮询 ── */ - const { data: taskData } = useQuery({ - queryKey: ["task", taskId], - queryFn: () => getTask(taskId!), - enabled: !!taskId && genPhase === "progress", - refetchInterval: (query) => { - const data = query.state.data; - if (!data) return 2000; - // 终态停止轮询 - if (data.status === "completed" || data.status === "failed") return false; - return 2000; - }, - }); - - /* 监听任务状态变化,自动切换阶段 */ useEffect(() => { - if (!taskData) return; - if (taskData.status === "completed") { - setGenPhase("completed"); - } else if (taskData.status === "failed") { - setGenPhase("failed"); - } - }, [taskData]); + loadTemplates(); + }, [loadTemplates]); - const generateMutation = useMutation({ - mutationFn: ({ templateId }: { templateId: string; duration: number }) => - createGenerationTask({ - template_id: templateId, - asset_ids: clips - .filter((c) => c.media_asset_id) - .map((c) => c.media_asset_id!), - title_ids: [], - voice_ids: clips - .filter((c) => c.material_type === "voiceover" && c.media_asset_id) - .map((c) => c.media_asset_id!), - }), - onSuccess: (data) => { - setTaskId(data.id); - setGenPhase("progress"); - showToast("生成任务已提交", "success"); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - showToast("生成失败", "error"); - setGenPhase("setup"); - }, + /* 加载模板详情 */ + useEffect(() => { + if (!loadedTemplateId) return; + getEditingTemplate(loadedTemplateId) + .then((tpl) => { + if (!tpl) return; + setCurrentMode(tpl.mode); + const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({ + id: seg.id || `seg-${idx}`, + template_segment_id: seg.id || `seg-${idx}`, + name: `片段${idx + 1} · ${MATERIAL_TYPE_LABELS[seg.material_type || "video"] || "视频"}`, + material_type: + (seg.material_type as ClipData["material_type"]) || "video", + script_text: "", + duration: (seg.duration_min + seg.duration_max) / 2, + order: seg.segment_order, + assetName: undefined, + transition: { type: "none", duration: 0 }, + })); + setClips(mapped); + + setTitleSettings((prev) => ({ + ...prev, + aiAutoSelect: tpl.title_config.ai_auto_select, + title: tpl.title_config.content, + position: tpl.title_config.position, + font: tpl.title_config.font_preset, + size: tpl.title_config.font_size, + })); + setSubtitleSettings({ + enabled: tpl.subtitle_config.enabled, + position: tpl.subtitle_config.position, + font: tpl.subtitle_config.font, + size: tpl.subtitle_config.size, + animation: tpl.subtitle_config.animation, + }); + setBgmSettings({ + music: tpl.bgm_config.music_id || "none", + }); + setDraftName(tpl.name); + setDraftCategory(tpl.category); + setDraftTags(tpl.tags.join(", ")); + }) + .catch(() => msg.error("加载模板详情失败")); + }, [loadedTemplateId]); + + /* ──────────── 计算 ──────────── */ + + const currentTemplate = templates.find((t) => t.id === loadedTemplateId); + const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0); + const selectedClip = clips.find((c) => c.id === selectedClipId) || null; + + const filteredTemplates = templates.filter((t) => { + if (currentFilter !== "全部" && t.category !== currentFilter) return false; + if ( + searchQuery && + !t.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + return false; + return true; }); - const saving = - createMutation.isPending || - updateMutation.isPending || - savePlanMutation.isPending || - updatePlanMutation.isPending; + /* ──────────── 事件 ──────────── */ - /* ──────────── 片段操作 ──────────── */ - - const handleSelectClip = useCallback((clipId: string | null) => { - setSelectedClipId(clipId); - }, []); - - const handleUpdateClip = useCallback( - (clipId: string, updates: Partial) => { - setClips((prev) => - prev.map((c) => (c.id === clipId ? { ...c, ...updates } : c)), - ); - }, - [], - ); - - const handleRemoveClip = useCallback( - (clipId: string) => { - setClips((prev) => { - const next = prev - .filter((c) => c.id !== clipId) - .map((c, i) => ({ ...c, order: i })); - return next; - }); - if (selectedClipId === clipId) { - setSelectedClipId(null); - } - }, - [selectedClipId], - ); - - const handleReorderClips = useCallback((fromIdx: number, toIdx: number) => { - setClips((prev) => { - const next = [...prev]; - const [moved] = next.splice(fromIdx, 1); - next.splice(toIdx, 0, moved); - return next.map((c, i) => ({ ...c, order: i })); - }); - }, []); - - const handleAssetDrop = useCallback( - (asset: MediaAsset, insertIdx: number) => { - const newClip: EditPlanClip = { - id: newClipId(), - template_segment_id: newSegId(), - media_asset_id: asset.id, - material_type: asset.type as EditPlanClip["material_type"], - script_text: "", - duration: asset.duration || 10, - transition: { type: "none", duration: 0 }, - order: insertIdx, - }; - setClips((prev) => { - const next = [...prev]; - next.splice(insertIdx, 0, newClip); - return next.map((c, i) => ({ ...c, order: i })); - }); - setSelectedClipId(newClip.id); - showToast(`已添加素材: ${asset.name}`, "success"); - }, - [showToast], - ); - - const handleAddClip = useCallback(() => { - const newClip: EditPlanClip = { - id: newClipId(), - template_segment_id: newSegId(), - material_type: "video", - script_text: "", - duration: 10, - transition: { type: "none", duration: 0 }, - order: clips.length, - }; - setClips((prev) => [...prev, newClip]); - setSelectedClipId(newClip.id); - }, [clips.length]); - - const handleAssetDragStart = useCallback((_asset: MediaAsset) => { - // 素材拖拽开始时的回调(可用于高亮时间线等) - }, []); - - /* ── 批量添加素材到时间线 ── */ - const handleBatchAddAssets = useCallback( - (assets: MediaAsset[], insertIdx?: number) => { - const at = insertIdx ?? clips.length; - const newClips: EditPlanClip[] = assets.map((asset, i) => ({ - id: newClipId(), - template_segment_id: newSegId(), - media_asset_id: asset.id, - material_type: asset.type as EditPlanClip["material_type"], - script_text: "", - duration: asset.duration || 10, - transition: { type: "none", duration: 0 }, - order: at + i, - })); - setClips((prev) => { - const next = [...prev]; - next.splice(at, 0, ...newClips); - return next.map((c, i) => ({ ...c, order: i })); - }); - if (newClips.length > 0) { - setSelectedClipId(newClips[0].id); - } - showToast(`已批量添加 ${assets.length} 个素材`, "success"); - }, - [clips.length, showToast], - ); - - /* ──────────── 模式切换 ──────────── */ + const handleLoadTemplate = (templateId: string) => { + setLoadedTemplateId(templateId); + setSelectedClipId(null); + }; const handleModeChange = (mode: TemplateMode) => { setCurrentMode(mode); }; - /* ──────────── 模板操作 ──────────── */ - - const loadTemplate = (tpl: EditingTemplate) => { - setLoadedTemplateId(tpl.id); - setCurrentMode(tpl.mode); - // 将模板片段转换为剪辑片段 - const newClips: EditPlanClip[] = tpl.segments.map((seg, i) => ({ - id: newClipId(), - template_segment_id: seg.id || `seg-${i}`, - material_type: - (seg.material_type as EditPlanClip["material_type"]) || "video", - script_text: "", - duration: Math.round((seg.duration_min + seg.duration_max) / 2), - transition: { type: "none", duration: 0 }, - order: i, - })); - setClips(newClips); - setSelectedClipId(null); + const handleClipSelect = (clipId: string) => { + setSelectedClipId(clipId); }; - const resetEditor = () => { - setLoadedTemplateId(null); - setEditPlanId(null); - setCurrentMode("pip"); - setClips([ - { - id: newClipId(), - template_segment_id: newSegId(), - material_type: "video", - script_text: "", - duration: 10, - transition: { type: "none", duration: 0 }, - order: 0, - }, - ]); - setSelectedClipId(null); - }; - - /* ──────────── 保存/生成 ──────────── */ - - const openSaveModal = () => { - if (clips.length === 0) { - showToast("请至少添加一个片段", "warning"); - return; - } - setDraftName( - loadedTemplateId - ? templates.find((t) => t.id === loadedTemplateId)?.name || "" - : "", - ); - setDraftCategory( - loadedTemplateId - ? templates.find((t) => t.id === loadedTemplateId)?.category || "" - : "", - ); - setDraftTags( - loadedTemplateId - ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(", ") || - "" - : "", - ); - setSaveModalOpen(true); - }; - - const handleSave = () => { - if (!draftName.trim()) { - showToast("请输入名称", "warning"); - return; - } - - // 保存剪辑计划 — 字段严格匹配后端 Schema - if (editPlanId) { - updatePlanMutation.mutate({ - id: editPlanId, - data: { - name: draftName.trim(), - config: { clips }, - }, - }); - } else { - const totalDuration = clips.reduce((s, c) => s + c.duration, 0); - savePlanMutation.mutate({ - template_id: loadedTemplateId || "default", - name: draftName.trim(), - config: { - mode: currentMode, - clips: clips.map(({ id: _id, ...rest }) => rest), - }, - total_duration: totalDuration, - }); - } - - // 同时保存模板(如果有 loadedTemplateId) - if (loadedTemplateId) { - const estimatedDuration = clips.reduce((s, c) => s + c.duration, 0); - const payload: SaveTemplatePayload = { - name: draftName.trim(), - mode: currentMode, - category: draftCategory, - tags: draftTags - .split(/[,,]/) - .map((t) => t.trim()) - .filter(Boolean), - title_config: { - ai_auto_select: true, - content: "", - font_preset: "思源黑体", - font_color: "#ffffff", - font_size: 32, - position: "top", - }, - subtitle_config: { - enabled: true, - position: "bottom", - font: "思源黑体", - color: "#ffffff", - size: 24, - animation: "fade", - }, - bgm_config: { enabled: false, music_id: "" }, - estimated_duration: estimatedDuration, - segments: clips.map((c) => ({ - id: c.template_segment_id, - segment_order: c.order + 1, - duration_min: Math.max(1, c.duration - 3), - duration_max: c.duration + 3, - material_type: - c.material_type === "voiceover" ? null : c.material_type, - })), - }; - updateMutation.mutate({ id: loadedTemplateId, data: payload }); - } - }; - - const handleGenerate = () => { - if (!loadedTemplateId) { - showToast("请先选择模板", "warning"); - return; - } - setGenPhase("setup"); - setTaskId(null); - setGenerateModalOpen(true); - }; - - const doGenerate = () => { - if (!voiceoverDuration || voiceoverDuration <= 0) { - showToast("请输入配音时长", "warning"); - return; - } - generateMutation.mutate({ - templateId: loadedTemplateId!, - duration: voiceoverDuration, + const handleClipReorder = (fromIdx: number, toIdx: number) => { + setClips((prev) => { + const updated = [...prev]; + const [moved] = updated.splice(fromIdx, 1); + updated.splice(toIdx, 0, moved); + return updated.map((c, i) => ({ ...c, order: i })); }); }; - const handleRetry = () => { - if (!taskId) return; - retryTask(taskId) - .then(() => { - setGenPhase("progress"); - showToast("任务已重新提交", "success"); - }) - .catch(() => { - showToast("重试失败", "error"); - }); + const handleClipRemove = (clipId: string) => { + setClips((prev) => prev.filter((c) => c.id !== clipId)); + if (selectedClipId === clipId) setSelectedClipId(null); }; - const handleCloseProgressModal = () => { - setGenerateModalOpen(false); + const handleClipUpdate = (clipId: string, data: Partial) => { + setClips((prev) => + prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)), + ); + }; + + const handleAssetDropToClip = (clipId: string, asset: MediaAsset) => { + handleClipUpdate(clipId, { + media_asset_id: asset.id, + assetName: asset.name, + thumbnail: asset.thumbnail_url, + }); + }; + + const handleAssetDropToTimeline = (asset: MediaAsset) => { + const newClip: ClipData = { + id: `clip-${Date.now()}`, + template_segment_id: "", + name: `片段${clips.length + 1} · ${MATERIAL_TYPE_LABELS[asset.type]}`, + material_type: + asset.type === "audio" + ? "voiceover" + : asset.type === "image" + ? "image" + : "video", + script_text: "", + duration: asset.duration || 5, + order: clips.length, + media_asset_id: asset.id, + assetName: asset.name, + thumbnail: asset.thumbnail_url, + transition: { type: "none", duration: 0 }, + }; + setClips((prev) => [...prev, newClip]); + }; + + /* 保存 */ + const handleOpenSaveModal = () => { + if (!loadedTemplateId) { + msg.warning("请先加载一个模板"); + return; + } + setSaveModalOpen(true); + }; + + const handleSave = async () => { + if (!draftName.trim()) { + msg.warning("请输入模板名称"); + return; + } + if (!loadedTemplateId) return; + setSaveLoading(true); + try { + const payload: SaveTemplatePayload = { + name: draftName, + mode: currentMode, + category: draftCategory, + tags: draftTags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + title_config: { + ai_auto_select: titleSettings.aiAutoSelect, + content: titleSettings.title, + font_preset: titleSettings.font, + font_color: "#ffffff", + font_size: titleSettings.size, + position: titleSettings.position, + }, + subtitle_config: { + enabled: subtitleSettings.enabled, + position: subtitleSettings.position, + font: subtitleSettings.font, + color: "#ffffff", + size: subtitleSettings.size, + animation: subtitleSettings.animation, + }, + bgm_config: { + enabled: bgmSettings.music !== "none", + music_id: bgmSettings.music, + }, + estimated_duration: totalDuration, + segments: clips.map((c, i) => ({ + segment_order: i, + duration_min: Math.max(1, c.duration - 2), + duration_max: c.duration + 2, + material_type: c.material_type, + })), + }; + await updateEditingTemplate(loadedTemplateId, payload); + msg.success("模板保存成功"); + setSaveModalOpen(false); + loadTemplates(); + } catch { + msg.error("保存失败"); + } finally { + setSaveLoading(false); + } + }; + + /* 生成 */ + const handleOpenGenerateModal = () => { + if (!loadedTemplateId) { + msg.warning("请先加载一个模板"); + return; + } setGenPhase("setup"); - setTaskId(null); + setGenTask(null); + setGenModalOpen(true); }; - const totalDuration = clips.reduce((s, c) => s + c.duration, 0); - const selectedClip = clips.find((c) => c.id === selectedClipId) || null; + const handleGenerate = async () => { + if (!loadedTemplateId) return; + setGenSubmitting(true); + try { + await generateFromTemplate(loadedTemplateId, { + voiceover_duration: voiceoverDuration || totalDuration, + }); + const task = await createGenerationTask({ + template_id: loadedTemplateId, + asset_ids: clips + .map((c) => c.media_asset_id) + .filter((v): v is string => !!v), + title_ids: [], + voice_ids: [], + }); + setGenTask(task as unknown as TaskItem); + setGenPhase("progress"); + msg.info("生成任务已创建"); + } catch { + msg.error("创建生成任务失败"); + } finally { + setGenSubmitting(false); + } + }; + + /* 轮询任务状态 */ + useEffect(() => { + if (genPhase !== "progress" || !genTask?.id) return; + const timer = setInterval(async () => { + try { + const t = await getTask(genTask.id); + setGenTask(t); + if (t.status === "completed") { + setGenPhase("completed"); + clearInterval(timer); + } else if (t.status === "failed") { + setGenPhase("failed"); + clearInterval(timer); + } + } catch { + /* ignore */ + } + }, 3000); + return () => clearInterval(timer); + }, [genPhase, genTask?.id]); + + const handleRetry = async () => { + if (!genTask?.id) return; + setGenSubmitting(true); + try { + const t = await retryTask(genTask.id); + setGenTask(t); + setGenPhase("progress"); + } catch { + msg.error("重试失败"); + } finally { + setGenSubmitting(false); + } + }; + + const handleCancelGen = () => { + setGenModalOpen(false); + setGenPhase("setup"); + setGenTask(null); + setVoiceoverDuration(null); + }; /* ──────────── 渲染 ──────────── */ return ( -
- {ToastNode} - - {/* ═══ 顶部工具栏 ═══ */} -
-
-
- {( - ["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[] - ).map((mode) => ( - - ))} -
+
+ {/* ═══ 第1行:顶栏 42px ═══ */} +
+
+ ✂️ + 小虾剪辑编排器 + | + + {currentTemplate?.name || "未选择模板"} +
-
- {loadedTemplateId && ( - - 📋{" "} - {templates.find((t) => t.id === loadedTemplateId)?.name || - "未命名模板"} - - )} - {editPlanId && 已保存} -
-
- - + 💾 保存模板 + +
- {/* ═══ 三栏主体 ═══ */} -
- {/* 左侧:素材面板(模板+素材 Tab) */} + {/* ═══ 第2行:模式栏 48px ═══ */} +
+ {MODE_LIST.map((m) => ( + + ))} +
+ + {/* ═══ 第3行:三栏主体 ═══ */} +
+ {/* 左栏 220px:模板列表 */} - {/* 中间:预览播放器 + 时间线 */} -
+ {/* 中栏 flex-1 */} +
+ {/* 上半部:视频预览 + 封面预览 */} setIsPlaying(!isPlaying)} /> + + {/* 下半部:水平时间线 */}
- {/* 右侧:片段属性 */} + {/* 右栏 260px:设置面板 */} + setTitleSettings((prev) => ({ ...prev, ...partial })) + } + onSubtitleSettingsChange={(partial) => + setSubtitleSettings((prev) => ({ ...prev, ...partial })) + } + onBgmSettingsChange={(partial) => + setBgmSettings((prev) => ({ ...prev, ...partial })) + } + onClipUpdate={handleClipUpdate} />
- {/* 保存模板弹窗 */} + {/* ═══ 第4行:底栏 40px ═══ */} +
+
+ 📋 片段: {clips.length} + | + ⏱️ 总时长: {totalDuration.toFixed(1)}s +
+
+ 🎬 {MODE_LABELS[currentMode]} + | + 📐 模板片段: {currentTemplate?.segments.length || 0} +
+
+ + {/* ═══ 弹窗 ═══ */} { onCancel={() => setSaveModalOpen(false)} /> - {/* 生成进度弹窗 */}
); diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index 3e6dbaf22..71f41595f 100644 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -1,204 +1,428 @@ /** - * 右侧片段属性面板 — V21 设计系统 - * 选中片段后编辑:文案、时长、转场效果 - * 未选中时显示全局设置(标题/字幕/BGM) + * 右栏设置面板 — V8 原型 1:1 还原 + * 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情 */ import React from "react"; -import type { EditPlanClip, TransitionEffect } from "@/api/editPlans"; -import { TRANSITION_OPTIONS, MATERIAL_TYPE_ICONS } from "@/api/editPlans"; +import type { TemplateMode } from "@/api/editingPlanner"; +import { MATERIAL_TYPE_LABELS } from "@/api/editPlans"; + +interface ClipData { + id: string; + name: string; + duration: number; + material_type: "video" | "image" | "audio" | "voiceover"; + thumbnail?: string; + assetName?: string; + media_asset_id?: string; + template_segment_id?: string; + script_text?: string; +} + +interface TitleSettings { + aiAutoSelect: boolean; + title: string; + position: string; + font: string; + size: number; + bold: boolean; + italic: boolean; + stroke: boolean; + shadow: boolean; +} + +interface SubtitleSettings { + enabled: boolean; + position: string; + font: string; + size: number; + animation: string; +} + +interface BgmSettings { + music: string; +} interface ClipPropertiesPanelProps { - /** 当前选中的片段 */ - selectedClip: EditPlanClip | null; - /** 更新片段属性 */ - onUpdateClip: (clipId: string, updates: Partial) => void; - /** 所有片段列表(用于显示上下文) */ - clips: EditPlanClip[]; + 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; } +const POSITION_OPTIONS = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, +]; + +const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑"]; + +const ANIMATION_OPTIONS = [ + { value: "none", label: "无" }, + { value: "fade", label: "淡入淡出" }, + { value: "slide", label: "滑动" }, + { value: "typewriter", label: "打字机" }, +]; + +const BGM_OPTIONS = [ + { value: "none", label: "无背景音乐" }, + { value: "bgm_01", label: "🎵 轻快节奏" }, + { value: "bgm_02", label: "🎵 温馨舒缓" }, + { value: "bgm_03", label: "🎵 动感活力" }, + { value: "bgm_04", label: "🎵 科技感" }, +]; + const ClipPropertiesPanel: React.FC = ({ selectedClip, - onUpdateClip, - clips, + titleSettings, + subtitleSettings, + bgmSettings, + clipsCount, + totalDuration, + currentMode, + onTitleSettingsChange, + onSubtitleSettingsChange, + onBgmSettingsChange, + onClipUpdate, }) => { - if (!selectedClip) { - return ( -
-
-
👆
-

选择一个片段

-

点击时间线上的片段来编辑属性

-
-
- 总片段数 - - {clips.length} - -
-
- 总时长 - - {clips.reduce((s, c) => s + c.duration, 0)}s - -
-
-
-
- ); - } - - const transition = selectedClip.transition ?? { - type: "none" as const, - duration: 0, - }; - - const handleTransitionTypeChange = (type: TransitionEffect["type"]) => { - const duration = type === "none" ? 0 : transition.duration || 0.5; - onUpdateClip(selectedClip.id, { - transition: { type, duration }, - }); - }; - - const handleTransitionDurationChange = (duration: number) => { - onUpdateClip(selectedClip.id, { - transition: { ...transition, duration }, - }); - }; - return ( -
- {/* 片段信息头 */} -
-
- - {MATERIAL_TYPE_ICONS[selectedClip.material_type] || "📄"} - -
-

片段 {selectedClip.order + 1}

- - {selectedClip.material_type} · {selectedClip.duration}s - -
+
+ {/* ═══ 标题设置 ═══ */} +
+
+ 📝 + 标题设置
-
- {/* 文案编辑 */} -
-

📝 文案

-
-