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 { SegmentContextMenu } from './SegmentContextMenu'
|
||||
import { SegmentTooltip } from './SegmentTooltip'
|
||||
import { useCategoriesData, useCategoryActions, useActiveCategoriesOnly } from '../../stores/useCategoryStore'
|
||||
import { CategorySelector } from './CategorySelector'
|
||||
import { useCategoryActions } from '../../stores/useCategoryStore'
|
||||
|
||||
export interface TrackSegment {
|
||||
id: string
|
||||
|
|
@ -40,7 +41,7 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
|||
onSegmentHover,
|
||||
onSegmentNameChange
|
||||
}) => {
|
||||
const [editingSegmentId, setEditingSegmentId] = React.useState<string | null>(null)
|
||||
|
||||
const [contextMenu, setContextMenu] = React.useState<{
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
|
|
@ -48,10 +49,14 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
|||
}>({ 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 [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 管理分类数据
|
||||
const { loading: loadingCategories } = useCategoriesData()
|
||||
const activeCategories = useActiveCategoriesOnly()
|
||||
const { loadCategories } = useCategoryActions()
|
||||
|
||||
// 组件挂载时加载分类(如果还没有加载)
|
||||
|
|
@ -96,9 +101,18 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
|||
return `${minutes}:${secs.padStart(5, '0')}`
|
||||
}
|
||||
|
||||
const handleSegmentDoubleClick = (segment: TrackSegment) => {
|
||||
setEditingSegmentId(segment.id)
|
||||
// Zustand store 会自动处理分类数据的加载和缓存
|
||||
const handleSegmentDoubleClick = (e: React.MouseEvent, segment: TrackSegment) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// 获取片段的屏幕位置
|
||||
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) => {
|
||||
|
|
@ -112,11 +126,15 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
|||
})
|
||||
}
|
||||
|
||||
const handleCategorySelect = (segmentId: string, categoryTitle: string) => {
|
||||
if (onSegmentNameChange && categoryTitle) {
|
||||
onSegmentNameChange(segmentId, categoryTitle)
|
||||
const handleCategorySelect = (categoryTitle: string) => {
|
||||
if (onSegmentNameChange && categorySelector.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 = () => {
|
||||
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'
|
||||
}}
|
||||
onClick={() => onSegmentClick?.(segment)}
|
||||
onDoubleClick={() => handleSegmentDoubleClick(segment)}
|
||||
onDoubleClick={(e) => handleSegmentDoubleClick(e, 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(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">
|
||||
{segment.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate flex-1">
|
||||
{segment.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
@ -281,7 +274,16 @@ export const TrackTimeline: React.FC<TrackTimelineProps> = ({
|
|||
<SegmentTooltip
|
||||
segment={hoveredSegment}
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue