This commit is contained in:
root 2025-07-13 00:15:45 +08:00
parent 1778f99d41
commit 30d4eb4c55
2 changed files with 255 additions and 51 deletions

View File

@ -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

View File

@ -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>
)