feat: V8 原型 1:1 还原剪辑计划编辑器页面
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
- 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) - 顶栏:logo + 模板名 + 保存/生成按钮 - 模式栏:4种剪辑模式(画中画/人物口播/一镜到底/口播+画中画)下划线选中态 - 左栏(220px):纯模板列表 + chip分类筛选 - 中栏:手机模型预览(150x267) + 封面预览 + 水平轨道时间线(100x100卡片) - 右栏(260px):标题/字幕/BGM设置 + 片段详情 - 底栏:片段数/总时长/当前模式 - 暗色主题 #0a0a14/#6c5ce7/#13131f - HTML5拖拽排序 + 封面4方案切换 - TypeScript 零错误
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<EditPlanClip>) => void;
|
||||
/** 所有片段列表(用于显示上下文) */
|
||||
clips: EditPlanClip[];
|
||||
selectedClip: ClipData | null;
|
||||
titleSettings: TitleSettings;
|
||||
subtitleSettings: SubtitleSettings;
|
||||
bgmSettings: BgmSettings;
|
||||
clipsCount: number;
|
||||
totalDuration: number;
|
||||
currentMode: TemplateMode;
|
||||
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => 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<ClipPropertiesPanelProps> = ({
|
||||
selectedClip,
|
||||
onUpdateClip,
|
||||
clips,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
}) => {
|
||||
if (!selectedClip) {
|
||||
return (
|
||||
<div className="ep-right">
|
||||
<div className="ep-clip-props-empty">
|
||||
<div className="ep-clip-props-empty-icon">👆</div>
|
||||
<h3>选择一个片段</h3>
|
||||
<p>点击时间线上的片段来编辑属性</p>
|
||||
<div className="ep-clip-props-summary">
|
||||
<div className="ep-clip-props-summary-item">
|
||||
<span className="ep-clip-props-summary-label">总片段数</span>
|
||||
<span className="ep-clip-props-summary-value">
|
||||
{clips.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-clip-props-summary-item">
|
||||
<span className="ep-clip-props-summary-label">总时长</span>
|
||||
<span className="ep-clip-props-summary-value">
|
||||
{clips.reduce((s, c) => s + c.duration, 0)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="ep-right">
|
||||
{/* 片段信息头 */}
|
||||
<div className="ep-right-section">
|
||||
<div className="ep-clip-props-header">
|
||||
<span className="ep-clip-props-header-icon">
|
||||
{MATERIAL_TYPE_ICONS[selectedClip.material_type] || "📄"}
|
||||
</span>
|
||||
<div>
|
||||
<h3>片段 {selectedClip.order + 1}</h3>
|
||||
<span className="ep-clip-props-header-type">
|
||||
{selectedClip.material_type} · {selectedClip.duration}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📝</span>
|
||||
标题设置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文案编辑 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>📝 文案</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
<textarea
|
||||
className="ep-clip-props-textarea"
|
||||
placeholder="输入片段文案..."
|
||||
value={selectedClip.script_text}
|
||||
onChange={(e) =>
|
||||
onUpdateClip(selectedClip.id, {
|
||||
script_text: e.target.value,
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">AI 自动选择</span>
|
||||
<div
|
||||
className={`ep-toggle ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({
|
||||
aiAutoSelect: !titleSettings.aiAutoSelect,
|
||||
})
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="ep-clip-props-field-hint">
|
||||
{selectedClip.script_text.length} 字
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长调整 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>⏱️ 时长</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
<div className="ep-clip-props-duration-control">
|
||||
<input
|
||||
type="range"
|
||||
className="ep-clip-props-range"
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
value={selectedClip.duration}
|
||||
onChange={(e) =>
|
||||
onUpdateClip(selectedClip.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-props-duration-value">
|
||||
{selectedClip.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>✨ 转场效果</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
<label className="ep-clip-props-label">转场类型</label>
|
||||
<div className="ep-clip-props-transition-grid">
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`ep-clip-props-transition-btn${transition.type === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleTransitionTypeChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{transition.type !== "none" && (
|
||||
<div className="ep-clip-props-field">
|
||||
<label className="ep-clip-props-label">转场时长</label>
|
||||
<div className="ep-clip-props-duration-control">
|
||||
<input
|
||||
type="range"
|
||||
className="ep-clip-props-range"
|
||||
min={0.1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={transition.duration}
|
||||
{!titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) =>
|
||||
handleTransitionDurationChange(Number(e.target.value))
|
||||
onTitleSettingsChange({ position: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-props-duration-value">
|
||||
{transition.duration.toFixed(1)}s
|
||||
</span>
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ font: e.target.value })
|
||||
}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">大小</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">样式</label>
|
||||
<div className="ep-style-btns">
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ bold: !titleSettings.bold })
|
||||
}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ italic: !titleSettings.italic })
|
||||
}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ stroke: !titleSettings.stroke })
|
||||
}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ shadow: !titleSettings.shadow })
|
||||
}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材关联 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>🔗 关联素材</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
{selectedClip.media_asset_id ? (
|
||||
<div className="ep-clip-props-asset-linked">
|
||||
<span className="ep-clip-props-asset-icon">
|
||||
{MATERIAL_TYPE_ICONS[selectedClip.material_type]}
|
||||
</span>
|
||||
<span className="ep-clip-props-asset-name">
|
||||
{selectedClip.media_asset_id}
|
||||
</span>
|
||||
<button
|
||||
className="ep-clip-props-asset-unlink"
|
||||
onClick={() =>
|
||||
onUpdateClip(selectedClip.id, {
|
||||
media_asset_id: undefined,
|
||||
})
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">💬</span>
|
||||
字幕设置
|
||||
</div>
|
||||
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle ${subtitleSettings.enabled ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onSubtitleSettingsChange({
|
||||
enabled: !subtitleSettings.enabled,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subtitleSettings.enabled && (
|
||||
<>
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.position}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ position: e.target.value })
|
||||
}
|
||||
title="取消关联"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-clip-props-asset-empty">
|
||||
<p>从左侧素材库拖拽素材到此片段</p>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.font}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ font: e.target.value })
|
||||
}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">大小</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={32}
|
||||
value={subtitleSettings.size}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">{subtitleSettings.size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">动画</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.animation}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ animation: e.target.value })
|
||||
}
|
||||
>
|
||||
{ANIMATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ BGM 设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎵</span>
|
||||
BGM 设置
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">背景音乐</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) =>
|
||||
onBgmSettingsChange({ music: e.target.value })
|
||||
}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎞️</span>
|
||||
片段详情
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
<div className="ep-clip-detail-header">
|
||||
<span className="ep-clip-detail-name">{selectedClip.name}</span>
|
||||
<span className="ep-clip-detail-type">
|
||||
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">时长范围</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={selectedClip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(selectedClip.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: "#666" }}>秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedClip.assetName && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">关联素材</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
🔗 {selectedClip.assetName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材类型</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="ep-ai-recommend-btn">
|
||||
✨ AI 推荐素材
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 统计信息(始终显示) ═══ */}
|
||||
{!selectedClip && (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📊</span>
|
||||
编辑统计
|
||||
</div>
|
||||
<div className="ep-clip-detail">
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">片段数</div>
|
||||
<div className="ep-clip-detail-value">{clipsCount}</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">总时长</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{totalDuration.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">当前模式</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{currentMode === "pip"
|
||||
? "画中画"
|
||||
: currentMode === "voice_over"
|
||||
? "人物口播"
|
||||
: currentMode === "one_take"
|
||||
? "一镜到底"
|
||||
: "口播+画中画"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,222 +1,102 @@
|
||||
/**
|
||||
* 左侧素材面板 — V21 设计系统
|
||||
* Tab 切换:模板列表 / 素材库
|
||||
* 素材 Tab 集成 AssetSelector 组件
|
||||
* 左侧模板面板 — V8 原型 1:1 还原
|
||||
* 纯模板列表 + chip 分类筛选(无素材 Tab)
|
||||
*/
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input, Select, Tag, Button } from "@/components/ui";
|
||||
import {
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateCategory,
|
||||
type TemplateMode,
|
||||
} from "@/api/editingPlanner";
|
||||
import { getMediaAssets, type MediaAsset } from "@/api/editPlans";
|
||||
import { getAssetLibraries } from "@/api/assets";
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector";
|
||||
|
||||
/** antd Tag color → V21 Tag variant */
|
||||
const modeVariantMap: Record<
|
||||
string,
|
||||
"primary" | "success" | "warning" | "info"
|
||||
> = {
|
||||
blue: "primary",
|
||||
green: "success",
|
||||
orange: "warning",
|
||||
purple: "info",
|
||||
};
|
||||
|
||||
type LeftTab = "templates" | "assets";
|
||||
import React from "react";
|
||||
import type { EditingTemplate } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS } from "@/api/editingPlanner";
|
||||
|
||||
interface MediaPanelProps {
|
||||
/* 模板相关 */
|
||||
templates: EditingTemplate[];
|
||||
categories: TemplateCategory[];
|
||||
isLoadingTemplates: boolean;
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
currentFilter: string;
|
||||
filterCategories: string[];
|
||||
loadedTemplateId: string | null;
|
||||
onTemplateSelect: (tpl: EditingTemplate) => void;
|
||||
onNewTemplate: () => void;
|
||||
/* 素材相关 */
|
||||
onAssetDragStart?: (asset: MediaAsset) => void;
|
||||
onBatchAddAssets?: (assets: MediaAsset[]) => void;
|
||||
onLoadTemplate: (id: string) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
onFilterChange: (f: string) => void;
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
templates,
|
||||
categories,
|
||||
isLoadingTemplates,
|
||||
loading,
|
||||
searchQuery,
|
||||
currentFilter,
|
||||
filterCategories,
|
||||
loadedTemplateId,
|
||||
onTemplateSelect,
|
||||
onNewTemplate,
|
||||
onAssetDragStart,
|
||||
onBatchAddAssets,
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<LeftTab>("templates");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
|
||||
/* 先获取素材库列表,再用第一个 library_id 获取素材 */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
});
|
||||
const libraryId = libraries.length > 0 ? libraries[0].id : undefined;
|
||||
|
||||
/* 素材数据查询 */
|
||||
const { data: assets = [] } = useQuery({
|
||||
queryKey: ["media-assets", libraryId],
|
||||
queryFn: () => getMediaAssets(libraryId),
|
||||
enabled: libraryId !== undefined,
|
||||
});
|
||||
|
||||
/* 过滤模板 */
|
||||
const filteredTemplates = templates.filter((tpl) => {
|
||||
if (
|
||||
searchText &&
|
||||
!tpl.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
if (filterCategory && tpl.category !== filterCategory) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/* 获取选中的素材 */
|
||||
const selectedAssets = assets.filter((a) => selectedAssetIds.includes(a.id));
|
||||
|
||||
/* 批量添加到时间线 */
|
||||
const handleBatchAdd = useCallback(() => {
|
||||
if (selectedAssets.length > 0 && onBatchAddAssets) {
|
||||
onBatchAddAssets(selectedAssets);
|
||||
setSelectedAssetIds([]);
|
||||
}
|
||||
}, [selectedAssets, onBatchAddAssets]);
|
||||
|
||||
return (
|
||||
<div className="ep-left">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab${activeTab === "templates" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📂 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab${activeTab === "assets" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
🎬 素材
|
||||
</button>
|
||||
<div className="ep-left-panel">
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap" style={{ position: "relative" }}>
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{activeTab === "templates" ? (
|
||||
<>
|
||||
{/* 搜索栏 */}
|
||||
<div className="ep-left-header">
|
||||
<Input.Search
|
||||
placeholder="搜索模板..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v: string) => setFilterCategory(v || "")}
|
||||
allowClear
|
||||
options={categories.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-left-list">
|
||||
{isLoadingTemplates ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">
|
||||
{MODE_LABELS[tpl.mode]}
|
||||
</span>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">📭</div>
|
||||
<p>暂无模板</p>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((tpl) => {
|
||||
const modeColor =
|
||||
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const variant = modeVariantMap[modeColor] || "primary";
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onTemplateSelect(tpl)}
|
||||
>
|
||||
<div className="ep-template-card-name">{tpl.name}</div>
|
||||
<div className="ep-template-card-tags">
|
||||
<Tag variant={variant}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
{tpl.tags.slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} variant="info">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
{loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<button className="ep-new-template-btn" onClick={onNewTemplate}>
|
||||
✨ 新建空白模板
|
||||
</button>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 素材 Tab — 使用 AssetSelector */}
|
||||
<div className="ep-left-media">
|
||||
<AssetSelector
|
||||
assets={assets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={setSelectedAssetIds}
|
||||
onAssetDragStart={onAssetDragStart}
|
||||
showQualityFilter
|
||||
showBatchSelect
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 批量添加按钮 */}
|
||||
{selectedAssetIds.length > 0 && onBatchAddAssets && (
|
||||
<div className="ep-left-media-footer">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={handleBatchAdd}
|
||||
block
|
||||
>
|
||||
📦 添加选中素材到时间线 ({selectedAssetIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,416 +1,113 @@
|
||||
/**
|
||||
* 预览播放器 — V21 设计系统
|
||||
* 模拟播放 EditPlan 片段序列,支持播放/暂停、进度条拖拽、时间线点击跳转
|
||||
* 任务 2.16
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览(150x267) + 封面预览(150x267) 并排
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import "./PreviewPlayer.css";
|
||||
import type { EditPlanClip } from "@/api/editPlans";
|
||||
import { MATERIAL_TYPE_ICONS } from "@/api/editPlans";
|
||||
import React from "react";
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: EditPlanClip[];
|
||||
totalDuration: number;
|
||||
selectedClipId: string | null;
|
||||
onSelectClip: (clipId: string | null) => void;
|
||||
interface ClipData {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
material_type: string;
|
||||
thumbnail?: string;
|
||||
assetName?: string;
|
||||
}
|
||||
|
||||
/* ── 片段颜色(与 TimelinePanel 保持一致) ── */
|
||||
const CLIP_COLORS = [
|
||||
"#4f46e5",
|
||||
"#7c3aed",
|
||||
"#2563eb",
|
||||
"#0891b2",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
];
|
||||
const getClipColor = (idx: number) => CLIP_COLORS[idx % CLIP_COLORS.length];
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/* ── 格式化时间 mm:ss ── */
|
||||
const formatTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
interface PreviewPlayerProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentCoverScheme: string;
|
||||
coverSchemes: CoverScheme[];
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onCoverSchemeChange: (scheme: string) => void;
|
||||
onPlayPause: () => void;
|
||||
}
|
||||
|
||||
/* ── 根据播放进度计算当前片段索引 ── */
|
||||
const getClipIndexAtTime = (clips: EditPlanClip[], time: number): number => {
|
||||
let elapsed = 0;
|
||||
for (let i = 0; i < clips.length; i++) {
|
||||
elapsed += clips[i].duration;
|
||||
if (time < elapsed) return i;
|
||||
}
|
||||
return Math.max(0, clips.length - 1);
|
||||
};
|
||||
|
||||
/* ── 根据片段索引计算起始时间 ── */
|
||||
const getClipStartTime = (clips: EditPlanClip[], clipIndex: number): number => {
|
||||
let time = 0;
|
||||
for (let i = 0; i < clipIndex; i++) {
|
||||
time += clips[i].duration;
|
||||
}
|
||||
return time;
|
||||
const MATERIAL_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
};
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
totalDuration,
|
||||
selectedClipId,
|
||||
onSelectClip,
|
||||
isPlaying,
|
||||
currentCoverScheme,
|
||||
coverSchemes,
|
||||
onCoverSchemeChange,
|
||||
onPlayPause,
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const currentClipIndex =
|
||||
clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1;
|
||||
const currentClip = currentClipIndex >= 0 ? clips[currentClipIndex] : null;
|
||||
const clipStartTime =
|
||||
currentClipIndex >= 0 ? getClipStartTime(clips, currentClipIndex) : 0;
|
||||
const clipProgress =
|
||||
currentClip && currentClip.duration > 0
|
||||
? ((currentTime - clipStartTime) / currentClip.duration) * 100
|
||||
: 0;
|
||||
const overallProgress =
|
||||
totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
|
||||
|
||||
/* ── 播放控制 ── */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
const startPlayback = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + 0.1;
|
||||
if (next >= totalDuration) {
|
||||
// 播放结束
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
setIsPlaying(false);
|
||||
return 0; // 回到起点
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 100);
|
||||
setIsPlaying(true);
|
||||
}, [totalDuration]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (clips.length === 0) return;
|
||||
if (isPlaying) {
|
||||
stopPlayback();
|
||||
} else {
|
||||
// 如果在末尾,从头开始
|
||||
if (currentTime >= totalDuration - 0.05) {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
clips.length,
|
||||
startPlayback,
|
||||
stopPlayback,
|
||||
]);
|
||||
|
||||
/* ── 停止/重置 ── */
|
||||
const handleStop = useCallback(() => {
|
||||
stopPlayback();
|
||||
setCurrentTime(0);
|
||||
}, [stopPlayback]);
|
||||
|
||||
/* ── 上一段/下一段 ── */
|
||||
const handlePrevClip = useCallback(() => {
|
||||
if (currentClipIndex <= 0) {
|
||||
setCurrentTime(0);
|
||||
} else {
|
||||
setCurrentTime(getClipStartTime(clips, currentClipIndex - 1));
|
||||
}
|
||||
}, [currentClipIndex, clips]);
|
||||
|
||||
const handleNextClip = useCallback(() => {
|
||||
if (currentClipIndex < clips.length - 1) {
|
||||
setCurrentTime(getClipStartTime(clips, currentClipIndex + 1));
|
||||
} else {
|
||||
setCurrentTime(totalDuration);
|
||||
}
|
||||
}, [currentClipIndex, clips, totalDuration]);
|
||||
|
||||
/* ── 进度条拖拽 ── */
|
||||
const updateTimeFromMouse = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!progressRef.current || totalDuration === 0) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (clientX - rect.left) / rect.width),
|
||||
);
|
||||
setCurrentTime(ratio * totalDuration);
|
||||
},
|
||||
[totalDuration],
|
||||
);
|
||||
|
||||
const handleProgressMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
wasPlayingRef.current = isPlaying;
|
||||
if (isPlaying) stopPlayback();
|
||||
updateTimeFromMouse(e.clientX);
|
||||
},
|
||||
[isPlaying, stopPlayback, updateTimeFromMouse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
updateTimeFromMouse(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
if (wasPlayingRef.current) {
|
||||
startPlayback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, updateTimeFromMouse, startPlayback]);
|
||||
|
||||
/* ── 点击时间线片段跳转 ── */
|
||||
const handleTimelineSegmentClick = useCallback(
|
||||
(idx: number) => {
|
||||
setCurrentTime(getClipStartTime(clips, idx));
|
||||
onSelectClip(clips[idx].id);
|
||||
},
|
||||
[clips, onSelectClip],
|
||||
);
|
||||
|
||||
/* ── 组件卸载时清理定时器 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 片段变化时同步播放位置(外部选中片段跳转) ── */
|
||||
useEffect(() => {
|
||||
if (selectedClipId && !isPlaying) {
|
||||
const idx = clips.findIndex((c) => c.id === selectedClipId);
|
||||
if (idx >= 0) {
|
||||
const startTime = getClipStartTime(clips, idx);
|
||||
// 只在当前不在该片段范围内时跳转
|
||||
const endTime = startTime + clips[idx].duration;
|
||||
if (currentTime < startTime || currentTime >= endTime) {
|
||||
setCurrentTime(startTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedClipId, clips, isPlaying]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/* ── 空状态 ── */
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-preview">
|
||||
<div className="ep-preview-screen">
|
||||
<div className="ep-preview-empty">
|
||||
<div className="ep-preview-empty-icon">🎬</div>
|
||||
<p>添加片段后预览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId);
|
||||
const displayClip = selectedClip || clips[0];
|
||||
|
||||
return (
|
||||
<div className="ep-preview">
|
||||
{/* ── 预览画面 ── */}
|
||||
<div className="ep-preview-screen">
|
||||
{/* 背景渐变(模拟视频画面) */}
|
||||
<div
|
||||
className="ep-preview-visual"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${getClipColor(currentClipIndex)}33, ${getClipColor(currentClipIndex)}11)`,
|
||||
}}
|
||||
>
|
||||
{/* 片段类型图标 */}
|
||||
<div className="ep-preview-type-icon">
|
||||
{currentClip
|
||||
? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄"
|
||||
: "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 文案字幕 */}
|
||||
{currentClip?.script_text && (
|
||||
<div className="ep-preview-subtitle">{currentClip.script_text}</div>
|
||||
)}
|
||||
|
||||
{/* 片段序号角标 */}
|
||||
<div
|
||||
className="ep-preview-clip-badge"
|
||||
style={{ backgroundColor: getClipColor(currentClipIndex) }}
|
||||
>
|
||||
#{currentClipIndex + 1}
|
||||
</div>
|
||||
|
||||
{/* 素材类型标签 */}
|
||||
{currentClip && (
|
||||
<div className="ep-preview-material-tag">
|
||||
{MATERIAL_TYPE_ICONS[currentClip.material_type]}{" "}
|
||||
{currentClip.material_type}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-preview-area">
|
||||
{/* 手机模型预览 */}
|
||||
<div className="ep-phone-preview">
|
||||
<div className="ep-phone-status-bar">
|
||||
<span>9:41</span>
|
||||
<span>📶 🔋</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 控制栏 ── */}
|
||||
<div className="ep-preview-controls">
|
||||
{/* 左侧:时间 */}
|
||||
<div className="ep-preview-time">
|
||||
<span className="ep-preview-time-current">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<span className="ep-preview-time-sep">/</span>
|
||||
<span className="ep-preview-time-total">
|
||||
{formatTime(totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 中间:播放控制按钮 */}
|
||||
<div className="ep-preview-buttons">
|
||||
<button
|
||||
className="ep-preview-btn"
|
||||
onClick={handlePrevClip}
|
||||
title="上一段"
|
||||
>
|
||||
⏮
|
||||
</button>
|
||||
<button
|
||||
className="ep-preview-btn ep-preview-btn-play"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button className="ep-preview-btn" onClick={handleStop} title="停止">
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
className="ep-preview-btn"
|
||||
onClick={handleNextClip}
|
||||
title="下一段"
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧:片段信息 */}
|
||||
<div className="ep-preview-clip-info">
|
||||
{currentClip && (
|
||||
<div className="ep-phone-content">
|
||||
{displayClip ? (
|
||||
<>
|
||||
<span className="ep-preview-clip-idx">
|
||||
片段 {currentClipIndex + 1}/{clips.length}
|
||||
</span>
|
||||
<span className="ep-preview-clip-dur">
|
||||
{currentClip.duration}s
|
||||
</span>
|
||||
<button className="ep-phone-play-btn" onClick={onPlayPause}>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<div className="ep-phone-progress">
|
||||
<div
|
||||
className="ep-phone-progress-fill"
|
||||
style={{ width: isPlaying ? "45%" : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ep-phone-clip-label">
|
||||
{displayClip.name}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#444", fontSize: 11 }}>暂无片段</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 进度条(可拖拽) ── */}
|
||||
<div
|
||||
className="ep-preview-progress"
|
||||
ref={progressRef}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
>
|
||||
<div className="ep-preview-progress-track">
|
||||
{/* 片段色块背景 */}
|
||||
{clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="ep-preview-progress-segment"
|
||||
style={{
|
||||
width: `${(clip.duration / totalDuration) * 100}%`,
|
||||
backgroundColor: getClipColor(idx),
|
||||
opacity: idx === currentClipIndex ? 0.6 : 0.25,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 已播放覆盖层 */}
|
||||
<div
|
||||
className="ep-preview-progress-fill"
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
/>
|
||||
{/* 封面预览 */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{displayClip ? (
|
||||
<span style={{ fontSize: 24 }}>
|
||||
{MATERIAL_ICONS[displayClip.material_type] || "🎬"}
|
||||
</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label ||
|
||||
"封面预览"}
|
||||
</div>
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
className="ep-preview-progress-handle"
|
||||
style={{ left: `${overallProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 迷你时间线(可点击跳转) ── */}
|
||||
<div className="ep-preview-timeline">
|
||||
{clips.map((clip, idx) => {
|
||||
const isActive = idx === currentClipIndex;
|
||||
const isSelected = clip.id === selectedClipId;
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-preview-timeline-seg${isActive ? " active" : ""}${isSelected ? " selected" : ""}`}
|
||||
style={{
|
||||
width: `${(clip.duration / totalDuration) * 100}%`,
|
||||
backgroundColor: isActive
|
||||
? getClipColor(idx)
|
||||
: `${getClipColor(idx)}55`,
|
||||
}}
|
||||
onClick={() => handleTimelineSegmentClick(idx)}
|
||||
title={`片段 ${idx + 1}: ${clip.duration}s`}
|
||||
>
|
||||
<span className="ep-preview-timeline-seg-label">
|
||||
{MATERIAL_TYPE_ICONS[clip.material_type]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 播放头指示器 */}
|
||||
<div
|
||||
className="ep-preview-playhead"
|
||||
style={{ left: `${overallProgress}%` }}
|
||||
/>
|
||||
{/* 封面方案按钮(竖排4个) */}
|
||||
<div className="ep-cover-tags">
|
||||
{coverSchemes.map((scheme) => (
|
||||
<button
|
||||
key={scheme.key}
|
||||
className={`ep-cover-tag ${currentCoverScheme === scheme.key ? "active" : ""}`}
|
||||
onClick={() => onCoverSchemeChange(scheme.key)}
|
||||
>
|
||||
{scheme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 片段内进度 ── */}
|
||||
{currentClip && (
|
||||
<div className="ep-preview-clip-progress">
|
||||
<div
|
||||
className="ep-preview-clip-progress-fill"
|
||||
style={{
|
||||
width: `${clipProgress}%`,
|
||||
backgroundColor: getClipColor(currentClipIndex),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,322 +1,224 @@
|
||||
/**
|
||||
* 中间时间线面板 — V21 设计系统
|
||||
* 可视化时长条 + 片段卡片 + 拖拽排序 + 素材拖入
|
||||
* 水平轨道时间线 — V8 原型 1:1 还原
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
*/
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { EditPlanClip, MediaAsset } from "@/api/editPlans";
|
||||
import { MATERIAL_TYPE_ICONS, TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
|
||||
interface ClipData {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
material_type: string;
|
||||
thumbnail?: string;
|
||||
assetName?: string;
|
||||
media_asset_id?: string;
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: EditPlanClip[];
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
onSelectClip: (clipId: string | null) => void;
|
||||
onRemoveClip: (clipId: string) => void;
|
||||
onReorderClips: (fromIdx: number, toIdx: number) => void;
|
||||
onAssetDrop: (asset: MediaAsset, insertIdx: number) => void;
|
||||
onBatchAssetDrop?: (assets: MediaAsset[], insertIdx: number) => void;
|
||||
onAddClip: () => void;
|
||||
totalDuration: number;
|
||||
currentMode: string;
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void;
|
||||
onClipRemove: (clipId: string) => void;
|
||||
onAssetDropToTimeline: (asset: MediaAsset) => void;
|
||||
onAssetDropToClip: (clipId: string, asset: MediaAsset) => void;
|
||||
mediaAssets: MediaAsset[];
|
||||
}
|
||||
|
||||
const MATERIAL_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
};
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelectClip,
|
||||
onRemoveClip,
|
||||
onReorderClips,
|
||||
onAssetDrop,
|
||||
onBatchAssetDrop,
|
||||
onAddClip,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onClipSelect,
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAssetDropToTimeline,
|
||||
onAssetDropToClip,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const [isDragOverEmpty, setIsDragOverEmpty] = useState(false);
|
||||
const dragIdxRef = useRef<number | null>(null);
|
||||
const dragRef = useRef<number | null>(null);
|
||||
|
||||
/* ── 内部片段拖拽排序 ── */
|
||||
const handleClipDragStart = (e: React.DragEvent, idx: number) => {
|
||||
dragIdxRef.current = idx;
|
||||
e.dataTransfer.setData("application/x-clip-index", String(idx));
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
dragRef.current = idx;
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleClipDragOver = (e: React.DragEvent, idx: number) => {
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIdx(idx);
|
||||
};
|
||||
|
||||
const handleClipDragEnd = () => {
|
||||
dragIdxRef.current = null;
|
||||
const handleDragEnd = () => {
|
||||
dragRef.current = null;
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
};
|
||||
|
||||
/* ── 外部素材拖入 ── */
|
||||
const isAssetDrag = (e: React.DragEvent) =>
|
||||
e.dataTransfer.types.includes("application/x-media-asset") ||
|
||||
e.dataTransfer.types.includes("application/x-media-assets");
|
||||
|
||||
const handleAssetDragOver = (e: React.DragEvent) => {
|
||||
if (isAssetDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnClip = (e: React.DragEvent, insertIdx: number) => {
|
||||
const handleDrop = (e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIdx(null);
|
||||
|
||||
// 内部片段排序
|
||||
const clipIdx = e.dataTransfer.getData("application/x-clip-index");
|
||||
if (clipIdx !== "") {
|
||||
const fromIdx = Number(clipIdx);
|
||||
if (fromIdx !== insertIdx && fromIdx !== insertIdx - 1) {
|
||||
const adjustedTo = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
|
||||
onReorderClips(fromIdx, adjustedTo);
|
||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag");
|
||||
if (fromStr !== "") {
|
||||
const fromIdx = Number(fromStr);
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量素材拖入
|
||||
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
|
||||
if (assetsJson) {
|
||||
try {
|
||||
const assets: MediaAsset[] = JSON.parse(assetsJson);
|
||||
if (onBatchAssetDrop) {
|
||||
onBatchAssetDrop(assets, insertIdx);
|
||||
} else {
|
||||
assets.forEach((asset, i) => onAssetDrop(asset, insertIdx + i));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个素材拖入
|
||||
// 素材拖到片段上
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
const asset: MediaAsset = JSON.parse(assetJson);
|
||||
onAssetDrop(asset, insertIdx);
|
||||
onAssetDropToClip(clips[toIdx].id, asset);
|
||||
} catch {
|
||||
// ignore
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnEmpty = (e: React.DragEvent) => {
|
||||
/* ── 空轨道区域拖入 ── */
|
||||
const handleEmptyDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOverEmpty(false);
|
||||
|
||||
// 批量素材拖入
|
||||
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
|
||||
if (assetsJson) {
|
||||
try {
|
||||
const assets: MediaAsset[] = JSON.parse(assetsJson);
|
||||
if (onBatchAssetDrop) {
|
||||
onBatchAssetDrop(assets, clips.length);
|
||||
} else {
|
||||
assets.forEach((asset, i) => onAssetDrop(asset, clips.length + i));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个素材拖入
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
const asset: MediaAsset = JSON.parse(assetJson);
|
||||
onAssetDrop(asset, clips.length);
|
||||
onAssetDropToTimeline(asset);
|
||||
} catch {
|
||||
// ignore
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||||
if (isAssetDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverEmpty(true);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
/* ── 转场标签 ── */
|
||||
const getTransitionLabel = (clip: EditPlanClip) => {
|
||||
if (!clip.transition || clip.transition.type === "none") return null;
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === clip.transition?.type,
|
||||
);
|
||||
return opt ? opt.label : clip.transition.type;
|
||||
};
|
||||
|
||||
/* ── 时长条宽度百分比 ── */
|
||||
const getClipWidth = (clip: EditPlanClip) => {
|
||||
if (totalDuration === 0) return 100 / Math.max(clips.length, 1);
|
||||
return (clip.duration / totalDuration) * 100;
|
||||
};
|
||||
|
||||
/* ── 片段颜色 ── */
|
||||
const clipColors = [
|
||||
"#4f46e5",
|
||||
"#7c3aed",
|
||||
"#2563eb",
|
||||
"#0891b2",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
];
|
||||
const getClipColor = (idx: number) => clipColors[idx % clipColors.length];
|
||||
/* ── 时间标尺 ── */
|
||||
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
|
||||
const rulerMarks: number[] = [];
|
||||
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
rulerMarks.push(t);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 可视化时长条 */}
|
||||
<div className="ep-timeline-bar">
|
||||
<div className="ep-timeline-bar-label">
|
||||
时间线{" "}
|
||||
<span className="ep-timeline-bar-duration">{totalDuration}s</span>
|
||||
</div>
|
||||
<div className="ep-timeline-bar-track">
|
||||
{clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="ep-timeline-bar-segment"
|
||||
style={{
|
||||
width: `${getClipWidth(clip)}%`,
|
||||
backgroundColor: getClipColor(idx),
|
||||
}}
|
||||
title={`片段 ${idx + 1}: ${clip.duration}s`}
|
||||
/>
|
||||
))}
|
||||
{clips.length === 0 && (
|
||||
<div className="ep-timeline-bar-empty">拖入素材开始编辑</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
<div className="ep-timeline-header">
|
||||
<h3>片段列表 ({clips.length})</h3>
|
||||
<Button buttonType="secondary" buttonSize="sm" onClick={onAddClip}>
|
||||
+ 添加片段
|
||||
</Button>
|
||||
<span className="ep-timeline-title">
|
||||
时间线 · {clips.length} 个片段
|
||||
</span>
|
||||
<div className="ep-timeline-actions">
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={() => {
|
||||
if (clips.length > 1) {
|
||||
const last = clips[clips.length - 1];
|
||||
onClipRemove(last.id);
|
||||
}
|
||||
}}
|
||||
title="删除最后一个片段"
|
||||
>
|
||||
✕ 末尾
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-timeline-list">
|
||||
{/* 一镜到底提示 */}
|
||||
{currentMode === "one_take" && (
|
||||
<div className="ep-one-take-hint">
|
||||
🎥 一镜到底模式:所有片段将无缝衔接,不可添加转场
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间标尺 */}
|
||||
<div className="ep-time-ruler">
|
||||
<div
|
||||
className="ep-time-ruler-inner"
|
||||
style={{ width: Math.max(clips.length * 108, 300) }}
|
||||
>
|
||||
{rulerMarks.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="ep-time-mark"
|
||||
style={{
|
||||
left:
|
||||
totalDuration > 0
|
||||
? `${(t / totalDuration) * clips.length * 108}px`
|
||||
: `${t * 20}px`,
|
||||
}}
|
||||
>
|
||||
{t}s
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
<div
|
||||
className="ep-clip-track"
|
||||
onDrop={handleEmptyDrop}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<div
|
||||
className={`ep-timeline-empty-drop${isDragOverEmpty ? " active" : ""}`}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
onDragLeave={() => setIsDragOverEmpty(false)}
|
||||
onDrop={handleDropOnEmpty}
|
||||
>
|
||||
<div className="ep-timeline-empty-icon">🎬</div>
|
||||
<p>从左侧拖拽素材到这里</p>
|
||||
<span>或点击「添加片段」手动创建</span>
|
||||
<div className="ep-track-empty">
|
||||
🎬 拖入素材或从模板加载片段
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => {
|
||||
const isSelected = clip.id === selectedClipId;
|
||||
const transitionLabel = getTransitionLabel(clip);
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
>
|
||||
{/* 缩略图区域 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{MATERIAL_ICONS[clip.material_type] || "🎬"}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 拖放插入指示器 */}
|
||||
{dragOverIdx === idx && (
|
||||
<div className="ep-timeline-drop-indicator" />
|
||||
)}
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">{clip.name}</span>
|
||||
<span className="ep-clip-duration">{clip.duration}s</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`ep-clip-card${isSelected ? " selected" : ""}${dragOverIdx === idx ? " drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleClipDragStart(e, idx)}
|
||||
onDragOver={(e) => {
|
||||
handleClipDragOver(e, idx);
|
||||
handleAssetDragOver(e);
|
||||
}}
|
||||
onDragEnd={handleClipDragEnd}
|
||||
onDrop={(e) => handleDropOnClip(e, idx)}
|
||||
onClick={() => onSelectClip(clip.id)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="ep-clip-drag">⠿</span>
|
||||
|
||||
{/* 序号徽标 */}
|
||||
<span
|
||||
className="ep-clip-index"
|
||||
style={{ backgroundColor: getClipColor(idx) }}
|
||||
>
|
||||
#{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<div className="ep-clip-info-top">
|
||||
<span className="ep-clip-type-icon">
|
||||
{MATERIAL_TYPE_ICONS[clip.material_type] || "📄"}
|
||||
</span>
|
||||
<span className="ep-clip-script">
|
||||
{clip.script_text || (
|
||||
<em className="ep-clip-script-empty">未填写文案</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-clip-info-bottom">
|
||||
<div className="ep-clip-duration-bar">
|
||||
<div
|
||||
className="ep-clip-duration-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, (clip.duration / 60) * 100)}%`,
|
||||
backgroundColor: getClipColor(idx),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="ep-clip-duration-text">
|
||||
{clip.duration}s
|
||||
</span>
|
||||
{clip.media_asset_id && (
|
||||
<span
|
||||
className="ep-clip-asset-badge"
|
||||
title="已关联素材"
|
||||
>
|
||||
🔗
|
||||
</span>
|
||||
)}
|
||||
{transitionLabel && (
|
||||
<span className="ep-clip-transition-badge">
|
||||
✨ {transitionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="ep-clip-actions">
|
||||
<button
|
||||
className="ep-clip-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveClip(clip.id);
|
||||
}}
|
||||
title="删除片段"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 末尾插入指示器 */}
|
||||
{clips.length > 0 && dragOverIdx === clips.length && (
|
||||
<div className="ep-timeline-drop-indicator" />
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user