fix
This commit is contained in:
parent
1778f99d41
commit
30d4eb4c55
|
|
@ -0,0 +1,202 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useActiveCategoriesOnly } from '../../stores/useCategoryStore'
|
||||||
|
|
||||||
|
interface CategorySelectorProps {
|
||||||
|
isOpen: boolean
|
||||||
|
position: { x: number; y: number }
|
||||||
|
currentName: string
|
||||||
|
onSelect: (categoryName: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategorySelector: React.FC<CategorySelectorProps> = ({
|
||||||
|
isOpen,
|
||||||
|
position,
|
||||||
|
currentName,
|
||||||
|
onSelect,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const activeCategories = useActiveCategoriesOnly()
|
||||||
|
const [selectedIndex, setSelectedIndex] = React.useState(-1)
|
||||||
|
const dropdownRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 处理键盘导航
|
||||||
|
const handleKeyDown = React.useCallback((e: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
onCancel()
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex(prev =>
|
||||||
|
prev < activeCategories.length ? prev + 1 : prev
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex(prev => prev > -1 ? prev - 1 : prev)
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex === -1) {
|
||||||
|
onSelect(currentName) // 保持原名称
|
||||||
|
} else if (selectedIndex < activeCategories.length) {
|
||||||
|
onSelect(activeCategories[selectedIndex].title)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, [isOpen, selectedIndex, activeCategories, currentName, onSelect, onCancel])
|
||||||
|
|
||||||
|
// 监听键盘事件
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isOpen, handleKeyDown])
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen, onCancel])
|
||||||
|
|
||||||
|
// 自动聚焦
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen && dropdownRef.current) {
|
||||||
|
dropdownRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// 计算下拉框位置,确保不超出视窗
|
||||||
|
const adjustedPosition = React.useMemo(() => {
|
||||||
|
const maxHeight = 300 // 下拉框最大高度
|
||||||
|
const padding = 10
|
||||||
|
|
||||||
|
let { x, y } = position
|
||||||
|
|
||||||
|
// 检查是否超出右边界
|
||||||
|
if (x + 250 > window.innerWidth) {
|
||||||
|
x = window.innerWidth - 250 - padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否超出下边界
|
||||||
|
if (y + maxHeight > window.innerHeight) {
|
||||||
|
y = position.y - maxHeight - padding
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: Math.max(padding, x), y: Math.max(padding, y) }
|
||||||
|
}, [position])
|
||||||
|
|
||||||
|
const dropdown = (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed z-50 bg-white border border-gray-300 rounded-lg shadow-lg min-w-[200px] max-w-[300px] max-h-[300px] overflow-hidden"
|
||||||
|
style={{
|
||||||
|
left: adjustedPosition.x,
|
||||||
|
top: adjustedPosition.y,
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="px-3 py-2 bg-gray-50 border-b border-gray-200">
|
||||||
|
<div className="text-sm font-medium text-gray-700">选择分类</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate">当前: {currentName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选项列表 */}
|
||||||
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
|
{/* 保持原名称选项 */}
|
||||||
|
<div
|
||||||
|
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||||
|
selectedIndex === -1
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(currentName)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(-1)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-gray-400 mr-2 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-700 truncate">
|
||||||
|
保持原名称
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate">
|
||||||
|
{currentName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
{activeCategories.length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 my-1" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分类选项 */}
|
||||||
|
{activeCategories.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-gray-500 text-sm">
|
||||||
|
暂无可用分类
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeCategories.map((category, index) => (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||||
|
selectedIndex === index
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(category.title)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mr-2 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: category.color }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{category.title}
|
||||||
|
</div>
|
||||||
|
{category.ai_prompt && (
|
||||||
|
<div className="text-xs text-gray-500 truncate">
|
||||||
|
{category.ai_prompt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示 */}
|
||||||
|
<div className="px-3 py-2 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
↑↓ 导航 • Enter 选择 • Esc 取消
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 使用 Portal 渲染到 body
|
||||||
|
return createPortal(dropdown, document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategorySelector
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { SegmentContextMenu } from './SegmentContextMenu'
|
import { SegmentContextMenu } from './SegmentContextMenu'
|
||||||
import { SegmentTooltip } from './SegmentTooltip'
|
import { SegmentTooltip } from './SegmentTooltip'
|
||||||
import { useCategoriesData, useCategoryActions, useActiveCategoriesOnly } from '../../stores/useCategoryStore'
|
import { CategorySelector } from './CategorySelector'
|
||||||
|
import { useCategoryActions } from '../../stores/useCategoryStore'
|
||||||
|
|
||||||
export interface TrackSegment {
|
export interface TrackSegment {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -40,7 +41,7 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
onSegmentHover,
|
onSegmentHover,
|
||||||
onSegmentNameChange
|
onSegmentNameChange
|
||||||
}) => {
|
}) => {
|
||||||
const [editingSegmentId, setEditingSegmentId] = React.useState<string | null>(null)
|
|
||||||
const [contextMenu, setContextMenu] = React.useState<{
|
const [contextMenu, setContextMenu] = React.useState<{
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
|
|
@ -48,10 +49,14 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
}>({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
|
}>({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
|
||||||
const [hoveredSegment, setHoveredSegment] = React.useState<TrackSegment | null>(null)
|
const [hoveredSegment, setHoveredSegment] = React.useState<TrackSegment | null>(null)
|
||||||
const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 })
|
const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 })
|
||||||
|
const [categorySelector, setCategorySelector] = React.useState<{
|
||||||
|
isOpen: boolean
|
||||||
|
position: { x: number; y: number }
|
||||||
|
segmentId: string | null
|
||||||
|
currentName: string
|
||||||
|
}>({ isOpen: false, position: { x: 0, y: 0 }, segmentId: null, currentName: '' })
|
||||||
|
|
||||||
// 使用 Zustand store 管理分类数据
|
// 使用 Zustand store 管理分类数据
|
||||||
const { loading: loadingCategories } = useCategoriesData()
|
|
||||||
const activeCategories = useActiveCategoriesOnly()
|
|
||||||
const { loadCategories } = useCategoryActions()
|
const { loadCategories } = useCategoryActions()
|
||||||
|
|
||||||
// 组件挂载时加载分类(如果还没有加载)
|
// 组件挂载时加载分类(如果还没有加载)
|
||||||
|
|
@ -96,9 +101,18 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
return `${minutes}:${secs.padStart(5, '0')}`
|
return `${minutes}:${secs.padStart(5, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSegmentDoubleClick = (segment: TrackSegment) => {
|
const handleSegmentDoubleClick = (e: React.MouseEvent, segment: TrackSegment) => {
|
||||||
setEditingSegmentId(segment.id)
|
e.stopPropagation()
|
||||||
// Zustand store 会自动处理分类数据的加载和缓存
|
|
||||||
|
// 获取片段的屏幕位置
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
|
||||||
|
setCategorySelector({
|
||||||
|
isOpen: true,
|
||||||
|
position: { x: rect.left, y: rect.bottom + 5 },
|
||||||
|
segmentId: segment.id,
|
||||||
|
currentName: segment.name
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSegmentRightClick = (e: React.MouseEvent, segment: TrackSegment) => {
|
const handleSegmentRightClick = (e: React.MouseEvent, segment: TrackSegment) => {
|
||||||
|
|
@ -112,11 +126,15 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCategorySelect = (segmentId: string, categoryTitle: string) => {
|
const handleCategorySelect = (categoryTitle: string) => {
|
||||||
if (onSegmentNameChange && categoryTitle) {
|
if (onSegmentNameChange && categorySelector.segmentId && categoryTitle) {
|
||||||
onSegmentNameChange(segmentId, categoryTitle)
|
onSegmentNameChange(categorySelector.segmentId, categoryTitle)
|
||||||
}
|
}
|
||||||
setEditingSegmentId(null)
|
setCategorySelector(prev => ({ ...prev, isOpen: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCategorySelectorCancel = () => {
|
||||||
|
setCategorySelector(prev => ({ ...prev, isOpen: false }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,8 +160,16 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
|
|
||||||
const handleContextMenuEdit = () => {
|
const handleContextMenuEdit = () => {
|
||||||
if (contextMenu.segment) {
|
if (contextMenu.segment) {
|
||||||
setEditingSegmentId(contextMenu.segment.id)
|
// 获取右键菜单的位置作为分类选择器的位置
|
||||||
// Zustand store 会自动处理分类数据的加载和缓存
|
setCategorySelector({
|
||||||
|
isOpen: true,
|
||||||
|
position: contextMenu.position,
|
||||||
|
segmentId: contextMenu.segment.id,
|
||||||
|
currentName: contextMenu.segment.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关闭右键菜单
|
||||||
|
setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,49 +222,16 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
minWidth: '80px'
|
minWidth: '80px'
|
||||||
}}
|
}}
|
||||||
onClick={() => onSegmentClick?.(segment)}
|
onClick={() => onSegmentClick?.(segment)}
|
||||||
onDoubleClick={() => handleSegmentDoubleClick(segment)}
|
onDoubleClick={(e) => handleSegmentDoubleClick(e, segment)}
|
||||||
onContextMenu={(e) => handleSegmentRightClick(e, segment)}
|
onContextMenu={(e) => handleSegmentRightClick(e, segment)}
|
||||||
onMouseEnter={(e) => handleSegmentMouseEnter(e, segment)}
|
onMouseEnter={(e) => handleSegmentMouseEnter(e, segment)}
|
||||||
onMouseLeave={handleSegmentMouseLeave}
|
onMouseLeave={handleSegmentMouseLeave}
|
||||||
onMouseMove={handleSegmentMouseMove}
|
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}` : ''}`}
|
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(activeCategories.length + 2, 8)} // 限制下拉框高度
|
|
||||||
>
|
|
||||||
<option value="">选择分类...</option>
|
|
||||||
<option value={segment.name} className="text-gray-600">
|
|
||||||
保持原名称: {segment.name}
|
|
||||||
</option>
|
|
||||||
{activeCategories.map((category) => (
|
|
||||||
<option key={category.id} value={category.title} className="flex items-center">
|
|
||||||
{category.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="truncate flex-1">
|
<div className="truncate flex-1">
|
||||||
{segment.name}
|
{segment.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -281,7 +274,16 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
||||||
<SegmentTooltip
|
<SegmentTooltip
|
||||||
segment={hoveredSegment}
|
segment={hoveredSegment}
|
||||||
position={mousePosition}
|
position={mousePosition}
|
||||||
isVisible={!!hoveredSegment && !editingSegmentId && !contextMenu.isOpen}
|
isVisible={!!hoveredSegment && !contextMenu.isOpen && !categorySelector.isOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category Selector */}
|
||||||
|
<CategorySelector
|
||||||
|
isOpen={categorySelector.isOpen}
|
||||||
|
position={categorySelector.position}
|
||||||
|
currentName={categorySelector.currentName}
|
||||||
|
onSelect={handleCategorySelect}
|
||||||
|
onCancel={handleCategorySelectorCancel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue