Files
xiaoxia-saas/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx
T
灵应 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
feat(phase8): 任务 2.06 — EditTemplateService + EditPlanService 服务层
- 新增 EditTemplateService:模板 CRUD、名称去重、片段配置管理、重排序
- 新增 EditPlanService:计划 CRUD、状态机流转、片段管理、素材分配、生成流程
- 重构 edit_templates 路由:使用 EditTemplateService 封装业务逻辑
- 重构 edit_plans 路由:使用 EditPlanService 封装业务逻辑
- 新增 77 个单元测试(33 EditTemplateService + 44 EditPlanService)
- 使用 stub 仓储注入,不依赖数据库
2026-07-01 19:24:31 +08:00

312 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 中间时间线面板 — 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;