311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import React from 'react'
|
|
import { SegmentContextMenu } from './SegmentContextMenu'
|
|
import { SegmentTooltip } from './SegmentTooltip'
|
|
import { ResourceCategoryServiceV2, ResourceCategoryV2 } from '../../services/resourceCategoryServiceV2'
|
|
|
|
export interface TrackSegment {
|
|
id: string
|
|
type: 'video' | 'audio' | 'image' | 'text' | 'effect'
|
|
name: string
|
|
start_time: number
|
|
end_time: number
|
|
duration: number
|
|
resource_path?: string
|
|
properties?: any
|
|
effects?: any[]
|
|
}
|
|
export interface Track {
|
|
id: string
|
|
name: string
|
|
type: 'video' | 'audio' | 'effect' | 'text' | 'sticker' | 'image'
|
|
index: number
|
|
segments: TrackSegment[]
|
|
properties?: any
|
|
}
|
|
|
|
interface TrackTimelineProps {
|
|
track: Track
|
|
totalDuration: number
|
|
currentTime: number
|
|
onSegmentClick?: (segment: TrackSegment) => void
|
|
onSegmentHover?: (segment: TrackSegment | null) => void
|
|
onSegmentNameChange?: (segmentId: string, newName: string) => void
|
|
}
|
|
|
|
export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
|
track,
|
|
totalDuration,
|
|
currentTime,
|
|
onSegmentClick,
|
|
onSegmentHover,
|
|
onSegmentNameChange
|
|
}) => {
|
|
const [editingSegmentId, setEditingSegmentId] = React.useState<string | null>(null)
|
|
const [contextMenu, setContextMenu] = React.useState<{
|
|
isOpen: boolean
|
|
position: { x: number; y: number }
|
|
segment: TrackSegment | null
|
|
}>({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
|
|
const [hoveredSegment, setHoveredSegment] = React.useState<TrackSegment | null>(null)
|
|
const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 })
|
|
const [categories, setCategories] = React.useState<ResourceCategoryV2[]>([])
|
|
const [loadingCategories, setLoadingCategories] = React.useState(false)
|
|
|
|
// 加载分类数据
|
|
const loadCategories = React.useCallback(async () => {
|
|
try {
|
|
setLoadingCategories(true)
|
|
const result = await ResourceCategoryServiceV2.getAllCategories({
|
|
include_cloud: true
|
|
})
|
|
setCategories(result)
|
|
} catch (error) {
|
|
console.error('Failed to load categories:', error)
|
|
} finally {
|
|
setLoadingCategories(false)
|
|
}
|
|
}, [])
|
|
|
|
// 组件挂载时加载分类
|
|
React.useEffect(() => {
|
|
loadCategories()
|
|
}, [])
|
|
|
|
const getSegmentColor = (type: string) => {
|
|
switch (type) {
|
|
case 'video': return 'bg-blue-500 hover:bg-blue-600'
|
|
case 'audio': return 'bg-green-500 hover:bg-green-600'
|
|
case 'image': return 'bg-yellow-500 hover:bg-yellow-600'
|
|
case 'text': return 'bg-purple-500 hover:bg-purple-600'
|
|
case 'effect': return 'bg-gray-500 hover:bg-gray-600'
|
|
default: return 'bg-gray-400 hover:bg-gray-500'
|
|
}
|
|
}
|
|
|
|
const getSegmentTextColor = (type: string) => {
|
|
switch (type) {
|
|
case 'video': return 'text-blue-100'
|
|
case 'audio': return 'text-green-100'
|
|
case 'image': return 'text-yellow-100'
|
|
case 'text': return 'text-purple-100'
|
|
case 'effect': return 'text-gray-100'
|
|
default: return 'text-gray-100'
|
|
}
|
|
}
|
|
|
|
const getTrackTypeColor = (type: string) => {
|
|
switch (type) {
|
|
case 'video': return 'bg-blue-100 text-blue-800 border-blue-200'
|
|
case 'audio': return 'bg-green-100 text-green-800 border-green-200'
|
|
case 'subtitle': return 'bg-purple-100 text-purple-800 border-purple-200'
|
|
default: return 'bg-gray-100 text-gray-800 border-gray-200'
|
|
}
|
|
}
|
|
|
|
const formatTime = (seconds: number): string => {
|
|
const minutes = Math.floor(seconds / 60)
|
|
const secs = (seconds % 60).toFixed(2)
|
|
return `${minutes}:${secs.padStart(5, '0')}`
|
|
}
|
|
|
|
const handleSegmentDoubleClick = async (segment: TrackSegment) => {
|
|
setEditingSegmentId(segment.id)
|
|
|
|
// 确保分类数据已加载
|
|
if (categories.length === 0 && !loadingCategories) {
|
|
await loadCategories()
|
|
}
|
|
}
|
|
|
|
const handleSegmentRightClick = (e: React.MouseEvent, segment: TrackSegment) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
setContextMenu({
|
|
isOpen: true,
|
|
position: { x: e.clientX, y: e.clientY },
|
|
segment
|
|
})
|
|
}
|
|
|
|
const handleCategorySelect = (segmentId: string, categoryTitle: string) => {
|
|
if (onSegmentNameChange && categoryTitle) {
|
|
onSegmentNameChange(segmentId, categoryTitle)
|
|
}
|
|
setEditingSegmentId(null)
|
|
}
|
|
|
|
|
|
|
|
const handleSegmentMouseEnter = (e: React.MouseEvent, segment: TrackSegment) => {
|
|
setHoveredSegment(segment)
|
|
setMousePosition({ x: e.clientX, y: e.clientY })
|
|
onSegmentHover?.(segment)
|
|
}
|
|
|
|
const handleSegmentMouseLeave = () => {
|
|
setHoveredSegment(null)
|
|
onSegmentHover?.(null)
|
|
}
|
|
|
|
const handleSegmentMouseMove = (e: React.MouseEvent) => {
|
|
setMousePosition({ x: e.clientX, y: e.clientY })
|
|
}
|
|
|
|
const handleContextMenuClose = () => {
|
|
setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
|
|
}
|
|
|
|
const handleContextMenuEdit = async () => {
|
|
if (contextMenu.segment) {
|
|
setEditingSegmentId(contextMenu.segment.id)
|
|
|
|
// 确保分类数据已加载
|
|
if (categories.length === 0 && !loadingCategories) {
|
|
await loadCategories()
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
{/* Track Header */}
|
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium border ${getTrackTypeColor(track.type)}`}>
|
|
{track.type === 'video' ? '视频' : track.type === 'audio' ? '音频' : '字幕'}
|
|
</span>
|
|
<h3 className="font-medium text-gray-900">
|
|
{track.type}轨道 {track.index}: {track.name}
|
|
</h3>
|
|
</div>
|
|
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
|
<span>{track.segments.length} 个片段</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline */}
|
|
<div className="relative h-16 bg-white">
|
|
{/* Background grid */}
|
|
<div className="absolute inset-0 opacity-10">
|
|
{Array.from({ length: Math.floor(totalDuration) + 1 }, (_, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute top-0 bottom-0 w-px bg-gray-300"
|
|
style={{ left: `${(i / totalDuration) * 100}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Segments */}
|
|
{track.segments.map((segment) => {
|
|
const startPercent = (segment.start_time / totalDuration) * 100
|
|
const widthPercent = (segment.duration / totalDuration) * 100
|
|
|
|
return (
|
|
<div
|
|
key={segment.id}
|
|
className={`absolute top-2 bottom-2 rounded-md ${getSegmentColor(segment.type)} ${getSegmentTextColor(segment.type)}
|
|
flex items-center px-3 text-sm font-medium cursor-pointer transition-all duration-200
|
|
shadow-sm hover:shadow-md transform`}
|
|
style={{
|
|
left: `${startPercent}%`,
|
|
width: `${Math.max(widthPercent, 8)}%`, // Minimum width for visibility
|
|
minWidth: '80px'
|
|
}}
|
|
onClick={() => onSegmentClick?.(segment)}
|
|
onDoubleClick={() => handleSegmentDoubleClick(segment)}
|
|
onContextMenu={(e) => handleSegmentRightClick(e, segment)}
|
|
onMouseEnter={(e) => handleSegmentMouseEnter(e, segment)}
|
|
onMouseLeave={handleSegmentMouseLeave}
|
|
onMouseMove={handleSegmentMouseMove}
|
|
title={`${segment.name}\n类型: ${segment.type}\n开始: ${formatTime(segment.start_time)}\n结束: ${formatTime(segment.end_time)}\n时长: ${formatTime(segment.duration)}${segment.resource_path ? `\n资源: ${segment.resource_path}` : ''}`}
|
|
>
|
|
{editingSegmentId === segment.id ? (
|
|
<div className="w-full relative" onClick={(e) => e.stopPropagation()}>
|
|
{loadingCategories ? (
|
|
<div className="w-full bg-white text-gray-900 border border-gray-300 rounded px-2 py-1 text-sm">
|
|
加载分类中...
|
|
</div>
|
|
) : (
|
|
<select
|
|
value=""
|
|
onChange={(e) => {
|
|
if (e.target.value) {
|
|
handleCategorySelect(segment.id, e.target.value)
|
|
}
|
|
}}
|
|
onBlur={() => setEditingSegmentId(null)}
|
|
className="w-full bg-white text-gray-900 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
|
autoFocus
|
|
size={Math.min(categories.length + 2, 8)} // 限制下拉框高度
|
|
>
|
|
<option value="">选择分类...</option>
|
|
<option value={segment.name} className="text-gray-600">
|
|
保持原名称: {segment.name}
|
|
</option>
|
|
{categories
|
|
.filter(category => category.is_active)
|
|
.map((category) => (
|
|
<option key={category.id} value={category.title} className="flex items-center">
|
|
{category.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="truncate flex-1">
|
|
{segment.name}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Current time indicator */}
|
|
<div
|
|
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-20 pointer-events-none"
|
|
style={{ left: `${(currentTime / totalDuration) * 100}%` }}
|
|
/>
|
|
|
|
{/* Empty track indicator */}
|
|
{track.segments.length === 0 && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm italic">
|
|
该轨道暂无片段
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Context Menu */}
|
|
<SegmentContextMenu
|
|
isOpen={contextMenu.isOpen}
|
|
position={contextMenu.position}
|
|
onClose={handleContextMenuClose}
|
|
onEdit={handleContextMenuEdit}
|
|
onCopy={() => {
|
|
// TODO: Implement copy functionality
|
|
console.log('Copy segment:', contextMenu.segment?.name)
|
|
}}
|
|
onDelete={() => {
|
|
// TODO: Implement delete functionality
|
|
console.log('Delete segment:', contextMenu.segment?.name)
|
|
}}
|
|
onInfo={() => {
|
|
// TODO: Implement info functionality
|
|
console.log('Show info for segment:', contextMenu.segment?.name)
|
|
}}
|
|
/>
|
|
|
|
{/* Segment Tooltip */}
|
|
<SegmentTooltip
|
|
segment={hoveredSegment}
|
|
position={mousePosition}
|
|
isVisible={!!hoveredSegment && !editingSegmentId && !contextMenu.isOpen}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|