Files
netdisk-fast-download/web-front/src/components/DirectoryTree.vue
2025-07-10 19:17:22 +08:00

993 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="main-container">
<div class="directory-tree" :class="{ 'dark-theme': isDarkTheme }">
<template v-if="viewMode === 'pane'">
<!-- 窗格模式原有 -->
<div class="breadcrumb">
<div
v-for="(item, index) in pathStack"
:key="index"
class="breadcrumb-item"
:class="{ 'active': index === pathStack.length - 1 }"
@click="goToDirectory(index)"
>
<i class="fas fa-folder" v-if="index === 0"></i>
<i class="fas fa-chevron-right" v-else-if="index > 0"></i>
{{ item.name }}
</div>
</div>
<div class="file-grid" v-loading="loading">
<div
v-for="file in currentFileList"
:key="file.fileName"
class="file-item"
:class="getFileTypeClass(file)"
@click="handleFileClick(file)"
>
<div class="file-icon">
<i :class="getFileIcon(file)"></i>
</div>
<div class="file-name">{{ file.fileName }}</div>
<div class="file-meta">
{{ file.sizeStr || '0B' }} · {{ formatDate(file.createTime) }}
</div>
</div>
<div v-if="!loading && (!currentFileList || currentFileList.length === 0)" class="empty-state">
<i class="fas fa-folder-open"></i>
<h3>此文件夹为空</h3>
<p>暂无文件或文件夹</p>
</div>
</div>
<div class="action-bar">
<el-button
type="primary"
@click="goBack"
:disabled="pathStack.length <= 1"
icon="el-icon-arrow-left"
>
返回上一级
</el-button>
<div class="stats">
<span class="stat-item">
<i class="fas fa-folder"></i> {{ folderCount }} 个文件夹
</span>
<span class="stat-item">
<i class="fas fa-file"></i> {{ fileCount }} 个文件
</span>
</div>
</div>
</template>
<template v-else-if="viewMode === 'tree'">
<div class="content-card">
<splitpanes class="split-theme custom-splitpanes" style="height:100%;">
<pane>
<div class="tree-sidebar">
<el-tree
:data="treeData"
:props="treeProps"
node-key="id"
lazy
:load="loadNode"
highlight-current
@node-click="onNodeClick"
:default-expand-all="false"
:default-expanded-keys="['root']"
:render-content="renderContent"
style="background:transparent;"
/>
</div>
</pane>
<pane>
<div class="tree-content">
<div v-if="selectedNode">
<div class="file-detail-icon-wrap">
<i :class="getFileIcon(selectedNode)" class="file-detail-icon"></i>
</div>
<h4>{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType === 'folder'">
<ul>
<li v-for="file in selectedNode.children || []" :key="file.id">
<i :class="getFileIcon(file)"></i> {{ file.fileName }}
</li>
</ul>
</div>
<div v-else>
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p>创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<!-- 文件详情区下载按钮 -->
<el-button v-if="selectedNode && selectedNode.parserUrl" @click="previewFile(selectedNode)">打开</el-button>
<a
v-if="selectedNode && selectedNode.parserUrl"
:href="selectedNode.parserUrl"
download
target="_blank"
class="el-button el-button--success"
style="margin-left: 8px;"
>
下载
</a>
</div>
</div>
<div v-else style="color: #888;">请选择左侧文件或文件夹</div>
</div>
</pane>
</splitpanes>
</div>
</template>
<!-- 文件操作对话框窗格模式下 -->
<el-dialog
v-if="viewMode === 'pane'"
title="文件操作"
v-model="fileDialogVisible"
width="400px"
:before-close="closeFileDialog"
>
<div class="file-dialog-content">
<p><strong>{{ selectedFile?.fileName || '未命名文件' }}</strong></p>
<p class="file-info">
大小: {{ selectedFile?.sizeStr || '0B' }}<br>
创建时间: {{ formatDate(selectedFile?.createTime) }}
</p>
</div>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="previewFile(selectedFile)">打开</el-button>
<!-- 弹窗下载按钮 -->
<a
v-if="selectedFile && selectedFile.parserUrl"
:href="selectedFile.parserUrl"
download
target="_blank"
class="el-button el-button--success"
style="margin-left: 8px;"
>
下载
</a>
</span>
</el-dialog>
<div v-if="isPreviewing" class="preview-mask">
<div class="preview-toolbar">
<el-button size="small" @click="closePreview">关闭预览</el-button>
<el-button size="small" type="primary" @click="openPreviewInNewTab">新窗口打开</el-button>
</div>
<iframe :src="previewUrl" frameborder="0" class="preview-iframe"></iframe>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import { ElTree } from 'element-plus'
import { Splitpanes, Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
import fileTypeUtils from '@/utils/fileTypeUtils'
export default {
name: 'DirectoryTree',
components: { ElTree, Splitpanes, Pane },
props: {
fileList: {
type: Array,
default: () => []
},
shareUrl: {
type: String,
required: true
},
password: {
type: String,
default: ''
},
viewMode: {
type: String,
default: 'pane' // 'pane' or 'tree'
}
},
data() {
return {
loading: false,
pathStack: [{ name: '全部文件', url: '' }],
currentFileList: [],
fileDialogVisible: false,
selectedFile: null,
isDarkTheme: false,
initialized: false,
// 文件树模式相关
treeData: [],
selectedNode: null,
isPreviewing: false,
previewUrl: '',
treeProps: {
label: 'fileName',
children: 'children',
isLeaf: 'isLeaf'
}
}
},
computed: {
folderCount() {
return this.currentFileList.filter(file => file.fileType === 'folder').length
},
fileCount() {
return this.currentFileList.filter(file => file.fileType !== 'folder').length
}
},
watch: {
fileList: {
immediate: true,
handler(newList) {
// 根节点children为当前目录下所有文件/文件夹
this.treeData = [
{
id: 'root',
fileName: '全部文件',
fileType: 'folder',
children: (newList || []).map(item => ({
...item,
isLeaf: item.fileType !== 'folder'
})),
isLeaf: false
}
]
this.currentFileList = newList
}
}
},
methods: {
...fileTypeUtils,
// 构建API URL
buildApiUrl() {
const baseUrl = `${window.location.origin}/v2/getFileList`
const params = new URLSearchParams({
url: this.shareUrl
})
if (this.password) {
params.append('pwd', this.password)
}
return `${baseUrl}?${params.toString()}`
},
// 文件树与窗格同源:直接返回当前目录数据
buildTree(list) {
return list || []
},
// 懒加载子节点
loadNode(node, resolve) {
if (node.level === 0) {
// 根节点
resolve(this.treeData[0].children)
} else if (node.data.fileType === 'folder' && node.data.parserUrl) {
axios.get(node.data.parserUrl).then(res => {
if (res.data.code === 200) {
const children = (res.data.data || []).map(item => ({
...item,
isLeaf: item.fileType !== 'folder'
}))
resolve(children)
} else {
resolve([])
}
}).catch(() => resolve([]))
} else {
resolve([])
}
},
onNodeClick(data) {
this.selectedNode = data
},
// 处理文件点击
handleFileClick(file) {
console.log('点击文件', file, this.viewMode)
if (file.fileType === 'folder') {
this.enterFolder(file)
} else if (this.viewMode === 'pane') {
this.selectedFile = file
this.fileDialogVisible = true
}
},
// 进入文件夹
async enterFolder(folder) {
if (!folder.parserUrl) {
this.$message.error('无法进入该文件夹,缺少访问链接')
return
}
try {
this.loading = true
const response = await axios.get(folder.parserUrl)
if (response.data.code === 200) {
const newDir = {
url: folder.parserUrl,
name: folder.fileName || '未命名文件夹'
}
this.pathStack.push(newDir)
this.currentFileList = response.data.data || []
} else {
this.$message.error(response.data.msg || '获取文件夹内容失败')
}
} catch (error) {
console.error('进入文件夹失败:', error)
this.$message.error('进入文件夹失败')
} finally {
this.loading = false
}
},
goBack() {
if (this.pathStack.length > 1) {
this.pathStack.pop()
this.loadCurrentDirectory()
}
},
goToDirectory(index) {
this.pathStack.splice(index + 1)
this.loadCurrentDirectory()
},
async loadCurrentDirectory() {
const currentDir = this.pathStack[this.pathStack.length - 1]
if (!currentDir.url) {
this.currentFileList = this.fileList
return
}
try {
this.loading = true
const response = await axios.get(currentDir.url)
if (response.data.code === 200) {
this.currentFileList = response.data.data || []
} else {
this.$message.error(response.data.msg || '加载目录失败')
}
} catch (error) {
console.error('加载目录失败:', error)
this.$message.error('加载目录失败')
} finally {
this.loading = false
}
},
// 预览文件
previewFile(file) {
if (file?.previewUrl || file?.parserUrl) {
this.previewUrl = file.previewUrl || file.parserUrl
this.isPreviewing = true
} else {
this.$message.warning('该文件暂无预览链接')
}
this.closeFileDialog()
},
// 下载文件
downloadFile(file) {
if (file?.parserUrl) {
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = file.parserUrl
document.body.appendChild(iframe)
setTimeout(() => {
document.body.removeChild(iframe)
}, 1000)
this.$message.success('开始下载文件')
} else {
this.$message.warning('该文件暂无下载链接')
}
this.closeFileDialog()
},
closeFileDialog() {
this.fileDialogVisible = false
this.selectedFile = null
},
closePreview() {
this.isPreviewing = false
this.previewUrl = ''
},
openPreviewInNewTab() {
if (this.previewUrl) {
window.open(this.previewUrl, '_blank')
}
},
formatDate(timestamp) {
if (!timestamp) return '未知时间'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN')
},
checkTheme() {
this.isDarkTheme = document.body.classList.contains('dark-theme') ||
document.documentElement.classList.contains('dark-theme')
},
renderContent(h, { node, data, store }) {
const isFolder = data.fileType === 'folder'
return h('div', {
class: 'custom-tree-node'
}, [
h('i', {
class: [this.getFileIcon(data), { 'folder-icon': isFolder, 'file-icon': !isFolder }]
}),
h('span', {
class: ['node-label', { 'folder-text': isFolder, 'file-text': !isFolder }]
}, node.label)
])
}
},
mounted() {
this.checkTheme()
this.initialized = true
// 监听主题变化
this._observer = new MutationObserver(() => {
this.checkTheme()
})
this._observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
})
},
beforeUnmount() {
if (this._observer) {
this._observer.disconnect()
}
}
}
</script>
<style>
html, body, #app, .main-container, .directory-tree, .content-card {
/* overflow: hidden; */
/* overflow: auto; */
/* position: relative; */
}
.main-container {
height: 100%;
display: flex;
flex-direction: column;
}
.directory-tree {
background: white;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s ease;
}
.directory-tree.dark-theme {
background: #2d2d2d;
color: #ffffff;
}
.breadcrumb {
background: #f8f9fa;
padding: 16px 24px;
border-bottom: 1px solid #eaeaea;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.dark-theme .breadcrumb {
background: #404040;
border-bottom-color: #555555;
}
.breadcrumb-item {
display: flex;
align-items: center;
font-size: 0.95rem;
color: #7f8c8d;
cursor: pointer;
transition: color 0.2s;
margin-right: 8px;
}
.breadcrumb-item:hover {
color: #3498db;
}
.breadcrumb-item.active {
color: #2c3e50;
font-weight: 600;
}
.dark-theme .breadcrumb-item.active {
color: #ffffff;
}
.breadcrumb-item i {
margin: 0 8px;
font-size: 0.8rem;
color: #bdc3c7;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
padding: 12px;
min-height: 200px;
}
.file-item {
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
cursor: pointer;
text-align: center;
padding: 10px 4px;
min-height: 80px;
border: 2px solid transparent;
}
.dark-theme .file-item {
background: #404040;
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.2);
}
.file-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
border-color: #3498db;
}
.dark-theme .file-item:hover {
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.file-icon {
font-size: 2.2rem;
margin-bottom: 8px;
transition: transform 0.3s;
}
.file-item:hover .file-icon {
transform: scale(1.1);
}
.folder .file-icon {
color: #3498db;
}
.image .file-icon {
color: #e74c3c;
}
.document .file-icon {
color: #f39c12;
}
.archive .file-icon {
color: #9b59b6;
}
.audio .file-icon {
color: #1abc9c;
}
.video .file-icon {
color: #d35400;
}
.code .file-icon {
color: #27ae60;
}
.file-name {
font-weight: 500;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
margin-bottom: 4px;
}
.file-meta {
font-size: 0.75rem;
color: #95a5a6;
}
.dark-theme .file-meta {
color: #bdc3c7;
}
.empty-state {
text-align: center;
padding: 30px 10px;
color: #7f8c8d;
grid-column: 1 / -1;
}
.dark-theme .empty-state {
color: #bdc3c7;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 10px;
color: #bdc3c7;
}
.dark-theme .empty-state i {
color: #555555;
}
.empty-state h3 {
font-size: 1.1rem;
margin-bottom: 6px;
color: #2c3e50;
}
.dark-theme .empty-state h3 {
color: #ffffff;
}
.action-bar {
display: flex;
justify-content: space-between;
padding: 10px 12px;
background: #f8f9fa;
border-top: 1px solid #eaeaea;
}
.dark-theme .action-bar {
background: #404040;
border-top-color: #555555;
}
.stats {
display: flex;
align-items: center;
gap: 10px;
color: #7f8c8d;
font-size: 0.85rem;
}
.dark-theme .stats {
color: #bdc3c7;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
}
.file-dialog-content {
text-align: center;
}
.file-info {
color: #7f8c8d;
font-size: 0.9rem;
margin-top: 10px;
}
.dark-theme .file-info {
color: #bdc3c7;
}
.tree-layout {
display: flex;
height: 500px;
}
.tree-sidebar {
width: 220px;
background: #f8f9fa;
border-right: 1px solid #eaeaea;
overflow-y: auto;
}
.directory-tree.dark-theme .tree-sidebar {
background: #232323;
border-right: 1px solid #404040;
}
.file-tree-root, .tree-node ul {
list-style: none;
padding-left: 12px;
margin: 0;
}
.tree-node {
margin-bottom: 2px;
}
.tree-node.selected > .tree-node-label {
background: #e6f7ff;
color: #409eff;
}
.directory-tree.dark-theme .tree-node.selected > .tree-node-label {
background: #333c4d;
color: #4a9eff;
}
.tree-node-label {
cursor: pointer;
padding: 3px 6px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.2s;
font-size: 0.95em;
}
.tree-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
/* 自定义树节点样式 */
.custom-tree-node {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0;
width: 100%;
}
.custom-tree-node i {
font-size: 16px;
width: 20px;
text-align: center;
color: #606266;
}
.dark-theme .custom-tree-node i {
color: #bdc3c7;
}
.custom-tree-node .node-label {
flex: 1;
font-size: 14px;
color: #303133;
}
.dark-theme .custom-tree-node .node-label {
color: #e1e1e1;
}
/* 文件夹样式 */
.custom-tree-node .folder-icon {
color: #409eff !important;
}
.dark-theme .custom-tree-node .folder-icon {
color: #4a9eff !important;
}
.custom-tree-node .folder-text {
color: #409eff !important;
font-weight: 500;
}
.dark-theme .custom-tree-node .folder-text {
color: #4a9eff !important;
}
/* 文件样式 */
.custom-tree-node .file-icon {
color: #95a5a6 !important;
}
.dark-theme .custom-tree-node .file-icon {
color: #bdc3c7 !important;
}
.custom-tree-node .file-text {
color: #606266 !important;
}
.dark-theme .custom-tree-node .file-text {
color: #e1e1e1 !important;
}
/* 特殊文件类型图标颜色 */
.custom-tree-node i.fa-file-image {
color: #e74c3c !important;
}
.custom-tree-node i.fa-file-pdf {
color: #e74c3c !important;
}
.custom-tree-node i.fa-file-word {
color: #3498db !important;
}
.custom-tree-node i.fa-file-excel {
color: #27ae60 !important;
}
.custom-tree-node i.fa-file-powerpoint {
color: #f39c12 !important;
}
.custom-tree-node i.fa-file-archive {
color: #9b59b6 !important;
}
.custom-tree-node i.fa-file-audio {
color: #1abc9c !important;
}
.custom-tree-node i.fa-file-video {
color: #d35400 !important;
}
.custom-tree-node i.fa-file-code {
color: #27ae60 !important;
}
/* 树节点悬停效果 */
.el-tree-node__content:hover .custom-tree-node {
background-color: #f5f7fa;
border-radius: 4px;
}
.dark-theme .el-tree-node__content:hover .custom-tree-node {
background-color: #2c2c2c;
}
/* 选中节点样式 */
.el-tree-node.is-current > .el-tree-node__content .custom-tree-node {
background-color: #e6f7ff;
border-radius: 4px;
}
.dark-theme .el-tree-node.is-current > .el-tree-node__content .custom-tree-node {
background-color: #333c4d;
}
.preview-mask { position: fixed; z-index: 9999; left: 0; top: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.85); display: flex; flex-direction: column; }
.preview-toolbar { padding: 12px; background: #232323; text-align: right; }
.preview-iframe { flex: 1; width: 100vw; border: none; background: #222; }
.content-card {
min-height: 500px;
height: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
margin: 0 0 12px 0;
display: flex;
flex-direction: column;
}
.dark-theme .content-card {
background: #232323;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
}
.split-theme {
flex: 1 1 0;
height: 100%;
}
.tree-sidebar, .tree-content {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.tree-content {
padding: 40px 16px 16px 16px;
align-items: flex-start;
}
.file-detail-icon-wrap {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.file-detail-icon {
font-size: 48px;
color: #409eff;
display: block;
}
.dark-theme .file-detail-icon {
color: #4a9eff;
}
/* splitpanes 拖拽条自定义按钮 */
.custom-splitpanes .splitpanes__splitter {
position: relative;
background: #e0e0e0;
transition: background 0.2s;
touch-action: pan-x pan-y;
}
.custom-splitpanes .splitpanes__splitter:hover {
background: #b3b3b3;
}
.custom-splitpanes .splitpanes__splitter:after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 28px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border: 1.5px solid #d0d0d0;
z-index: 2;
display: block;
}
.dark-theme .custom-splitpanes .splitpanes__splitter:after {
background: #232323;
border-color: #444;
}
.feedback-bar {
width: 100%;
text-align: right;
padding: 12px 18px 0 0;
}
.feedback-link {
color: #e74c3c;
font-weight: bold;
font-size: 1.08rem;
text-decoration: none;
border: 1px solid #e74c3c;
border-radius: 6px;
padding: 4px 14px;
background: #fff5f5;
transition: background 0.2s, color 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 10px;
}
.feedback-link:first-child { margin-left: 0; }
.feedback-link:hover {
background: #e74c3c;
color: #fff;
}
.dark-theme .feedback-link {
background: #2d2d2d;
color: #ff7675;
border-color: #ff7675;
}
.dark-theme .feedback-link:hover {
background: #ff7675;
color: #232323;
}
.feedback-icon {
font-size: 1.15em;
color: #e74c3c;
margin-right: 2px;
}
.feedback-link:hover .feedback-icon {
color: #fff;
}
.feedback-link:nth-child(2) .feedback-icon { color: #333; }
.feedback-link:nth-child(3) .feedback-icon { color: #f39c12; }
.dark-theme .feedback-icon {
color: #ff7675;
}
.dark-theme .feedback-link:nth-child(2) .feedback-icon { color: #fff; }
.dark-theme .feedback-link:nth-child(3) .feedback-icon { color: #f7ca77; }
@media (max-width: 768px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
padding: 6px;
}
.file-item {
padding: 6px 2px;
min-height: 60px;
}
.file-icon {
font-size: 1.5rem;
}
}
@media (max-width: 480px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 4px;
}
.action-bar {
flex-direction: column;
gap: 6px;
}
}
</style>