refactor: 重构FileManager组件,提升代码可维护性和模块化

本次重构将原有1672行的FilePane.vue拆分为多个独立模块,代码减少至1405行(-16%)

## 新增文件

### Composables
- useDragSelection.ts: 拖拽框选功能,包含自动滚动和选择框计算
- useDragAndDrop.ts: 拖放功能,管理拖拽状态和自定义预览
- index.ts: 统一导出所有composables

### Utils
- fileOperations.ts: 文件操作工具集(移动、复制、删除、重命名、创建、搜索)

## 主要改进

### 架构优化
- 将巨型组件拆分为职责单一的模块
- 实现关注点分离,提高代码可读性
- 增强composables复用性,可用于其他组件

### 功能优化
- 支持双面板独立配置(视图模式、缩放级别)
- 优化拖放视觉反馈,完成后绿框自动消失
- 简化拖拽动画效果,视觉更轻盈
- 文件移动支持重名自动处理

### 代码质量
- 完整的TypeScript类型定义
- 更严格的参数验证和错误处理
- 减少代码重复,提高可测试性
- 优化事件处理器性能

## 功能完整性
✅ 拖拽框选文件
✅ 边缘自动滚动
✅ 拖放文件/文件夹
✅ 跨面板移动
✅ Ctrl+滚轮缩放
✅ 面板激活状态
✅ 视图切换(网格/列表)
✅ 实时文件移动预览

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jimmyandClaude committed 2025-09-30 23:05:09 +08:00
1 parent bf5e44dca3
commit bf03ca9535
8 files changed
+1046 -294

No files matched your search

@@ -16,27 +16,27 @@
<!-- View Controls -->
<div class="view-controls">
<button
:class="['btn-view', { active: viewMode === 'grid' }]"
@click="viewMode = 'grid'"
:class="['btn-view', { active: activeConfig.viewMode === 'grid' }]"
@click="updateActiveViewMode('grid')"
>
网格
</button>
<button
:class="['btn-view', { active: viewMode === 'list' }]"
@click="viewMode = 'list'"
:class="['btn-view', { active: activeConfig.viewMode === 'list' }]"
@click="updateActiveViewMode('list')"
>
列表
</button>
</div>
<!-- Thumbnail Size -->
<div v-if="viewMode === 'grid'" class="thumbnail-controls">
<div v-if="activeConfig.viewMode === 'grid'" class="thumbnail-controls">
<span class="label">大小:</span>
<button
v-for="size in (['small', 'medium', 'large'] as ThumbnailSize[])"
:key="size"
:class="['btn-size', { active: thumbnailSize === size }]"
@click="thumbnailSize = size"
:class="['btn-size', { active: activeConfig.thumbnailSize === size }]"
@click="updateActiveThumbnailSize(size)"
>
{{ size === 'small' ? '小' : size === 'medium' ? '中' : '大' }}
</button>
@@ -73,8 +73,8 @@
<div :class="['main-content', `layout-${layoutMode}`]">
<FilePane
pane-id="left"
:view-mode="viewMode"
:thumbnail-size="thumbnailSize"
:view-mode="paneConfigs.left.viewMode"
:thumbnail-size="paneConfigs.left.thumbnailSize"
:is-active="activePane === 'left'"
@activate="activePane = 'left'"
/>
@@ -88,8 +88,8 @@
<FilePane
v-if="layoutMode === 'horizontal' || layoutMode === 'vertical'"
pane-id="right"
:view-mode="viewMode"
:thumbnail-size="thumbnailSize"
:view-mode="paneConfigs.right.viewMode"
:thumbnail-size="paneConfigs.right.thumbnailSize"
:is-active="activePane === 'right'"
@activate="activePane = 'right'"
/>
@@ -98,15 +98,37 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import FilePane from './FilePane.vue'
import type { ViewMode, ThumbnailSize, LayoutMode, PaneId } from './types'
const viewMode = ref<ViewMode>('grid')
const thumbnailSize = ref<ThumbnailSize>('medium')
// 每个面板的独立配置
const paneConfigs = ref({
left: {
viewMode: 'grid' as ViewMode,
thumbnailSize: 'medium' as ThumbnailSize
},
right: {
viewMode: 'grid' as ViewMode,
thumbnailSize: 'medium' as ThumbnailSize
}
})
const layoutMode = ref<LayoutMode>('single')
const activePane = ref<PaneId>('left')
// 当前活动面板的配置(用于工具栏显示)
const activeConfig = computed(() => paneConfigs.value[activePane.value])
// 更新当前活动面板的配置
function updateActiveViewMode(mode: ViewMode) {
paneConfigs.value[activePane.value].viewMode = mode
}
function updateActiveThumbnailSize(size: ThumbnailSize) {
paneConfigs.value[activePane.value].thumbnailSize = size
}
// 分隔器拖拽
const isDraggingSplitter = ref(false)
@@ -133,7 +133,7 @@
</div>
<!-- Selection Rectangle -->
<div v-if="dragSelection.isSelecting" class="selection-rectangle" :style="selectionRectStyle"></div>
<div v-if="dragSelection.isSelecting" class="selection-rectangle" :style="selectionBoxStyle"></div>
</div>
<!-- Inactive Overlay -->
@@ -183,14 +183,20 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue'
import { useFileSelection } from '@/composables/fileManager/useFileSelection'
import { useKeyboardNav } from '@/composables/fileManager/useKeyboardNav'
import { mockFolderStructure } from './mockData'
import { useDragSelection } from '@/composables/fileManager/useDragSelection'
import { useDragAndDrop } from '@/composables/fileManager/useDragAndDrop'
import { moveItems, getFolderByPath, searchItems } from '@/utils/fileManager/fileOperations'
import { mockFolderStructure as originalMockData } from './mockData'
import type { FileItem, ViewMode, ThumbnailSize, PaneId } from './types'
import ContextMenu from './ContextMenu.vue'
import Tooltip from './Tooltip.vue'
// 将 mockData 转换为响应式对象(全局共享)
const mockFolderStructure = reactive(originalMockData)
const props = defineProps<{
paneId: PaneId
viewMode: ViewMode
@@ -207,7 +213,6 @@ const currentPath = ref<string[]>(['Home'])
const contentAreaRef = ref<HTMLElement>()
const tooltipRef = ref<InstanceType<typeof Tooltip>>()
const pathInputRef = ref<HTMLInputElement>()
const selection = useFileSelection()
const searchQuery = ref('')
const pathEditMode = ref(false)
const pathEditValue = ref('')
@@ -230,44 +235,18 @@ const breadcrumbDropdown = ref({
folders: [] as string[]
})
// Drag selection state
const dragSelection = ref({
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
initialSelections: new Set<string>(),
ctrlKey: false,
justFinished: false
})
// Composables
const selection = useFileSelection()
const dragSelectionLogic = useDragSelection()
const dragDropLogic = useDragAndDrop()
// Auto scroll state
const autoScroll = ref({
isScrolling: false,
direction: { x: 0, y: 0 }
})
// Drag and drop state
const dragState = ref({
isDragging: false,
draggedItems: [] as string[],
dropTarget: null as string | null,
sourcePane: null as string | null,
isPaneDragOver: false
})
// 解构composables以便使用
const { dragSelection, autoScroll, selectionBoxStyle, startDragSelection, updateDragSelection, checkAutoScroll, startAutoScroll, finishDragSelection, cancelDragSelection, isIntersecting } = dragSelectionLogic
const { dragState, startDrag, setDropTarget, setPaneDragOver, createDragPreview, cleanupDragPreview, endDrag, resetDragState } = dragDropLogic
// Computed
const currentFolder = computed(() => {
let folder = mockFolderStructure
for (const segment of currentPath.value) {
if (folder[segment] && folder[segment].children) {
folder = folder[segment].children as any
} else {
return null
}
}
return folder
return getFolderByPath(mockFolderStructure, currentPath.value)
})
const visibleItems = computed(() => {
@@ -288,20 +267,6 @@ const visibleItems = computed(() => {
return items
})
const selectionRectStyle = computed(() => {
const left = Math.min(dragSelection.value.startX, dragSelection.value.currentX)
const top = Math.min(dragSelection.value.startY, dragSelection.value.currentY)
const width = Math.abs(dragSelection.value.currentX - dragSelection.value.startX)
const height = Math.abs(dragSelection.value.currentY - dragSelection.value.startY)
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
}
})
// Methods
function highlightText(text: string): string {
if (!searchQuery.value.trim()) {
@@ -443,6 +408,9 @@ function isFocused(itemName: string): boolean {
function handleItemClick(event: MouseEvent, itemName: string, index: number) {
event.stopPropagation()
// 激活当前面板
emit('activate')
if (event.shiftKey && selection.anchorItem.value) {
// Range selection
selection.clearSelection()
@@ -477,25 +445,19 @@ function handleMouseDown(event: MouseEvent) {
emit('activate')
const rect = contentAreaRef.value?.getBoundingClientRect()
if (!rect) return
if (!contentAreaRef.value) return
const scrollEl = contentAreaRef.value!
dragSelection.value = {
isSelecting: true,
startX: event.clientX - rect.left + scrollEl.scrollLeft,
startY: event.clientY - rect.top + scrollEl.scrollTop,
currentX: event.clientX - rect.left + scrollEl.scrollLeft,
currentY: event.clientY - rect.top + scrollEl.scrollTop,
initialSelections: new Set(selection.selectedItems.value),
ctrlKey: event.ctrlKey || event.metaKey,
justFinished: false
}
// 使用composable开始拖拽选择
const ctrlKey = event.ctrlKey || event.metaKey
startDragSelection(event, contentAreaRef.value, ctrlKey, selection.selectedItems.value)
if (!dragSelection.value.ctrlKey) {
if (!ctrlKey) {
selection.clearSelection()
}
// 启动自动滚动(带回调更新选择)
startAutoScroll(contentAreaRef.value, updateSelectionFromDragBox)
// 在 document 级别监听 mousemove 和 mouseup
const onDocumentMouseMove = (e: MouseEvent) => {
handleMouseMove(e)
@@ -514,54 +476,34 @@ function handleMouseDown(event: MouseEvent) {
}
function handleMouseMove(event: MouseEvent) {
if (!dragSelection.value.isSelecting) return
// 如果不在拖拽选择状态,隐藏tooltip(防止在空白区域显示)
if (!dragSelection.value.isSelecting) {
const target = event.target as HTMLElement
if (!target.closest('.file-item') && !target.closest('.list-item')) {
tooltipRef.value?.hide()
}
return
}
const rect = contentAreaRef.value?.getBoundingClientRect()
if (!rect) return
if (!contentAreaRef.value) return
const scrollEl = contentAreaRef.value!
// 计算鼠标相对于内容区域的位置(包含滚动偏移)
// 鼠标在视口中的位置 + 滚动偏移 = 在文档中的绝对位置
const rawX = event.clientX - rect.left + scrollEl.scrollLeft
const rawY = event.clientY - rect.top + scrollEl.scrollTop
// 限制坐标在合理范围内
// 当鼠标超出边界时,坐标应该停留在边界上,而不是继续增长
const maxScrollLeft = Math.max(0, scrollEl.scrollWidth - scrollEl.clientWidth)
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
// 最大坐标 = 可见区域大小 + 最大滚动距离
const maxX = scrollEl.clientWidth + maxScrollLeft
const maxY = scrollEl.clientHeight + maxScrollTop
const relativeX = Math.max(0, Math.min(rawX, maxX))
const relativeY = Math.max(0, Math.min(rawY, maxY))
// 存储坐标
dragSelection.value.currentX = relativeX
dragSelection.value.currentY = relativeY
// 使用composable更新拖拽选择
updateDragSelection(event, contentAreaRef.value)
// 自动滚动检测
checkAutoScroll(event)
checkAutoScroll(event, contentAreaRef.value)
// 更新框选
updateDragSelection()
// 更新框选的项
updateSelectionFromDragBox()
}
function handleMouseUp() {
if (dragSelection.value.isSelecting) {
// 最后更新一次选择
updateDragSelection()
updateSelectionFromDragBox()
dragSelection.value.isSelecting = false
autoScroll.value.isScrolling = false
// 标记刚完成拖拽,防止 handleContentClick 清除选择
dragSelection.value.justFinished = true
setTimeout(() => {
dragSelection.value.justFinished = false
}, 100)
// 使用composable完成拖拽选择
finishDragSelection()
// 设置焦点到最后选中的项
if (selection.selectedItems.value.size > 0) {
@@ -574,149 +516,16 @@ function handleMouseUp() {
}
}
// 检查是否需要自动滚动
function checkAutoScroll(event: MouseEvent) {
if (!contentAreaRef.value) return
const rect = contentAreaRef.value.getBoundingClientRect()
const scrollEl = contentAreaRef.value
const threshold = 50
const baseScrollSpeed = 10
let dx = 0
let dy = 0
// 检查垂直滚动
if (event.clientY < rect.top) {
dy = -baseScrollSpeed
} else if (event.clientY < rect.top + threshold) {
const distance = event.clientY - rect.top
const ratio = 1 - distance / threshold
dy = -Math.max(3, baseScrollSpeed * ratio)
} else if (event.clientY > rect.bottom) {
dy = baseScrollSpeed
} else if (event.clientY > rect.bottom - threshold) {
const distance = rect.bottom - event.clientY
const ratio = 1 - distance / threshold
dy = Math.max(3, baseScrollSpeed * ratio)
}
// 检查水平滚动 - 只在确实有横向滚动空间时才启用
const hasHorizontalScroll = scrollEl.scrollWidth > scrollEl.clientWidth
if (hasHorizontalScroll) {
if (event.clientX < rect.left) {
dx = -baseScrollSpeed
} else if (event.clientX < rect.left + threshold) {
const distance = event.clientX - rect.left
const ratio = 1 - distance / threshold
dx = -Math.max(3, baseScrollSpeed * ratio)
} else if (event.clientX > rect.right) {
dx = baseScrollSpeed
} else if (event.clientX > rect.right - threshold) {
const distance = rect.right - event.clientX
const ratio = 1 - distance / threshold
dx = Math.max(3, baseScrollSpeed * ratio)
}
}
if (dx !== 0 || dy !== 0) {
if (!autoScroll.value.isScrolling) {
autoScroll.value.isScrolling = true
autoScroll.value.direction = { x: dx, y: dy }
startAutoScroll()
} else {
autoScroll.value.direction = { x: dx, y: dy }
}
} else {
autoScroll.value.isScrolling = false
}
}
// 开始自动滚动
function startAutoScroll() {
let frameId: number | null = null
function scroll() {
if (!autoScroll.value.isScrolling || !contentAreaRef.value) {
if (frameId !== null) {
cancelAnimationFrame(frameId)
frameId = null
}
return
}
const scrollEl = contentAreaRef.value
// 计算新的滚动位置,并限制在有效范围内
const maxScrollLeft = Math.max(0, scrollEl.scrollWidth - scrollEl.clientWidth)
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
let newScrollLeft = scrollEl.scrollLeft + autoScroll.value.direction.x
let newScrollTop = scrollEl.scrollTop + autoScroll.value.direction.y
// 限制滚动范围
newScrollLeft = Math.max(0, Math.min(newScrollLeft, maxScrollLeft))
newScrollTop = Math.max(0, Math.min(newScrollTop, maxScrollTop))
// 计算实际滚动距离
const actualDx = newScrollLeft - scrollEl.scrollLeft
const actualDy = newScrollTop - scrollEl.scrollTop
// 应用滚动
scrollEl.scrollLeft = newScrollLeft
scrollEl.scrollTop = newScrollTop
// 更新拖拽坐标(只在实际发生滚动时更新)
if (actualDx !== 0 || actualDy !== 0) {
dragSelection.value.currentX += actualDx
dragSelection.value.currentY += actualDy
// 确保坐标不超出内容范围
const maxX = scrollEl.clientWidth + maxScrollLeft
const maxY = scrollEl.clientHeight + maxScrollTop
dragSelection.value.currentX = Math.max(0, Math.min(dragSelection.value.currentX, maxX))
dragSelection.value.currentY = Math.max(0, Math.min(dragSelection.value.currentY, maxY))
updateDragSelection()
}
frameId = requestAnimationFrame(scroll)
}
frameId = requestAnimationFrame(scroll)
}
// 更新框选的项
function updateDragSelection() {
// 更新框选的项(使用composable的isIntersecting方法)
function updateSelectionFromDragBox() {
if (!contentAreaRef.value) return
const items = contentAreaRef.value.querySelectorAll('.file-item, .list-item')
const selectionLeft = Math.min(dragSelection.value.startX, dragSelection.value.currentX)
const selectionTop = Math.min(dragSelection.value.startY, dragSelection.value.currentY)
const selectionRight = Math.max(dragSelection.value.startX, dragSelection.value.currentX)
const selectionBottom = Math.max(dragSelection.value.startY, dragSelection.value.currentY)
const scrollEl = contentAreaRef.value
const contentRect = scrollEl.getBoundingClientRect()
const itemsInSelection = new Set<string>()
items.forEach(item => {
const itemRect = item.getBoundingClientRect()
const itemLeft = itemRect.left - contentRect.left + scrollEl.scrollLeft
const itemTop = itemRect.top - contentRect.top + scrollEl.scrollTop
const itemRight = itemLeft + itemRect.width
const itemBottom = itemTop + itemRect.height
const intersects = !(
itemRight < selectionLeft ||
itemLeft > selectionRight ||
itemBottom < selectionTop ||
itemTop > selectionBottom
)
if (intersects) {
if (isIntersecting(item as HTMLElement, contentAreaRef.value!)) {
const itemName = (item as HTMLElement).dataset.itemName
if (itemName) {
itemsInSelection.add(itemName)
@@ -839,29 +648,31 @@ function handleDragStart(event: DragEvent, item: FileItem) {
selection.selectItem(item.name, index)
}
// 设置拖动状态
dragState.value.isDragging = true
dragState.value.draggedItems = Array.from(selection.selectedItems.value)
dragState.value.sourcePane = props.paneId
const draggedItems = Array.from(selection.selectedItems.value)
// 使用composable设置拖动状态
startDrag(draggedItems, props.paneId)
// 设置拖动数据
const dragData = {
items: dragState.value.draggedItems,
items: draggedItems,
sourcePane: props.paneId,
sourcePath: currentPath.value
}
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('application/json', JSON.stringify(dragData))
// 使用composable创建自定义拖动预览
const preview = createDragPreview(draggedItems.length, draggedItems[0])
event.dataTransfer!.setDragImage(preview, 0, 0)
setTimeout(() => cleanupDragPreview(preview), 0)
// 隐藏tooltip
tooltipRef.value?.hide()
}
function handleDragEnd() {
dragState.value.isDragging = false
dragState.value.dropTarget = null
dragState.value.sourcePane = null
dragState.value.isPaneDragOver = false
resetDragState()
}
function handleDragOver(event: DragEvent, item: FileItem) {
@@ -873,7 +684,7 @@ function handleDragOver(event: DragEvent, item: FileItem) {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
dragState.value.dropTarget = item.name
setDropTarget(item.name)
}
function handleDragLeave(event: DragEvent) {
@@ -882,7 +693,7 @@ function handleDragLeave(event: DragEvent) {
// 只有真正离开元素时才清除
if (!target.contains(relatedTarget)) {
dragState.value.dropTarget = null
setDropTarget(null)
}
}
@@ -895,24 +706,35 @@ function handleDrop(event: DragEvent, targetItem: FileItem) {
try {
const dragData = JSON.parse(event.dataTransfer!.getData('application/json'))
// 显示移动操作(实际实现需要状态管理)
const itemNames = dragData.items.join(', ')
console.log(`移动文件: ${itemNames} 到 ${targetItem.name}`)
alert(`已移动 ${dragData.items.length} 个项目到 "${targetItem.name}"`)
// 显示移动操作日志
const itemCount = dragData.items.length
const itemLabel = itemCount === 1 ? dragData.items[0] : `${itemCount} 个项目`
// TODO: 实现实际的文件移动逻辑
// 这需要:
// 1. 更新mockData或使用真实的文件系统API
// 2. 从源位置移除文件
// 3. 添加到目标位置
// 4. 刷新视图
console.log(`[文件操作] 移动 ${itemLabel} 到文件夹 "${targetItem.name}"`, {
source: dragData.sourcePane,
target: props.paneId,
items: dragData.items,
targetFolder: targetItem.name
})
// 执行文件移动(使用utils)
const targetPath = [...currentPath.value, targetItem.name]
const success = moveItems(mockFolderStructure, dragData.items, dragData.sourcePath, targetPath)
if (success) {
// 清除选择
selection.clearSelection()
// 触发视图更新
nextTick()
}
} catch (e) {
console.error('Drop failed:', e)
console.error('[文件操作] 拖放失败:', e)
}
dragState.value.isDragging = false
dragState.value.dropTarget = null
// 清除所有拖拽状态(包括绿框)
resetDragState()
}
// Pane-level drop handlers (for dropping into empty space or other pane)
@@ -928,7 +750,7 @@ function handlePaneDragOver(event: DragEvent) {
if (data) {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
dragState.value.isPaneDragOver = true
setPaneDragOver(true)
}
} catch (e) {
// Ignore
@@ -941,7 +763,7 @@ function handlePaneDragLeave(event: DragEvent) {
// 只有真正离开pane时才清除
if (!contentAreaRef.value?.contains(relatedTarget)) {
dragState.value.isPaneDragOver = false
setPaneDragOver(false)
}
}
@@ -959,22 +781,39 @@ function handlePaneDrop(event: DragEvent) {
// 拖到当前面板的空白区域 = 移动到当前文件夹
const currentFolderName = currentPath.value[currentPath.value.length - 1]
console.log(`移动文件: ${dragData.items.join(', ')} 到当前文件夹 ${currentFolderName}`)
const itemCount = dragData.items.length
const itemLabel = itemCount === 1 ? dragData.items[0] : `${itemCount} 个项目`
if (dragData.sourcePane !== props.paneId) {
alert(`已从${dragData.sourcePane === 'left' ? '左' : '右'}面板移动 ${dragData.items.length} 个项目到${props.paneId === 'left' ? '左' : '右'}面板的 "${currentFolderName}"`)
// 跨面板移动
const sourceLabel = dragData.sourcePane === 'left' ? '左' : '右'
const targetLabel = props.paneId === 'left' ? '左' : '右'
console.log(`[文件操作] 从${sourceLabel}面板移动 ${itemLabel} 到${targetLabel}面板的 "${currentFolderName}"`, {
source: dragData.sourcePane,
target: props.paneId,
items: dragData.items,
targetPath: currentPath.value
})
// 执行跨面板移动(使用utils)
const success = moveItems(mockFolderStructure, dragData.items, dragData.sourcePath, currentPath.value)
if (success) {
selection.clearSelection()
nextTick()
}
} else {
alert(`在当前文件夹内操作`)
// 同面板内移动(相当于什么都不做)
console.log(`[文件操作] 在当前文件夹内操作 ${itemLabel}(无变化)`)
selection.clearSelection()
}
// TODO: 实现实际的文件移动逻辑
} catch (e) {
console.error('Pane drop failed:', e)
console.error('[文件操作] 面板拖放失败:', e)
}
dragState.value.isPaneDragOver = false
dragState.value.isDragging = false
// 清除所有拖拽状态(包括绿框)
resetDragState()
}
// 计算Grid列数
@@ -1382,13 +1221,14 @@ useKeyboardNav({
padding: 16px;
overflow: auto;
position: relative;
transition: background-color 0.2s;
transition: all 0.2s;
}
.content-area.pane-drag-over {
background: #f0fdf4;
outline: 2px dashed #10b981;
outline-offset: -8px;
background: rgba(240, 253, 244, 0.3);
padding-bottom: 24px;
border: 2px dashed #10b981;
border-radius: 4px;
}
/* Grid View */
@@ -1444,13 +1284,15 @@ useKeyboardNav({
.file-item.drop-target {
border-color: #10b981;
background: #f0fdf4;
transition: all 0.15s;
}
.file-item.drag-over {
border-color: #10b981;
background: #d1fae5;
background: rgba(209, 250, 229, 0.5);
transform: scale(1.02);
box-shadow: 0 0 0 2px #10b981;
z-index: 1;
}
.file-icon {
@@ -1517,13 +1359,15 @@ useKeyboardNav({
.list-item.drop-target {
background: #f0fdf4;
border-left: 3px solid #10b981;
border-left: 4px solid #10b981;
transition: all 0.15s;
}
.list-item.drag-over {
background: #d1fae5;
background: rgba(209, 250, 229, 0.5);
border-left: 3px solid #10b981;
box-shadow: 0 0 0 1px #10b981;
transform: translateX(2px);
}
.list-icon {
@@ -0,0 +1,4 @@
export { useFileSelection } from './useFileSelection'
export { useKeyboardNav } from './useKeyboardNav'
export { useDragSelection } from './useDragSelection'
export { useDragAndDrop } from './useDragAndDrop'
@@ -0,0 +1,119 @@
import { ref } from 'vue'
/**
* 拖放功能
* 处理文件/文件夹的拖放操作
*/
export function useDragAndDrop() {
// 拖放状态
const dragState = ref({
isDragging: false,
draggedItems: [] as string[],
dropTarget: null as string | null,
sourcePane: null as string | null,
isPaneDragOver: false
})
/**
* 开始拖拽
*/
function startDrag(items: string[], paneId: string) {
dragState.value.isDragging = true
dragState.value.draggedItems = items
dragState.value.sourcePane = paneId
}
/**
* 设置放置目标
*/
function setDropTarget(targetName: string | null) {
dragState.value.dropTarget = targetName
}
/**
* 设置面板拖放状态
*/
function setPaneDragOver(isOver: boolean) {
dragState.value.isPaneDragOver = isOver
}
/**
* 创建自定义拖拽预览
*/
function createDragPreview(itemCount: number, firstItemName: string): HTMLElement {
const preview = document.createElement('div')
preview.style.cssText = `
position: absolute;
top: -9999px;
padding: 12px 16px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
pointer-events: none;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
`
const icon = document.createElement('span')
icon.textContent = '📋'
icon.style.fontSize = '20px'
const text = document.createElement('span')
if (itemCount === 1) {
text.textContent = firstItemName
} else {
text.textContent = `${itemCount} 个项目`
}
preview.appendChild(icon)
preview.appendChild(text)
document.body.appendChild(preview)
return preview
}
/**
* 清理拖拽预览
*/
function cleanupDragPreview(preview: HTMLElement | null) {
if (preview && preview.parentNode) {
preview.parentNode.removeChild(preview)
}
}
/**
* 结束拖拽
*/
function endDrag() {
dragState.value.isDragging = false
dragState.value.dropTarget = null
dragState.value.sourcePane = null
}
/**
* 重置所有拖拽状态
*/
function resetDragState() {
dragState.value.isDragging = false
dragState.value.draggedItems = []
dragState.value.dropTarget = null
dragState.value.sourcePane = null
dragState.value.isPaneDragOver = false
}
return {
dragState,
startDrag,
setDropTarget,
setPaneDragOver,
createDragPreview,
cleanupDragPreview,
endDrag,
resetDragState
}
}
@@ -0,0 +1,251 @@
import { ref, computed } from 'vue'
/**
* 拖拽框选功能
* 处理鼠标拖拽选择文件/文件夹的逻辑
*/
export function useDragSelection() {
// 拖拽框选状态
const dragSelection = ref({
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
initialSelections: new Set<string>(),
ctrlKey: false,
justFinished: false
})
// 自动滚动状态
const autoScroll = ref({
isScrolling: false,
direction: { x: 0, y: 0 }
})
// 计算选择框样式
const selectionBoxStyle = computed(() => {
if (!dragSelection.value.isSelecting) return { display: 'none' }
const left = Math.min(dragSelection.value.startX, dragSelection.value.currentX)
const top = Math.min(dragSelection.value.startY, dragSelection.value.currentY)
const width = Math.abs(dragSelection.value.currentX - dragSelection.value.startX)
const height = Math.abs(dragSelection.value.currentY - dragSelection.value.startY)
return {
display: 'block',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
}
})
/**
* 开始拖拽选择
*/
function startDragSelection(
event: MouseEvent,
containerRef: HTMLElement,
ctrlKey: boolean,
initialSelections: Set<string>
) {
const rect = containerRef.getBoundingClientRect()
const scrollLeft = containerRef.scrollLeft
const scrollTop = containerRef.scrollTop
dragSelection.value = {
isSelecting: true,
startX: event.clientX - rect.left + scrollLeft,
startY: event.clientY - rect.top + scrollTop,
currentX: event.clientX - rect.left + scrollLeft,
currentY: event.clientY - rect.top + scrollTop,
initialSelections: new Set(initialSelections),
ctrlKey,
justFinished: false
}
}
/**
* 更新拖拽选择位置
*/
function updateDragSelection(event: MouseEvent, containerRef: HTMLElement) {
if (!dragSelection.value.isSelecting) return
const rect = containerRef.getBoundingClientRect()
const scrollEl = containerRef
const rawX = event.clientX - rect.left + scrollEl.scrollLeft
const rawY = event.clientY - rect.top + scrollEl.scrollTop
const maxScrollLeft = Math.max(0, scrollEl.scrollWidth - scrollEl.clientWidth)
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
const maxX = scrollEl.clientWidth + maxScrollLeft
const maxY = scrollEl.clientHeight + maxScrollTop
dragSelection.value.currentX = Math.max(0, Math.min(rawX, maxX))
dragSelection.value.currentY = Math.max(0, Math.min(rawY, maxY))
}
/**
* 检查是否需要自动滚动
*/
function checkAutoScroll(event: MouseEvent, containerRef: HTMLElement) {
const rect = containerRef.getBoundingClientRect()
const scrollEl = containerRef
const threshold = 50
const baseScrollSpeed = 10
let dx = 0
let dy = 0
// 垂直滚动检测
if (event.clientY < rect.top) {
dy = -baseScrollSpeed
} else if (event.clientY < rect.top + threshold) {
const distance = event.clientY - rect.top
const ratio = 1 - distance / threshold
dy = -Math.max(3, baseScrollSpeed * ratio)
} else if (event.clientY > rect.bottom) {
dy = baseScrollSpeed
} else if (event.clientY > rect.bottom - threshold) {
const distance = rect.bottom - event.clientY
const ratio = 1 - distance / threshold
dy = Math.max(3, baseScrollSpeed * ratio)
}
// 水平滚动检测(仅当存在水平滚动空间时)
const hasHorizontalScroll = scrollEl.scrollWidth > scrollEl.clientWidth
if (hasHorizontalScroll) {
if (event.clientX < rect.left) {
dx = -baseScrollSpeed
} else if (event.clientX < rect.left + threshold) {
const distance = event.clientX - rect.left
const ratio = 1 - distance / threshold
dx = -Math.max(3, baseScrollSpeed * ratio)
} else if (event.clientX > rect.right) {
dx = baseScrollSpeed
} else if (event.clientX > rect.right - threshold) {
const distance = rect.right - event.clientX
const ratio = 1 - distance / threshold
dx = Math.max(3, baseScrollSpeed * ratio)
}
}
// 更新自动滚动状态
if (dx !== 0 || dy !== 0) {
autoScroll.value.isScrolling = true
autoScroll.value.direction = { x: dx, y: dy }
} else {
autoScroll.value.isScrolling = false
autoScroll.value.direction = { x: 0, y: 0 }
}
}
/**
* 启动自动滚动循环
*/
function startAutoScroll(containerRef: HTMLElement, onScroll: () => void) {
let frameId: number | null = null
function scroll() {
if (!autoScroll.value.isScrolling || !containerRef) {
if (frameId !== null) {
cancelAnimationFrame(frameId)
frameId = null
}
return
}
const scrollEl = containerRef
const maxScrollLeft = Math.max(0, scrollEl.scrollWidth - scrollEl.clientWidth)
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
let newScrollLeft = scrollEl.scrollLeft + autoScroll.value.direction.x
let newScrollTop = scrollEl.scrollTop + autoScroll.value.direction.y
newScrollLeft = Math.max(0, Math.min(newScrollLeft, maxScrollLeft))
newScrollTop = Math.max(0, Math.min(newScrollTop, maxScrollTop))
const actualDx = newScrollLeft - scrollEl.scrollLeft
const actualDy = newScrollTop - scrollEl.scrollTop
scrollEl.scrollLeft = newScrollLeft
scrollEl.scrollTop = newScrollTop
if (actualDx !== 0 || actualDy !== 0) {
dragSelection.value.currentX += actualDx
dragSelection.value.currentY += actualDy
const maxX = scrollEl.clientWidth + maxScrollLeft
const maxY = scrollEl.clientHeight + maxScrollTop
dragSelection.value.currentX = Math.max(0, Math.min(dragSelection.value.currentX, maxX))
dragSelection.value.currentY = Math.max(0, Math.min(dragSelection.value.currentY, maxY))
onScroll()
}
frameId = requestAnimationFrame(scroll)
}
frameId = requestAnimationFrame(scroll)
}
/**
* 完成拖拽选择
*/
function finishDragSelection() {
dragSelection.value.isSelecting = false
dragSelection.value.justFinished = true
autoScroll.value.isScrolling = false
setTimeout(() => {
dragSelection.value.justFinished = false
}, 10)
}
/**
* 取消拖拽选择
*/
function cancelDragSelection() {
dragSelection.value.isSelecting = false
autoScroll.value.isScrolling = false
}
/**
* 检测元素是否与选择框相交
*/
function isIntersecting(element: HTMLElement, containerRef: HTMLElement): boolean {
const rect = element.getBoundingClientRect()
const containerRect = containerRef.getBoundingClientRect()
const scrollLeft = containerRef.scrollLeft
const scrollTop = containerRef.scrollTop
const elemLeft = rect.left - containerRect.left + scrollLeft
const elemTop = rect.top - containerRect.top + scrollTop
const elemRight = elemLeft + rect.width
const elemBottom = elemTop + rect.height
const selLeft = Math.min(dragSelection.value.startX, dragSelection.value.currentX)
const selTop = Math.min(dragSelection.value.startY, dragSelection.value.currentY)
const selRight = Math.max(dragSelection.value.startX, dragSelection.value.currentX)
const selBottom = Math.max(dragSelection.value.startY, dragSelection.value.currentY)
return !(elemRight < selLeft || elemLeft > selRight || elemBottom < selTop || elemTop > selBottom)
}
return {
dragSelection,
autoScroll,
selectionBoxStyle,
startDragSelection,
updateDragSelection,
checkAutoScroll,
startAutoScroll,
finishDragSelection,
cancelDragSelection,
isIntersecting
}
}
@@ -0,0 +1,77 @@
import { ref, computed } from 'vue'
import type { SelectionState } from '@/components/FileManager/types'
export function useFileSelection() {
const selectedItems = ref<Set<string>>(new Set())
const focusedItem = ref<{ name: string; index: number } | null>(null)
const anchorItem = ref<{ name: string; index: number } | null>(null)
const selectionCount = computed(() => selectedItems.value.size)
const hasSelection = computed(() => selectedItems.value.size > 0)
function selectItem(itemName: string, index: number) {
selectedItems.value.add(itemName)
focusedItem.value = { name: itemName, index }
anchorItem.value = { name: itemName, index }
}
function deselectItem(itemName: string) {
selectedItems.value.delete(itemName)
}
function toggleItemSelection(itemName: string, index: number) {
if (selectedItems.value.has(itemName)) {
deselectItem(itemName)
} else {
selectedItems.value.add(itemName)
}
focusedItem.value = { name: itemName, index }
anchorItem.value = { name: itemName, index }
}
function clearSelection() {
selectedItems.value.clear()
}
function selectRange(startIndex: number, endIndex: number, items: Array<{ name: string }>) {
const start = Math.min(startIndex, endIndex)
const end = Math.max(startIndex, endIndex)
for (let i = start; i <= end; i++) {
if (items[i]) {
selectedItems.value.add(items[i].name)
}
}
}
function selectAll(items: Array<{ name: string }>) {
clearSelection()
items.forEach(item => {
selectedItems.value.add(item.name)
})
}
function isSelected(itemName: string): boolean {
return selectedItems.value.has(itemName)
}
function setFocusedItem(itemName: string, index: number) {
focusedItem.value = { name: itemName, index }
}
return {
selectedItems,
focusedItem,
anchorItem,
selectionCount,
hasSelection,
selectItem,
deselectItem,
toggleItemSelection,
clearSelection,
selectRange,
selectAll,
isSelected,
setFocusedItem
}
}
@@ -0,0 +1,135 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
import type { FileItem, ViewMode } from '@/components/FileManager/types'
interface UseKeyboardNavOptions {
items: Ref<FileItem[]>
viewMode: Ref<ViewMode>
isActive: Ref<boolean>
focusedItem: Ref<{ name: string; index: number } | null>
anchorItem: Ref<{ name: string; index: number } | null>
selectedItems: Ref<Set<string>>
onNavigate: (newIndex: number, event: KeyboardEvent) => void
onEnter: () => void
onSpace: () => void
onSelectAll: () => void
getGridColumns: () => number
}
export function useKeyboardNav(options: UseKeyboardNavOptions) {
const {
items,
viewMode,
isActive,
focusedItem,
anchorItem,
selectedItems,
onNavigate,
onEnter,
onSpace,
onSelectAll,
getGridColumns
} = options
function handleKeyDown(event: KeyboardEvent) {
// 只处理激活面板的键盘事件
if (!isActive.value) return
// 不处理输入框的事件
if (event.target instanceof HTMLInputElement) return
const itemList = items.value
if (itemList.length === 0) return
const focused = focusedItem.value
if (!focused && itemList.length > 0) {
// 如果没有焦点项,设置第一个
onNavigate(0, event)
return
}
if (!focused) return
let newIndex = focused.index
let shouldNavigate = false
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
if (viewMode.value === 'list') {
newIndex = Math.max(0, focused.index - 1)
} else {
// Grid视图 - 向上移动一行
const cols = getGridColumns()
newIndex = Math.max(0, focused.index - cols)
}
shouldNavigate = true
break
case 'ArrowDown':
event.preventDefault()
if (viewMode.value === 'list') {
newIndex = Math.min(itemList.length - 1, focused.index + 1)
} else {
// Grid视图 - 向下移动一行
const cols = getGridColumns()
newIndex = Math.min(itemList.length - 1, focused.index + cols)
}
shouldNavigate = true
break
case 'ArrowLeft':
if (viewMode.value === 'grid') {
event.preventDefault()
newIndex = Math.max(0, focused.index - 1)
shouldNavigate = true
}
break
case 'ArrowRight':
if (viewMode.value === 'grid') {
event.preventDefault()
newIndex = Math.min(itemList.length - 1, focused.index + 1)
shouldNavigate = true
}
break
case 'Enter':
event.preventDefault()
onEnter()
break
case ' ':
event.preventDefault()
onSpace()
break
case 'a':
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
onSelectAll()
}
break
case 'Escape':
event.preventDefault()
selectedItems.value.clear()
break
}
if (shouldNavigate && newIndex !== focused.index) {
onNavigate(newIndex, event)
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
return {
handleKeyDown
}
}
@@ -0,0 +1,300 @@
import type { FileItem, FolderStructure } from '@/components/FileManager/types'
/**
* 文件管理器操作工具
* 提供文件/文件夹的基本操作功能
*/
/**
* 根据路径获取文件夹对象
* @param structure 文件夹结构对象
* @param path 路径数组
* @returns 文件夹对象,如果路径无效则返回 null
*/
export function getFolderByPath(
structure: FolderStructure,
path: string[]
): Record<string, FileItem> | null {
let folder: any = structure
for (const segment of path) {
if (folder[segment] && folder[segment].children) {
folder = folder[segment].children
} else {
return null
}
}
return folder
}
/**
* 移动文件/文件夹
* @param structure 文件夹结构对象(必须是响应式对象)
* @param itemNames 要移动的项目名称数组
* @param sourcePath 源路径
* @param targetPath 目标路径
* @returns 是否成功移动
*/
export function moveItems(
structure: FolderStructure,
itemNames: string[],
sourcePath: string[],
targetPath: string[]
): boolean {
const sourceFolder = getFolderByPath(structure, sourcePath)
const targetFolder = getFolderByPath(structure, targetPath)
if (!sourceFolder || !targetFolder) {
console.error('[文件操作] 移动失败:路径无效', {
sourcePath,
targetPath
})
return false
}
// 检查是否尝试将文件夹移动到自己内部
if (isPathDescendant(sourcePath, targetPath)) {
console.error('[文件操作] 移动失败:不能将文件夹移动到自己内部')
return false
}
// 收集要移动的项目
const itemsToMove: Record<string, FileItem> = {}
for (const name of itemNames) {
if (sourceFolder[name]) {
itemsToMove[name] = sourceFolder[name]
}
}
// 从源文件夹删除
for (const name of itemNames) {
if (sourceFolder[name]) {
delete sourceFolder[name]
}
}
// 添加到目标文件夹
for (const [name, item] of Object.entries(itemsToMove)) {
// 处理重名冲突
let finalName = name
let counter = 1
while (targetFolder[finalName]) {
const ext = name.lastIndexOf('.') > 0 ? name.substring(name.lastIndexOf('.')) : ''
const base = ext ? name.substring(0, name.lastIndexOf('.')) : name
finalName = `${base} (${counter})${ext}`
counter++
}
targetFolder[finalName] = item
}
console.log(`[文件操作] 成功移动 ${itemNames.length} 个项目`, {
from: sourcePath.join('/'),
to: targetPath.join('/')
})
return true
}
/**
* 复制文件/文件夹
* @param structure 文件夹结构对象(必须是响应式对象)
* @param itemNames 要复制的项目名称数组
* @param sourcePath 源路径
* @param targetPath 目标路径
* @returns 是否成功复制
*/
export function copyItems(
structure: FolderStructure,
itemNames: string[],
sourcePath: string[],
targetPath: string[]
): boolean {
const sourceFolder = getFolderByPath(structure, sourcePath)
const targetFolder = getFolderByPath(structure, targetPath)
if (!sourceFolder || !targetFolder) {
console.error('[文件操作] 复制失败:路径无效')
return false
}
// 深拷贝并添加到目标文件夹
for (const name of itemNames) {
if (sourceFolder[name]) {
const item = JSON.parse(JSON.stringify(sourceFolder[name]))
// 处理重名冲突
let finalName = name
let counter = 1
while (targetFolder[finalName]) {
const ext = name.lastIndexOf('.') > 0 ? name.substring(name.lastIndexOf('.')) : ''
const base = ext ? name.substring(0, name.lastIndexOf('.')) : name
finalName = `${base} (${counter})${ext}`
counter++
}
targetFolder[finalName] = item
}
}
console.log(`[文件操作] 成功复制 ${itemNames.length} 个项目`)
return true
}
/**
* 删除文件/文件夹
* @param structure 文件夹结构对象(必须是响应式对象)
* @param itemNames 要删除的项目名称数组
* @param path 路径
* @returns 是否成功删除
*/
export function deleteItems(
structure: FolderStructure,
itemNames: string[],
path: string[]
): boolean {
const folder = getFolderByPath(structure, path)
if (!folder) {
console.error('[文件操作] 删除失败:路径无效')
return false
}
for (const name of itemNames) {
if (folder[name]) {
delete folder[name]
}
}
console.log(`[文件操作] 成功删除 ${itemNames.length} 个项目`)
return true
}
/**
* 重命名文件/文件夹
* @param structure 文件夹结构对象(必须是响应式对象)
* @param oldName 旧名称
* @param newName 新名称
* @param path 路径
* @returns 是否成功重命名
*/
export function renameItem(
structure: FolderStructure,
oldName: string,
newName: string,
path: string[]
): boolean {
const folder = getFolderByPath(structure, path)
if (!folder || !folder[oldName]) {
console.error('[文件操作] 重命名失败:项目不存在')
return false
}
if (folder[newName]) {
console.error('[文件操作] 重命名失败:目标名称已存在')
return false
}
folder[newName] = folder[oldName]
folder[newName].name = newName
delete folder[oldName]
console.log(`[文件操作] 成功重命名: ${oldName} -> ${newName}`)
return true
}
/**
* 创建新文件夹
* @param structure 文件夹结构对象(必须是响应式对象)
* @param folderName 文件夹名称
* @param path 路径
* @returns 是否成功创建
*/
export function createFolder(
structure: FolderStructure,
folderName: string,
path: string[]
): boolean {
const folder = getFolderByPath(structure, path)
if (!folder) {
console.error('[文件操作] 创建文件夹失败:路径无效')
return false
}
// 处理重名
let finalName = folderName
let counter = 1
while (folder[finalName]) {
finalName = `${folderName} (${counter})`
counter++
}
folder[finalName] = {
name: finalName,
type: 'folder',
children: {}
}
console.log(`[文件操作] 成功创建文件夹: ${finalName}`)
return true
}
/**
* 检查 targetPath 是否是 sourcePath 的后代路径
*/
function isPathDescendant(sourcePath: string[], targetPath: string[]): boolean {
if (targetPath.length <= sourcePath.length) {
return false
}
for (let i = 0; i < sourcePath.length; i++) {
if (sourcePath[i] !== targetPath[i]) {
return false
}
}
return true
}
/**
* 获取文件夹内的所有项目
* @param structure 文件夹结构对象
* @param path 路径
* @returns 项目数组
*/
export function getItems(
structure: FolderStructure,
path: string[]
): FileItem[] {
const folder = getFolderByPath(structure, path)
if (!folder) {
return []
}
return Object.values(folder)
}
/**
* 搜索文件/文件夹
* @param structure 文件夹结构对象
* @param path 搜索路径
* @param query 搜索关键词
* @returns 匹配的项目数组
*/
export function searchItems(
structure: FolderStructure,
path: string[],
query: string
): FileItem[] {
const items = getItems(structure, path)
if (!query.trim()) {
return items
}
const lowerQuery = query.toLowerCase()
return items.filter(item =>
item.name.toLowerCase().includes(lowerQuery)
)
}