EDMS/components/dms/explorer/WindowsExplorerTree.vue
2025-06-05 14:57:08 +08:00

732 lines
20 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted, nextTick, onUnmounted } from 'vue';
import { useDesignSystem } from '~/composables/useDesignSystem';
import { useNotifications } from '~/composables/useNotifications';
import { useTouchInteractions } from '~/composables/useTouchInteractions';
import LoadingStates from '~/components/base/LoadingStates.vue';
import ResponsiveContainer from '~/components/base/ResponsiveContainer.vue';
import BaseModal from '~/components/base/BaseModal.vue';
import RsButton from '~/components/RsButton.vue';
const props = defineProps({
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
allowDragDrop: {
type: Boolean,
default: true
},
allowMultiSelect: {
type: Boolean,
default: true
},
allowInlineEdit: {
type: Boolean,
default: true
},
showContextMenu: {
type: Boolean,
default: true
}
});
const emit = defineEmits([
'node-click', 'node-double-click', 'node-expand', 'node-collapse',
'node-rename', 'node-delete', 'node-move', 'folder-create', 'file-upload',
'context-menu', 'selection-change'
]);
// Design system and utilities
const { tokens, utils, dmsPatterns, statusColors } = useDesignSystem();
const { success, error, confirm } = useNotifications();
const { setupTouchInteractions } = useTouchInteractions();
// Tree state
const treeData = ref([]);
const selectedNodes = ref(new Set());
const expandedNodes = ref(new Set());
const editingNode = ref(null);
const dragNode = ref(null);
const dropTarget = ref(null);
const contextMenuNode = ref(null);
// UI state
const showContextMenu = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showNewFolderDialog = ref(false);
const showRenameDialog = ref(false);
const newFolderName = ref('');
const renameValue = ref('');
// Responsive state
const containerRef = ref(null);
const isMobile = ref(false);
// Build hierarchical tree from flat data
const buildTreeStructure = (flatData) => {
const nodeMap = new Map();
const roots = [];
// First pass: create all nodes
flatData.forEach(item => {
nodeMap.set(item.id, {
...item,
children: [],
isExpanded: expandedNodes.value.has(item.id),
isSelected: selectedNodes.value.has(item.id),
level: 0
});
});
// Second pass: build hierarchy
flatData.forEach(item => {
const node = nodeMap.get(item.id);
if (item.parentId && nodeMap.has(item.parentId)) {
const parent = nodeMap.get(item.parentId);
parent.children.push(node);
node.level = parent.level + 1;
} else {
roots.push(node);
}
});
return roots;
};
// Computed tree structure
const computedTreeData = computed(() => {
return buildTreeStructure(props.data);
});
// Update tree data when props change
watch(() => props.data, (newData) => {
treeData.value = buildTreeStructure(newData);
}, { immediate: true });
// Node operations
const toggleNode = async (node) => {
if (node.type === 'folder') {
if (expandedNodes.value.has(node.id)) {
expandedNodes.value.delete(node.id);
emit('node-collapse', node);
} else {
expandedNodes.value.add(node.id);
emit('node-expand', node);
}
// Rebuild tree to update expansion state
treeData.value = buildTreeStructure(props.data);
}
};
const selectNode = (node, multi = false) => {
if (!multi || !props.allowMultiSelect) {
selectedNodes.value.clear();
}
if (selectedNodes.value.has(node.id)) {
selectedNodes.value.delete(node.id);
} else {
selectedNodes.value.add(node.id);
}
emit('selection-change', Array.from(selectedNodes.value));
treeData.value = buildTreeStructure(props.data);
};
const handleNodeClick = (node, event) => {
const isMultiSelect = event.ctrlKey || event.metaKey;
selectNode(node, isMultiSelect);
emit('node-click', node);
};
const handleNodeDoubleClick = (node) => {
if (node.type === 'folder') {
toggleNode(node);
}
emit('node-double-click', node);
};
// Context menu operations
const showNodeContextMenu = (node, event) => {
if (!props.showContextMenu) return;
event.preventDefault();
contextMenuNode.value = node;
contextMenuPosition.value = {
x: event.clientX,
y: event.clientY
};
showContextMenu.value = true;
emit('context-menu', { node, position: contextMenuPosition.value });
};
const hideContextMenu = () => {
showContextMenu.value = false;
contextMenuNode.value = null;
};
// Editing operations
const startRename = (node) => {
if (!props.allowInlineEdit) return;
editingNode.value = node;
renameValue.value = node.name;
showRenameDialog.value = true;
hideContextMenu();
};
const confirmRename = async () => {
if (!editingNode.value || !renameValue.value.trim()) return;
try {
await emit('node-rename', {
node: editingNode.value,
newName: renameValue.value.trim()
});
success(`Successfully renamed to "${renameValue.value}"`);
showRenameDialog.value = false;
editingNode.value = null;
renameValue.value = '';
} catch (err) {
error(`Failed to rename: ${err.message}`);
}
};
const deleteNode = async (node) => {
const confirmed = await confirm({
title: 'Delete Item',
message: `Are you sure you want to delete "${node.name}"?`,
dangerous: true
});
if (confirmed) {
try {
await emit('node-delete', node);
selectedNodes.value.delete(node.id);
success(`Successfully deleted "${node.name}"`);
} catch (err) {
error(`Failed to delete: ${err.message}`);
}
}
hideContextMenu();
};
// Folder operations
const createNewFolder = () => {
newFolderName.value = '';
showNewFolderDialog.value = true;
hideContextMenu();
};
const confirmCreateFolder = async () => {
if (!newFolderName.value.trim()) return;
try {
await emit('folder-create', {
name: newFolderName.value.trim(),
parentId: contextMenuNode.value?.id || null
});
success(`Folder "${newFolderName.value}" created successfully`);
showNewFolderDialog.value = false;
newFolderName.value = '';
} catch (err) {
error(`Failed to create folder: ${err.message}`);
}
};
// Drag and drop
const handleDragStart = (node, event) => {
if (!props.allowDragDrop) return;
dragNode.value = node;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', node.id);
};
const handleDragOver = (node, event) => {
if (!props.allowDragDrop || !dragNode.value) return;
if (node.type !== 'folder' || node.id === dragNode.value.id) return;
event.preventDefault();
dropTarget.value = node;
};
const handleDragLeave = () => {
dropTarget.value = null;
};
const handleDrop = async (node, event) => {
if (!props.allowDragDrop || !dragNode.value) return;
event.preventDefault();
try {
await emit('node-move', {
node: dragNode.value,
targetParent: node
});
success(`Moved "${dragNode.value.name}" to "${node.name}"`);
} catch (err) {
error(`Failed to move: ${err.message}`);
}
dragNode.value = null;
dropTarget.value = null;
};
// Mobile adaptations
const setupMobileInteractions = () => {
if (!containerRef.value) return;
const cleanup = setupTouchInteractions(containerRef.value);
// Long press for context menu on mobile
const { onLongPress } = useTouchInteractions();
onLongPress((state) => {
const element = document.elementFromPoint(state.x, state.y);
const nodeElement = element?.closest('[data-node-id]');
if (nodeElement) {
const nodeId = nodeElement.dataset.nodeId;
const node = props.data.find(n => n.id === nodeId);
if (node) {
showNodeContextMenu(node, {
preventDefault: () => {},
clientX: state.x,
clientY: state.y
});
}
}
});
return cleanup;
};
// File type icon helper
const getFileIcon = (node) => {
if (node.type === 'folder') {
return expandedNodes.value.has(node.id) ? 'mdi:folder-open' : 'mdi:folder';
}
const extension = node.name.split('.').pop()?.toLowerCase();
return dmsPatterns.fileIcons[extension] || dmsPatterns.fileIcons.default;
};
// Status classes
const getNodeClasses = (node) => {
return utils.composeClasses(
'tree-node flex items-center p-2 rounded-md cursor-pointer transition-all duration-200',
selectedNodes.value.has(node.id)
? 'bg-primary/10 dark:bg-primary/20 text-primary'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300',
dropTarget.value?.id === node.id ? 'bg-blue-100 dark:bg-blue-900/20 border-2 border-blue-400' : '',
'select-none'
);
};
// Lifecycle
onMounted(() => {
isMobile.value = window.innerWidth < 768;
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768;
});
// Click outside to hide context menu
document.addEventListener('click', hideContextMenu);
// Setup mobile interactions
const cleanup = setupMobileInteractions();
// Cleanup on unmount
onUnmounted(() => {
document.removeEventListener('click', hideContextMenu);
cleanup?.();
});
});
// Utility functions
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString();
};
</script>
<template>
<ResponsiveContainer
ref="containerRef"
layout="default"
padding="md"
class="windows-explorer-tree h-full"
>
<!-- Loading State -->
<LoadingStates
v-if="loading"
type="skeleton-tree"
:count="5"
message="Loading file structure..."
/>
<!-- Tree Content -->
<div v-else class="tree-container h-full flex flex-col">
<!-- Header Actions -->
<div class="tree-header mb-4 flex items-center justify-between flex-shrink-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
File Explorer
</h3>
<div class="flex items-center space-x-2">
<!-- New Folder Button -->
<RsButton
variant="secondary-outline"
size="sm"
@click="createNewFolder"
:title="isMobile ? 'New Folder' : undefined"
>
<Icon name="mdi:folder-plus" class="w-4 h-4" />
<span v-if="!isMobile" class="ml-2">New Folder</span>
</RsButton>
<!-- Upload Button -->
<RsButton
variant="primary"
size="sm"
@click="emit('file-upload')"
:title="isMobile ? 'Upload Files' : undefined"
>
<Icon name="mdi:upload" class="w-4 h-4" />
<span v-if="!isMobile" class="ml-2">Upload</span>
</RsButton>
</div>
</div>
<!-- Tree Nodes - Scrollable Container -->
<div class="tree-nodes flex-1 overflow-auto min-h-0 space-y-1 pr-2">
<div
v-for="node in computedTreeData"
:key="node.id"
class="tree-node-container"
>
<!-- Root Node -->
<div
:data-node-id="node.id"
:class="getNodeClasses(node)"
:style="{ paddingLeft: `${node.level * 20}px` }"
:draggable="allowDragDrop"
@click="handleNodeClick(node, $event)"
@dblclick="handleNodeDoubleClick(node)"
@contextmenu="showNodeContextMenu(node, $event)"
@dragstart="handleDragStart(node, $event)"
@dragover="handleDragOver(node, $event)"
@dragleave="handleDragLeave"
@drop="handleDrop(node, $event)"
>
<!-- Expand/Collapse Icon -->
<button
v-if="node.type === 'folder' && node.children.length > 0"
@click.stop="toggleNode(node)"
class="expand-button mr-1 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<Icon
:name="expandedNodes.has(node.id) ? 'mdi:chevron-down' : 'mdi:chevron-right'"
class="w-4 h-4"
/>
</button>
<div v-else class="w-6"></div>
<!-- File/Folder Icon -->
<Icon
:name="getFileIcon(node)"
class="w-5 h-5 mr-2 flex-shrink-0"
:class="node.type === 'folder' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'"
/>
<!-- Node Name -->
<span class="node-name flex-1 truncate text-sm">
{{ node.name }}
</span>
<!-- Node Metadata -->
<div class="node-metadata flex items-center space-x-2 text-xs text-gray-500">
<span v-if="node.size && node.type !== 'folder'">
{{ formatFileSize(node.size) }}
</span>
<span v-if="node.modifiedAt">
{{ formatDate(node.modifiedAt) }}
</span>
</div>
</div>
<!-- Child Nodes (Recursive) -->
<div
v-if="node.type === 'folder' && expandedNodes.has(node.id)"
class="child-nodes ml-4"
>
<div
v-for="child in node.children"
:key="child.id"
:data-node-id="child.id"
:class="getNodeClasses(child)"
:style="{ paddingLeft: `${(child.level - 1) * 20}px` }"
:draggable="allowDragDrop"
@click="handleNodeClick(child, $event)"
@dblclick="handleNodeDoubleClick(child)"
@contextmenu="showNodeContextMenu(child, $event)"
@dragstart="handleDragStart(child, $event)"
@dragover="handleDragOver(child, $event)"
@dragleave="handleDragLeave"
@drop="handleDrop(child, $event)"
>
<div class="w-6"></div>
<Icon
:name="getFileIcon(child)"
class="w-5 h-5 mr-2 flex-shrink-0"
:class="child.type === 'folder' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'"
/>
<span class="node-name flex-1 truncate text-sm">
{{ child.name }}
</span>
<div class="node-metadata flex items-center space-x-2 text-xs text-gray-500">
<span v-if="child.size && child.type !== 'folder'">
{{ formatFileSize(child.size) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Context Menu -->
<Teleport to="body">
<div
v-if="showContextMenu && contextMenuNode"
class="context-menu fixed z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg py-2 min-w-[160px]"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`
}"
>
<button
@click="handleNodeDoubleClick(contextMenuNode)"
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
>
<Icon name="mdi:open-in-new" class="w-4 h-4 mr-2" />
Open
</button>
<button
v-if="allowInlineEdit"
@click="startRename(contextMenuNode)"
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
>
<Icon name="mdi:pencil" class="w-4 h-4 mr-2" />
Rename
</button>
<button
@click="deleteNode(contextMenuNode)"
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 flex items-center"
>
<Icon name="mdi:delete" class="w-4 h-4 mr-2" />
Delete
</button>
<hr class="my-1 border-gray-200 dark:border-gray-600">
<button
v-if="contextMenuNode.type === 'folder'"
@click="createNewFolder"
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
>
<Icon name="mdi:folder-plus" class="w-4 h-4 mr-2" />
New Folder
</button>
<button
@click="emit('file-upload', contextMenuNode)"
class="context-menu-item w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
>
<Icon name="mdi:upload" class="w-4 h-4 mr-2" />
Upload Files
</button>
</div>
</Teleport>
<!-- New Folder Dialog -->
<BaseModal
v-model:visible="showNewFolderDialog"
title="Create New Folder"
size="md"
@confirm="confirmCreateFolder"
@cancel="showNewFolderDialog = false"
>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Folder Name
</label>
<input
v-model="newFolderName"
type="text"
placeholder="Enter folder name..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-primary focus:border-transparent"
@keyup.enter="confirmCreateFolder"
/>
</div>
</div>
</BaseModal>
<!-- Rename Dialog -->
<BaseModal
v-model:visible="showRenameDialog"
title="Rename Item"
size="md"
@confirm="confirmRename"
@cancel="showRenameDialog = false"
>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
New Name
</label>
<input
v-model="renameValue"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-primary focus:border-transparent"
@keyup.enter="confirmRename"
/>
</div>
</div>
</BaseModal>
</ResponsiveContainer>
</template>
<style scoped>
.windows-explorer-tree {
@apply h-full overflow-hidden;
}
.tree-container {
@apply h-full flex flex-col min-h-0;
}
.tree-nodes {
@apply flex-1 overflow-y-auto min-h-0;
/* Ensure smooth scrolling */
scroll-behavior: smooth;
}
.tree-node {
@apply relative;
}
.tree-node.dragging {
@apply opacity-50;
}
.tree-node.drop-target {
@apply bg-blue-100 dark:bg-blue-900/20 border-2 border-blue-400 border-dashed;
}
.expand-button {
@apply transition-transform duration-200;
}
.context-menu {
animation: contextMenuFadeIn 0.15s ease-out;
}
@keyframes contextMenuFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-menu-item {
@apply transition-colors duration-150;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.tree-node {
@apply p-3;
}
.node-metadata {
@apply hidden;
}
.expand-button {
@apply p-2;
}
}
/* Enhanced scrollbar styles */
.tree-nodes::-webkit-scrollbar {
width: 8px;
}
.tree-nodes::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.tree-nodes::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 4px;
border: 2px solid #f1f5f9;
}
.tree-nodes::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.tree-nodes::-webkit-scrollbar-corner {
background: #f1f5f9;
}
/* Dark mode scrollbar */
.dark .tree-nodes::-webkit-scrollbar-track {
background: #374151;
}
.dark .tree-nodes::-webkit-scrollbar-thumb {
background-color: #6b7280;
border-color: #374151;
}
.dark .tree-nodes::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af;
}
.dark .tree-nodes::-webkit-scrollbar-corner {
background: #374151;
}
</style>