A:调整文件夹
This commit is contained in:
1 parent
d9690fbaae
commit
7af4f31538
10 files changed
+2799
-1020
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -31,7 +31,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000, // 设置你想要的端口号
|
||||
port: 8567, // 设置你想要的端口号
|
||||
host: false, // 可选:允许外部访问
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user