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

- 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
- 顶栏:logo + 模板名 + 保存/生成按钮
- 模式栏:4种剪辑模式(画中画/人物口播/一镜到底/口播+画中画)下划线选中态
- 左栏(220px):纯模板列表 + chip分类筛选
- 中栏:手机模型预览(150x267) + 封面预览 + 水平轨道时间线(100x100卡片)
- 右栏(260px):标题/字幕/BGM设置 + 片段详情
- 底栏:片段数/总时长/当前模式
- 暗色主题 #0a0a14/#6c5ce7/#13131f
- HTML5拖拽排序 + 封面4方案切换
- TypeScript 零错误
This commit is contained in:
灵应
2026-07-04 20:06:00 +08:00
parent fb47512f9c
commit ffd533b552
6 changed files with 2074 additions and 2556 deletions
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>