企业级文件浏览系统的Vue实现:架构设计与最佳实践

概述

在现代Web应用中,文件管理是一个常见的需求。本文介绍了一个基于Vue.js的双模式文件浏览器组件的设计与实现。该组件支持两种文件模式:手机文件和任务文件,提供了直观的目录树导航、文件列表展示、文件预览和下载等功能。

功能特性

  • 双模式文件浏览:支持手机文件(如sdcard和private目录)和任务文件(如aieprivate目录)的切换。
  • 目录树导航:左侧面板展示可展开/折叠的目录树,右侧面板展示当前目录下的文件和子目录。
  • 面包屑导航:显示当前路径,并允许用户快速跳转到上级目录。
  • 文件操作:支持打开目录、预览文件(文本、JSON、JS等)和下载文件。
  • 状态管理:使用Vuex管理全局状态(如任务ID和令牌),组件内部管理UI状态(如当前路径、展开的节点等)。
  • 响应式设计:适配不同屏幕尺寸,提供良好的用户体验。

实现效果

技术实现

组件结构

该文件浏览器由三个主要组件构成:

  1. FileBrowsing:主组件,负责处理核心逻辑和API通信。
  2. DirectoryTree:目录树组件,负责展示目录结构。
  3. TreeNode:树节点组件,负责单个节点的渲染和交互。

状态管理

组件内部使用Vue的data属性来管理状态,包括:

  • currentFileType:当前文件类型('phone'或'task')。
  • currentPath:当前浏览的路径。
  • expandedNodes:已展开的节点路径数组(之前使用Set,现改为Array)。
  • filePreview:文件预览的相关状态。

同时,使用Vuex的mapGetters来获取全局状态,如任务ID和令牌。

API通信

组件通过RESTful API与后端交互,主要接口包括:

  • 目录列表:获取指定路径下的目录列表。
  • 文件列表:获取指定路径下的文件列表。
  • 文件下载:下载指定文件。
  • 文件打开:打开并预览文件。

API请求使用fetch函数发送,并处理响应结果。请求参数包括任务ID、令牌、执行类型和远程路径。

文件预览

文件预览功能根据文件类型提供不同的展示方式:

  • 文本文件(txt、log、js、json等):直接显示内容,其中JSON文件会尝试格式化。
  • 二进制文件:显示十六进制预览。
  • 不支持的文件类型:显示提示信息。

预览内容通过模态框展示,用户可以通过点击关闭按钮或背景来关闭预览。

目录树操作

目录树支持展开、折叠和选择节点等操作。展开节点时会加载子目录,选择节点时会更新当前路径并加载该路径下的文件和子目录。

样式设计

组件使用Flex布局实现左右面板结构,左侧面板宽度固定,右侧面板自适应。使用CSS样式美化界面,包括按钮、表格、滚动条等。

FileBrowsing主组件:

xml 复制代码
<template>
    <div class="file-browsing">
        <h4>文件浏览</h4>
        <div class="file-browser-container">
            <!-- Left Panel: Directory Tree -->
            <div class="left-panel">
                <div class="tree-header">
                    <div class="file-type-tabs">
                        <button class="tab-btn" :class="{ active: currentFileType === 'phone' }"
                            @click="switchToPhoneFiles">手机文件</button>
                        <button class="tab-btn" :class="{ active: currentFileType === 'task' }"
                            @click="switchToTaskFiles">任务文件</button>
                    </div>
                </div>
                <div class="tree-container">
                    <DirectoryTree :items="treeItems" :selected-path="currentPath" :expanded-nodes="expandedNodes"
                        @select="onNodeSelect" @expand="onNodeExpand" @collapse="onNodeCollapse" />
                </div>
            </div>

            <!-- Right Panel: File List -->
            <div class="right-panel">
                <div class="file-browser-header">
                    <div class="breadcrumb">
                        <span v-for="(segment, index) in pathSegments" :key="index" class="breadcrumb-item">
                            {{ segment.name }}
                            <i v-if="index < pathSegments.length - 1" class="fas fa-chevron-right"></i>
                        </span>
                    </div>
                    <button class="btn btn-primary" @click="refreshFileList">
                        <i class="fas fa-sync-alt"></i> 刷新
                    </button>
                </div>

                <div class="file-list-container">
                    <table class="file-table">
                        <thead>
                            <tr>
                                <th class="col-name">文件名</th>
                                <th class="col-modified">修改时间</th>
                                <th class="col-size">文件大小</th>
                                <th class="col-actions">操作</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr v-for="item in currentItems" :key="item.path" class="file-row">
                                <td class="file-name">
                                    <i :class="getFileIcon(item)" class="file-icon"></i>
                                    <span>{{ item.name }}</span>
                                </td>
                                <td class="file-modified">{{ formatDate(item.modified) }}</td>
                                <td class="file-size">{{ formatFileSize(item.size) }}</td>
                                <td class="file-actions">
                                    <button class="btn btn-sm btn-orange" @click="onOpen(item)">
                                        打开
                                    </button>
                                    <template v-if="item.type === 'file'">
                                        <span class="action-separator"></span>
                                        <button class="btn btn-sm btn-red" @click="onDownload(item)">
                                            下载
                                        </button>
                                    </template>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
        <div v-if="isLoading" class="loading-overlay">
            <div class="spinner"></div>
            <div class="loading-text">{{ loadingText }}</div>
        </div>
        <div v-if="filePreview.visible" class="file-preview-overlay">
            <div class="file-preview-modal">
                <div class="file-preview-header">
                    <span>{{ filePreview.fileName }}</span>
                    <button class="btn btn-outline" @click="filePreview.visible = false">关闭</button>
                </div>
                <div class="file-preview-body" v-html="filePreview.html"></div>
            </div>
            <div class="file-preview-backdrop" @click="filePreview.visible = false"></div>
        </div>
    </div>
</template>
js 复制代码
<script>
import DirectoryTree from './DirectoryTree.vue'
import { mapGetters } from "vuex";

export default {
    name: 'FileBrowsing',
    components: { DirectoryTree },
    props: {
        // taskId: {
        //     type: String,
        //     default: ''
        // },
        // taskToken: {
        //     type: String,
        //     default: ''
        // }
    },
    data() {
        return {
            // 加载状态
            isLoading: false,
            loadingText: '加载中,请稍候...',
            // 手机文件数据
            phoneFilesData: [
                { name: 'sdcard', path: '/sdcard', type: 'directory', children: null, loaded: false },
                { name: 'private', path: '/private', type: 'directory', children: null, loaded: false }
            ],
            // 任务文件数据
            taskFilesData: [
                { name: 'private', path: '/aieprivate', type: 'directory', children: null, loaded: false }
            ],
            currentFileType: 'phone', // 'phone' 或 'task'
            currentPath: '/',
            treeItems: [],
            // 关键修改:将expandedNodes从Set改为Array
            expandedNodes: ['/'],
            // Overlay state for file preview
            filePreview: {
                visible: false,
                fileName: '',
                extension: '',
                content: '',
                html: ''
            },
            // 保存每种文件类型的状态
            fileTypeStates: {
                phone: {
                    currentPath: '/sdcard',
                    expandedNodes: [] // 改为数组
                },
                task: {
                    currentPath: '/aieprivate',
                    expandedNodes: [] // 改为数组
                }
            },
            inputData: -1,
        }
    },
    computed: {
        ...mapGetters("sandbox", {
            // taskId: "taskId",
            token: "token",
            apkInfoId: "apkInfoId"
        }),
        pathSegments() {
            const segments = this.currentPath.split('/').filter(segment => segment)
            // 根据当前文件类型显示不同的根目录名称
            const isPhoneDir = this.currentFileType === 'phone'
            const rootName = isPhoneDir ? '手机存储' : '任务存储'
            const result = [{ name: rootName, path: '/' }]
            let path = '/'
            segments.forEach(segment => {
                path += segment + '/'
                // 只有当完整路径是'/aieprivate'时才替换为'private'
                const displayName = (!isPhoneDir && segment === 'aieprivate') ? 'private' : segment
                result.push({ name: displayName, path })
            })
            return result
        },
        currentItems() {
            const findNodeByPath = (nodes, path) => {
                for (const node of nodes) {
                    if (node.path === path) return node
                    if (node.children) {
                        const found = findNodeByPath(node.children, path)
                        if (found) return found
                    }
                }
                return null
            }
            const node = findNodeByPath(this.treeItems, this.currentPath)
            return node && node.children ? node.children : []
        }
    },
    async created() {
        if (process.env.NODE_ENV === 'development') {
            // 开发环境默认值
            this.inputData = 500;
        } else if (process.env.NODE_ENV === 'production') {
            // 生产环境接口调用
            try {
                const res = await fetch('/init_data')
                if (!res.ok) {
                    throw new Error(`server error: ${res.status}`)
                }
                this.inputData = await res.json()
            } catch (error) {
                console.error('init-data error:', error)
            }
        }
        this.treeItems = this.phoneFilesData
    },
    methods: {
        getTimestamp() {
            return BigInt(Date.now()) & 0x7fffffffffffffffn
        },
        /**
         * Add all parent paths of a given path to the expandedNodes set.
         * e.g. /a/b/c => /a, /a/b, /a/b/c
         */
        expandAllParentPaths(path, expandedArray) {
            const segments = path.split('/').filter(Boolean)
            let current = ''
            for (const segment of segments) {
                current += '/' + segment
                // 检查路径是否已存在,不存在则添加
                if (!expandedArray.includes(current)) {
                    expandedArray.push(current)
                }
            }
        },

        /**
         * 辅助函数:将子节点映射为名称+类型的键值对
         */
        mapByNameType(children) {
            const map = new Map()
            for (const child of children || []) {
                map.set(child.name + ':' + child.type, child)
            }
            return map
        },

        sendRequest({ cmdAct, execType, rmtPath }) {
            return new Promise(async (resolve, reject) => {
                // Get taskId from props
                const taskId = this.apkInfoId || ''
                if (!taskId) {
                    this.$message.warning('任务ID不存在!')
                    return reject(new Error('task_id is notfound'))
                }

                // Build query string
                const url = `${cmdAct}` +
                    `?req_id=${this.getTimestamp()}` +
                    `&task_id=${this.apkInfoId}` +
                    `&token=${this.token}` +
                    `&exec_type=${execType}` +
                    `&rmt_path=${rmtPath}`

                try {
                    const res = await fetch(url, { method: 'GET' })
                    if (!res.ok) throw new Error(`请求失败: ${res.status}`)
                    const data = await res.json()
                    resolve(data)
                } catch (err) {
                    console.error('File browsing request failed:', err)
                    reject(err)
                }
            })
        },

        /**
         * 处理后端API返回的目录列表响应
         */
        processDirListResponse(browsing, node) {
            // 处理手机文件目录(1)和任务文件目录(6)的响应
            if (browsing.exec_type !== 1 && browsing.exec_type !== 6) return

            // 根据当前文件类型确定使用哪个数据源
            const isPhoneFile = browsing.exec_type === 1
            const targetData = isPhoneFile ? this.phoneFilesData : this.taskFilesData

            // 在目标数据源中查找对应节点
            const targetNode = this.findNodeByPath(targetData, node.path) || node

            const separator = node.path.endsWith('/') ? '' : '/'
            const oldMap = this.mapByNameType((targetNode.children || []).filter(child => child.type === 'directory'))
            const fileChildren = (targetNode.children || []).filter(child => child.type !== 'directory')
            const newDirs = []

            for (const dir of browsing.dir_list) {
                const key = dir + ':directory'
                let child = oldMap.get(key)
                if (!child) {
                    child = { name: dir, path: node.path + separator + dir, type: 'directory', children: null }
                }
                newDirs.push(child)
                oldMap.delete(key)
            }

            // 更新目标节点的子节点
            targetNode.children = [...newDirs, ...fileChildren]

            // 如果目标节点不是当前节点,则同步更新
            if (targetNode !== node) {
                node.children = targetNode.children
            }
        },

        /**
         * 处理后端API返回的文件列表响应
         */
        processFileListResponse(browsing, node, basePath) {
            // 处理手机文件列表(2)和任务文件列表(7)的响应
            if (browsing.exec_type !== 2 && browsing.exec_type !== 7) return

            // 根据当前文件类型确定使用哪个数据源
            const isPhoneFile = browsing.exec_type === 2
            const targetData = isPhoneFile ? this.phoneFilesData : this.taskFilesData

            // 在目标数据源中查找对应节点
            const targetNode = this.findNodeByPath(targetData, basePath) || node

            const separator = basePath.endsWith('/') ? '' : '/'
            const oldMap = this.mapByNameType(targetNode.children)
            const newChildren = []

            for (const file of browsing.file_list) {
                const type = file.file_type === 2 ? 'directory' : file.file_type === 1 ? 'file' : 'unknown'
                const key = file.name + ':' + type
                let child = oldMap.get(key)
                if (child) {
                    child.size = file.size
                    child.modified = file.modified
                } else {
                    child = {
                        name: file.name,
                        path: basePath + separator + file.name,
                        type,
                        size: file.size,
                        modified: file.modified,
                        children: type === 'directory' ? null : undefined
                    }
                }
                newChildren.push(child)
                oldMap.delete(key)
            }

            // 更新目标节点的子节点
            targetNode.children = newChildren

            // 如果目标节点不是当前节点,则同步更新
            if (targetNode !== node) {
                node.children = targetNode.children
            }
        },

        processFileDownloadResponse(browsingFile, item) {
            // 处理手机文件下载(4)和任务文件下载(9)的响应
            if (browsingFile.exec_type === 4 || browsingFile.exec_type === 9) {
                const bytes = new Uint8Array(browsingFile.file_content)
                const blob = new Blob([bytes], { type: 'application/octet-stream' })
                const url = URL.createObjectURL(blob)
                const a = document.createElement('a')
                a.href = url
                a.download = browsingFile.file_name || item.name
                document.body.appendChild(a)
                a.click()
                document.body.removeChild(a)
                URL.revokeObjectURL(url)
            }
        },

        processFileOpenResponse(browsingFile) {
            // 处理手机文件打开(5)和任务文件打开(10)的响应
            if (browsingFile.exec_type === 5 || browsingFile.exec_type === 10) {
                const fileName = browsingFile.file_name || ''
                const fileContentRaw = new Uint8Array(browsingFile.file_content) || new Uint8Array()
                const extension = fileName.split('.').pop() && fileName.split('.').pop().toLowerCase()

                // Helper: show hex preview for binary
                const toHexPreview = (bytes, maxLen = 256) => {
                    let hex = Array.from(bytes.slice(0, maxLen)).map(b => b.toString(16).padStart(2, '0')).join(' ')
                    if (bytes.length > maxLen) hex += ' ...'
                    return hex
                }

                const escapeHtml = (str) => {
                    return String(str)
                        .replace(/&/g, '&amp;')
                        .replace(/</g, '&lt;')
                        .replace(/>/g, '&gt;')
                        .replace(/"/g, '&quot;')
                        .replace(/'/g, '&#39;')
                }

                const previewHandler = (ext, fileContentRaw) => {
                    const decodeUtf8 = (bytes) => new TextDecoder('utf-8').decode(bytes)

                    switch (ext) {
                        case 'txt':
                        case 'log':
                        case 'js':
                        case 'json': {
                            const fileContent = decodeUtf8(fileContentRaw)
                            let displayContent = fileContent
                            let bg = '#fff'
                            let color = '#000'
                            if (ext === 'js' || ext === 'log') {
                                bg = '#222'
                                color = '#eee'
                            } else if (ext === 'json') {
                                bg = '#f6f8fa'
                                try {
                                    displayContent = JSON.stringify(JSON.parse(fileContent), null, 2)
                                } catch {
                                    displayContent = fileContent
                                }
                            }
                            return `
                <div class="file-content-viewer">
                  <pre style="white-space: pre-wrap; word-break: break-all; padding: 12px; background:${bg}; color:${color};">${escapeHtml(displayContent)}</pre>
                </div>
              `
                        }

                        case 'dex':
                            return `
                <div class="file-content-viewer">
                  <div style="color: #888; padding: 12px;">DEX 文件预览功能即将支持 (WASM)...</div>
                </div>
              `

                        default: {
                            const hex = toHexPreview(fileContentRaw)
                            return `
                <div class="file-content-viewer">
                  <div style="color: #888; padding: 12px;">暂不支持该文件类型的预览。</div>
                  <div style="margin-top:8px; font-size:14px; color:#555;">Hex Preview:
                    <pre style="background:#f6f8fa;">${hex}</pre>
                  </div>
                </div>
              `
                        }
                    }
                }

                const html = previewHandler(extension, fileContentRaw)
                this.filePreview = {
                    visible: true,
                    fileName,
                    extension,
                    content: '', // not used in UI
                    html
                }
            }
        },

        onNodeExpand(node) {
            // 使用数组版本的展开方法
            this.expandAllParentPaths(node.path, this.expandedNodes)
            const execType = this.currentFileType === 'phone' ? 1 : 6
            this.handleApiResponse(
                this.sendRequest({ cmdAct: '/browsing', execType, rmtPath: node.path }),
                resp => this.processDirListResponse(resp, node),
                'Node expand failed'
            )
        },

        onNodeCollapse(node) {
            const index = this.expandedNodes.indexOf(node.path)
            if (index !== -1) {
                this.expandedNodes.splice(index, 1)
            }
        },

        onNodeSelect(node) {
            this.currentPath = node.path
            // 根据当前文件类型选择正确的execType
            const execType = this.currentFileType === 'phone' ? 2 : 7
            this.handleApiResponse(
                this.sendRequest({ cmdAct: '/browsing', execType, rmtPath: node.path }),
                resp => this.processFileListResponse(resp, node, node.path),
                'Node select failed'
            )
        },

        onOpen(item) {
            if (item.type === 'directory') {
                this.currentPath = item.path
                this.expandAllParentPaths(item.path, this.expandedNodes)
                // 根据当前文件类型选择正确的execType
                const execType = this.currentFileType === 'phone' ? 2 : 7
                this.handleApiResponse(
                    this.sendRequest({ cmdAct: '/browsing', execType, rmtPath: item.path }),
                    resp => this.processFileListResponse(resp, item, item.path),
                    'Open dir failed'
                )
            } else {
                const maxOpenSize = (this.inputData && this.inputData.max_open_size) || 10
                if (item.size && item.size > maxOpenSize * 1024 * 1024) {
                    this.$message.warning(`文件大小 ${this.formatFileSize(item.size)} 已超过打开限制 ${maxOpenSize} MB`)
                    return
                }

                // 根据当前文件类型选择正确的execType
                const execType = this.currentFileType === 'phone' ? 5 : 10
                // 设置延迟加载定时器
                let loadingTimer = setTimeout(() => {
                    this.isLoading = true
                    this.loadingText = '打开文件中,请稍候...'
                }, 500)

                this.handleApiResponse(
                    this.sendRequest({ cmdAct: '/browsing_file', execType, rmtPath: item.path }),
                    resp => {
                        clearTimeout(loadingTimer)
                        this.processFileOpenResponse(resp)
                        this.isLoading = false
                    },
                    'Open file failed'
                ).finally(() => {
                    clearTimeout(loadingTimer)
                    if (this.isLoading) this.isLoading = false
                })
            }
        },

        onDownload(item) {
            // Check file size before making the request
            const maxDownloadSize = (this.inputData && this.inputData.max_download_size) || 500
            if (item.size && item.size > maxDownloadSize * 1024 * 1024) {
                this.$message.warning(`文件大小 ${this.formatFileSize(item.size)} 已超过下载限制 ${maxDownloadSize} MB`)
                return
            }

            // 根据当前文件类型选择正确的execType
            const execType = this.currentFileType === 'phone' ? 4 : 9
            // 设置延迟加载定时器
            let loadingTimer = setTimeout(() => {
                this.isLoading = true
                this.loadingText = '下载文件中,请稍候...'
            }, 500)

            this.handleApiResponse(
                this.sendRequest({ cmdAct: '/browsing_file', execType, rmtPath: item.path }),
                resp => {
                    clearTimeout(loadingTimer)
                    this.processFileDownloadResponse(resp, item)
                    this.isLoading = false
                },
                'File download failed'
            ).finally(() => {
                clearTimeout(loadingTimer)
                if (this.isLoading) this.isLoading = false
            })
        },

        refreshFileList() {
            const currentNode = this.findNodeByPath(this.treeItems, this.currentPath)
            if (currentNode) {
                // 根据当前文件类型选择正确的execType
                const execType = this.currentFileType === 'phone' ? 2 : 7
                this.handleApiResponse(
                    this.sendRequest({ cmdAct: '/browsing', execType, rmtPath: this.currentPath }),
                    resp => this.processFileListResponse(resp, currentNode, this.currentPath),
                    'Refresh file list failed'
                )
            }
        },

        getFileIcon(item) {
            if (item.type === 'directory') return 'fas fa-folder'
            const extension = item.name.split('.').pop() && item.name.split('.').pop().toLowerCase()
            const iconMap = {
                'txt': 'fas fa-file-alt',
                'json': 'fas fa-file-code',
                'js': 'fas fa-file-code',
                'html': 'fas fa-file-code',
                'css': 'fas fa-file-code',
                'png': 'fas fa-file-image',
                'jpg': 'fas fa-file-image',
                'jpeg': 'fas fa-file-image',
                'pdf': 'fas fa-file-pdf',
                'zip': 'fas fa-file-archive',
                'rar': 'fas fa-file-archive'
            }
            return iconMap[extension] || 'fas fa-file'
        },

        formatFileSize(size) {
            if (size === null || size === undefined) return '-'
            if (size < 1024) return size + ' B'
            if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
            if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
            return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
        },

        formatDate(date) {
            if (!date) return '-'
            return date
        },

        async handleApiResponse(promise, processor, errorMessage = '操作失败') {
            try {
                let response = await promise
                const browsing = response
                if (!browsing) {
                    this.$message.error('响应格式错误')
                    return false
                }
                if (!browsing.result) {
                    console.error(errorMessage + ':', browsing.err_msg || browsing.errMsg)
                    this.$message.warning(browsing.err_msg || browsing.errMsg || errorMessage)
                    return false
                }
                if (processor) {
                    processor(browsing)
                }
                return true
            } catch (error) {
                console.error(errorMessage + ':', error)
                this.$message.error(errorMessage)
                return false
            }
        },

        switchFileType(targetType) {
            if (this.currentFileType !== targetType) {
                const sourceType = this.currentFileType

                // 保存当前文件类型的状态(数组复制)
                this.fileTypeStates[sourceType].currentPath = this.currentPath
                // 复制数组而不是Set
                this.fileTypeStates[sourceType].expandedNodes = [...this.expandedNodes]

                // 切换到目标文件类型
                this.currentFileType = targetType
                this.treeItems = targetType === 'phone' ? this.phoneFilesData : this.taskFilesData

                // 恢复目标文件类型的状态
                this.currentPath = this.fileTypeStates[targetType].currentPath
                // 复制数组而不是Set
                this.expandedNodes = [...this.fileTypeStates[targetType].expandedNodes]

                // 重新加载当前路径的内容
                const currentNode = this.findNodeByPath(this.treeItems, this.currentPath)
                if (currentNode) {
                    const execType = targetType === 'phone' ? 2 : 7
                    this.handleApiResponse(
                        this.sendRequest({ cmdAct: '/browsing', execType, rmtPath: this.currentPath }),
                        resp => this.processFileListResponse(resp, currentNode, this.currentPath),
                        `Switch to ${targetType} files failed`
                    )
                }
            }
        },

        switchToPhoneFiles() {
            this.switchFileType('phone')
        },

        switchToTaskFiles() {
            this.switchFileType('task')
        },

        // 辅助函数:在树中查找指定路径的节点
        findNodeByPath(nodes, path) {
            if (!nodes) return null
            for (const node of nodes) {
                if (node.path === path) return node
                if (node.children) {
                    const found = this.findNodeByPath(node.children, path)
                    if (found) return found
                }
            }
            return null
        }
    }
}
</script>
css 复制代码
<style scoped>
.file-browsing {
    padding: 20px;
    height: 100%;
    width: 100%;
}

.file-type-tabs {
    display: flex;
    width: 100%;
    border-bottom: 1px solid #e0e0e0;
}

.tab-btn {
    flex: 1;
    padding: 8px 12px;
    background: none;
    border: none;
    cursor: pointer;
    font-size: 14px;
    font-weight: 500;
    color: #666;
    transition: all 0.3s ease;
}

.tab-btn:hover {
    background-color: #f5f5f5;
}

.tab-btn.active {
    color: #1976d2;
    border-bottom: 2px solid #1976d2;
    background-color: rgba(25, 118, 210, 0.05);
}

.file-browser-container {
    display: flex;
    height: 1000px;
    border: 1px solid #ddd;
    background: #fff;
    border-radius: 8px;
    overflow: hidden;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* Left Panel Styles */
.left-panel {
    width: 280px;
    min-width: 280px;
    flex-shrink: 0;
    background: #f8f9fa;
    border-right: 1px solid #e9ecef;
    display: flex;
    flex-direction: column;
    overflow-x: auto;
    /* allow horizontal scroll if needed */
}

.tree-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px 16px;
    border-bottom: 1px solid #e9ecef;
    background: #fff;
}

.tree-header h5 {
    margin: 0;
    font-size: 14px;
    font-weight: 600;
    color: #495057;
}

.tree-container {
    flex: 1;
    overflow-y: auto;
    overflow-x: auto;
    /* allow horizontal scroll if needed */
    padding: 8px 0;
}

/* Right Panel Styles */
.right-panel {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    background: #fff;
}

.file-browser-header {
    display: flex;
    justify-content: space-between;
    padding: 16px;
    border-bottom: 1px solid #e9ecef;
    background: #f8f9fa;
    box-sizing: border-box;
    overflow: auto;
}

.breadcrumb {
    align-items: center;
    white-space: nowrap;
    padding-right: 70px;
}

.breadcrumb-item {
    align-items: center;
    padding: 4px 8px;
    color: #6c757d;
    font-size: 14px;
}

.breadcrumb-item i {
    margin: 0 8px;
    font-size: 10px;
    color: #6c757d;
}

.file-list-container {
    flex: 1;
    box-sizing: border-box;
    overflow: auto;
}

.file-table {
    min-width: max-content;
    /* Allow table to be wider than container */
    width: 100%;
    border-collapse: collapse;
    font-size: 14px;
}

.file-table th {
    background: #f8f9fa;
    padding: 12px 16px;
    text-align: left;
    font-weight: 600;
    color: #495057;
    border-bottom: 2px solid #dee2e6;
    position: sticky;
    top: 0;
    z-index: 10;
}

.file-table th.col-name {
    min-width: 200px;
    /* Minimum width for filename column */
}

.file-table th.col-modified {
    min-width: 150px;
    /* Minimum width for modified date */
    flex-shrink: 0;
    /* Prevent shrinking */
}

.file-table th.col-size {
    text-align: right !important;
    min-width: 100px;
    /* Minimum width for file size */
    flex-shrink: 0;
    /* Prevent shrinking */
}

.file-table th.col-actions {
    min-width: 120px;
    /* Minimum width for action buttons */
    flex-shrink: 0;
    /* Prevent shrinking */
}

.file-table td {
    padding: 12px 16px;
    /* border-bottom: 1px solid #e9ecef; */
    vertical-align: middle;
}

.file-row:hover {
    background-color: #f8f9fa;
}
.file-row {
    border-bottom: 1px solid #e9ecef;
    /* height: 50px; */
}

.file-name {
    display: flex;
    align-items: center;
}

.file-icon {
    margin-right: 8px;
    width: 16px;
    text-align: center;
    color: #6c757d;
}

.file-name span {
    color: #495057;
}

.file-modified {
    color: #6c757d;
    font-size: 13px;
}

.file-size {
    color: #6c757d;
    font-size: 13px;
    text-align: right !important;
}

.file-actions {
    display: flex;
    align-items: center;
    gap: 8px;
}

.action-separator {
    width: 1px;
    height: 20px;
    background: #dee2e6;
}

/* Button Styles */
.btn {
    padding: 6px 12px;
    border: 1px solid transparent;
    border-radius: 4px;
    font-size: 13px;
    cursor: pointer;
    transition: all 0.2s;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    gap: 4px;
    white-space: nowrap;
}

.btn-sm {
    padding: 4px 8px;
    font-size: 12px;
}

.btn-primary {
    background: #007bff;
    color: white;
    border-color: #007bff;
}

.btn-primary:hover {
    background: #0056b3;
    border-color: #0056b3;
}

.btn-outline {
    background: transparent;
    color: #6c757d;
    border-color: #6c757d;
}

.btn-outline:hover {
    background: #6c757d;
    color: white;
}

.btn-orange {
    background: #fff;
    color: #fd7e14;
    border-color: #fd7e14;
}

.btn-orange:hover {
    background: #fd7e14;
    color: white;
}

.btn-red {
    background: #fff;
    color: #dc3545;
    border-color: #dc3545;
}

.btn-red:hover {
    background: #dc3545;
    color: white;
}

/* Scrollbar Styles */
.tree-container::-webkit-scrollbar,
.file-browser-header::-webkit-scrollbar,
.file-list-container::-webkit-scrollbar {
    width: 6px;
    height: 6px;
}

.tree-container::-webkit-scrollbar-track,
.file-browser-header::-webkit-scrollbar-track,
.file-list-container::-webkit-scrollbar-track {
    background: #f1f1f1;
}

.tree-container::-webkit-scrollbar-thumb,
.file-browser-header::-webkit-scrollbar-thumb,
.file-list-container::-webkit-scrollbar-thumb {
    background: #c1c1c1;
    border-radius: 3px;
}

.tree-container::-webkit-scrollbar-thumb:hover,
.file-browser-header::-webkit-scrollbar-thumb:hover,
.file-list-container::-webkit-scrollbar-thumb:hover {
    background: #a8a8a8;
}

.file-preview-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 2000;
}

/* Loading Overlay Styles */
.loading-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.8);
    z-index: 3000;
}

.spinner {
    border: 4px solid rgba(0, 0, 0, 0.1);
    width: 36px;
    height: 36px;
    border-radius: 50%;
    border-left-color: #09f;
    animation: spin 1s linear infinite;
    margin-bottom: 16px;
}

@keyframes spin {
    0% {
        transform: rotate(0deg);
    }

    100% {
        transform: rotate(360deg);
    }
}

.loading-text {
    font-size: 16px;
    color: #333;
    font-weight: 500;
}

.file-preview-modal {
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 4px 32px rgba(0, 0, 0, 0.25);
    max-width: 80vw;
    max-height: 80vh;
    min-width: 320px;
    min-height: 200px;
    overflow: auto;
    position: relative;
    z-index: 2001;
    display: flex;
    flex-direction: column;
}

.file-preview-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px;
    border-bottom: 1px solid #e9ecef;
    background: #f8f9fa;
    font-weight: 600;
    font-size: 16px;
}

.file-preview-body {
    padding: 24px;
    overflow: auto;
    font-size: 15px;
}

.file-preview-backdrop {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background: rgba(0, 0, 0, 0.25);
    z-index: 2000;
}
</style>

DirectoryTree目录树组件:

xml 复制代码
<template>
  <div class="directory-tree">
    <TreeNode v-for="item in items" :key="item.path" :node="item" :selected-path="selectedPath"
      :expanded-nodes="expandedNodes" @expand="$emit('expand', $event)" @collapse="$emit('collapse', $event)"
      @select="$emit('select', $event)" />
  </div>
</template>
js 复制代码
<script>
import TreeNode from './TreeNode.vue'

export default {
   name: 'DirectoryTree',
  components: {
    TreeNode
  },
  props: {
    items: { type: Array, default: () => [] },
    selectedPath: { type: String, default: '/' },
    expandedNodes: { 
      type: Array,  // 改为 Array 类型
      required: true,
      default: () => []  // 添加默认值
    }
  }
}
</script>
css 复制代码
<style scoped>
.directory-tree {
  padding: 8px 0;
  overflow-y: auto;
}
</style>

TreeNode树节点组件:

xml 复制代码
<template>
  <div class="tree-node" :class="{ selected: isSelected }">
    <div class="tree-node-content" @click="selectNode">
      <span class="tree-arrow" @click.stop="toggleNode">
        <i v-if="hasChildren" :class="isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'" class="arrow-icon"></i>
        <span v-else class="arrow-placeholder"></span>
      </span>
      <i class="fas fa-folder tree-icon"></i>
      <span class="tree-label">{{ node.name }}</span>
    </div>
    <div v-if="hasChildren && isExpanded" class="tree-children">
      <TreeNode v-for="child in filteredChildren" :key="child.path"
        :node="child" :selected-path="selectedPath" :expanded-nodes="expandedNodes" @expand="$emit('expand', $event)"
        @collapse="$emit('collapse', $event)" @select="$emit('select', $event)" />
    </div>
  </div>
</template>
js 复制代码
<script>
export default {
  name: 'TreeNode',
  props: {
    node: { type: Object, required: true },
    selectedPath: { type: String, default: '/' },
    expandedNodes: { type: Array, required: true },
  },
  computed: {
    isExpanded() {
      return this.expandedNodes.includes(this.node.path);
    },
    isSelected() {
      return this.selectedPath === this.node.path;
    },
    hasChildren() {
      return this.node.children && this.node.children.some(child => child.type === 'directory');
    },
    filteredChildren() {
      return this.node.children.filter(child => child.type === 'directory');
    }
  },
  methods: {
    toggleNode() {
      if (this.isExpanded) {
        this.$emit('collapse', this.node);
      } else {
        this.$emit('expand', this.node);
      }
    },
    selectNode() {
      this.$emit('select', this.node);
    }
  }
}
</script>
css 复制代码
<style scoped>
.tree-node {
  cursor: pointer;
}

.tree-node-content {
  display: flex;
  align-items: center;
  padding: 2px 4px 2px 4px;
  transition: background-color 0.2s;
  min-width: 200px;
}

.tree-node-content:hover {
  background-color: #e9ecef;
}

.tree-node.selected>.tree-node-content {
  background-color: #007bff;
  color: white;
}

.tree-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 4px;
  min-width: 4px;
  margin-right: 14px;
  margin-left: 4px;
  font-size: 12px;
  color: #6c757d;
}

.arrow-icon {
  font-size: 12px;
  color: #6c757d;
  transition: transform 0.2s ease;
}

.arrow-placeholder {
  display: inline-block;
  width: 12px;
  height: 12px;
}

.tree-icon {
  margin-right: 8px;
  width: 16px;
  text-align: center;
  color: #ffa726;
}

.tree-label {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tree-children {
  margin-left: 4px;
  margin-top: 2px;
  margin-bottom: 2px;
  padding-left: 8px;
  border-left: 1px solid #e0e0e0;
}

.directory-tree {
  font-size: 12px;
}
</style>
相关推荐
红红大虾2 小时前
Defold引擎中关于CollectionProxy的使用
前端·游戏开发
RoyLin2 小时前
TypeScript设计模式:迭代器模式
javascript·后端·node.js
xw53 小时前
uni-app中v-if使用”异常”
前端·uni-app
!win !3 小时前
uni-app中v-if使用”异常”
前端·uni-app
IT_陈寒3 小时前
Java 性能优化:5个被低估的JVM参数让你的应用吞吐量提升50%
前端·人工智能·后端
南囝coding4 小时前
《独立开发者精选工具》第 018 期
前端·后端
小桥风满袖4 小时前
极简三分钟ES6 - ES9中for await of
前端·javascript
半花4 小时前
i18n国际语言化配置
前端