b7bb572b46
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 185h28m1s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 185h28m5s
Deploy / Deploy Staging (push) Failing after 185h28m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 185h28m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 185h29m4s
Deploy / Production Browser E2E (push) Failing after 1844h3m44s
Deploy / Deploy Production (push) Failing after 1844h3m46s
Deploy / Build Production Runtime Images (push) Failing after 1844h4m15s
- 新增 EditTemplateService:模板 CRUD、名称去重、片段配置管理、重排序 - 新增 EditPlanService:计划 CRUD、状态机流转、片段管理、素材分配、生成流程 - 重构 edit_templates 路由:使用 EditTemplateService 封装业务逻辑 - 重构 edit_plans 路由:使用 EditPlanService 封装业务逻辑 - 新增 77 个单元测试(33 EditTemplateService + 44 EditPlanService) - 使用 stub 仓储注入,不依赖数据库
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
/**
|
||
* 中间时间线面板 — V21 设计系统
|
||
* 可视化时长条 + 片段卡片 + 拖拽排序 + 素材拖入
|
||
*/
|
||
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";
|
||
|
||
interface TimelinePanelProps {
|
||
clips: EditPlanClip[];
|
||
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;
|
||
}
|
||
|
||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||
clips,
|
||
selectedClipId,
|
||
onSelectClip,
|
||
onRemoveClip,
|
||
onReorderClips,
|
||
onAssetDrop,
|
||
onBatchAssetDrop,
|
||
onAddClip,
|
||
totalDuration,
|
||
}) => {
|
||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||
const [isDragOverEmpty, setIsDragOverEmpty] = useState(false);
|
||
const dragIdxRef = useRef<number | null>(null);
|
||
|
||
/* ── 内部片段拖拽排序 ── */
|
||
const handleClipDragStart = (e: React.DragEvent, idx: number) => {
|
||
dragIdxRef.current = idx;
|
||
e.dataTransfer.setData("application/x-clip-index", String(idx));
|
||
e.dataTransfer.effectAllowed = "move";
|
||
};
|
||
|
||
const handleClipDragOver = (e: React.DragEvent, idx: number) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverIdx(idx);
|
||
};
|
||
|
||
const handleClipDragEnd = () => {
|
||
dragIdxRef.current = 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) => {
|
||
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);
|
||
}
|
||
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);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDropOnEmpty = (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);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||
if (isAssetDrag(e)) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
setIsDragOverEmpty(true);
|
||
}
|
||
};
|
||
|
||
/* ── 转场标签 ── */
|
||
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];
|
||
|
||
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-header">
|
||
<h3>片段列表 ({clips.length})</h3>
|
||
<Button buttonType="secondary" buttonSize="sm" onClick={onAddClip}>
|
||
+ 添加片段
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="ep-timeline-list">
|
||
{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>
|
||
) : (
|
||
clips.map((clip, idx) => {
|
||
const isSelected = clip.id === selectedClipId;
|
||
const transitionLabel = getTransitionLabel(clip);
|
||
|
||
return (
|
||
<React.Fragment key={clip.id}>
|
||
{/* 拖放插入指示器 */}
|
||
{dragOverIdx === idx && (
|
||
<div className="ep-timeline-drop-indicator" />
|
||
)}
|
||
|
||
<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" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TimelinePanel;
|