A:调整文件夹

This commit is contained in:
jimmy committed 2025-09-30 21:06:00 +08:00
1 parent d9690fbaae
commit 7af4f31538
10 files changed
+2799 -1020

No files matched your search

+728 -87
View File
@@ -213,6 +213,114 @@
.content-area {
position: relative;
}
.pane-container {
transition: all 0.15s ease;
position: relative;
}
.pane-container.inactive {
opacity: 0.88;
}
.pane-container.active {
opacity: 1;
}
.pane-container.active::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(to right, #3b82f6, #60a5fa);
z-index: 10;
}
.pane-inactive-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.015);
pointer-events: none;
z-index: 5;
display: none;
transition: background-color 0.15s ease;
}
.pane-container.inactive .pane-inactive-overlay {
display: block;
}
/* Active pane indicator for breadcrumb - more subtle */
.pane-container.active .breadcrumb-area {
background-color: rgba(59, 130, 246, 0.02);
}
/* Clickable breadcrumb area to activate pane */
.breadcrumb-header {
cursor: pointer;
transition: background-color 0.15s ease;
}
.breadcrumb-header:hover {
background-color: rgba(59, 130, 246, 0.03);
}
/* Context Menu */
.context-menu {
position: fixed;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
min-width: 200px;
z-index: 1000;
display: none;
padding: 4px;
}
.context-menu.show {
display: block;
}
.context-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
color: #374151;
transition: background-color 0.15s ease;
}
.context-menu-item:hover {
background-color: #f3f4f6;
}
.context-menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.context-menu-item.disabled:hover {
background-color: transparent;
}
.context-menu-item ion-icon {
margin-right: 8px;
font-size: 16px;
}
.context-menu-separator {
height: 1px;
background-color: #e5e7eb;
margin: 4px 0;
}
/* Tooltip */
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
pointer-events: none;
z-index: 100;
white-space: nowrap;
display: none;
max-width: 300px;
}
.tooltip.show {
display: block;
}
</style>
</head>
<body class="bg-gray-50 font-inter">
@@ -302,17 +410,17 @@
<!-- Main Content -->
<div id="mainContent" class="flex-1 flex overflow-hidden">
<!-- Left Pane -->
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
<div id="leftPane" class="pane-container flex-1 flex flex-col bg-white border-r border-gray-200 active">
<!-- Breadcrumb -->
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div id="leftBreadcrumb" class="breadcrumb-header px-4 py-2 border-b border-gray-100 bg-gray-50" onclick="activatePaneOnly('left')">
<div class="flex items-center">
<div class="flex items-center space-x-1">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="leftBreadcrumbArea">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="leftBreadcrumbArea" onclick="event.stopPropagation()">
<div id="leftBreadcrumbContent">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<div class="path-edit-trigger ml-2" onclick="enterEditMode('left')" title="Edit path">
<div class="path-edit-trigger ml-2" onclick="event.stopPropagation(); enterEditMode('left')" title="Edit path">
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
</div>
</div>
@@ -326,29 +434,30 @@
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto content-area" id="leftContentArea" onmousedown="startDragSelection(event, 'left')" onmouseup="endDragSelection(event, 'left')" onmousemove="updateDragSelection(event, 'left')" onclick="handleContentAreaClick(event, 'left')">
<div class="flex-1 p-4 overflow-auto content-area" id="leftContentArea" onmousedown="handlePaneMouseDown(event, 'left')" onmouseup="endDragSelection(event, 'left')" onmousemove="updateDragSelection(event, 'left')" onclick="handleContentAreaClick(event, 'left')">
<div id="leftContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
<div id="leftSelectionRect" class="selection-rectangle"></div>
</div>
<div class="pane-inactive-overlay"></div>
</div>
<!-- Splitter -->
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
<!-- Right Pane (hidden by default) -->
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
<div id="rightPane" class="pane-container flex-1 flex-col bg-white hidden inactive">
<!-- Breadcrumb -->
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div id="rightBreadcrumb" class="breadcrumb-header px-4 py-2 border-b border-gray-100 bg-gray-50" onclick="activatePaneOnly('right')">
<div class="flex items-center">
<div class="flex items-center space-x-1">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="rightBreadcrumbArea">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="rightBreadcrumbArea" onclick="event.stopPropagation()">
<div id="rightBreadcrumbContent">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<div class="path-edit-trigger ml-2" onclick="enterEditMode('right')" title="Edit path">
<div class="path-edit-trigger ml-2" onclick="event.stopPropagation(); enterEditMode('right')" title="Edit path">
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
</div>
</div>
@@ -362,16 +471,25 @@
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto content-area" id="rightContentArea" onmousedown="startDragSelection(event, 'right')" onmouseup="endDragSelection(event, 'right')" onmousemove="updateDragSelection(event, 'right')" onclick="handleContentAreaClick(event, 'right')">
<div class="flex-1 p-4 overflow-auto content-area" id="rightContentArea" onmousedown="handlePaneMouseDown(event, 'right')" onmouseup="endDragSelection(event, 'right')" onmousemove="updateDragSelection(event, 'right')" onclick="handleContentAreaClick(event, 'right')">
<div id="rightContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
<div id="rightSelectionRect" class="selection-rectangle"></div>
</div>
<div class="pane-inactive-overlay"></div>
</div>
</div>
</div>
<!-- Context Menu -->
<div id="contextMenu" class="context-menu">
<!-- Menu items will be populated dynamically -->
</div>
<!-- Tooltip -->
<div id="tooltip" class="tooltip"></div>
<script>
// Sample data structure
const folderStructure = {
@@ -471,26 +589,47 @@
},
'Camera Roll': {
type: 'folder',
children: {
'Recent': {
type: 'folder',
children: {
'IMG_010.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-25' },
'IMG_011.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-25' },
'IMG_012.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-26' },
'IMG_013.jpg': { type: 'photo', size: '4.1 MB', date: '2023-09-26' }
children: (() => {
const items = {
'Recent': {
type: 'folder',
children: {}
},
'Favorites': {
type: 'folder',
children: {}
}
},
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' },
'IMG_004.jpg': { type: 'photo', size: '2.7 MB', date: '2023-09-17' },
'IMG_005.jpg': { type: 'photo', size: '3.8 MB', date: '2023-09-18' },
'IMG_006.jpg': { type: 'photo', size: '4.2 MB', date: '2023-09-19' },
'IMG_007.jpg': { type: 'photo', size: '3.1 MB', date: '2023-09-20' },
'IMG_008.jpg': { type: 'photo', size: '2.8 MB', date: '2023-09-21' },
'IMG_009.jpg': { type: 'photo', size: '3.6 MB', date: '2023-09-22' }
}
};
// 生成100张照片
for (let i = 1; i <= 100; i++) {
const size = (Math.random() * 3 + 1).toFixed(1);
const day = Math.floor(Math.random() * 30) + 1;
items[`IMG_${String(i).padStart(4, '0')}.jpg`] = {
type: 'photo',
size: `${size} MB`,
date: `2023-09-${String(day).padStart(2, '0')}`
};
}
// Recent文件夹也添加一些照片
for (let i = 1; i <= 30; i++) {
const size = (Math.random() * 3 + 1).toFixed(1);
items.Recent.children[`Recent_${String(i).padStart(3, '0')}.jpg`] = {
type: 'photo',
size: `${size} MB`,
date: `2023-12-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`
};
}
// Favorites文件夹添加照片
for (let i = 1; i <= 20; i++) {
const size = (Math.random() * 4 + 1).toFixed(1);
items.Favorites.children[`Favorite_${i}.jpg`] = {
type: 'photo',
size: `${size} MB`,
date: `2023-11-${String(Math.floor(Math.random() * 30) + 1).padStart(2, '0')}`
};
}
return items;
})()
},
'Archives': {
type: 'folder',
@@ -522,12 +661,15 @@
let leftPath = ['Home'];
let rightPath = ['Home'];
let isDragging = false;
let activePane = 'left'; // Track which pane is currently active
// Selection state
let leftSelectedItems = new Set();
let rightSelectedItems = new Set();
let leftFocusedItem = null;
let rightFocusedItem = null;
let leftAnchorItem = null; // For shift selection
let rightAnchorItem = null; // For shift selection
// Drag selection state
let isDragSelecting = false;
@@ -536,6 +678,220 @@
let dragCurrentX = 0;
let dragCurrentY = 0;
let dragPane = null;
let dragInitialSelections = new Set(); // Store selections before drag starts
let dragCtrlKey = false; // Store ctrl key state during drag
let justFinishedDrag = false; // Prevent click event from clearing selection after drag
// Context Menu State
let contextMenuVisible = false;
let contextMenuPane = null;
let contextMenuTarget = null;
// Show context menu
function showContextMenu(event, pane, targetItem = null) {
event.preventDefault();
const menu = document.getElementById('contextMenu');
contextMenuPane = pane;
contextMenuTarget = targetItem;
// Build menu items based on context
let menuHTML = '';
if (targetItem) {
// Right-clicked on an item
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
const isSelected = selectedItems.has(targetItem);
const selectionCount = selectedItems.size;
menuHTML = `
<div class="context-menu-item" onclick="openItem('${pane}', '${targetItem}')">
<ion-icon name="open-outline"></ion-icon>
<span>打开</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" onclick="cutItems('${pane}')">
<ion-icon name="cut-outline"></ion-icon>
<span>剪切${selectionCount > 1 ? ' (' + selectionCount + ' 项)' : ''}</span>
</div>
<div class="context-menu-item" onclick="copyItems('${pane}')">
<ion-icon name="copy-outline"></ion-icon>
<span>复制${selectionCount > 1 ? ' (' + selectionCount + ' 项)' : ''}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" onclick="deleteItems('${pane}')">
<ion-icon name="trash-outline"></ion-icon>
<span>删除${selectionCount > 1 ? ' (' + selectionCount + ' 项)' : ''}</span>
</div>
<div class="context-menu-item" onclick="renameItem('${pane}', '${targetItem}')">
<ion-icon name="create-outline"></ion-icon>
<span>重命名</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" onclick="showProperties('${pane}', '${targetItem}')">
<ion-icon name="information-circle-outline"></ion-icon>
<span>属性</span>
</div>
`;
} else {
// Right-clicked on empty area
menuHTML = `
<div class="context-menu-item" onclick="pasteItems('${pane}')">
<ion-icon name="clipboard-outline"></ion-icon>
<span>粘贴</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" onclick="createNewFolder('${pane}')">
<ion-icon name="folder-outline"></ion-icon>
<span>新建文件夹</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" onclick="selectAllItems('${pane}')">
<ion-icon name="checkbox-outline"></ion-icon>
<span>全选</span>
</div>
<div class="context-menu-item" onclick="refreshPane('${pane}')">
<ion-icon name="refresh-outline"></ion-icon>
<span>刷新</span>
</div>
`;
}
menu.innerHTML = menuHTML;
// Position the menu
const menuWidth = 200;
const menuHeight = menu.offsetHeight || 300;
let x = event.clientX;
let y = event.clientY;
// Adjust if menu would go off screen
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10;
}
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.classList.add('show');
contextMenuVisible = true;
// Hide menu on any click
setTimeout(() => {
document.addEventListener('click', hideContextMenu, { once: true });
document.addEventListener('contextmenu', hideContextMenu, { once: true });
}, 0);
}
// Hide context menu
function hideContextMenu() {
const menu = document.getElementById('contextMenu');
menu.classList.remove('show');
contextMenuVisible = false;
contextMenuPane = null;
contextMenuTarget = null;
}
// Context menu actions (placeholder implementations)
function openItem(pane, itemName) {
const path = pane === 'left' ? leftPath : rightPath;
const currentFolder = getCurrentFolder(path);
const item = currentFolder[itemName];
if (item && item.type === 'folder') {
navigateToFolder(pane, itemName);
}
hideContextMenu();
}
function cutItems(pane) {
console.log('Cut items from', pane);
hideContextMenu();
}
function copyItems(pane) {
console.log('Copy items from', pane);
hideContextMenu();
}
function pasteItems(pane) {
console.log('Paste items to', pane);
hideContextMenu();
}
function deleteItems(pane) {
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
console.log('Delete items:', Array.from(selectedItems));
hideContextMenu();
}
function renameItem(pane, itemName) {
console.log('Rename item:', itemName);
hideContextMenu();
}
function createNewFolder(pane) {
console.log('Create new folder in', pane);
hideContextMenu();
}
function selectAllItems(pane) {
const items = getVisibleItems(pane);
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
clearAllSelections(pane);
items.forEach(item => {
selectedItems.add(item.name);
addItemSelection(pane, item.name);
});
hideContextMenu();
}
function refreshPane(pane) {
loadContent(pane);
hideContextMenu();
}
function showProperties(pane, itemName) {
const path = pane === 'left' ? leftPath : rightPath;
const currentFolder = getCurrentFolder(path);
const item = currentFolder[itemName];
if (item) {
alert(`名称: ${itemName}\n类型: ${item.type === 'folder' ? '文件夹' : '文件'}\n大小: ${item.size || 'N/A'}\n日期: ${item.date || 'N/A'}`);
}
hideContextMenu();
}
// Tooltip functions
let tooltipTimeout = null;
function showTooltip(event, text) {
clearTimeout(tooltipTimeout);
tooltipTimeout = setTimeout(() => {
const tooltip = document.getElementById('tooltip');
tooltip.textContent = text;
tooltip.classList.add('show');
// Position tooltip
const x = event.clientX + 10;
const y = event.clientY + 10;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
}, 500); // Show after 500ms hover
}
function hideTooltip() {
clearTimeout(tooltipTimeout);
const tooltip = document.getElementById('tooltip');
tooltip.classList.remove('show');
}
// Initialize the application
function init() {
@@ -543,6 +899,31 @@
updateBreadcrumb('left');
setupSplitter();
setupKeyboardNavigation();
setupContextMenu();
}
// Setup context menu event listeners
function setupContextMenu() {
// Prevent default context menu on content areas
document.getElementById('leftContentArea').addEventListener('contextmenu', (e) => {
const item = e.target.closest('.selectable-item');
if (item) {
const itemName = item.dataset.itemName;
showContextMenu(e, 'left', itemName);
} else {
showContextMenu(e, 'left', null);
}
});
document.getElementById('rightContentArea').addEventListener('contextmenu', (e) => {
const item = e.target.closest('.selectable-item');
if (item) {
const itemName = item.dataset.itemName;
showContextMenu(e, 'right', itemName);
} else {
showContextMenu(e, 'right', null);
}
});
}
// Get current folder from path
@@ -568,9 +949,11 @@
if (pane === 'left') {
leftSelectedItems.clear();
leftFocusedItem = null;
leftAnchorItem = null;
} else {
rightSelectedItems.clear();
rightFocusedItem = null;
rightAnchorItem = null;
}
if (!currentFolder) {
@@ -651,9 +1034,15 @@
contentElement.innerHTML = html;
}
// Set default selection to first item
// Set default focus to first item (but don't select it)
if (items.length > 0) {
setFocusedItem(pane, items[0][0], 0);
if (pane === 'left') {
leftFocusedItem = { name: items[0][0], index: 0 };
leftAnchorItem = { name: items[0][0], index: 0 };
} else {
rightFocusedItem = { name: items[0][0], index: 0 };
rightAnchorItem = { name: items[0][0], index: 0 };
}
}
}
@@ -1104,10 +1493,47 @@
function handleItemClick(event, pane, itemName, index) {
event.stopPropagation();
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
// Activate the pane when clicking an item
setActivePane(pane);
if (event.ctrlKey || event.metaKey) {
// Multi-select with Ctrl/Cmd
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
const items = getVisibleItems(pane);
if (event.shiftKey) {
// Range selection with Shift
const anchorItem = pane === 'left' ? leftAnchorItem : rightAnchorItem;
if (anchorItem !== null) {
// Clear previous selections if not holding Ctrl
if (!event.ctrlKey && !event.metaKey) {
clearAllSelections(pane);
}
// Select range from anchor to current
const startIndex = Math.min(anchorItem.index, index);
const endIndex = Math.max(anchorItem.index, index);
for (let i = startIndex; i <= endIndex; i++) {
if (items[i]) {
selectedItems.add(items[i].name);
addItemSelection(pane, items[i].name);
}
}
} else {
// No anchor, treat as normal click
clearAllSelections(pane);
selectedItems.add(itemName);
addItemSelection(pane, itemName);
// Set anchor
if (pane === 'left') {
leftAnchorItem = { name: itemName, index: index };
} else {
rightAnchorItem = { name: itemName, index: index };
}
}
} else if (event.ctrlKey || event.metaKey) {
// Toggle selection with Ctrl/Cmd
if (selectedItems.has(itemName)) {
selectedItems.delete(itemName);
removeItemSelection(pane, itemName);
@@ -1115,11 +1541,25 @@
selectedItems.add(itemName);
addItemSelection(pane, itemName);
}
// Update anchor
if (pane === 'left') {
leftAnchorItem = { name: itemName, index: index };
} else {
rightAnchorItem = { name: itemName, index: index };
}
} else {
// Single select - clear all previous selections first
// Single select - clear all previous selections
clearAllSelections(pane);
selectedItems.add(itemName);
addItemSelection(pane, itemName);
// Set anchor
if (pane === 'left') {
leftAnchorItem = { name: itemName, index: index };
} else {
rightAnchorItem = { name: itemName, index: index };
}
}
setFocusedItem(pane, itemName, index);
@@ -1127,10 +1567,13 @@
// Set focused item
function setFocusedItem(pane, itemName, index) {
// Clear previous focus
document.querySelectorAll(`[data-pane="${pane}"] .item-focused`).forEach(el => {
el.classList.remove('item-focused');
});
// Clear previous focus in the specific pane
const contentArea = document.getElementById(pane + 'ContentArea');
if (contentArea) {
contentArea.querySelectorAll('.item-focused').forEach(el => {
el.classList.remove('item-focused');
});
}
if (pane === 'left') {
leftFocusedItem = { name: itemName, index: index };
@@ -1177,13 +1620,25 @@
const activePane = getActivePane();
if (!activePane) return;
// Don't interfere with path input
if (event.target.tagName === 'INPUT') return;
const items = getVisibleItems(activePane);
if (items.length === 0) return;
const focusedItem = activePane === 'left' ? leftFocusedItem : rightFocusedItem;
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
// If no focused item, focus first item
if (!focusedItem && items.length > 0) {
setFocusedItem(activePane, items[0].name, 0);
return;
}
if (!focusedItem) return;
let newIndex = focusedItem.index;
let shouldUpdateFocus = false;
switch (event.key) {
case 'ArrowUp':
@@ -1192,32 +1647,40 @@
newIndex = Math.max(0, focusedItem.index - 1);
} else {
// Grid view - move up by number of columns
const cols = getGridColumns();
const cols = getGridColumns(activePane);
newIndex = Math.max(0, focusedItem.index - cols);
}
shouldUpdateFocus = true;
break;
case 'ArrowDown':
event.preventDefault();
if (viewMode === 'list') {
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
} else {
// Grid view - move down by number of columns
const cols = getGridColumns();
const cols = getGridColumns(activePane);
newIndex = Math.min(items.length - 1, focusedItem.index + cols);
}
shouldUpdateFocus = true;
break;
case 'ArrowLeft':
if (viewMode === 'grid') {
event.preventDefault();
newIndex = Math.max(0, focusedItem.index - 1);
shouldUpdateFocus = true;
}
break;
case 'ArrowRight':
if (viewMode === 'grid') {
event.preventDefault();
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
shouldUpdateFocus = true;
}
break;
case 'Enter':
event.preventDefault();
const currentItem = items[focusedItem.index];
@@ -1225,9 +1688,10 @@
navigateToFolder(activePane, currentItem.name);
}
break;
case ' ':
event.preventDefault();
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
// Space toggles selection of focused item
const currentItemName = items[focusedItem.index].name;
if (selectedItems.has(currentItemName)) {
selectedItems.delete(currentItemName);
@@ -1236,19 +1700,86 @@
selectedItems.add(currentItemName);
addItemSelection(activePane, currentItemName);
}
// Update anchor
if (activePane === 'left') {
leftAnchorItem = { name: currentItemName, index: focusedItem.index };
} else {
rightAnchorItem = { name: currentItemName, index: focusedItem.index };
}
break;
case 'a':
// Ctrl+A to select all
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
clearAllSelections(activePane);
items.forEach(item => {
selectedItems.add(item.name);
addItemSelection(activePane, item.name);
});
}
break;
}
if (newIndex !== focusedItem.index && (event.key.startsWith('Arrow') || event.key === 'Enter')) {
// Handle focus and selection changes for arrow keys
if (shouldUpdateFocus && newIndex !== focusedItem.index) {
const newItem = items[newIndex];
if (newItem) {
if (!newItem) return;
if (event.shiftKey) {
// Shift+Arrow: extend selection from anchor to new position
const anchorItem = activePane === 'left' ? leftAnchorItem : rightAnchorItem;
if (anchorItem !== null) {
// Clear and reselect range
clearAllSelections(activePane);
const startIndex = Math.min(anchorItem.index, newIndex);
const endIndex = Math.max(anchorItem.index, newIndex);
for (let i = startIndex; i <= endIndex; i++) {
if (items[i]) {
selectedItems.add(items[i].name);
addItemSelection(activePane, items[i].name);
}
}
} else {
// No anchor, select from current to new
const startIndex = Math.min(focusedItem.index, newIndex);
const endIndex = Math.max(focusedItem.index, newIndex);
for (let i = startIndex; i <= endIndex; i++) {
if (items[i]) {
selectedItems.add(items[i].name);
addItemSelection(activePane, items[i].name);
}
}
// Set anchor to starting position
if (activePane === 'left') {
leftAnchorItem = { name: focusedItem.name, index: focusedItem.index };
} else {
rightAnchorItem = { name: focusedItem.name, index: focusedItem.index };
}
}
setFocusedItem(activePane, newItem.name, newIndex);
} else if (event.ctrlKey || event.metaKey) {
// Ctrl+Arrow: move focus only, don't change selection
setFocusedItem(activePane, newItem.name, newIndex);
} else {
// Plain arrow: move focus and select only the new item
clearAllSelections(activePane);
selectedItems.add(newItem.name);
addItemSelection(activePane, newItem.name);
setFocusedItem(activePane, newItem.name, newIndex);
// Auto-select on navigation if no items are selected
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
if (selectedItems.size === 0) {
selectedItems.add(newItem.name);
addItemSelection(activePane, newItem.name);
// Update anchor
if (activePane === 'left') {
leftAnchorItem = { name: newItem.name, index: newIndex };
} else {
rightAnchorItem = { name: newItem.name, index: newIndex };
}
}
}
@@ -1257,17 +1788,7 @@
// Get currently active pane
function getActivePane() {
const leftPane = document.getElementById('leftPane');
const rightPane = document.getElementById('rightPane');
if (document.activeElement && leftPane.contains(document.activeElement)) {
return 'left';
} else if (document.activeElement && rightPane.contains(document.activeElement)) {
return 'right';
} else {
// Default to left pane
return 'left';
}
return activePane;
}
// Get visible items in current folder
@@ -1288,26 +1809,90 @@
}
// Get number of columns in grid view
function getGridColumns() {
const contentElement = document.getElementById('leftContent');
function getGridColumns(pane) {
const contentElement = document.getElementById((pane || activePane) + 'Content');
if (!contentElement) return 4;
const computedStyle = window.getComputedStyle(contentElement);
const gridCols = computedStyle.gridTemplateColumns;
const items = contentElement.querySelectorAll('.selectable-item');
if (items.length < 2) return 1;
if (gridCols && gridCols !== 'none') {
return gridCols.split(' ').length;
// 计算实际列数:检查前两个元素的位置
const firstRect = items[0].getBoundingClientRect();
const secondRect = items[1].getBoundingClientRect();
// 如果第二个元素在第一个右边(同一行),继续计算列数
if (Math.abs(secondRect.top - firstRect.top) < 10) {
// 它们在同一行,计算一行有多少个
let cols = 1;
for (let i = 1; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (Math.abs(rect.top - firstRect.top) < 10) {
cols++;
} else {
break;
}
}
return cols;
}
// Fallback based on thumbnail size
switch (thumbnailSize) {
case 'small': return 8;
case 'medium': return 6;
case 'large': return 4;
default: return 4;
// 第二个元素在第一个下面,说明只有一列
return 1;
}
// Set active pane
function setActivePane(pane) {
activePane = pane;
const leftPane = document.getElementById('leftPane');
const rightPane = document.getElementById('rightPane');
if (pane === 'left') {
leftPane.classList.add('active');
leftPane.classList.remove('inactive');
if (!rightPane.classList.contains('hidden')) {
rightPane.classList.add('inactive');
rightPane.classList.remove('active');
}
} else {
rightPane.classList.add('active');
rightPane.classList.remove('inactive');
leftPane.classList.add('inactive');
leftPane.classList.remove('active');
}
}
// Activate pane without clearing selections (for breadcrumb clicks)
function activatePaneOnly(pane) {
setActivePane(pane);
// Set focus to the first selected item or focused item if any
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
const focusedItem = pane === 'left' ? leftFocusedItem : rightFocusedItem;
if (selectedItems.size > 0 && focusedItem) {
// Re-focus the existing focused item to ensure keyboard navigation works
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${focusedItem.name}"]`);
if (itemElement) {
itemElement.focus();
}
} else if (focusedItem) {
// Just refocus the item
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${focusedItem.name}"]`);
if (itemElement) {
itemElement.focus();
}
}
}
// Handle pane mouse down - activate pane and start drag selection
function handlePaneMouseDown(event, pane) {
// Activate the pane
setActivePane(pane);
// Start drag selection
startDragSelection(event, pane);
}
// Start drag selection
function startDragSelection(event, pane) {
// Only start drag selection if clicking on empty area (not on an item)
@@ -1315,8 +1900,12 @@
return;
}
// Reset the flag
justFinishedDrag = false;
isDragSelecting = true;
dragPane = pane;
dragCtrlKey = event.ctrlKey || event.metaKey;
const contentArea = document.getElementById(pane + 'ContentArea');
const rect = contentArea.getBoundingClientRect();
@@ -1326,11 +1915,12 @@
dragCurrentX = dragStartX;
dragCurrentY = dragStartY;
// Store the initial event for later use
window.dragEvent = event;
// Store initial selections when Ctrl is held
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
dragInitialSelections = new Set(selectedItems);
// Clear previous selections if not holding Ctrl
if (!event.ctrlKey && !event.metaKey) {
if (!dragCtrlKey) {
clearAllSelections(pane);
}
@@ -1353,7 +1943,7 @@
updateSelectionRectangle(pane);
// Check which items intersect with selection rectangle
updateItemSelection(pane, event);
updateItemSelection(pane);
}
// End drag selection
@@ -1362,12 +1952,51 @@
return;
}
// Final selection update to ensure all intersecting items are selected
updateItemSelection(pane, window.dragEvent);
// Check if this was actually a drag (mouse moved significantly)
const dragDistance = Math.sqrt(
Math.pow(dragCurrentX - dragStartX, 2) +
Math.pow(dragCurrentY - dragStartY, 2)
);
// Only treat as drag if moved more than 5 pixels
if (dragDistance > 5) {
justFinishedDrag = true;
// Final selection update to ensure all intersecting items are selected
updateItemSelection(pane);
// Set focus to the last selected item
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
if (selectedItems.size > 0) {
const items = getVisibleItems(pane);
const selectedArray = Array.from(selectedItems);
const lastSelected = selectedArray[selectedArray.length - 1];
// Find index of last selected item
const itemIndex = items.findIndex(item => item.name === lastSelected);
if (itemIndex !== -1) {
setFocusedItem(pane, lastSelected, itemIndex);
// Set anchor to last selected item
if (pane === 'left') {
leftAnchorItem = { name: lastSelected, index: itemIndex };
} else {
rightAnchorItem = { name: lastSelected, index: itemIndex };
}
}
}
// Clear the flag after a short delay to allow click event to check it
setTimeout(() => {
justFinishedDrag = false;
}, 100);
}
// Cleanup
isDragSelecting = false;
dragPane = null;
window.dragEvent = null;
dragCtrlKey = false;
dragInitialSelections.clear();
// Hide selection rectangle
const selectionRect = document.getElementById(pane + 'SelectionRect');
@@ -1391,7 +2020,7 @@
}
// Update item selection based on rectangle intersection
function updateItemSelection(pane, event) {
function updateItemSelection(pane) {
const contentArea = document.getElementById(pane + 'ContentArea');
const items = contentArea.querySelectorAll('.selectable-item');
@@ -1425,16 +2054,23 @@
}
});
// Update selection based on whether Ctrl is held
// Update selection based on whether Ctrl was held at drag start
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
if (event && (event.ctrlKey || event.metaKey)) {
// Additive selection - only add new items
if (dragCtrlKey) {
// Additive selection - restore initial selections and add drag selections
clearAllSelections(pane);
// Re-add initial selections
dragInitialSelections.forEach(itemName => {
selectedItems.add(itemName);
addItemSelection(pane, itemName);
});
// Add new drag selections
itemsInSelection.forEach(itemName => {
if (!selectedItems.has(itemName)) {
selectedItems.add(itemName);
addItemSelection(pane, itemName);
}
selectedItems.add(itemName);
addItemSelection(pane, itemName);
});
} else {
// Replace selection - clear all and select only intersecting items
@@ -1448,6 +2084,11 @@
// Add click handler to clear selection when clicking empty area
function handleContentAreaClick(event, pane) {
// Don't clear if we just finished a drag selection
if (justFinishedDrag) {
return;
}
// Only clear selection if clicking on empty area (not on an item)
if (!event.target.closest('.selectable-item') && !isDragSelecting) {
if (!event.ctrlKey && !event.metaKey) {
@@ -0,0 +1,210 @@
<template>
<Teleport to="body">
<div
v-if="visible"
ref="menuRef"
class="context-menu"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
@click="handleMenuClick"
>
<template v-for="(item, index) in menuItems" :key="index">
<div v-if="item.separator" class="menu-separator"></div>
<div
v-else
:class="['menu-item', { disabled: item.disabled }]"
@click="handleItemClick(item)"
>
<span class="menu-icon">{{ item.icon }}</span>
<span class="menu-label">{{ item.label }}</span>
<span v-if="item.shortcut" class="menu-shortcut">{{ item.shortcut }}</span>
</div>
</template>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
interface ContextMenuItem {
label: string
icon: string
action: () => void
disabled?: boolean
separator?: boolean
shortcut?: string
}
interface Props {
visible: boolean
x: number
y: number
targetItem?: string | null
selectedCount?: number
paneId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
action: [action: string, params?: any]
}>()
const menuRef = ref<HTMLElement>()
const position = ref({ x: 0, y: 0 })
// 根据上下文生成菜单项
const menuItems = computed((): ContextMenuItem[] => {
if (props.targetItem) {
// 右键点击文件/文件夹
return [
{ label: '打开', icon: '📂', action: () => emit('action', 'open', props.targetItem) },
{ separator: true } as any,
{
label: `剪切${props.selectedCount && props.selectedCount > 1 ? ` (${props.selectedCount} 项)` : ''}`,
icon: '✂️',
action: () => emit('action', 'cut'),
shortcut: 'Ctrl+X'
},
{
label: `复制${props.selectedCount && props.selectedCount > 1 ? ` (${props.selectedCount} 项)` : ''}`,
icon: '📋',
action: () => emit('action', 'copy'),
shortcut: 'Ctrl+C'
},
{ separator: true } as any,
{
label: `删除${props.selectedCount && props.selectedCount > 1 ? ` (${props.selectedCount} 项)` : ''}`,
icon: '🗑️',
action: () => emit('action', 'delete'),
shortcut: 'Delete'
},
{ label: '重命名', icon: '✏️', action: () => emit('action', 'rename', props.targetItem), shortcut: 'F2' },
{ separator: true } as any,
{ label: '属性', icon: 'ℹ️', action: () => emit('action', 'properties', props.targetItem) }
]
} else {
// 右键点击空白区域
return [
{ label: '粘贴', icon: '📋', action: () => emit('action', 'paste'), shortcut: 'Ctrl+V', disabled: true },
{ separator: true } as any,
{ label: '新建文件夹', icon: '📁', action: () => emit('action', 'newFolder') },
{ separator: true } as any,
{ label: '全选', icon: '☑️', action: () => emit('action', 'selectAll'), shortcut: 'Ctrl+A' },
{ label: '刷新', icon: '🔄', action: () => emit('action', 'refresh'), shortcut: 'F5' }
]
}
})
// 调整菜单位置,防止超出屏幕
function adjustPosition() {
nextTick(() => {
if (!menuRef.value) return
const menuWidth = menuRef.value.offsetWidth || 200
const menuHeight = menuRef.value.offsetHeight || 300
let x = props.x
let y = props.y
// 防止超出右边界
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10
}
// 防止超出下边界
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10
}
position.value = { x, y }
})
}
watch(() => props.visible, (visible) => {
if (visible) {
adjustPosition()
// 添加全局点击监听,点击外部关闭菜单
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('contextmenu', handleClickOutside)
}, 0)
} else {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('contextmenu', handleClickOutside)
}
})
function handleClickOutside() {
emit('close')
}
function handleMenuClick(event: MouseEvent) {
event.stopPropagation()
}
function handleItemClick(item: ContextMenuItem) {
if (item.disabled) return
item.action()
emit('close')
}
</script>
<style scoped>
.context-menu {
position: fixed;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
min-width: 200px;
padding: 4px;
z-index: 1000;
user-select: none;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
font-size: 14px;
color: #374151;
}
.menu-item:hover {
background-color: #f3f4f6;
}
.menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item.disabled:hover {
background-color: transparent;
}
.menu-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.menu-label {
flex: 1;
}
.menu-shortcut {
font-size: 12px;
color: #9ca3af;
}
.menu-separator {
height: 1px;
background-color: #e5e7eb;
margin: 4px 0;
}
</style>
@@ -0,0 +1,246 @@
<template>
<div class="file-manager">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<button class="btn btn-primary">
<span class="icon">+</span>
<span>新建</span>
</button>
<div class="divider"></div>
<button class="btn">复制</button>
<button class="btn">剪切</button>
<button class="btn">粘贴</button>
</div>
<div class="toolbar-right">
<!-- View Controls -->
<div class="view-controls">
<button
:class="['btn-view', { active: viewMode === 'grid' }]"
@click="viewMode = 'grid'"
>
网格
</button>
<button
:class="['btn-view', { active: viewMode === 'list' }]"
@click="viewMode = 'list'"
>
列表
</button>
</div>
<!-- Thumbnail Size -->
<div v-if="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"
>
{{ size === 'small' ? '小' : size === 'medium' ? '中' : '大' }}
</button>
</div>
<!-- Layout Controls -->
<div class="layout-controls">
<button
:class="['btn-layout', { active: layoutMode === 'single' }]"
@click="layoutMode = 'single'"
title="单面板"
>
▢
</button>
<button
:class="['btn-layout', { active: layoutMode === 'horizontal' }]"
@click="layoutMode = 'horizontal'"
title="横向双面板"
>
▭
</button>
</div>
</div>
</div>
<!-- Main Content -->
<div :class="['main-content', `layout-${layoutMode}`]">
<FilePane
pane-id="left"
:view-mode="viewMode"
:thumbnail-size="thumbnailSize"
:is-active="activePane === 'left'"
@activate="activePane = 'left'"
/>
<div
v-if="layoutMode === 'horizontal'"
class="splitter"
@mousedown="handleSplitterMouseDown"
></div>
<FilePane
v-if="layoutMode === 'horizontal'"
pane-id="right"
:view-mode="viewMode"
:thumbnail-size="thumbnailSize"
:is-active="activePane === 'right'"
@activate="activePane = 'right'"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } 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 layoutMode = ref<LayoutMode>('single')
const activePane = ref<PaneId>('left')
// 分隔器拖拽
const isDraggingSplitter = ref(false)
function handleSplitterMouseDown(event: MouseEvent) {
isDraggingSplitter.value = true
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const mainContent = document.querySelector('.main-content') as HTMLElement
if (!mainContent) return
const onMouseMove = (e: MouseEvent) => {
if (!isDraggingSplitter.value) return
const rect = mainContent.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = (x / rect.width) * 100
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`
}
}
const onMouseUp = () => {
isDraggingSplitter.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>
<style scoped>
.file-manager {
display: flex;
flex-direction: column;
height: 100vh;
background: #f9fafb;
}
/* Toolbar */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.toolbar-left, .toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
padding: 8px 12px;
background: #f3f4f6;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover {
background: #e5e7eb;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.divider {
width: 1px;
height: 24px;
background: #d1d5db;
}
.view-controls, .thumbnail-controls, .layout-controls {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: #f3f4f6;
border-radius: 6px;
}
.btn-view, .btn-size, .btn-layout {
padding: 6px 12px;
background: transparent;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-view.active, .btn-size.active, .btn-layout.active {
background: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.label {
font-size: 13px;
color: #6b7280;
margin-right: 4px;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.main-content.layout-single {
display: block;
}
.main-content.layout-horizontal {
display: grid;
grid-template-columns: 1fr 4px 1fr;
}
.splitter {
background: #d1d5db;
cursor: col-resize;
}
.splitter:hover {
background: #9ca3af;
}
</style>
@@ -0,0 +1,1402 @@
<template>
<div :class="['file-pane', { active: isActive, inactive: !isActive }]" @click="$emit('activate')">
<!-- Top indicator -->
<div v-if="isActive" class="active-indicator"></div>
<!-- Breadcrumb -->
<div class="breadcrumb" @click.stop="$emit('activate')">
<div v-if="!pathEditMode" class="breadcrumb-content">
<span
v-for="(segment, index) in currentPath"
:key="index"
:class="['breadcrumb-segment']"
>
<span
:class="['breadcrumb-item', { current: index === currentPath.length - 1 }]"
@click.stop="navigateToIndex(index)"
>
{{ segment }}
</span>
<span
v-if="index < currentPath.length - 1"
:class="['separator', { active: breadcrumbDropdown.index === index }]"
@click.stop="toggleBreadcrumbDropdown(index)"
>
›
</span>
</span>
<button class="path-edit-btn" @click.stop="enterPathEditMode" title="编辑路径">
✏️
</button>
</div>
<div v-else class="path-edit-container">
<input
ref="pathInputRef"
v-model="pathEditValue"
class="path-input"
@blur="exitPathEditMode"
@keydown.enter="applyPathEdit"
@keydown.esc="cancelPathEdit"
@click.stop
/>
<button class="path-edit-confirm" @click.stop="applyPathEdit" title="确认">✓</button>
<button class="path-edit-cancel" @click.stop="cancelPathEdit" title="取消">✗</button>
</div>
<!-- Search Input -->
<div class="search-box">
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="搜索..."
@click.stop
/>
<button v-if="searchQuery" class="search-clear" @click.stop="searchQuery = ''">×</button>
</div>
</div>
<!-- Content Area -->
<div
ref="contentAreaRef"
:class="['content-area', { 'pane-drag-over': dragState.isPaneDragOver }]"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@click="handleContentClick"
@contextmenu="handleContextMenu"
@dragover="handlePaneDragOver"
@dragleave="handlePaneDragLeave"
@drop="handlePaneDrop"
>
<!-- Grid View -->
<div v-if="viewMode === 'grid'" :class="['file-grid', `grid-${thumbnailSize}`]">
<div
v-for="(item, index) in visibleItems"
:key="item.name"
:class="['file-item', {
selected: selection.isSelected(item.name),
focused: isFocused(item.name),
'drop-target': item.type === 'folder' && dragState.isDragging && !selection.isSelected(item.name),
'drag-over': dragState.dropTarget === item.name
}]"
:data-item-name="item.name"
:data-item-index="index"
:draggable="true"
@click="handleItemClick($event, item.name, index)"
@dblclick="handleItemDoubleClick(item)"
@mouseenter="handleItemMouseEnter($event, item)"
@mouseleave="handleItemMouseLeave"
@dragstart="handleDragStart($event, item)"
@dragend="handleDragEnd"
@dragover="handleDragOver($event, item)"
@dragleave="handleDragLeave($event)"
@drop="handleDrop($event, item)"
>
<div class="file-icon">
{{ item.type === 'folder' ? '📁' : '🖼️' }}
</div>
<div class="file-name" v-html="highlightText(item.name)"></div>
<div v-if="item.size" class="file-size">{{ item.size }}</div>
</div>
</div>
<!-- List View -->
<div v-else class="file-list">
<div
v-for="(item, index) in visibleItems"
:key="item.name"
:class="['list-item', {
selected: selection.isSelected(item.name),
focused: isFocused(item.name),
'drop-target': item.type === 'folder' && dragState.isDragging && !selection.isSelected(item.name),
'drag-over': dragState.dropTarget === item.name
}]"
:data-item-name="item.name"
:data-item-index="index"
:draggable="true"
@click="handleItemClick($event, item.name, index)"
@dblclick="handleItemDoubleClick(item)"
@mouseenter="handleItemMouseEnter($event, item)"
@mouseleave="handleItemMouseLeave"
@dragstart="handleDragStart($event, item)"
@dragend="handleDragEnd"
@dragover="handleDragOver($event, item)"
@dragleave="handleDragLeave($event)"
@drop="handleDrop($event, item)"
>
<span class="list-icon">{{ item.type === 'folder' ? '📁' : '🖼️' }}</span>
<span class="list-name" v-html="highlightText(item.name)"></span>
<span class="list-size">{{ item.size || '-' }}</span>
<span class="list-date">{{ item.date || '-' }}</span>
</div>
</div>
<!-- Selection Rectangle -->
<div v-if="dragSelection.isSelecting" class="selection-rectangle" :style="selectionRectStyle"></div>
</div>
<!-- Inactive Overlay -->
<div v-if="!isActive" class="inactive-overlay"></div>
<!-- Context Menu -->
<ContextMenu
:visible="contextMenu.visible"
:x="contextMenu.x"
:y="contextMenu.y"
:target-item="contextMenu.targetItem"
:selected-count="selection.selectionCount.value"
:pane-id="paneId"
@close="contextMenu.visible = false"
@action="handleContextMenuAction"
/>
<!-- Tooltip -->
<Tooltip ref="tooltipRef" />
<!-- Breadcrumb Dropdown -->
<Teleport to="body">
<div
v-if="breadcrumbDropdown.visible"
class="breadcrumb-dropdown"
:style="{
left: `${breadcrumbDropdown.x}px`,
top: `${breadcrumbDropdown.y}px`
}"
@click.stop
>
<div
v-for="folder in breadcrumbDropdown.folders"
:key="folder"
class="dropdown-item"
@click="navigateToBreadcrumbFolder(folder)"
>
<span class="dropdown-icon">📁</span>
<span class="dropdown-label">{{ folder }}</span>
</div>
<div v-if="breadcrumbDropdown.folders.length === 0" class="dropdown-empty">
没有其他文件夹
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useFileSelection } from '@/composables/fileManager/useFileSelection'
import { useKeyboardNav } from '@/composables/fileManager/useKeyboardNav'
import { mockFolderStructure } from './mockData'
import type { FileItem, ViewMode, ThumbnailSize, PaneId } from './types'
import ContextMenu from './ContextMenu.vue'
import Tooltip from './Tooltip.vue'
const props = defineProps<{
paneId: PaneId
viewMode: ViewMode
thumbnailSize: ThumbnailSize
isActive: boolean
}>()
const emit = defineEmits<{
activate: []
}>()
// State
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('')
// Context Menu state
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
targetItem: null as string | null
})
// Breadcrumb dropdown state
const breadcrumbDropdown = ref({
visible: false,
x: 0,
y: 0,
index: -1,
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
})
// 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
})
// 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
})
const visibleItems = computed(() => {
if (!currentFolder.value) return []
let items = Object.values(currentFolder.value).sort((a, b) => {
if (a.type === 'folder' && b.type !== 'folder') return -1
if (a.type !== 'folder' && b.type === 'folder') return 1
return a.name.localeCompare(b.name)
})
// 搜索过滤
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
items = items.filter(item => item.name.toLowerCase().includes(query))
}
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()) {
return text
}
const query = searchQuery.value.trim()
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<mark>$1</mark>')
}
function navigateToIndex(index: number) {
currentPath.value = currentPath.value.slice(0, index + 1)
selection.clearSelection()
searchQuery.value = ''
breadcrumbDropdown.value.visible = false
}
// Breadcrumb dropdown
function toggleBreadcrumbDropdown(index: number) {
if (breadcrumbDropdown.value.visible && breadcrumbDropdown.value.index === index) {
breadcrumbDropdown.value.visible = false
return
}
// 获取父文件夹中的所有文件夹
const pathToParent = currentPath.value.slice(0, index + 1)
let parentFolder: any = mockFolderStructure
for (const segment of pathToParent) {
if (parentFolder[segment] && parentFolder[segment].children) {
parentFolder = parentFolder[segment].children
} else {
return
}
}
// 获取当前路径的下一个segment(当前选中的文件夹)
const currentSegment = currentPath.value[index + 1]
// 收集所有文件夹(排除当前选中的)
const folders = Object.values(parentFolder)
.filter((item: any) => item.type === 'folder' && item.name !== currentSegment)
.map((item: any) => item.name)
.sort()
// 计算位置(相对于separator)
const separatorElements = document.querySelectorAll('.breadcrumb-segment .separator')
const separatorEl = separatorElements[index] as HTMLElement
if (!separatorEl) return
const rect = separatorEl.getBoundingClientRect()
breadcrumbDropdown.value = {
visible: true,
x: rect.left,
y: rect.bottom + 4,
index,
folders
}
}
function navigateToBreadcrumbFolder(folderName: string) {
const index = breadcrumbDropdown.value.index
if (index < 0) return
// 导航到选中的文件夹
const newPath = [...currentPath.value.slice(0, index + 1), folderName]
currentPath.value = newPath
selection.clearSelection()
searchQuery.value = ''
breadcrumbDropdown.value.visible = false
}
// Path editing
function enterPathEditMode() {
pathEditMode.value = true
pathEditValue.value = currentPath.value.join('/')
nextTick(() => {
pathInputRef.value?.focus()
pathInputRef.value?.select()
})
}
function exitPathEditMode() {
// 延迟一点,让按钮点击事件先触发
setTimeout(() => {
pathEditMode.value = false
}, 150)
}
function applyPathEdit() {
const path = pathEditValue.value.trim()
if (!path) {
cancelPathEdit()
return
}
// 解析路径
const segments = path.split('/').filter(s => s.trim())
if (segments.length === 0) {
cancelPathEdit()
return
}
// 验证路径是否存在
let folder: any = mockFolderStructure
for (const segment of segments) {
if (folder[segment] && folder[segment].children) {
folder = folder[segment].children
} else {
alert(`路径不存在: ${path}`)
return
}
}
// 应用路径
currentPath.value = segments
selection.clearSelection()
searchQuery.value = ''
pathEditMode.value = false
}
function cancelPathEdit() {
pathEditMode.value = false
pathEditValue.value = ''
}
function navigateToFolder(folderName: string) {
currentPath.value.push(folderName)
selection.clearSelection()
searchQuery.value = ''
}
function isFocused(itemName: string): boolean {
return selection.focusedItem.value?.name === itemName
}
function handleItemClick(event: MouseEvent, itemName: string, index: number) {
event.stopPropagation()
if (event.shiftKey && selection.anchorItem.value) {
// Range selection
selection.clearSelection()
selection.selectRange(selection.anchorItem.value.index, index, visibleItems.value)
selection.setFocusedItem(itemName, index)
} else if (event.ctrlKey || event.metaKey) {
// Toggle selection
selection.toggleItemSelection(itemName, index)
} else {
// Single selection
selection.clearSelection()
selection.selectItem(itemName, index)
}
}
function handleItemDoubleClick(item: FileItem) {
if (item.type === 'folder') {
navigateToFolder(item.name)
}
}
function handleMouseDown(event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest('.file-item') || target.closest('.list-item')) {
return
}
// 如果正在拖动文件,不要开始框选
if (dragState.value.isDragging) {
return
}
emit('activate')
const rect = contentAreaRef.value?.getBoundingClientRect()
if (!rect) 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
}
if (!dragSelection.value.ctrlKey) {
selection.clearSelection()
}
event.preventDefault()
}
function handleMouseMove(event: MouseEvent) {
if (!dragSelection.value.isSelecting) return
const rect = contentAreaRef.value?.getBoundingClientRect()
if (!rect) return
const scrollEl = contentAreaRef.value!
dragSelection.value.currentX = event.clientX - rect.left + scrollEl.scrollLeft
dragSelection.value.currentY = event.clientY - rect.top + scrollEl.scrollTop
// 自动滚动检测
checkAutoScroll(event)
// 更新框选
updateDragSelection()
}
function handleMouseUp() {
if (dragSelection.value.isSelecting) {
// 最后更新一次选择
updateDragSelection()
dragSelection.value.isSelecting = false
autoScroll.value.isScrolling = false
// 设置焦点到最后选中的项
if (selection.selectedItems.value.size > 0) {
const lastSelected = Array.from(selection.selectedItems.value).pop()!
const index = visibleItems.value.findIndex(item => item.name === lastSelected)
if (index !== -1) {
selection.setFocusedItem(lastSelected, index)
}
}
}
}
// 检查是否需要自动滚动
function checkAutoScroll(event: MouseEvent) {
if (!contentAreaRef.value) return
const rect = contentAreaRef.value.getBoundingClientRect()
const threshold = 50
let dx = 0
let dy = 0
if (event.clientY < rect.top + threshold) {
dy = -5
} else if (event.clientY > rect.bottom - threshold) {
dy = 5
}
if (event.clientX < rect.left + threshold) {
dx = -5
} else if (event.clientX > rect.right - threshold) {
dx = 5
}
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() {
function scroll() {
if (!autoScroll.value.isScrolling || !contentAreaRef.value) return
contentAreaRef.value.scrollLeft += autoScroll.value.direction.x
contentAreaRef.value.scrollTop += autoScroll.value.direction.y
// 更新拖拽坐标
const rect = contentAreaRef.value.getBoundingClientRect()
dragSelection.value.currentX += autoScroll.value.direction.x
dragSelection.value.currentY += autoScroll.value.direction.y
updateDragSelection()
requestAnimationFrame(scroll)
}
requestAnimationFrame(scroll)
}
// 更新框选的项
function updateDragSelection() {
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) {
const itemName = (item as HTMLElement).dataset.itemName
if (itemName) {
itemsInSelection.add(itemName)
}
}
})
// 更新选择
if (dragSelection.value.ctrlKey) {
// Ctrl拖拽:保留初始选择 + 新框选
selection.clearSelection()
dragSelection.value.initialSelections.forEach(name => {
selection.selectedItems.value.add(name)
})
itemsInSelection.forEach(name => {
selection.selectedItems.value.add(name)
})
} else {
// 普通拖拽:只选择框选的
selection.clearSelection()
itemsInSelection.forEach(name => {
selection.selectedItems.value.add(name)
})
}
}
function handleContentClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.file-item') && !target.closest('.list-item')) {
if (!event.ctrlKey) {
selection.clearSelection()
}
}
}
function handleContextMenu(event: MouseEvent) {
event.preventDefault()
const target = event.target as HTMLElement
const item = target.closest('.file-item, .list-item') as HTMLElement
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
targetItem: item?.dataset.itemName || null
}
}
function handleContextMenuAction(action: string, params?: any) {
console.log('Context menu action:', action, params)
switch (action) {
case 'open':
if (params) {
const item = visibleItems.value.find(i => i.name === params)
if (item && item.type === 'folder') {
navigateToFolder(item.name)
}
}
break
case 'cut':
case 'copy':
case 'paste':
case 'delete':
case 'rename':
case 'properties':
// TODO: 实现这些操作
alert(`执行操作: ${action}`)
break
case 'newFolder':
alert('创建新文件夹')
break
case 'selectAll':
selection.selectAll(visibleItems.value)
break
case 'refresh':
// 重新加载当前文件夹
const temp = [...currentPath.value]
currentPath.value = []
nextTick(() => {
currentPath.value = temp
})
break
}
}
// Tooltip 处理
function handleItemMouseEnter(event: MouseEvent, item: FileItem) {
if (dragState.value.isDragging) return
const tooltip = `名称: ${item.name}\n类型: ${item.type === 'folder' ? '文件夹' : '文件'}\n${item.size ? '大小: ' + item.size : ''}\n${item.date ? '日期: ' + item.date : ''}`
tooltipRef.value?.show(tooltip.trim(), event.clientX, event.clientY)
}
function handleItemMouseLeave() {
tooltipRef.value?.hide()
}
// 拖放处理
function handleDragStart(event: DragEvent, item: FileItem) {
// 如果拖动的项未选中,选中它
if (!selection.isSelected(item.name)) {
selection.clearSelection()
const index = visibleItems.value.findIndex(i => i.name === item.name)
selection.selectItem(item.name, index)
}
// 设置拖动状态
dragState.value.isDragging = true
dragState.value.draggedItems = Array.from(selection.selectedItems.value)
dragState.value.sourcePane = props.paneId
// 设置拖动数据
const dragData = {
items: dragState.value.draggedItems,
sourcePane: props.paneId,
sourcePath: currentPath.value
}
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('application/json', JSON.stringify(dragData))
// 隐藏tooltip
tooltipRef.value?.hide()
}
function handleDragEnd() {
dragState.value.isDragging = false
dragState.value.dropTarget = null
dragState.value.sourcePane = null
dragState.value.isPaneDragOver = false
}
function handleDragOver(event: DragEvent, item: FileItem) {
// 只有文件夹可以作为drop target
if (item.type !== 'folder') return
// 不能拖到自己身上
if (selection.isSelected(item.name)) return
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
dragState.value.dropTarget = item.name
}
function handleDragLeave(event: DragEvent) {
const target = event.target as HTMLElement
const relatedTarget = event.relatedTarget as HTMLElement
// 只有真正离开元素时才清除
if (!target.contains(relatedTarget)) {
dragState.value.dropTarget = null
}
}
function handleDrop(event: DragEvent, targetItem: FileItem) {
event.preventDefault()
if (targetItem.type !== 'folder') return
if (selection.isSelected(targetItem.name)) return
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}"`)
// TODO: 实现实际的文件移动逻辑
// 这需要:
// 1. 更新mockData或使用真实的文件系统API
// 2. 从源位置移除文件
// 3. 添加到目标位置
// 4. 刷新视图
} catch (e) {
console.error('Drop failed:', e)
}
dragState.value.isDragging = false
dragState.value.dropTarget = null
}
// Pane-level drop handlers (for dropping into empty space or other pane)
function handlePaneDragOver(event: DragEvent) {
// 检查是否在拖动文件项
const target = event.target as HTMLElement
if (target.closest('.file-item') || target.closest('.list-item')) {
return
}
try {
const data = event.dataTransfer?.types.includes('application/json')
if (data) {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
dragState.value.isPaneDragOver = true
}
} catch (e) {
// Ignore
}
}
function handlePaneDragLeave(event: DragEvent) {
const target = event.target as HTMLElement
const relatedTarget = event.relatedTarget as HTMLElement
// 只有真正离开pane时才清除
if (!contentAreaRef.value?.contains(relatedTarget)) {
dragState.value.isPaneDragOver = false
}
}
function handlePaneDrop(event: DragEvent) {
// 如果拖到具体的item上,不处理(由item的drop处理)
const target = event.target as HTMLElement
if (target.closest('.file-item') || target.closest('.list-item')) {
return
}
event.preventDefault()
try {
const dragData = JSON.parse(event.dataTransfer!.getData('application/json'))
// 拖到当前面板的空白区域 = 移动到当前文件夹
const currentFolderName = currentPath.value[currentPath.value.length - 1]
console.log(`移动文件: ${dragData.items.join(', ')} 到当前文件夹 ${currentFolderName}`)
if (dragData.sourcePane !== props.paneId) {
alert(`已从${dragData.sourcePane === 'left' ? '左' : '右'}面板移动 ${dragData.items.length} 个项目到${props.paneId === 'left' ? '左' : '右'}面板的 "${currentFolderName}"`)
} else {
alert(`在当前文件夹内操作`)
}
// TODO: 实现实际的文件移动逻辑
} catch (e) {
console.error('Pane drop failed:', e)
}
dragState.value.isPaneDragOver = false
dragState.value.isDragging = false
}
// 计算Grid列数
function getGridColumns(): number {
if (!contentAreaRef.value) return 4
const items = contentAreaRef.value.querySelectorAll('.file-item')
if (items.length < 2) return 1
const firstRect = items[0].getBoundingClientRect()
const secondRect = items[1].getBoundingClientRect()
if (Math.abs(secondRect.top - firstRect.top) < 10) {
let cols = 1
for (let i = 1; i < items.length; i++) {
const rect = items[i].getBoundingClientRect()
if (Math.abs(rect.top - firstRect.top) < 10) {
cols++
} else {
break
}
}
return cols
}
return 1
}
// 监听搜索变化清除选择
watch(searchQuery, () => {
selection.clearSelection()
})
// 关闭breadcrumb dropdown
onMounted(() => {
document.addEventListener('click', () => {
breadcrumbDropdown.value.visible = false
})
})
onUnmounted(() => {
document.removeEventListener('click', () => {
breadcrumbDropdown.value.visible = false
})
})
// 键盘导航
useKeyboardNav({
items: computed(() => visibleItems.value),
viewMode: computed(() => props.viewMode),
isActive: computed(() => props.isActive),
focusedItem: selection.focusedItem,
anchorItem: selection.anchorItem,
selectedItems: selection.selectedItems,
onNavigate: (newIndex: number, event: KeyboardEvent) => {
const newItem = visibleItems.value[newIndex]
if (!newItem) return
if (event.shiftKey) {
// Shift+方向键:扩展选择
const anchor = selection.anchorItem.value
if (anchor) {
selection.clearSelection()
selection.selectRange(anchor.index, newIndex, visibleItems.value)
} else {
selection.clearSelection()
selection.selectItem(newItem.name, newIndex)
}
selection.setFocusedItem(newItem.name, newIndex)
} else if (event.ctrlKey || event.metaKey) {
// Ctrl+方向键:只移动焦点
selection.setFocusedItem(newItem.name, newIndex)
} else {
// 普通方向键:单选并移动焦点
selection.clearSelection()
selection.selectItem(newItem.name, newIndex)
}
},
onEnter: () => {
const focused = selection.focusedItem.value
if (focused) {
const item = visibleItems.value[focused.index]
if (item && item.type === 'folder') {
navigateToFolder(item.name)
}
}
},
onSpace: () => {
const focused = selection.focusedItem.value
if (focused) {
selection.toggleItemSelection(focused.name, focused.index)
}
},
onSelectAll: () => {
selection.selectAll(visibleItems.value)
},
getGridColumns
})
</script>
<style scoped>
.file-pane {
position: relative;
display: flex;
flex-direction: column;
background: white;
transition: opacity 0.15s;
}
.file-pane.inactive {
opacity: 0.88;
}
.active-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(to right, #3b82f6, #60a5fa);
z-index: 10;
}
.inactive-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.015);
pointer-events: none;
z-index: 5;
}
/* Breadcrumb */
.breadcrumb {
padding: 12px 16px;
border-bottom: 1px solid #f3f4f6;
background: #f9fafb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
transition: background-color 0.15s;
}
.breadcrumb:hover {
background: rgba(59, 130, 246, 0.03);
}
.breadcrumb-content {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
flex: 1;
min-width: 0;
position: relative;
}
.path-edit-btn {
margin-left: 8px;
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
opacity: 0.6;
}
.path-edit-btn:hover {
background: #f3f4f6;
opacity: 1;
}
.path-edit-container {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.path-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #3b82f6;
border-radius: 6px;
font-size: 13px;
outline: none;
background: white;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.path-edit-confirm,
.path-edit-cancel {
padding: 6px 10px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
}
.path-edit-confirm {
background: #10b981;
color: white;
}
.path-edit-confirm:hover {
background: #059669;
}
.path-edit-cancel {
background: #ef4444;
color: white;
}
.path-edit-cancel:hover {
background: #dc2626;
}
.breadcrumb-item {
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
color: #6b7280;
}
.breadcrumb-item:hover {
background: #f3f4f6;
color: #3b82f6;
}
.breadcrumb-item.current {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
font-weight: 500;
}
.breadcrumb-segment {
display: inline-flex;
align-items: center;
}
.separator {
margin: 0 4px;
color: #9ca3af;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.separator:hover {
background: #f3f4f6;
color: #3b82f6;
}
.separator.active {
background: #dbeafe;
color: #3b82f6;
}
/* Breadcrumb Dropdown */
.breadcrumb-dropdown {
position: fixed;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 10px 15px rgba(0, 0, 0, 0.1);
max-height: 300px;
overflow-y: auto;
min-width: 200px;
z-index: 1000;
padding: 4px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
font-size: 14px;
}
.dropdown-item:hover {
background: #f3f4f6;
}
.dropdown-icon {
font-size: 16px;
}
.dropdown-label {
color: #374151;
}
.dropdown-empty {
padding: 12px;
text-align: center;
color: #9ca3af;
font-size: 13px;
}
/* Search Box */
.search-box {
position: relative;
flex-shrink: 0;
width: 200px;
}
.search-input {
width: 100%;
padding: 6px 30px 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: all 0.2s;
background: white;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
}
.search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.search-clear:hover {
background: #f3f4f6;
color: #374151;
}
/* Content Area */
.content-area {
flex: 1;
padding: 16px;
overflow: auto;
position: relative;
transition: background-color 0.2s;
}
.content-area.pane-drag-over {
background: #f0fdf4;
outline: 2px dashed #10b981;
outline-offset: -8px;
}
/* Grid View */
.file-grid {
display: grid;
gap: 16px;
}
.file-grid.grid-small {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.file-grid.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.file-grid.grid-large {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.file-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: white;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.file-item:hover {
background: #f9fafb;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.file-item.selected {
background: #dbeafe;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.file-item.focused {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.file-item.drop-target {
border-color: #10b981;
background: #f0fdf4;
}
.file-item.drag-over {
border-color: #10b981;
background: #d1fae5;
transform: scale(1.02);
box-shadow: 0 0 0 2px #10b981;
}
.file-icon {
font-size: 32px;
margin-bottom: 8px;
}
.file-name {
font-size: 13px;
text-align: center;
word-break: break-word;
font-weight: 500;
color: #374151;
}
.file-name :deep(mark) {
background: #fef08a;
color: #854d0e;
padding: 1px 2px;
border-radius: 2px;
font-weight: 600;
}
.file-size {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
/* List View */
.file-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.list-item {
display: grid;
grid-template-columns: 40px 1fr 100px 120px;
align-items: center;
padding: 10px 12px;
background: white;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.list-item:hover {
background: #f9fafb;
}
.list-item.selected {
background: #dbeafe;
}
.list-item.drop-target {
background: #f0fdf4;
border-left: 3px solid #10b981;
}
.list-item.drag-over {
background: #d1fae5;
border-left: 3px solid #10b981;
box-shadow: 0 0 0 1px #10b981;
}
.list-icon {
font-size: 20px;
}
.list-name {
font-size: 14px;
color: #374151;
}
.list-name :deep(mark) {
background: #fef08a;
color: #854d0e;
padding: 1px 2px;
border-radius: 2px;
font-weight: 600;
}
.list-size, .list-date {
font-size: 13px;
color: #6b7280;
}
/* Selection Rectangle */
.selection-rectangle {
position: absolute;
border: 2px solid #3b82f6;
background: rgba(59, 130, 246, 0.1);
pointer-events: none;
z-index: 10;
}
</style>
@@ -0,0 +1,57 @@
<template>
<Teleport to="body">
<div
v-if="visible && content"
class="tooltip"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
>
{{ content }}
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const content = ref('')
const position = ref({ x: 0, y: 0 })
const timeout = ref<number>()
function show(text: string, x: number, y: number, delay = 500) {
clearTimeout(timeout.value)
timeout.value = window.setTimeout(() => {
content.value = text
position.value = { x: x + 10, y: y + 10 }
visible.value = true
}, delay)
}
function hide() {
clearTimeout(timeout.value)
visible.value = false
}
defineExpose({
show,
hide
})
</script>
<style scoped>
.tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
pointer-events: none;
z-index: 100;
white-space: nowrap;
max-width: 300px;
word-wrap: break-word;
white-space: normal;
}
</style>
@@ -0,0 +1,6 @@
export { default as FileManager } from './FileManager.vue'
export { default as FilePane } from './FilePane.vue'
export { default as ContextMenu } from './ContextMenu.vue'
export { default as Tooltip } from './Tooltip.vue'
export * from './types'
export * from './mockData'
@@ -0,0 +1,107 @@
import type { FolderStructure } from './types'
// 生成模拟数据
export const mockFolderStructure: FolderStructure = {
Home: {
name: 'Home',
type: 'folder',
children: {
'My Photos': {
name: 'My Photos',
type: 'folder',
children: {
'Vacation 2023': {
name: 'Vacation 2023',
type: 'folder',
children: {
Beach: {
name: 'Beach',
type: 'folder',
children: {
'photo1.jpg': { name: 'photo1.jpg', type: 'photo', size: '2.4 MB', date: '2023-07-15' },
'photo2.jpg': { name: 'photo2.jpg', type: 'photo', size: '3.1 MB', date: '2023-07-15' },
'photo3.jpg': { name: 'photo3.jpg', type: 'photo', size: '2.8 MB', date: '2023-07-16' },
'beach_sunset.jpg': { name: 'beach_sunset.jpg', type: 'photo', size: '5.2 MB', date: '2023-07-17' },
'waves.jpg': { name: 'waves.jpg', type: 'photo', size: '3.8 MB', date: '2023-07-18' }
}
},
Mountains: {
name: 'Mountains',
type: 'folder',
children: {
'sunrise.jpg': { name: 'sunrise.jpg', type: 'photo', size: '4.2 MB', date: '2023-07-20' },
'peak_view.jpg': { name: 'peak_view.jpg', type: 'photo', size: '6.1 MB', date: '2023-07-21' }
}
}
}
},
Family: {
name: 'Family',
type: 'folder',
children: {
'dinner.jpg': { name: 'dinner.jpg', type: 'photo', size: '2.2 MB', date: '2023-08-15' },
'family_portrait.jpg': { name: 'family_portrait.jpg', type: 'photo', size: '4.1 MB', date: '2023-08-20' }
}
}
}
},
'Camera Roll': {
name: 'Camera Roll',
type: 'folder',
children: (() => {
const items: Record<string, any> = {
Recent: {
name: 'Recent',
type: 'folder',
children: {}
},
Favorites: {
name: 'Favorites',
type: 'folder',
children: {}
}
}
// 生成100张照片
for (let i = 1; i <= 100; i++) {
const size = (Math.random() * 3 + 1).toFixed(1)
const day = Math.floor(Math.random() * 30) + 1
items[`IMG_${String(i).padStart(4, '0')}.jpg`] = {
name: `IMG_${String(i).padStart(4, '0')}.jpg`,
type: 'photo',
size: `${size} MB`,
date: `2023-09-${String(day).padStart(2, '0')}`
}
}
// Recent文件夹添加照片
for (let i = 1; i <= 30; i++) {
const size = (Math.random() * 3 + 1).toFixed(1)
items.Recent.children[`Recent_${String(i).padStart(3, '0')}.jpg`] = {
name: `Recent_${String(i).padStart(3, '0')}.jpg`,
type: 'photo',
size: `${size} MB`,
date: `2023-12-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`
}
}
return items
})()
},
'Work Projects': {
name: 'Work Projects',
type: 'folder',
children: {
Screenshots: {
name: 'Screenshots',
type: 'folder',
children: {
'design1.png': { name: 'design1.png', type: 'photo', size: '1.2 MB', date: '2023-09-01' },
'design2.png': { name: 'design2.png', type: 'photo', size: '1.5 MB', date: '2023-09-02' }
}
}
}
}
}
}
}
@@ -0,0 +1,37 @@
// 文件管理器类型定义
export interface FileItem {
name: string
type: 'folder' | 'photo' | 'video' | 'file'
size?: string
date?: string
children?: Record<string, FileItem>
}
export interface FolderStructure {
[key: string]: FileItem
}
export type ViewMode = 'grid' | 'list'
export type ThumbnailSize = 'small' | 'medium' | 'large'
export type LayoutMode = 'single' | 'horizontal' | 'vertical'
export type PaneId = 'left' | 'right'
export interface SelectionState {
selectedItems: Set<string>
focusedItem: { name: string; index: number } | null
anchorItem: { name: string; index: number } | null
}
export interface ContextMenuPosition {
x: number
y: number
}
export interface ContextMenuItem {
label: string
icon: string
action: () => void
disabled?: boolean
separator?: boolean
}
+5 -932
View File
@@ -1,944 +1,17 @@
<template>
<div class="file-explorer">
<!-- 左侧目录树 -->
<div class="sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="sidebar-header">
<n-button quaternary circle size="small" @click="toggleSidebar">
<template #icon>
<n-icon><menu-outline /></n-icon>
</template>
</n-button>
<span v-if="!sidebarCollapsed" class="sidebar-title">文件夹</span>
</div>
<div v-if="!sidebarCollapsed" class="directory-tree">
<n-tree
:data="directoryTree"
:selected-keys="selectedKeys"
:expanded-keys="expandedKeys"
selectable
expand-on-click
@update:selected-keys="handleDirectorySelect"
@update:expanded-keys="handleDirectoryExpand"
>
<template #prefix="{ option }">
<n-icon :color="option.type === 'folder' ? 'var(--color-warning)' : 'var(--text-color-secondary)'">
<folder-outline v-if="option.type === 'folder'" />
<image-outline v-else-if="option.type === 'images'" />
<document-outline v-else />
</n-icon>
</template>
</n-tree>
</div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<!-- 面包屑导航 -->
<n-breadcrumb class="breadcrumb">
<n-breadcrumb-item
v-for="(item, index) in breadcrumbItems"
:key="index"
@click="navigateTo(item.path)"
>
<template #icon>
<n-icon><home-outline v-if="index === 0" /><folder-outline v-else /></n-icon>
</template>
{{ item.label }}
</n-breadcrumb-item>
</n-breadcrumb>
</div>
<div class="toolbar-center">
<!-- 搜索框 -->
<n-input
v-model:value="searchQuery"
placeholder="搜索文件和文件夹..."
class="search-input"
clearable
>
<template #prefix>
<n-icon><search-outline /></n-icon>
</template>
</n-input>
</div>
<div class="toolbar-right">
<!-- 视图模式切换 -->
<n-button-group>
<n-button
:type="viewMode === 'grid' ? 'primary' : 'default'"
quaternary
@click="setViewMode('grid')"
>
<template #icon>
<n-icon><grid-outline /></n-icon>
</template>
</n-button>
<n-button
:type="viewMode === 'list' ? 'primary' : 'default'"
quaternary
@click="setViewMode('list')"
>
<template #icon>
<n-icon><list-outline /></n-icon>
</template>
</n-button>
</n-button-group>
<!-- 排序方式 -->
<n-dropdown :options="sortOptions" @select="handleSortSelect">
<n-button quaternary>
<template #icon>
<n-icon><funnel-outline /></n-icon>
</template>
排序
</n-button>
</n-dropdown>
<!-- 缩放控制 -->
<div class="zoom-controls">
<n-button quaternary size="small" @click="zoomOut" :disabled="zoomLevel <= 1">
<template #icon>
<n-icon><remove-outline /></n-icon>
</template>
</n-button>
<span class="zoom-level">{{ Math.round(zoomLevel * 100) }}%</span>
<n-button quaternary size="small" @click="zoomIn" :disabled="zoomLevel >= 3">
<template #icon>
<n-icon><add-outline /></n-icon>
</template>
</n-button>
</div>
<!-- 更多操作 -->
<n-dropdown :options="moreOptions" @select="handleMoreAction">
<n-button quaternary>
<template #icon>
<n-icon><ellipsis-vertical-outline /></n-icon>
</template>
</n-button>
</n-dropdown>
</div>
</div>
<!-- 文件显示区域 -->
<div class="file-area" @wheel="handleWheel" @click="clearSelection">
<!-- 网格视图 -->
<div v-if="viewMode === 'grid'" class="file-grid" :style="{ '--zoom-level': zoomLevel }">
<div
v-for="file in filteredFiles"
:key="file.id"
class="file-item"
:class="{
selected: selectedFiles.includes(file.id),
'is-image': file.type === 'image'
}"
@click="handleFileClick(file, $event)"
@dblclick="handleFileDoubleClick(file)"
>
<!-- 图片预览 -->
<div v-if="file.type === 'image'" class="file-preview image-preview">
<img :src="file.thumbnail" :alt="file.name" @load="handleImageLoad" />
<div class="image-overlay">
<div class="image-info">
<span class="image-resolution">{{ file.resolution }}</span>
</div>
</div>
</div>
<!-- 非图片文件图标 -->
<div v-else class="file-preview icon-preview" :class="`file-type-${file.type}`">
<n-icon size="48" :color="getFileTypeColor(file.type)">
<component :is="getFileIcon(file.type)" />
</n-icon>
</div>
<div class="file-info">
<div class="file-name" :title="file.name">{{ file.name }}</div>
<div class="file-meta">
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<span class="file-date">{{ formatDate(file.modifiedAt) }}</span>
</div>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="file-list">
<n-data-table
:columns="listColumns"
:data="filteredFiles"
:row-key="(row) => row.id"
:checked-row-keys="selectedFiles"
@update:checked-row-keys="handleRowSelection"
>
<template #name="{ row }">
<div class="list-file-name">
<n-icon :color="getFileTypeColor(row.type)">
<component :is="getFileIcon(row.type)" />
</n-icon>
<span>{{ row.name }}</span>
</div>
</template>
<template #size="{ row }">
{{ formatFileSize(row.size) }}
</template>
<template #modifiedAt="{ row }">
{{ formatDate(row.modifiedAt) }}
</template>
</n-data-table>
</div>
<!-- 空状态 -->
<div v-if="filteredFiles.length === 0" class="empty-state">
<n-empty description="此文件夹为空">
<template #icon>
<n-icon size="64" color="var(--text-color-tertiary)">
<folder-outline />
</n-icon>
</template>
</n-empty>
</div>
</div>
</div>
<!-- 预览模态框 -->
<n-modal
v-model:show="previewVisible"
preset="card"
:title="currentPreviewFile?.name"
size="huge"
:bordered="false"
class="preview-modal"
>
<div v-if="currentPreviewFile" class="preview-content">
<img
v-if="currentPreviewFile.type === 'image'"
:src="currentPreviewFile.url"
:alt="currentPreviewFile.name"
class="preview-image"
/>
<div v-else class="preview-placeholder">
<n-icon size="64" :color="getFileTypeColor(currentPreviewFile.type)">
<component :is="getFileIcon(currentPreviewFile.type)" />
</n-icon>
<p>无法预览此文件类型</p>
</div>
</div>
<div class="preview-controls">
<n-button @click="previousFile" :disabled="currentFileIndex <= 0">
<template #icon>
<n-icon><chevron-back-outline /></n-icon>
</template>
上一个
</n-button>
<span>{{ currentFileIndex + 1 }} / {{ filteredFiles.length }}</span>
<n-button @click="nextFile" :disabled="currentFileIndex >= filteredFiles.length - 1">
下一个
<template #icon>
<n-icon><chevron-forward-outline /></n-icon>
</template>
</n-button>
</div>
</n-modal>
<!-- 右键菜单 -->
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="contextMenuX"
:y="contextMenuY"
:options="contextMenuOptions"
:show="showContextMenu"
@select="handleContextMenuSelect"
@clickoutside="showContextMenu = false"
/>
<div class="field-view">
<FileManager />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
NButton,
NButtonGroup,
NInput,
NIcon,
NTree,
NBreadcrumb,
NBreadcrumbItem,
NDropdown,
NDataTable,
NEmpty,
NModal
} from 'naive-ui'
import {
MenuOutline,
FolderOutline,
ImageOutline,
DocumentOutline,
HomeOutline,
SearchOutline,
GridOutline,
ListOutline,
FunnelOutline,
AddOutline,
RemoveOutline,
EllipsisVerticalOutline,
ChevronBackOutline,
ChevronForwardOutline,
VideocamOutline,
MusicalNoteOutline,
ArchiveOutline,
CodeSlashOutline
} from '@vicons/ionicons5'
// 状态管理
const sidebarCollapsed = ref(false)
const viewMode = ref<'grid' | 'list'>('grid')
const zoomLevel = ref(1.2)
const searchQuery = ref('')
const currentPath = ref('/Photos')
const selectedFiles = ref<string[]>([])
const selectedKeys = ref<string[]>(['photos'])
const expandedKeys = ref<string[]>(['root'])
const previewVisible = ref(false)
const currentPreviewFile = ref<any>(null)
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
// 假数据
const directoryTree = ref([
{
key: 'root',
label: '我的照片',
type: 'folder',
children: [
{
key: 'photos',
label: '照片',
type: 'images',
children: [
{ key: 'photos-2024', label: '2024', type: 'folder' },
{ key: 'photos-2023', label: '2023', type: 'folder' }
]
},
{ key: 'videos', label: '视频', type: 'folder' },
{ key: 'documents', label: '文档', type: 'folder' },
{ key: 'downloads', label: '下载', type: 'folder' }
]
}
])
const allFiles = ref([
{
id: '1',
name: 'sunset-beach.jpg',
type: 'image',
size: 2048576,
modifiedAt: '2024-01-15T10:30:00Z',
thumbnail: 'https://picsum.photos/300/200?random=1',
url: 'https://picsum.photos/1920/1080?random=1',
resolution: '1920×1080'
},
{
id: '2',
name: 'mountain-view.jpg',
type: 'image',
size: 3145728,
modifiedAt: '2024-01-14T15:45:00Z',
thumbnail: 'https://picsum.photos/300/200?random=2',
url: 'https://picsum.photos/1920/1080?random=2',
resolution: '1920×1080'
},
{
id: '3',
name: 'presentation.pdf',
type: 'document',
size: 1048576,
modifiedAt: '2024-01-13T09:15:00Z'
},
{
id: '4',
name: 'holiday-video.mp4',
type: 'video',
size: 52428800,
modifiedAt: '2024-01-12T20:00:00Z'
},
{
id: '5',
name: 'project-files.zip',
type: 'archive',
size: 10485760,
modifiedAt: '2024-01-11T11:30:00Z'
},
{
id: '6',
name: 'script.js',
type: 'code',
size: 4096,
modifiedAt: '2024-01-10T16:20:00Z'
}
])
// 计算属性
const filteredFiles = computed(() => {
let files = allFiles.value
if (searchQuery.value) {
files = files.filter(file =>
file.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
return files
})
const breadcrumbItems = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
const items = [{ label: '主页', path: '/' }]
let currentFullPath = ''
for (const part of parts) {
currentFullPath += '/' + part
items.push({ label: part, path: currentFullPath })
}
return items
})
const currentFileIndex = computed(() => {
if (!currentPreviewFile.value) return -1
return filteredFiles.value.findIndex(file => file.id === currentPreviewFile.value.id)
})
// 排序和更多操作选项
const sortOptions = ref([
{ label: '按名称排序', key: 'name' },
{ label: '按大小排序', key: 'size' },
{ label: '按修改时间排序', key: 'modifiedAt' },
{ label: '按类型排序', key: 'type' }
])
const moreOptions = ref([
{ label: '新建文件夹', key: 'new-folder' },
{ label: '上传文件', key: 'upload' },
{ label: '刷新', key: 'refresh' },
{ label: '显示隐藏文件', key: 'show-hidden' }
])
const contextMenuOptions = computed(() => [
{ label: '打开', key: 'open' },
{ label: '重命名', key: 'rename' },
{ label: '删除', key: 'delete', disabled: selectedFiles.value.length === 0 },
{ type: 'divider' },
{ label: '复制', key: 'copy' },
{ label: '剪切', key: 'cut' },
{ label: '粘贴', key: 'paste' },
{ type: 'divider' },
{ label: '属性', key: 'properties' }
])
// 列表视图的表格列定义
const listColumns = [
{ type: 'selection' },
{ title: '名称', key: 'name', render: 'name' },
{ title: '大小', key: 'size', render: 'size', width: 120 },
{ title: '修改时间', key: 'modifiedAt', render: 'modifiedAt', width: 180 }
]
// 方法
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const setViewMode = (mode: 'grid' | 'list') => {
viewMode.value = mode
}
const zoomIn = () => {
if (zoomLevel.value < 3) {
zoomLevel.value += 0.2
}
}
const zoomOut = () => {
if (zoomLevel.value > 0.6) {
zoomLevel.value -= 0.2
}
}
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
if (e.deltaY < 0) {
zoomIn()
} else {
zoomOut()
}
}
}
const handleDirectorySelect = (keys: string[]) => {
selectedKeys.value = keys
if (keys.length > 0) {
// 这里可以根据选中的目录加载相应的文件
console.log('Selected directory:', keys[0])
}
}
const handleDirectoryExpand = (keys: string[]) => {
expandedKeys.value = keys
}
const navigateTo = (path: string) => {
currentPath.value = path
// 加载对应路径的文件
}
const handleFileClick = (file: any, event: MouseEvent) => {
event.stopPropagation()
if (event.ctrlKey || event.metaKey) {
// Ctrl+点击多选
if (selectedFiles.value.includes(file.id)) {
selectedFiles.value = selectedFiles.value.filter(id => id !== file.id)
} else {
selectedFiles.value.push(file.id)
}
} else if (event.shiftKey && selectedFiles.value.length > 0) {
// Shift+点击范围选择
const lastSelected = selectedFiles.value[selectedFiles.value.length - 1]
const lastIndex = filteredFiles.value.findIndex(f => f.id === lastSelected)
const currentIndex = filteredFiles.value.findIndex(f => f.id === file.id)
const start = Math.min(lastIndex, currentIndex)
const end = Math.max(lastIndex, currentIndex)
selectedFiles.value = filteredFiles.value.slice(start, end + 1).map(f => f.id)
} else {
// 单选
selectedFiles.value = [file.id]
}
}
const handleFileDoubleClick = (file: any) => {
if (file.type === 'image') {
currentPreviewFile.value = file
previewVisible.value = true
} else {
// 其他文件类型的打开逻辑
console.log('Opening file:', file.name)
}
}
const clearSelection = () => {
selectedFiles.value = []
}
const handleRowSelection = (keys: string[]) => {
selectedFiles.value = keys
}
const previousFile = () => {
if (currentFileIndex.value > 0) {
currentPreviewFile.value = filteredFiles.value[currentFileIndex.value - 1]
}
}
const nextFile = () => {
if (currentFileIndex.value < filteredFiles.value.length - 1) {
currentPreviewFile.value = filteredFiles.value[currentFileIndex.value + 1]
}
}
const handleSortSelect = (key: string) => {
console.log('Sort by:', key)
}
const handleMoreAction = (key: string) => {
console.log('More action:', key)
}
const handleContextMenuSelect = (key: string) => {
console.log('Context menu:', key)
showContextMenu.value = false
}
const handleImageLoad = (event: Event) => {
// 图片加载完成的处理
}
// 工具函数
const getFileIcon = (type: string) => {
const iconMap: { [key: string]: any } = {
image: ImageOutline,
video: VideocamOutline,
audio: MusicalNoteOutline,
document: DocumentOutline,
archive: ArchiveOutline,
code: CodeSlashOutline,
folder: FolderOutline
}
return iconMap[type] || DocumentOutline
}
const getFileTypeColor = (type: string) => {
const colorMap: { [key: string]: string } = {
image: 'var(--color-success)',
video: 'var(--color-info)',
audio: 'var(--color-secondary)',
document: 'var(--text-color-secondary)',
archive: 'var(--color-warning)',
code: 'var(--color-primary)',
folder: 'var(--color-warning)'
}
return colorMap[type] || 'var(--text-color-secondary)'
}
const formatFileSize = (bytes: number) => {
const sizes = ['B', 'KB', 'MB', 'GB']
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
// 组件挂载后的初始化
})
import FileManager from '@/components/FileManager/FileManager.vue'
</script>
<style scoped>
.file-explorer {
display: flex;
.field-view {
width: 100%;
height: 100vh;
background: var(--bg-color);
overflow: hidden;
}
/* 左侧边栏 */
.sidebar {
width: 280px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width var(--transition-normal);
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-header {
display: flex;
align-items: center;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color-light);
gap: var(--spacing-sm);
}
.sidebar-title {
font-weight: var(--font-weight-semibold);
color: var(--text-color);
}
.directory-tree {
flex: 1;
padding: var(--spacing-sm);
overflow: auto;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 工具栏 */
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--header-bg);
border-bottom: 1px solid var(--border-color-light);
min-height: 60px;
}
.toolbar-left {
min-width: 200px;
}
.toolbar-center {
flex: 1;
max-width: 400px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.search-input {
width: 100%;
}
.breadcrumb :deep(.n-breadcrumb-item) {
cursor: pointer;
}
.zoom-controls {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 0 var(--spacing-sm);
}
.zoom-level {
font-size: var(--font-size-sm);
color: var(--text-color-secondary);
min-width: 40px;
text-align: center;
}
/* 文件区域 */
.file-area {
flex: 1;
padding: var(--spacing-lg);
overflow: auto;
background: var(--content-bg);
}
/* 网格视图 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(calc(160px * var(--zoom-level)), 1fr));
gap: calc(var(--spacing-md) * var(--zoom-level));
padding: var(--spacing-sm);
}
.file-item {
background: var(--card-bg);
border: 2px solid transparent;
border-radius: var(--radius-md);
padding: var(--spacing-sm);
cursor: pointer;
transition: all var(--transition-fast);
user-select: none;
}
.file-item:hover {
border-color: var(--border-color);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.file-item.selected {
border-color: var(--color-primary);
background: rgba(59, 130, 246, 0.08);
}
.file-item.is-image .file-preview {
aspect-ratio: 4/3;
}
.file-preview {
position: relative;
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: var(--spacing-sm);
background: var(--bg-color-secondary);
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.7));
opacity: 0;
transition: opacity var(--transition-fast);
display: flex;
align-items: flex-end;
}
.file-item:hover .image-overlay {
opacity: 1;
}
.image-info {
padding: var(--spacing-sm);
color: white;
font-size: var(--font-size-xs);
}
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 4/3;
background: var(--bg-color-tertiary);
}
.file-info {
text-align: center;
}
.file-name {
font-weight: var(--font-weight-medium);
color: var(--text-color);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: var(--text-color-secondary);
gap: var(--spacing-xs);
}
/* 列表视图 */
.file-list {
background: var(--card-bg);
border-radius: var(--radius-md);
overflow: hidden;
}
.list-file-name {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* 空状态 */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
}
/* 预览模态框 */
.preview-modal {
max-width: 90vw;
max-height: 90vh;
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: var(--bg-color-secondary);
border-radius: var(--radius-md);
overflow: hidden;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
color: var(--text-color-secondary);
}
.preview-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color-light);
}
/* 文件类型颜色 */
.file-type-document {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.file-type-video {
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
}
.file-type-audio {
background: linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%);
}
.file-type-archive {
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
}
.file-type-code {
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-explorer {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
max-height: 200px;
}
.toolbar {
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.toolbar-left,
.toolbar-center {
min-width: auto;
flex: 1;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
@media (max-width: 480px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
}
</style>
+1 -1
View File
@@ -31,7 +31,7 @@ export default defineConfig({
},
},
server: {
port: 3000, // 设置你想要的端口号
port: 8567, // 设置你想要的端口号
host: false, // 可选:允许外部访问
},
})