3054 lines
81 KiB
Vue
3054 lines
81 KiB
Vue
<template>
|
||
<div class="container-file-manager">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<h2><el-icon class="header-icon"><Folder /></el-icon> 容器文件管理器</h2>
|
||
<div class="container-info">
|
||
<el-tag type="info" size="small">{{ containerId }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<el-button size="default" @click="showUploadDialog = true" :icon="Upload">上传文件</el-button>
|
||
<el-button size="default" @click="showCreateFileDialog = true" :icon="Document">新建文件</el-button>
|
||
<el-button size="default" @click="showCreateFolderDialog = true" :icon="FolderAdd">新建文件夹</el-button>
|
||
<el-button size="default" @click="refreshFileList" :icon="Refresh">刷新</el-button>
|
||
<el-button size="default" @click="goBack" :icon="Back">返回</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 路径导航 -->
|
||
<div class="path-navigation">
|
||
<div class="path-breadcrumb">
|
||
<span class="path-label">当前路径:</span>
|
||
<el-breadcrumb separator="/">
|
||
<el-breadcrumb-item
|
||
v-for="(path, index) in breadcrumbPaths"
|
||
:key="index"
|
||
@click="navigateToPath(index)"
|
||
:class="{ 'breadcrumb-clickable': index < breadcrumbPaths.length - 1 }"
|
||
>
|
||
{{ path.name }}
|
||
</el-breadcrumb-item>
|
||
</el-breadcrumb>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="file-manager-container">
|
||
<!-- 左侧文件树 -->
|
||
<div class="file-sidebar">
|
||
<div class="sidebar-header">
|
||
<span class="sidebar-title">文件树</span>
|
||
<div class="sidebar-actions">
|
||
<el-tooltip content="刷新文件树">
|
||
<el-button size="small" text @click="refreshFileList" :icon="Refresh" />
|
||
</el-tooltip>
|
||
<el-tooltip content="展开全部">
|
||
<el-button size="small" text @click="expandAllNodes" :icon="DCaret" />
|
||
</el-tooltip>
|
||
<el-tooltip content="收起全部">
|
||
<el-button size="small" text @click="collapseAllNodes" :icon="CaretRight" />
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
<div class="file-tree-container">
|
||
<el-scrollbar class="tree-scrollbar">
|
||
<el-tree
|
||
ref="fileTreeRef"
|
||
:data="fileTreeData"
|
||
:props="treeProps"
|
||
:load="loadTreeNode"
|
||
lazy
|
||
:expand-on-click-node="false"
|
||
@node-click="handleTreeNodeClick"
|
||
@node-contextmenu="handleTreeContextMenu"
|
||
node-key="id"
|
||
class="file-tree"
|
||
>
|
||
<template #default="{ node, data }">
|
||
<div class="tree-node-content">
|
||
<el-icon class="node-icon" :class="{ 'folder-icon': data.type === 'directory', 'file-icon': data.type === 'file' }">
|
||
<Folder v-if="data.type === 'directory'" />
|
||
<Document v-else />
|
||
</el-icon>
|
||
<span class="node-label">{{ node.label }}</span>
|
||
<div class="node-actions" v-if="hoveredNodeId === data.id">
|
||
<el-tooltip content="新建文件">
|
||
<el-button size="small" text @click.stop="createFileInNode(data)" :icon="DocumentAdd" />
|
||
</el-tooltip>
|
||
<el-tooltip content="新建文件夹">
|
||
<el-button size="small" text @click.stop="createFolderInNode(data)" :icon="FolderAdd" />
|
||
</el-tooltip>
|
||
<el-tooltip content="删除">
|
||
<el-button size="small" text @click.stop="handleDeleteFile(data)" :icon="Delete" />
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
</el-scrollbar>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧内容区域 -->
|
||
<div class="content-area">
|
||
<!-- 选项卡 -->
|
||
<div class="content-tabs">
|
||
<el-tabs
|
||
v-model="activeTabName"
|
||
type="border-card"
|
||
@tab-click="handleContentTabClick"
|
||
@tab-remove="handleTabClose"
|
||
class="editor-tabs"
|
||
>
|
||
<el-tab-pane
|
||
v-for="tab in openTabs"
|
||
:key="tab.key"
|
||
:name="tab.key"
|
||
:closable="tab.closable"
|
||
>
|
||
<template #label>
|
||
<span :class="{ 'modified-tab': tab.modified }">
|
||
{{ tab.title }}
|
||
<span v-if="tab.modified" class="modified-indicator">●</span>
|
||
</span>
|
||
</template>
|
||
<!-- 文件编辑器 -->
|
||
<div v-if="tab.type === 'file'" class="file-editor">
|
||
<div class="editor-toolbar">
|
||
<div class="toolbar-left">
|
||
<span class="file-info">{{ tab.title }}</span>
|
||
<el-tag v-if="tab.modified" type="warning" size="small">已修改</el-tag>
|
||
<el-tag type="info" size="small">{{ getFileExtension(tab.title) }}</el-tag>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<el-select
|
||
v-model="editorTheme"
|
||
size="small"
|
||
style="width: 120px; margin-right: 8px;"
|
||
@change="handleThemeChange"
|
||
>
|
||
<el-option label="深色主题" value="custom-dark" />
|
||
<el-option label="浅色主题" value="custom-light" />
|
||
<el-option label="VS Dark" value="vs-dark" />
|
||
<el-option label="VS Light" value="vs" />
|
||
</el-select>
|
||
<el-button size="small" @click="saveCurrentFile" :disabled="!tab.modified">
|
||
<el-icon><DocumentChecked /></el-icon>
|
||
保存
|
||
</el-button>
|
||
<el-button size="small" @click="downloadCurrentFile">
|
||
<el-icon><Download /></el-icon>
|
||
下载
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="editor-content">
|
||
<MonacoEditor
|
||
v-model="tab.content"
|
||
:language="getFileLanguage(tab.title)"
|
||
:theme="editorTheme"
|
||
:height="600"
|
||
:options="editorOptions"
|
||
@change="markTabAsModified(tab.key)"
|
||
@save="saveCurrentFile"
|
||
ref="monacoEditorRef"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件夹内容视图 -->
|
||
<div v-else-if="tab.type === 'directory'" class="directory-view">
|
||
<div class="directory-header">
|
||
<h3>{{ tab.title }}</h3>
|
||
<div class="view-actions">
|
||
<el-button size="small" @click="switchView('grid')" :type="viewMode === 'grid' ? 'primary' : ''" text>
|
||
<el-icon><Grid /></el-icon>
|
||
</el-button>
|
||
<el-button size="small" @click="switchView('list')" :type="viewMode === 'list' ? 'primary' : ''" text>
|
||
<el-icon><List /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 网格视图 -->
|
||
<div v-if="viewMode === 'grid'" class="grid-view">
|
||
<div
|
||
v-for="item in currentDirectoryItems"
|
||
:key="item.id"
|
||
class="file-item"
|
||
@click="handleFileItemClick(item)"
|
||
@dblclick="handleFileItemDoubleClick(item)"
|
||
@contextmenu="handleFileContextMenu($event, item)"
|
||
>
|
||
<div class="file-icon-large">
|
||
<el-icon v-if="item.type === 'directory'" size="48" color="#409EFF">
|
||
<Folder />
|
||
</el-icon>
|
||
<el-icon v-else size="48" color="#67C23A">
|
||
<Document />
|
||
</el-icon>
|
||
</div>
|
||
<div class="file-name">{{ item.name }}</div>
|
||
<div class="file-details">
|
||
<span v-if="item.type === 'file'">{{ formatFileSize(item.size) }}</span>
|
||
<span v-else>文件夹</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 列表视图 -->
|
||
<div v-if="viewMode === 'list'" class="list-view">
|
||
<el-table
|
||
:data="currentDirectoryItems"
|
||
@row-click="handleFileItemClick"
|
||
@row-dblclick="handleFileItemDoubleClick"
|
||
@row-contextmenu="handleFileContextMenu"
|
||
class="directory-table"
|
||
:height="tableHeight"
|
||
:max-height="tableHeight"
|
||
>
|
||
<el-table-column width="50">
|
||
<template #default="scope">
|
||
<el-icon v-if="scope.row.type === 'directory'" color="#409EFF">
|
||
<Folder />
|
||
</el-icon>
|
||
<el-icon v-else color="#67C23A">
|
||
<Document />
|
||
</el-icon>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="name" label="名称" min-width="200" />
|
||
<el-table-column prop="size" label="大小" width="120">
|
||
<template #default="scope">
|
||
{{ scope.row.type === 'directory' ? '-' : formatFileSize(scope.row.size) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="modified_time" label="修改时间" width="180" />
|
||
<el-table-column prop="permissions" label="权限" width="120" />
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 欢迎页 -->
|
||
<div v-else-if="tab.type === 'welcome'" class="welcome-view">
|
||
<div class="welcome-container">
|
||
<div class="welcome-header">
|
||
<div class="welcome-icon">
|
||
<el-icon size="80" color="#409EFF"><Folder /></el-icon>
|
||
</div>
|
||
<h1 class="welcome-title">容器文件管理器</h1>
|
||
<p class="welcome-subtitle">高效管理您的容器文件,支持在线编辑和多种操作</p>
|
||
</div>
|
||
|
||
<div class="welcome-features">
|
||
<div class="feature-grid">
|
||
<div class="feature-item">
|
||
<el-icon size="24" color="#67C23A"><DocumentChecked /></el-icon>
|
||
<span>代码编辑</span>
|
||
<small>Monaco Editor 语法高亮</small>
|
||
</div>
|
||
<div class="feature-item">
|
||
<el-icon size="24" color="#E6A23C"><FolderOpened /></el-icon>
|
||
<span>文件管理</span>
|
||
<small>创建、删除、上传下载</small>
|
||
</div>
|
||
<div class="feature-item">
|
||
<el-icon size="24" color="#F56C6C"><Document /></el-icon>
|
||
<span>多格式支持</span>
|
||
<small>支持多种文件格式</small>
|
||
</div>
|
||
<div class="feature-item">
|
||
<el-icon size="24" color="#909399"><Grid /></el-icon>
|
||
<span>多视图模式</span>
|
||
<small>网格和列表视图切换</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="quick-actions">
|
||
<h3 class="actions-title">快速操作</h3>
|
||
<div class="actions-grid">
|
||
<div class="action-card primary" @click="showCreateFileDialog = true">
|
||
<div class="action-icon">
|
||
<el-icon size="28"><DocumentAdd /></el-icon>
|
||
</div>
|
||
<div class="action-text">
|
||
<span class="action-name">新建文件</span>
|
||
<small class="action-desc">创建新的文本文件</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="action-card success" @click="showCreateFolderDialog = true">
|
||
<div class="action-icon">
|
||
<el-icon size="28"><FolderAdd /></el-icon>
|
||
</div>
|
||
<div class="action-text">
|
||
<span class="action-name">新建文件夹</span>
|
||
<small class="action-desc">创建新的目录</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="action-card warning" @click="showUploadDialog = true">
|
||
<div class="action-icon">
|
||
<el-icon size="28"><Upload /></el-icon>
|
||
</div>
|
||
<div class="action-text">
|
||
<span class="action-name">上传文件</span>
|
||
<small class="action-desc">从本地上传文件</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="welcome-tips">
|
||
<el-alert
|
||
title="使用提示"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #default>
|
||
<ul class="tips-list">
|
||
<li>点击左侧文件树中的文件进行编辑</li>
|
||
<li>支持 Ctrl+S 快捷键保存文件</li>
|
||
<li>右键文件可进行更多操作</li>
|
||
<li>支持拖拽上传文件</li>
|
||
</ul>
|
||
</template>
|
||
</el-alert>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上传文件对话框 -->
|
||
<el-dialog
|
||
v-model="showUploadDialog"
|
||
title="上传文件"
|
||
width="600px"
|
||
append-to-body
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="upload-container">
|
||
<div class="upload-path-info">
|
||
<el-alert
|
||
:title="`上传到: ${currentPath}`"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
/>
|
||
</div>
|
||
|
||
<el-upload
|
||
ref="uploadRef"
|
||
:action="uploadAction"
|
||
:headers="uploadHeaders"
|
||
:data="uploadData"
|
||
:before-upload="beforeUpload"
|
||
:on-remove="handleRemoveFile"
|
||
:on-change="handleFileChange"
|
||
drag
|
||
multiple
|
||
class="upload-area"
|
||
:auto-upload="false"
|
||
:file-list="displayFileList"
|
||
>
|
||
<el-icon class="el-icon--upload"><Upload /></el-icon>
|
||
<div class="el-upload__text">
|
||
将文件拖到此处,或<em>点击选择</em>
|
||
</div>
|
||
<template #tip>
|
||
<div class="el-upload__tip">
|
||
支持多文件上传,单个文件大小不超过100MB
|
||
</div>
|
||
</template>
|
||
</el-upload>
|
||
|
||
<!-- 上传按钮 -->
|
||
<div class="upload-actions">
|
||
<el-button
|
||
type="primary"
|
||
@click="handleManualUpload"
|
||
:disabled="uploadFileList.length === 0"
|
||
>
|
||
开始上传 ({{ uploadFileList.length }} 个文件)
|
||
</el-button>
|
||
<el-button
|
||
@click="clearUploadList"
|
||
:disabled="uploadFileList.length === 0"
|
||
>
|
||
清空列表
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 上传进度 -->
|
||
<div v-if="uploadingFiles.length > 0" class="upload-progress">
|
||
<h4>上传进度</h4>
|
||
<div v-for="file in uploadingFiles" :key="file.name" class="file-progress">
|
||
<div class="file-info">
|
||
<span class="file-name">{{ file.name }}</span>
|
||
<span class="file-status" :class="file.status">
|
||
{{
|
||
file.status === 'waiting' ? '等待中' :
|
||
file.status === 'uploading' ? '上传中' :
|
||
file.status === 'success' ? '成功' : '失败'
|
||
}}
|
||
</span>
|
||
</div>
|
||
<el-progress
|
||
:percentage="file.percentage"
|
||
:status="file.status === 'success' ? 'success' : file.status === 'error' ? 'exception' : ''"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<!-- 创建文件对话框 -->
|
||
<el-dialog
|
||
v-model="showCreateFileDialog"
|
||
title="写入文件"
|
||
width="600px"
|
||
append-to-body
|
||
@open="onCreateFileDialogOpen"
|
||
>
|
||
<el-form :model="createFileForm" label-width="100px">
|
||
<el-form-item label="文件名" required>
|
||
<el-input
|
||
v-model="createFileForm.filename"
|
||
placeholder="请输入文件名(如:myFile.txt)"
|
||
@blur="validateFileNameInput"
|
||
/>
|
||
<div v-if="fileNameError" class="error-message">
|
||
{{ fileNameError }}
|
||
</div>
|
||
<div class="file-name-hint">
|
||
<small>注意:文件名只能包含一个扩展名后缀(如:.txt、.js、.md等)</small>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="文件内容">
|
||
<el-input
|
||
v-model="createFileForm.content"
|
||
type="textarea"
|
||
:rows="10"
|
||
placeholder="请输入文件内容"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="closeCreateFileDialog">取消</el-button>
|
||
<el-button type="primary" @click="handleCreateFile" :disabled="!!fileNameError">
|
||
确认创建
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建文件夹对话框 -->
|
||
<el-dialog
|
||
v-model="showCreateFolderDialog"
|
||
title="创建文件夹"
|
||
width="400px"
|
||
append-to-body
|
||
>
|
||
<el-form :model="createFolderForm" label-width="100px">
|
||
<el-form-item label="文件夹名" required>
|
||
<el-input v-model="createFolderForm.foldername" placeholder="请输入文件夹名" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="showCreateFolderDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="handleCreateFolder">
|
||
确认创建
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 压缩文件对话框 -->
|
||
<el-dialog
|
||
v-model="showCompressDialog"
|
||
title="压缩文件"
|
||
width="500px"
|
||
append-to-body
|
||
>
|
||
<el-form :model="compressForm" label-width="100px">
|
||
<el-form-item label="压缩包名称" required>
|
||
<el-input v-model="compressForm.zipName" placeholder="请输入压缩包名称(含.zip后缀)" />
|
||
</el-form-item>
|
||
<el-form-item label="文件列表">
|
||
<el-tag v-for="file in compressForm.fileList" :key="file" type="info" style="margin: 2px;">
|
||
{{ file }}
|
||
</el-tag>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="showCompressDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="executeCompress">
|
||
确认压缩
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 解压文件对话框 -->
|
||
<el-dialog
|
||
v-model="showDecompressDialog"
|
||
title="解压文件"
|
||
width="600px"
|
||
append-to-body
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="decompress-container">
|
||
<div class="decompress-info">
|
||
<el-alert
|
||
title="解压信息"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #default>
|
||
<p><strong>压缩文件:</strong> {{ decompressForm.zipName }}</p>
|
||
<p><strong>文件类型:</strong> {{ getArchiveType(decompressForm.zipName) }}</p>
|
||
</template>
|
||
</el-alert>
|
||
</div>
|
||
|
||
<el-form :model="decompressForm" label-width="100px" style="margin-top: 20px;">
|
||
<el-form-item label="解压目录" required>
|
||
<el-input
|
||
v-model="decompressForm.outputDir"
|
||
placeholder="请输入解压目录路径(如:home/user 或留空表示根目录)"
|
||
:prefix-icon="Folder"
|
||
>
|
||
<template #append>
|
||
<el-button @click="setCurrentPathAsOutput" type="primary" text>
|
||
当前目录
|
||
</el-button>
|
||
</template>
|
||
</el-input>
|
||
<div class="form-hint">
|
||
<small>提示:解压目录不能以 / 开头,如 home/user 或留空表示根目录</small>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="解压选项">
|
||
<el-checkbox v-model="decompressOptions.overwrite">覆盖已存在的文件</el-checkbox>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<!-- 解压预览 -->
|
||
<div class="decompress-preview">
|
||
<el-alert
|
||
:title="`将解压到: ${getDecompressPreviewPath()}`"
|
||
type="success"
|
||
:closable="false"
|
||
show-icon
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="closeDecompressDialog">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
@click="executeDecompress"
|
||
:disabled="!decompressForm.outputDir.trim()"
|
||
>
|
||
<el-icon><FolderOpened /></el-icon>
|
||
开始解压
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
|
||
|
||
<!-- 右键菜单 -->
|
||
<div
|
||
v-if="contextMenuVisible"
|
||
class="context-menu"
|
||
:style="{ left: contextMenuPosition.x + 'px', top: contextMenuPosition.y + 'px' }"
|
||
@click.stop
|
||
>
|
||
<div class="context-menu-item" @click="handleContextMenuAction('download')" v-if="contextMenuItem?.type === 'file'">
|
||
<el-icon><Download /></el-icon>
|
||
<span>下载</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="handleContextMenuAction('compress')">
|
||
<el-icon><Folder /></el-icon>
|
||
<span>压缩</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="handleContextMenuAction('decompress')" v-if="isArchiveFile(contextMenuItem?.name)">
|
||
<el-icon><FolderOpened /></el-icon>
|
||
<span>解压</span>
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
|
||
<el-icon><Delete /></el-icon>
|
||
<span>删除</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右键菜单遮罩 -->
|
||
<div v-if="contextMenuVisible" class="context-menu-overlay" @click="contextMenuVisible = false"></div>
|
||
|
||
<!-- 文件树右键菜单 -->
|
||
<div
|
||
v-if="treeContextMenuVisible"
|
||
class="context-menu"
|
||
:style="{ left: treeContextMenuPosition.x + 'px', top: treeContextMenuPosition.y + 'px' }"
|
||
@click.stop
|
||
>
|
||
<div class="context-menu-item" @click="handleTreeContextMenuAction('download')" v-if="treeContextMenuItem?.type === 'file'">
|
||
<el-icon><Download /></el-icon>
|
||
<span>下载</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="handleTreeContextMenuAction('compress')">
|
||
<el-icon><Folder /></el-icon>
|
||
<span>压缩</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="handleTreeContextMenuAction('decompress')" v-if="isArchiveFile(treeContextMenuItem?.name)">
|
||
<el-icon><FolderOpened /></el-icon>
|
||
<span>解压</span>
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item danger" @click="handleTreeContextMenuAction('delete')">
|
||
<el-icon><Delete /></el-icon>
|
||
<span>删除</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件树右键菜单遮罩 -->
|
||
<div v-if="treeContextMenuVisible" class="context-menu-overlay" @click="handleTreeContextMenuClose"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, computed } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import {
|
||
Folder,
|
||
Document,
|
||
Upload,
|
||
FolderAdd,
|
||
Back,
|
||
DCaret,
|
||
CaretRight,
|
||
DocumentAdd,
|
||
DocumentChecked,
|
||
Download,
|
||
Grid,
|
||
List,
|
||
Delete,
|
||
Refresh,
|
||
FolderOpened
|
||
} from '@element-plus/icons-vue';
|
||
|
||
import { FileName } from '@/utils/tool';
|
||
import MonacoEditor from '@/components/MonacoEditor.vue';
|
||
|
||
import {
|
||
getFileList,
|
||
readFile,
|
||
deleteFile,
|
||
writeFile,
|
||
createFolder,
|
||
uploadFile,
|
||
downloadFile,
|
||
compressFile,
|
||
decompressFile
|
||
} from '@/utils/acs/file';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
// 从URL参数获取容器ID
|
||
const containerId = ref(route.query.container_id || '');
|
||
|
||
// 如果没有容器ID,返回上一页
|
||
if (!containerId.value) {
|
||
ElMessage.error('缺少容器ID参数');
|
||
router.go(-1);
|
||
}
|
||
|
||
// 文件管理相关数据
|
||
const currentPath = ref('/');
|
||
const breadcrumbPaths = ref([{ name: 'root', path: '/' }]);
|
||
|
||
// 编辑器相关
|
||
const fileTreeRef = ref(null);
|
||
const fileTreeData = ref([]);
|
||
const treeProps = {
|
||
children: 'children',
|
||
label: 'name',
|
||
isLeaf: 'isLeaf'
|
||
};
|
||
const hoveredNodeId = ref(null);
|
||
|
||
// 选项卡管理
|
||
const openTabs = ref([
|
||
{
|
||
key: 'welcome',
|
||
title: '欢迎',
|
||
type: 'welcome',
|
||
closable: false,
|
||
content: '',
|
||
modified: false
|
||
}
|
||
]);
|
||
const activeTabName = ref('welcome');
|
||
|
||
// 视图模式
|
||
const viewMode = ref('grid');
|
||
const currentDirectoryItems = ref([]);
|
||
|
||
// 文件操作对话框
|
||
const showUploadDialog = ref(false);
|
||
const showCreateFileDialog = ref(false);
|
||
const showCreateFolderDialog = ref(false);
|
||
|
||
// 文件名验证错误信息
|
||
const fileNameError = ref('');
|
||
|
||
// 文件操作表单
|
||
const createFileForm = reactive({
|
||
filename: '',
|
||
content: ''
|
||
});
|
||
|
||
const createFolderForm = reactive({
|
||
foldername: ''
|
||
});
|
||
|
||
// 生成驼峰命名的默认文件名
|
||
const generateCamelCaseFileName = (extension = 'txt') => {
|
||
// 获取当前时间戳的后6位作为唯一标识
|
||
const timestamp = Date.now().toString().slice(-6);
|
||
// 生成驼峰命名:newFile + 时间戳 + 扩展名
|
||
return `newFile${timestamp}.${extension}`;
|
||
};
|
||
|
||
// 验证文件名是否只有一个后缀
|
||
const validateFileName = (filename) => {
|
||
if (!filename || !filename.trim()) {
|
||
return { valid: false, message: '文件名不能为空' };
|
||
}
|
||
|
||
const trimmedName = filename.trim();
|
||
const dotCount = (trimmedName.match(/\./g) || []).length;
|
||
|
||
if (dotCount === 0) {
|
||
return { valid: false, message: '文件名必须包含扩展名(如:.txt、.js等)' };
|
||
}
|
||
|
||
if (dotCount > 1) {
|
||
return { valid: false, message: '文件名只能包含一个扩展名后缀' };
|
||
}
|
||
|
||
// 检查是否以点开头或结尾
|
||
if (trimmedName.startsWith('.') || trimmedName.endsWith('.')) {
|
||
return { valid: false, message: '文件名格式不正确' };
|
||
}
|
||
|
||
// 检查扩展名部分是否为空
|
||
const parts = trimmedName.split('.');
|
||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||
return { valid: false, message: '文件名和扩展名都不能为空' };
|
||
}
|
||
|
||
return { valid: true, message: '' };
|
||
};
|
||
|
||
// 上传相关
|
||
const uploadRef = ref(null);
|
||
const uploadAction = ref(''); // 不使用action,改用手动上传
|
||
const uploadHeaders = ref({});
|
||
const uploadData = ref({});
|
||
|
||
// 上传进度和状态
|
||
const uploadProgress = ref(0);
|
||
const uploadingFiles = ref([]);
|
||
const uploadFileList = ref([]);
|
||
const displayFileList = ref([]); // 用于 el-upload 组件显示的文件列表
|
||
|
||
// Monaco Editor 相关配置
|
||
const monacoEditorRef = ref(null);
|
||
const editorTheme = ref('custom-dark'); // 默认使用深色主题
|
||
const editorHeight = ref(500); // 编辑器高度
|
||
const editorOptions = ref({
|
||
fontSize: 14,
|
||
minimap: { enabled: true },
|
||
wordWrap: 'on',
|
||
automaticLayout: true,
|
||
scrollBeyondLastLine: false,
|
||
renderWhitespace: 'selection',
|
||
tabSize: 2,
|
||
insertSpaces: true,
|
||
// 增强语法高亮配置
|
||
semanticHighlighting: {
|
||
enabled: true
|
||
},
|
||
colorDecorators: true,
|
||
bracketPairColorization: {
|
||
enabled: true,
|
||
independentColorPoolPerBracketType: true
|
||
},
|
||
guides: {
|
||
bracketPairs: true,
|
||
bracketPairsHorizontal: true,
|
||
highlightActiveBracketPair: true,
|
||
indentation: true,
|
||
highlightActiveIndentation: true
|
||
},
|
||
// 代码提示增强
|
||
quickSuggestions: {
|
||
other: true,
|
||
comments: false,
|
||
strings: false
|
||
},
|
||
parameterHints: {
|
||
enabled: true,
|
||
cycle: true
|
||
},
|
||
hover: {
|
||
enabled: true,
|
||
delay: 300
|
||
},
|
||
// 其他增强功能
|
||
occurrencesHighlight: true,
|
||
selectionHighlight: true,
|
||
matchBrackets: 'always',
|
||
foldingHighlight: true,
|
||
links: true
|
||
});
|
||
|
||
// 计算表格高度(基于屏幕高度)
|
||
const tableHeight = computed(() => {
|
||
return window.innerHeight - 360; // 屏幕高度减去页面头部、工具栏等空间
|
||
});
|
||
|
||
// 页面初始化
|
||
onMounted(() => {
|
||
if (containerId.value) {
|
||
initFileTree();
|
||
}
|
||
// 初始化编辑器主题
|
||
initEditorTheme();
|
||
});
|
||
|
||
// ========== 核心功能函数 ==========
|
||
|
||
// 统一的路径处理函数
|
||
// 根据需求:根目录是 '/',其他路径去掉前面的 '/'
|
||
const formatApiPath = (path, needsTrailingSlash = false) => {
|
||
if (!path || path === '/') {
|
||
return '/';
|
||
}
|
||
|
||
// 去掉前面的 '/'
|
||
let formattedPath = path.startsWith('/') ? path.substring(1) : path;
|
||
|
||
// 根据需要添加后面的 '/'
|
||
if (needsTrailingSlash && !formattedPath.endsWith('/')) {
|
||
formattedPath += '/';
|
||
}
|
||
|
||
return formattedPath;
|
||
};
|
||
|
||
// 转换API数据格式为树组件需要的格式
|
||
const convertApiDataToTreeData = (apiData, basePath = '/') => {
|
||
const treeData = [];
|
||
|
||
Object.keys(apiData).forEach(fileName => {
|
||
const fileInfo = apiData[fileName];
|
||
const filePath = basePath === '/' ? `/${fileName}` : `${basePath}/${fileName}`;
|
||
|
||
const treeNode = {
|
||
id: filePath, // 使用完整路径作为唯一ID
|
||
name: fileName,
|
||
type: fileInfo.type === 'dir' ? 'directory' : 'file',
|
||
path: filePath,
|
||
isLeaf: fileInfo.type !== 'dir',
|
||
info: fileInfo.info, // 保存完整的文件信息
|
||
children: fileInfo.type === 'dir' ? [] : undefined
|
||
};
|
||
|
||
// 如果是文件,添加大小信息
|
||
if (fileInfo.type === 'file') {
|
||
treeNode.size = fileInfo.info.st_size;
|
||
}
|
||
|
||
treeData.push(treeNode);
|
||
});
|
||
|
||
// 按类型和名称排序:目录在前,文件在后,同类型按名称排序
|
||
treeData.sort((a, b) => {
|
||
if (a.type !== b.type) {
|
||
return a.type === 'directory' ? -1 : 1;
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
return treeData;
|
||
};
|
||
|
||
// 初始化文件树
|
||
const initFileTree = async () => {
|
||
try {
|
||
console.log('初始化文件树,请求根目录: /');
|
||
|
||
const res = await getFileList({container_id: containerId.value, path: '/'});
|
||
console.log('获取文件列表', res);
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
const apiData = res.data.data.data;
|
||
|
||
// 检查是否有数据
|
||
if (!apiData || Object.keys(apiData).length === 0) {
|
||
fileTreeData.value = [];
|
||
ElMessage.info('根目录为空');
|
||
return;
|
||
}
|
||
|
||
const treeData = convertApiDataToTreeData(apiData, '/');
|
||
|
||
fileTreeData.value = treeData;
|
||
currentPath.value = '/';
|
||
updateBreadcrumb('/');
|
||
|
||
ElMessage.success(`文件树初始化成功,加载了 ${treeData.length} 个项目`);
|
||
} else {
|
||
throw new Error(res.data?.msg || '获取文件列表失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('初始化文件树出错:', error);
|
||
ElMessage.error(`初始化文件树出错: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 更新面包屑导航
|
||
const updateBreadcrumb = (path) => {
|
||
const parts = path.split('/').filter(part => part !== '');
|
||
breadcrumbPaths.value = [{ name: 'root', path: '/' }];
|
||
|
||
let currentPath = '';
|
||
parts.forEach(part => {
|
||
currentPath += `/${part}`;
|
||
breadcrumbPaths.value.push({ name: part, path: currentPath });
|
||
});
|
||
};
|
||
|
||
// 导航到指定路径
|
||
const navigateToPath = (index) => {
|
||
if (index < breadcrumbPaths.value.length - 1) {
|
||
const targetPath = breadcrumbPaths.value[index].path;
|
||
currentPath.value = targetPath;
|
||
updateBreadcrumb(targetPath);
|
||
}
|
||
};
|
||
|
||
// 懒加载树节点
|
||
const loadTreeNode = async (node, resolve) => {
|
||
try {
|
||
if (node.level === 0) {
|
||
return resolve(fileTreeData.value);
|
||
}
|
||
|
||
// 只有目录类型才能懒加载子节点
|
||
if (node.data.type !== 'directory') {
|
||
return resolve([]);
|
||
}
|
||
|
||
// 使用统一的路径处理函数
|
||
const requestPath = formatApiPath(node.data.path, true);
|
||
|
||
console.log('懒加载请求路径:', requestPath);
|
||
|
||
const res = await getFileList({
|
||
container_id: containerId.value,
|
||
path: requestPath
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
const apiData = res.data.data.data;
|
||
|
||
// 如果没有数据或者数据为空,返回空数组
|
||
if (!apiData || Object.keys(apiData).length === 0) {
|
||
resolve([]);
|
||
return;
|
||
}
|
||
|
||
const children = convertApiDataToTreeData(apiData, node.data.path);
|
||
resolve(children);
|
||
} else {
|
||
console.error('获取子目录失败:', res.data?.msg);
|
||
resolve([]);
|
||
}
|
||
} catch (error) {
|
||
console.error('加载树节点出错:', error);
|
||
ElMessage.error(`加载目录 ${node.data.name} 失败`);
|
||
resolve([]);
|
||
}
|
||
};
|
||
|
||
// 处理树节点点击
|
||
const handleTreeNodeClick = (data, node) => {
|
||
currentPath.value = data.path;
|
||
updateBreadcrumb(data.path);
|
||
|
||
if (data.type === 'file') {
|
||
openFileTab(data);
|
||
} else {
|
||
openDirectoryTab(data);
|
||
}
|
||
};
|
||
|
||
// 树节点右键菜单
|
||
const treeContextMenuVisible = ref(false);
|
||
const treeContextMenuPosition = ref({ x: 0, y: 0 });
|
||
const treeContextMenuItem = ref(null);
|
||
|
||
// 处理树节点右键菜单
|
||
const handleTreeContextMenu = (event, data, node) => {
|
||
event.preventDefault();
|
||
treeContextMenuItem.value = data;
|
||
treeContextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
||
treeContextMenuVisible.value = true;
|
||
|
||
// 点击其他地方关闭菜单
|
||
document.addEventListener('click', handleTreeContextMenuClose);
|
||
};
|
||
|
||
// 关闭树节点右键菜单
|
||
const handleTreeContextMenuClose = () => {
|
||
treeContextMenuVisible.value = false;
|
||
document.removeEventListener('click', handleTreeContextMenuClose);
|
||
};
|
||
|
||
// 处理树节点右键菜单操作
|
||
const handleTreeContextMenuAction = (action) => {
|
||
const item = treeContextMenuItem.value;
|
||
|
||
switch (action) {
|
||
case 'compress':
|
||
handleCompressFile(item);
|
||
break;
|
||
case 'decompress':
|
||
if (isArchiveFile(item.name)) {
|
||
handleDecompressFile(item);
|
||
} else {
|
||
ElMessage.warning('只能解压压缩文件');
|
||
}
|
||
break;
|
||
case 'delete':
|
||
handleDeleteFile(item);
|
||
break;
|
||
case 'download':
|
||
handleDownloadFile(item);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
handleTreeContextMenuClose();
|
||
};
|
||
|
||
|
||
|
||
// 打开文件选项卡
|
||
const openFileTab = async (fileData) => {
|
||
const existingTab = openTabs.value.find(tab => tab.key === fileData.path);
|
||
|
||
if (existingTab) {
|
||
activeTabName.value = existingTab.key;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
ElMessage.info(`正在读取文件: ${fileData.name}`);
|
||
|
||
const res = await readFile({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(fileData.path)
|
||
});
|
||
|
||
let fileContent = '';
|
||
if (res.data && res.data.code === 200) {
|
||
fileContent = res.data.data.data.content || '';
|
||
} else {
|
||
throw new Error(res.data?.data?.msg || '读取文件失败');
|
||
}
|
||
|
||
const newTab = {
|
||
key: fileData.path,
|
||
title: fileData.name,
|
||
type: 'file',
|
||
closable: true,
|
||
content: atob(fileContent),
|
||
originalContent: fileContent, // 保存原始内容用于比较是否修改
|
||
modified: false,
|
||
fileData: fileData
|
||
};
|
||
|
||
openTabs.value.push(newTab);
|
||
activeTabName.value = newTab.key;
|
||
|
||
ElMessage.success(`文件 ${fileData.name} 读取成功`);
|
||
} catch (error) {
|
||
console.error('打开文件出错:', error);
|
||
ElMessage.error(`读取文件失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// 打开目录选项卡
|
||
const openDirectoryTab = async (dirData) => {
|
||
const existingTab = openTabs.value.find(tab => tab.key === dirData.path);
|
||
|
||
if (existingTab) {
|
||
activeTabName.value = existingTab.key;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await loadDirectoryItems(dirData.path);
|
||
|
||
const newTab = {
|
||
key: dirData.path,
|
||
title: dirData.name,
|
||
type: 'directory',
|
||
closable: true,
|
||
content: '',
|
||
modified: false,
|
||
dirData: dirData
|
||
};
|
||
|
||
openTabs.value.push(newTab);
|
||
activeTabName.value = newTab.key;
|
||
} catch (error) {
|
||
console.error('打开目录出错:', error);
|
||
ElMessage.error('打开目录出错');
|
||
}
|
||
};
|
||
|
||
// 转换时间戳为可读格式
|
||
const formatTimestamp = (timestamp) => {
|
||
const date = new Date(timestamp * 1000);
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
};
|
||
|
||
// 转换文件权限为可读格式
|
||
const formatPermissions = (mode) => {
|
||
const permissions = [];
|
||
// 文件类型判断
|
||
const isDir = (mode & 0o040000) !== 0;
|
||
permissions.push(isDir ? 'd' : '-');
|
||
|
||
// 所有者权限
|
||
permissions.push((mode & 0o400) ? 'r' : '-');
|
||
permissions.push((mode & 0o200) ? 'w' : '-');
|
||
permissions.push((mode & 0o100) ? 'x' : '-');
|
||
|
||
// 组权限
|
||
permissions.push((mode & 0o040) ? 'r' : '-');
|
||
permissions.push((mode & 0o020) ? 'w' : '-');
|
||
permissions.push((mode & 0o010) ? 'x' : '-');
|
||
|
||
// 其他用户权限
|
||
permissions.push((mode & 0o004) ? 'r' : '-');
|
||
permissions.push((mode & 0o002) ? 'w' : '-');
|
||
permissions.push((mode & 0o001) ? 'x' : '-');
|
||
|
||
return permissions.join('');
|
||
};
|
||
|
||
// 加载目录项目
|
||
const loadDirectoryItems = async (path) => {
|
||
try {
|
||
// 使用统一的路径处理函数
|
||
const requestPath = formatApiPath(path, true);
|
||
|
||
console.log('加载目录项目请求路径:', requestPath);
|
||
|
||
const res = await getFileList({
|
||
container_id: containerId.value,
|
||
path: requestPath
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
const apiData = res.data.data.data;
|
||
const items = [];
|
||
|
||
Object.keys(apiData).forEach(fileName => {
|
||
const fileInfo = apiData[fileName];
|
||
const item = {
|
||
id: `${path}/${fileName}`,
|
||
name: fileName,
|
||
type: fileInfo.type === 'dir' ? 'directory' : 'file',
|
||
size: fileInfo.info.st_size || 0,
|
||
modified_time: formatTimestamp(fileInfo.info.st_mtime),
|
||
permissions: formatPermissions(fileInfo.info.st_mode),
|
||
info: fileInfo.info
|
||
};
|
||
items.push(item);
|
||
});
|
||
|
||
// 排序:目录在前,文件在后
|
||
items.sort((a, b) => {
|
||
if (a.type !== b.type) {
|
||
return a.type === 'directory' ? -1 : 1;
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
currentDirectoryItems.value = items;
|
||
} else {
|
||
throw new Error(res.data?.msg || '获取目录内容失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('加载目录项目出错:', error);
|
||
ElMessage.error(`加载目录内容失败: ${error.message || error}`);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 选项卡操作
|
||
const handleContentTabClick = (tab) => {
|
||
activeTabName.value = tab.name;
|
||
};
|
||
|
||
const handleTabClose = (targetName) => {
|
||
const tabs = openTabs.value;
|
||
let activeName = activeTabName.value;
|
||
|
||
if (activeName === targetName) {
|
||
tabs.forEach((tab, index) => {
|
||
if (tab.key === targetName) {
|
||
const nextTab = tabs[index + 1] || tabs[index - 1];
|
||
if (nextTab) {
|
||
activeName = nextTab.key;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
activeTabName.value = activeName;
|
||
openTabs.value = tabs.filter(tab => tab.key !== targetName);
|
||
};
|
||
|
||
// 标记选项卡为已修改
|
||
const markTabAsModified = (tabKey) => {
|
||
const tab = openTabs.value.find(t => t.key === tabKey);
|
||
if (tab && tab.type === 'file') {
|
||
// 检查内容是否真的发生了变化
|
||
tab.modified = tab.content !== tab.originalContent;
|
||
}
|
||
};
|
||
|
||
// 保存当前文件
|
||
const saveCurrentFile = async () => {
|
||
const currentTab = openTabs.value.find(tab => tab.key === activeTabName.value);
|
||
if (!currentTab || currentTab.type !== 'file') return;
|
||
|
||
if (!currentTab.modified) {
|
||
ElMessage.info('文件内容未发生变化');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在保存文件...');
|
||
|
||
const res = await writeFile({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(currentTab.fileData.path),
|
||
content: currentTab.content
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
currentTab.modified = false;
|
||
currentTab.originalContent = currentTab.content; // 更新原始内容
|
||
ElMessage.success('文件保存成功');
|
||
} else {
|
||
throw new Error(res.data?.msg || '保存文件失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('保存文件出错:', error);
|
||
ElMessage.error(`保存文件失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 下载当前文件
|
||
const downloadCurrentFile = async () => {
|
||
const currentTab = openTabs.value.find(tab => tab.key === activeTabName.value);
|
||
if (!currentTab || currentTab.type !== 'file') return;
|
||
|
||
try {
|
||
ElMessage.info('正在获取下载链接...');
|
||
|
||
const res = await downloadFile({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(currentTab.fileData.path)
|
||
});
|
||
console.log('下载文件', res);
|
||
if (res.data && res.data.code === 200) {
|
||
const downloadUrl = res.data.base_url;
|
||
const filename = FileName(currentTab.fileData.name);
|
||
const downloadFileUuid = res.data.data.data.downloadFileUuid
|
||
const link = `${downloadUrl}?downloadFileUuid=${downloadFileUuid}&filename=${filename}`
|
||
window.open(link, '_blank');
|
||
ElMessage.success('文件下载已开始');
|
||
} else {
|
||
throw new Error(res.data?.msg || '获取下载链接失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('下载文件出错:', error);
|
||
ElMessage.error(`下载文件失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 切换视图模式
|
||
const switchView = (mode) => {
|
||
viewMode.value = mode;
|
||
};
|
||
|
||
// 文件项目操作
|
||
const handleFileItemClick = (item) => {
|
||
console.log('点击文件项:', item.name);
|
||
};
|
||
|
||
const handleFileItemDoubleClick = (item) => {
|
||
// 构建正确的路径
|
||
let newPath;
|
||
if (currentPath.value === '/') {
|
||
newPath = `/${item.name}`;
|
||
} else {
|
||
newPath = `${currentPath.value}/${item.name}`;
|
||
}
|
||
|
||
if (item.type === 'directory') {
|
||
const dirData = { ...item, path: newPath };
|
||
openDirectoryTab(dirData);
|
||
} else {
|
||
const fileData = { ...item, path: newPath };
|
||
openFileTab(fileData);
|
||
}
|
||
};
|
||
|
||
// 右键菜单相关
|
||
const contextMenuVisible = ref(false);
|
||
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||
const contextMenuItem = ref(null);
|
||
|
||
const handleFileContextMenu = (event, item) => {
|
||
event.preventDefault();
|
||
contextMenuItem.value = item;
|
||
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
||
contextMenuVisible.value = true;
|
||
};
|
||
|
||
// 右键菜单操作
|
||
const handleContextMenuAction = (action) => {
|
||
contextMenuVisible.value = false;
|
||
const item = contextMenuItem.value;
|
||
if (!item) return;
|
||
|
||
switch (action) {
|
||
case 'download':
|
||
handleDownloadFile(item);
|
||
break;
|
||
case 'delete':
|
||
handleDeleteFile(item);
|
||
break;
|
||
case 'compress':
|
||
handleCompressFile(item);
|
||
break;
|
||
case 'decompress':
|
||
handleDecompressFile(item);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
// 下载文件
|
||
const handleDownloadFile = async (file) => {
|
||
try {
|
||
ElMessage.info(`正在准备下载: ${file.name}`);
|
||
|
||
const filePath = file.path || `${currentPath.value}/${file.name}`;
|
||
const res = await downloadFile({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(filePath)
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
const downloadUrl = res.data.base_url;
|
||
const filename = FileName(file.name);
|
||
const downloadFileUuid = res.data.data.data.downloadFileUuid;
|
||
|
||
const link = `${downloadUrl}?downloadFileUuid=${downloadFileUuid}&filename=${filename}`;
|
||
window.open(link, '_blank');
|
||
|
||
ElMessage.success('文件下载已开始');
|
||
} else {
|
||
throw new Error(res.data?.msg || '获取下载链接失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('下载文件出错:', error);
|
||
ElMessage.error(`下载文件失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 压缩文件相关数据
|
||
const showCompressDialog = ref(false);
|
||
const compressForm = reactive({
|
||
zipName: '',
|
||
fileList: []
|
||
});
|
||
|
||
// 压缩文件
|
||
const handleCompressFile = async (file) => {
|
||
console.log('压缩文件111', file);
|
||
try {
|
||
// 打开压缩对话框
|
||
compressForm.zipName = `${file.name}.zip`;
|
||
compressForm.fileList = [file.name];
|
||
showCompressDialog.value = true;
|
||
} catch (error) {
|
||
console.error('压缩文件出错:', error);
|
||
ElMessage.error(`压缩失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 执行压缩
|
||
const executeCompress = async () => {
|
||
if (!compressForm.zipName.trim()) {
|
||
ElMessage.error('请输入压缩包名称');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在压缩...');
|
||
|
||
const res = await compressFile({
|
||
container_id: containerId.value,
|
||
file_list: JSON.stringify(compressForm.fileList),
|
||
zip_name: compressForm.zipName.trim()
|
||
});
|
||
if(res.data.data.code === 500){
|
||
ElMessage.error(res.data.data.msg);
|
||
return;
|
||
}
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
ElMessage.success('压缩成功');
|
||
showCompressDialog.value = false;
|
||
compressForm.zipName = '';
|
||
compressForm.fileList = [];
|
||
refreshFileList();
|
||
} else {
|
||
throw new Error(res.data?.msg || '压缩失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('压缩文件出错:', error);
|
||
ElMessage.error(`压缩失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 解压文件相关数据
|
||
const showDecompressDialog = ref(false);
|
||
const decompressForm = reactive({
|
||
zipName: '',
|
||
outputDir: ''
|
||
});
|
||
|
||
// 解压选项
|
||
const decompressOptions = reactive({
|
||
overwrite: false
|
||
});
|
||
|
||
|
||
|
||
// 解压文件
|
||
const handleDecompressFile = async (file) => {
|
||
try {
|
||
// 验证是否为压缩文件
|
||
if (!isArchiveFile(file.name)) {
|
||
ElMessage.warning('只能解压压缩文件(.zip, .rar, .7z, .tar, .gz, .bz2, .xz)');
|
||
return;
|
||
}
|
||
|
||
// 打开解压对话框
|
||
decompressForm.zipName = file.path;
|
||
|
||
// 设置解压目录为文件所在目录
|
||
const filePath = file.path;
|
||
const lastSlashIndex = filePath.lastIndexOf('/');
|
||
const outputDir = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/';
|
||
|
||
// 根据新的路径规则设置解压目录
|
||
if (outputDir === '/') {
|
||
decompressForm.outputDir = ''; // 根目录用空字符串表示
|
||
} else {
|
||
// 去掉开头的 / 符号
|
||
decompressForm.outputDir = outputDir.startsWith('/') ? outputDir.substring(1) : outputDir;
|
||
}
|
||
|
||
// 重置解压选项
|
||
decompressOptions.overwrite = false;
|
||
|
||
showDecompressDialog.value = true;
|
||
|
||
ElMessage.info(`准备解压 ${file.name}`);
|
||
} catch (error) {
|
||
console.error('解压文件出错:', error);
|
||
ElMessage.error(`解压失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 执行解压
|
||
const executeDecompress = async () => {
|
||
// 验证解压目录格式
|
||
const outputDir = decompressForm.outputDir.trim();
|
||
|
||
// 检查路径是否以 / 开头(不允许)
|
||
if (outputDir.startsWith('/')) {
|
||
ElMessage.error('解压目录不能以 / 开头,请输入相对路径或留空表示根目录');
|
||
return;
|
||
}
|
||
|
||
// 空字符串表示根目录,这是允许的
|
||
// 非空字符串应该是相对路径,如 "home/user"
|
||
|
||
try {
|
||
ElMessage.info('正在解压...');
|
||
// 处理解压目录路径:空字符串表示根目录 "/",否则直接使用用户输入的路径
|
||
const apiOutputDir = outputDir === '' ? '/' : outputDir;
|
||
|
||
const res = await decompressFile({
|
||
container_id: containerId.value,
|
||
zip_name: formatApiPath(decompressForm.zipName),
|
||
output_dir: apiOutputDir
|
||
});
|
||
|
||
console.log('解压响应:', res);
|
||
|
||
// 处理不同的响应格式
|
||
if (res.data && res.data.code === 200) {
|
||
const displayPath = apiOutputDir === '/' ? '根目录' : apiOutputDir;
|
||
ElMessage.success(`解压成功!文件已解压到 ${displayPath}`);
|
||
closeDecompressDialog();
|
||
refreshFileList();
|
||
} else if (res.data && res.data.data && res.data.data.code === 500) {
|
||
// 处理内部错误
|
||
ElMessage.error(res.data.data.msg || '解压失败');
|
||
return;
|
||
} else {
|
||
throw new Error(res.data?.msg || res.data?.data?.msg || '解压失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('解压文件出错:', error);
|
||
ElMessage.error(`解压失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 树操作
|
||
const expandAllNodes = () => {
|
||
if (fileTreeRef.value && fileTreeRef.value.store) {
|
||
try {
|
||
// 获取所有已加载的目录节点
|
||
const allDirectoryKeys = [];
|
||
|
||
// 递归收集所有目录节点的key
|
||
const collectDirectoryKeys = (store, nodeKey = null) => {
|
||
const nodes = nodeKey ? store.getNode(nodeKey)?.childNodes || [] : store.root.childNodes;
|
||
|
||
nodes.forEach(node => {
|
||
if (node.data && node.data.type === 'directory') {
|
||
allDirectoryKeys.push(node.key);
|
||
// 如果节点已经加载了子节点,继续递归
|
||
if (node.childNodes && node.childNodes.length > 0) {
|
||
collectDirectoryKeys(store, node.key);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
collectDirectoryKeys(fileTreeRef.value.store);
|
||
|
||
// 展开所有找到的目录节点
|
||
allDirectoryKeys.forEach(key => {
|
||
const node = fileTreeRef.value.store.getNode(key);
|
||
if (node && !node.expanded) {
|
||
node.expand();
|
||
}
|
||
});
|
||
|
||
ElMessage.success(`已展开 ${allDirectoryKeys.length} 个文件夹`);
|
||
} catch (error) {
|
||
console.error('展开文件夹出错:', error);
|
||
ElMessage.error('展开文件夹失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
const collapseAllNodes = () => {
|
||
if (fileTreeRef.value && fileTreeRef.value.store) {
|
||
try {
|
||
// 获取所有已展开的目录节点
|
||
const expandedDirectoryKeys = [];
|
||
|
||
// 递归收集所有已展开的目录节点
|
||
const collectExpandedKeys = (store, nodeKey = null) => {
|
||
const nodes = nodeKey ? store.getNode(nodeKey)?.childNodes || [] : store.root.childNodes;
|
||
|
||
nodes.forEach(node => {
|
||
if (node.data && node.data.type === 'directory' && node.expanded) {
|
||
expandedDirectoryKeys.push(node.key);
|
||
// 继续递归子节点
|
||
if (node.childNodes && node.childNodes.length > 0) {
|
||
collectExpandedKeys(store, node.key);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
collectExpandedKeys(fileTreeRef.value.store);
|
||
|
||
// 收起所有已展开的目录节点
|
||
expandedDirectoryKeys.forEach(key => {
|
||
const node = fileTreeRef.value.store.getNode(key);
|
||
if (node && node.expanded) {
|
||
node.collapse();
|
||
}
|
||
});
|
||
|
||
ElMessage.success(`已收起 ${expandedDirectoryKeys.length} 个文件夹`);
|
||
} catch (error) {
|
||
console.error('收起文件夹出错:', error);
|
||
ElMessage.error('收起文件夹失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
// 在节点中创建文件/文件夹
|
||
const createFileInNode = (nodeData) => {
|
||
currentPath.value = nodeData.path;
|
||
showCreateFileDialog.value = true;
|
||
};
|
||
|
||
const createFolderInNode = (nodeData) => {
|
||
currentPath.value = nodeData.path;
|
||
showCreateFolderDialog.value = true;
|
||
};
|
||
|
||
// 创建文件对话框打开时的处理
|
||
const onCreateFileDialogOpen = () => {
|
||
// 生成默认的驼峰命名文件名
|
||
createFileForm.filename = generateCamelCaseFileName('txt');
|
||
createFileForm.content = '';
|
||
fileNameError.value = '';
|
||
};
|
||
|
||
// 验证文件名输入
|
||
const validateFileNameInput = () => {
|
||
const validation = validateFileName(createFileForm.filename);
|
||
fileNameError.value = validation.valid ? '' : validation.message;
|
||
};
|
||
|
||
// 关闭创建文件对话框
|
||
const closeCreateFileDialog = () => {
|
||
showCreateFileDialog.value = false;
|
||
createFileForm.filename = '';
|
||
createFileForm.content = '';
|
||
fileNameError.value = '';
|
||
};
|
||
|
||
// 创建文件
|
||
const handleCreateFile = async () => {
|
||
// 验证文件名
|
||
const validation = validateFileName(createFileForm.filename);
|
||
if (!validation.valid) {
|
||
fileNameError.value = validation.message;
|
||
ElMessage.error(validation.message);
|
||
return;
|
||
}
|
||
|
||
// 构建文件路径
|
||
const filePath = currentPath.value === '/'
|
||
? `/${createFileForm.filename.trim()}`
|
||
: `${currentPath.value}/${createFileForm.filename.trim()}`;
|
||
|
||
try {
|
||
ElMessage.info('正在创建文件...');
|
||
|
||
const res = await writeFile({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(filePath),
|
||
content: createFileForm.content || ''
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
ElMessage.success('文件创建成功');
|
||
closeCreateFileDialog(); // 使用统一的关闭函数
|
||
refreshFileList();
|
||
} else {
|
||
throw new Error(res.data?.msg || '创建文件失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('创建文件出错:', error);
|
||
ElMessage.error(`创建文件失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 创建文件夹
|
||
const handleCreateFolder = async () => {
|
||
if (!createFolderForm.foldername.trim()) {
|
||
ElMessage.error('请输入文件夹名');
|
||
return;
|
||
}
|
||
|
||
// 构建文件夹路径
|
||
const folderPath = currentPath.value === '/'
|
||
? `/${createFolderForm.foldername.trim()}`
|
||
: `${currentPath.value}/${createFolderForm.foldername.trim()}`;
|
||
|
||
try {
|
||
ElMessage.info('正在创建文件夹...');
|
||
|
||
const res = await createFolder({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(folderPath)
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
ElMessage.success('文件夹创建成功');
|
||
showCreateFolderDialog.value = false;
|
||
createFolderForm.foldername = '';
|
||
refreshFileList();
|
||
} else {
|
||
throw new Error(res.data?.msg || '创建文件夹失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('创建文件夹出错:', error);
|
||
ElMessage.error(`创建文件夹失败: ${error.message || error}`);
|
||
}
|
||
};
|
||
|
||
// 删除文件/文件夹
|
||
const handleDeleteFile = async (file) => {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定要删除 ${file.type === 'directory' ? '文件夹' : '文件'} "${file.name}" 吗?`,
|
||
'确认删除',
|
||
{
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
dangerouslyUseHTMLString: true,
|
||
message: `
|
||
<div style="margin-top: 10px;">
|
||
<p><strong>文件路径:</strong> ${file.path}</p>
|
||
<p style="color: #f56c6c; margin-top: 8px;"><strong>注意:</strong> 此操作不可恢复!</p>
|
||
</div>
|
||
`
|
||
}
|
||
);
|
||
|
||
ElMessage.info('正在删除...');
|
||
|
||
const res = await deleteFile({
|
||
container_id: containerId.value,
|
||
path: formatApiPath(file.path)
|
||
});
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
ElMessage.success('删除成功');
|
||
|
||
// 如果删除的文件在编辑器中打开,关闭对应的选项卡
|
||
const openTab = openTabs.value.find(tab => tab.key === file.path);
|
||
if (openTab) {
|
||
handleTabClose(openTab.key);
|
||
}
|
||
|
||
refreshFileList();
|
||
} else {
|
||
throw new Error(res.data?.msg || '删除失败');
|
||
}
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
console.error('删除文件出错:', error);
|
||
ElMessage.error(`删除失败: ${error.message || error}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 刷新文件列表
|
||
const refreshFileList = async () => {
|
||
try {
|
||
await initFileTree();
|
||
// 如果当前有打开的目录选项卡,也刷新其内容
|
||
const currentTab = openTabs.value.find(tab => tab.key === activeTabName.value);
|
||
if (currentTab && currentTab.type === 'directory') {
|
||
await loadDirectoryItems(currentTab.dirData.path);
|
||
}
|
||
ElMessage.success('文件列表刷新成功');
|
||
} catch (error) {
|
||
console.error('刷新文件列表失败:', error);
|
||
ElMessage.error('刷新文件列表失败');
|
||
}
|
||
};
|
||
|
||
// 返回上一页
|
||
const goBack = () => {
|
||
router.go(-1);
|
||
};
|
||
|
||
// 文件选择变化处理
|
||
const handleFileChange = (file, fileList) => {
|
||
console.log('handleFileChange', file, fileList);
|
||
|
||
if (file.status === 'ready') {
|
||
// 检查文件大小(限制为100MB)
|
||
const maxSize = 100 * 1024 * 1024;
|
||
if (file.size > maxSize) {
|
||
ElMessage.error('文件大小不能超过100MB');
|
||
// 从文件列表中移除这个文件
|
||
const index = fileList.findIndex(f => f.uid === file.uid);
|
||
if (index > -1) {
|
||
fileList.splice(index, 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 添加到我们的上传列表
|
||
uploadFileList.value.push(file.raw);
|
||
uploadingFiles.value.push({
|
||
name: file.name,
|
||
status: 'waiting',
|
||
percentage: 0,
|
||
file: file.raw,
|
||
uid: file.uid
|
||
});
|
||
}
|
||
|
||
// 更新显示的文件列表
|
||
displayFileList.value = fileList.filter(f => f.status === 'ready');
|
||
};
|
||
|
||
// 上传前检查(这个现在主要用于阻止自动上传)
|
||
const beforeUpload = (file) => {
|
||
console.log('beforeUpload', file);
|
||
return false; // 阻止自动上传
|
||
};
|
||
|
||
// 手动上传所有文件
|
||
const handleManualUpload = async () => {
|
||
if (uploadFileList.value.length === 0) {
|
||
ElMessage.warning('请选择要上传的文件');
|
||
return;
|
||
}
|
||
|
||
for (const file of uploadFileList.value) {
|
||
await uploadSingleFile(file);
|
||
}
|
||
|
||
// 检查是否所有文件都上传完成
|
||
const allCompleted = uploadingFiles.value.every(f => f.status === 'success' || f.status === 'error');
|
||
if (allCompleted) {
|
||
const successCount = uploadingFiles.value.filter(f => f.status === 'success').length;
|
||
const errorCount = uploadingFiles.value.filter(f => f.status === 'error').length;
|
||
|
||
if (errorCount === 0) {
|
||
ElMessage.success(`所有文件上传成功 (${successCount}/${uploadingFiles.value.length})`);
|
||
} else {
|
||
ElMessage.warning(`上传完成: 成功 ${successCount} 个,失败 ${errorCount} 个`);
|
||
}
|
||
|
||
showUploadDialog.value = false;
|
||
clearUploadList();
|
||
refreshFileList();
|
||
}
|
||
};
|
||
|
||
// 上传单个文件
|
||
const uploadSingleFile = async (file) => {
|
||
const fileItem = uploadingFiles.value.find(f => f.name === file.name);
|
||
if (!fileItem) return;
|
||
|
||
try {
|
||
fileItem.status = 'uploading';
|
||
fileItem.percentage = 0;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('container_id', containerId.value);
|
||
// 根目录是 '/',其他路径去掉前面的 '/' 并添加后面的 '/'
|
||
const uploadPath = currentPath.value === '/' ? '/' : currentPath.value.substring(1) + '/';
|
||
formData.append('path', uploadPath);
|
||
formData.append('filename', file.name);
|
||
const res = await uploadFile(formData);
|
||
|
||
if (res.data && res.data.code === 200) {
|
||
fileItem.status = 'success';
|
||
fileItem.percentage = 100;
|
||
ElMessage.success(`文件 ${file.name} 上传成功`);
|
||
} else {
|
||
throw new Error(res.data?.msg || '上传失败');
|
||
}
|
||
} catch (error) {
|
||
fileItem.status = 'error';
|
||
console.error('上传失败:', error);
|
||
ElMessage.error(`文件 ${file.name} 上传失败: ${error.message || '未知错误'}`);
|
||
}
|
||
};
|
||
|
||
// 移除文件
|
||
const handleRemoveFile = (file) => {
|
||
console.log('handleRemoveFile', file);
|
||
|
||
// 从 uploadFileList 中移除
|
||
const index = uploadFileList.value.findIndex(f => f.name === file.name);
|
||
if (index > -1) {
|
||
uploadFileList.value.splice(index, 1);
|
||
}
|
||
|
||
// 从 uploadingFiles 中移除
|
||
const fileIndex = uploadingFiles.value.findIndex(f => f.uid === file.uid || f.name === file.name);
|
||
if (fileIndex > -1) {
|
||
uploadingFiles.value.splice(fileIndex, 1);
|
||
}
|
||
|
||
// 从 displayFileList 中移除
|
||
const displayIndex = displayFileList.value.findIndex(f => f.uid === file.uid);
|
||
if (displayIndex > -1) {
|
||
displayFileList.value.splice(displayIndex, 1);
|
||
}
|
||
};
|
||
|
||
// 清空上传列表
|
||
const clearUploadList = () => {
|
||
uploadFileList.value = [];
|
||
uploadingFiles.value = [];
|
||
displayFileList.value = [];
|
||
uploadProgress.value = 0;
|
||
|
||
// 清空 el-upload 组件的文件列表
|
||
if (uploadRef.value) {
|
||
uploadRef.value.clearFiles();
|
||
}
|
||
};
|
||
|
||
// 工具函数
|
||
const formatFileSize = (bytes) => {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
};
|
||
|
||
const getFileExtension = (filename) => {
|
||
const ext = filename.split('.').pop();
|
||
return ext ? ext.toUpperCase() : 'TXT';
|
||
};
|
||
|
||
// 判断是否为压缩文件
|
||
const isArchiveFile = (filename) => {
|
||
if (!filename) return false;
|
||
const ext = filename.split('.').pop()?.toLowerCase();
|
||
const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'];
|
||
return archiveExtensions.includes(ext);
|
||
};
|
||
|
||
// 获取压缩文件类型
|
||
const getArchiveType = (filename) => {
|
||
if (!filename) return '未知';
|
||
const ext = filename.split('.').pop()?.toLowerCase();
|
||
|
||
const typeMap = {
|
||
'zip': 'ZIP 压缩文件',
|
||
'rar': 'RAR 压缩文件',
|
||
'7z': '7-Zip 压缩文件',
|
||
'tar': 'TAR 归档文件',
|
||
'gz': 'GZIP 压缩文件',
|
||
'bz2': 'BZIP2 压缩文件',
|
||
'xz': 'XZ 压缩文件'
|
||
};
|
||
|
||
return typeMap[ext] || '压缩文件';
|
||
};
|
||
|
||
// 设置当前路径为解压目录
|
||
const setCurrentPathAsOutput = () => {
|
||
// 如果当前路径是根目录,设置为空字符串
|
||
if (currentPath.value === '/') {
|
||
decompressForm.outputDir = '';
|
||
} else {
|
||
// 去掉开头的 / 符号
|
||
decompressForm.outputDir = currentPath.value.startsWith('/') ? currentPath.value.substring(1) : currentPath.value;
|
||
}
|
||
};
|
||
|
||
// 获取解压预览路径
|
||
const getDecompressPreviewPath = () => {
|
||
const outputDir = decompressForm.outputDir.trim();
|
||
if (outputDir === '') {
|
||
return '根目录 (/)';
|
||
} else if (outputDir.startsWith('/')) {
|
||
return '❌ 路径不能以 / 开头';
|
||
} else {
|
||
return outputDir;
|
||
}
|
||
};
|
||
|
||
// 关闭解压对话框
|
||
const closeDecompressDialog = () => {
|
||
showDecompressDialog.value = false;
|
||
decompressForm.zipName = '';
|
||
decompressForm.outputDir = '';
|
||
decompressOptions.overwrite = false;
|
||
};
|
||
|
||
// 根据文件名获取编程语言
|
||
const getFileLanguage = (filename) => {
|
||
if (!filename) return 'plaintext';
|
||
|
||
const ext = filename.split('.').pop()?.toLowerCase();
|
||
|
||
const languageMap = {
|
||
// Web 技术
|
||
'js': 'javascript',
|
||
'jsx': 'javascript',
|
||
'ts': 'typescript',
|
||
'tsx': 'typescript',
|
||
'html': 'html',
|
||
'htm': 'html',
|
||
'css': 'css',
|
||
'scss': 'scss',
|
||
'sass': 'sass',
|
||
'less': 'less',
|
||
'vue': 'html', // Vue SFC 使用 HTML 语法高亮
|
||
'json': 'json',
|
||
'xml': 'xml',
|
||
|
||
// 编程语言
|
||
'py': 'python',
|
||
'java': 'java',
|
||
'c': 'c',
|
||
'cpp': 'cpp',
|
||
'cxx': 'cpp',
|
||
'cc': 'cpp',
|
||
'h': 'c',
|
||
'hpp': 'cpp',
|
||
'cs': 'csharp',
|
||
'php': 'php',
|
||
'rb': 'ruby',
|
||
'go': 'go',
|
||
'rs': 'rust',
|
||
'swift': 'swift',
|
||
'kt': 'kotlin',
|
||
'scala': 'scala',
|
||
'r': 'r',
|
||
'pl': 'perl',
|
||
'lua': 'lua',
|
||
|
||
// Shell 和配置
|
||
'sh': 'shell',
|
||
'bash': 'shell',
|
||
'zsh': 'shell',
|
||
'fish': 'shell',
|
||
'ps1': 'powershell',
|
||
'bat': 'bat',
|
||
'cmd': 'bat',
|
||
'dockerfile': 'dockerfile',
|
||
'yaml': 'yaml',
|
||
'yml': 'yaml',
|
||
'toml': 'toml',
|
||
'ini': 'ini',
|
||
'conf': 'ini',
|
||
'cfg': 'ini',
|
||
|
||
// 数据格式
|
||
'sql': 'sql',
|
||
'md': 'markdown',
|
||
'markdown': 'markdown',
|
||
'tex': 'latex',
|
||
|
||
// 其他
|
||
'log': 'plaintext',
|
||
'txt': 'plaintext'
|
||
};
|
||
|
||
return languageMap[ext] || 'plaintext';
|
||
};
|
||
|
||
// 处理主题切换
|
||
const handleThemeChange = (theme) => {
|
||
editorTheme.value = theme;
|
||
// 保存主题设置到本地存储
|
||
localStorage.setItem('monaco-editor-theme', theme);
|
||
};
|
||
|
||
// 初始化时加载保存的主题
|
||
const initEditorTheme = () => {
|
||
const savedTheme = localStorage.getItem('monaco-editor-theme');
|
||
if (savedTheme) {
|
||
editorTheme.value = savedTheme;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 文件管理页面样式 */
|
||
.container-file-manager {
|
||
padding: 24px;
|
||
background-color: #f5f7fa;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 页面头部样式 */
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
background: #fff;
|
||
padding: 16px 24px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.page-header h2 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
color: #303133;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.page-header h2::before {
|
||
content: '';
|
||
display: inline-block;
|
||
width: 4px;
|
||
height: 22px;
|
||
background-color: #409EFF;
|
||
margin-right: 10px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.header-icon {
|
||
font-size: 20px;
|
||
color: #409EFF;
|
||
}
|
||
|
||
.container-info {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* 路径导航样式 */
|
||
.path-navigation {
|
||
margin-bottom: 24px;
|
||
background: #fff;
|
||
padding: 12px 20px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.path-breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.path-label {
|
||
font-size: 14px;
|
||
color: #606266;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.breadcrumb-clickable {
|
||
cursor: pointer;
|
||
color: #409EFF;
|
||
}
|
||
|
||
.breadcrumb-clickable:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.file-manager-container {
|
||
flex: 1;
|
||
display: flex;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
overflow: hidden;
|
||
height: calc(100vh - 180px); /* 固定为屏幕高度减去页面头部和导航 */
|
||
max-height: calc(100vh - 180px);
|
||
}
|
||
|
||
/* 左侧文件树 */
|
||
.file-sidebar {
|
||
width: 300px;
|
||
background: #ffffff;
|
||
border-right: 1px solid #e9ecef;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%; /* 使用父容器的完整高度 */
|
||
max-height: calc(100vh - 180px); /* 最大高度限制为屏幕高度 */
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
background: #f8f9fa;
|
||
border-bottom: 1px solid #ebeef5;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.sidebar-title {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.sidebar-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.file-tree-container {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
padding: 0;
|
||
min-height: 0; /* 允许flex子项目缩小 */
|
||
}
|
||
|
||
/* 文件树滚动条样式 */
|
||
.tree-scrollbar {
|
||
height: 100%;
|
||
padding: 8px;
|
||
}
|
||
|
||
.tree-scrollbar :deep(.el-scrollbar__view) {
|
||
height: calc(100vh - 280px); /* 屏幕高度减去页面头部等空间 */
|
||
max-height: calc(100vh - 280px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.tree-scrollbar :deep(.el-scrollbar__bar.is-vertical) {
|
||
right: 2px;
|
||
width: 6px;
|
||
}
|
||
|
||
.tree-scrollbar :deep(.el-scrollbar__thumb) {
|
||
background-color: rgba(144, 147, 153, 0.3);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.tree-scrollbar :deep(.el-scrollbar__thumb:hover) {
|
||
background-color: rgba(144, 147, 153, 0.5);
|
||
}
|
||
|
||
.file-tree {
|
||
background: transparent;
|
||
}
|
||
|
||
.file-tree .el-tree-node__content {
|
||
height: 36px;
|
||
line-height: 36px;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.file-tree .el-tree-node__content:hover {
|
||
background-color: #f8f9fa;
|
||
}
|
||
|
||
.file-tree .el-tree-node:focus > .el-tree-node__content {
|
||
background-color: #e3f2fd;
|
||
}
|
||
|
||
.tree-node-content {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.node-icon {
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.folder-icon {
|
||
color: #ffd54f;
|
||
}
|
||
|
||
.file-icon {
|
||
color: #90a4ae;
|
||
}
|
||
|
||
.node-label {
|
||
flex: 1;
|
||
font-size: 14px;
|
||
color: #495057;
|
||
}
|
||
|
||
.node-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 右侧内容区域 */
|
||
.content-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #ffffff;
|
||
height: calc(100vh - 200px); /* 固定为屏幕高度减去页面头部等空间 */
|
||
max-height: calc(100vh - 200px);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.content-tabs {
|
||
height: 100%;
|
||
}
|
||
|
||
.editor-tabs {
|
||
height: 100%;
|
||
}
|
||
|
||
.editor-tabs .el-tabs__content {
|
||
height: calc(100% - 40px);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.editor-tabs .el-tab-pane {
|
||
height: 100%;
|
||
}
|
||
|
||
/* 文件编辑器 */
|
||
.file-editor {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.editor-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: #f8f9fa;
|
||
border-bottom: 1px solid #e9ecef;
|
||
}
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.file-info {
|
||
font-weight: 600;
|
||
color: #495057;
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.editor-content {
|
||
flex: 1;
|
||
padding: 0;
|
||
height: calc(100% - 60px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* height: 600px; */
|
||
}
|
||
|
||
.editor-content :deep(.monaco-editor-container) {
|
||
flex: 1;
|
||
border: none;
|
||
border-radius: 0;
|
||
}
|
||
|
||
.code-editor {
|
||
height: 100%;
|
||
}
|
||
|
||
.code-editor .el-textarea__inner {
|
||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
border: none;
|
||
border-radius: 0;
|
||
resize: none;
|
||
background: #ffffff;
|
||
color: #212529;
|
||
}
|
||
|
||
.code-editor .el-textarea__inner:focus {
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* 目录视图 */
|
||
.directory-view {
|
||
height: calc(100vh - 240px); /* 基于屏幕高度 */
|
||
max-height: calc(100vh - 240px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.directory-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
background: #f8f9fa;
|
||
border-bottom: 1px solid #e9ecef;
|
||
}
|
||
|
||
.directory-header h3 {
|
||
margin: 0;
|
||
color: #495057;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.view-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* 网格视图 */
|
||
.grid-view {
|
||
padding: 20px;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||
gap: 16px;
|
||
height: calc(100vh - 320px); /* 基于屏幕高度 */
|
||
max-height: calc(100vh - 320px);
|
||
overflow-y: auto;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.file-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 16px 8px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 2px solid transparent;
|
||
}
|
||
|
||
.file-item:hover {
|
||
background: #f8f9fa;
|
||
border-color: #dee2e6;
|
||
}
|
||
|
||
.file-item:active {
|
||
background: #e9ecef;
|
||
}
|
||
|
||
.file-icon-large {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.file-name {
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: #495057;
|
||
word-break: break-all;
|
||
line-height: 1.3;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
|
||
|
||
.file-details {
|
||
font-size: 11px;
|
||
color: #6c757d;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 列表视图 */
|
||
.list-view {
|
||
padding: 0 20px 20px;
|
||
height: calc(100vh - 320px); /* 基于屏幕高度 */
|
||
max-height: calc(100vh - 320px);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.list-view .directory-table {
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.directory-table .el-table__row {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.directory-table .el-table__row:hover {
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
/* 欢迎页 */
|
||
.welcome-view {
|
||
height: calc(100vh - 240px);
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.welcome-container {
|
||
width: 100%;
|
||
max-width: 900px;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||
padding: 40px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.welcome-header {
|
||
text-align: center;
|
||
margin-bottom: 40px;
|
||
padding-bottom: 30px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.welcome-icon {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.welcome-title {
|
||
margin: 0 0 16px;
|
||
color: #303133;
|
||
font-size: 32px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.5px;
|
||
}
|
||
|
||
.welcome-subtitle {
|
||
color: #606266;
|
||
font-size: 16px;
|
||
margin: 0;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.welcome-features {
|
||
margin-bottom: 40px
|
||
}
|
||
|
||
.feature-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.feature-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
padding: 20px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
border: 1px solid #e9ecef;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.feature-item:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
border-color: #409EFF;
|
||
}
|
||
|
||
.feature-item span {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
margin: 8px 0 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.feature-item small {
|
||
color: #909399;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.quick-actions {
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.actions-title {
|
||
margin: 0 0 20px;
|
||
color: #303133;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.actions-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.action-card {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 20px;
|
||
background: #ffffff;
|
||
border: 2px solid #e4e7ed;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.action-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.action-card.primary:hover {
|
||
border-color: #409EFF;
|
||
background: linear-gradient(135deg, #ecf5ff 0%, #ffffff 100%);
|
||
}
|
||
|
||
.action-card.success:hover {
|
||
border-color: #67C23A;
|
||
background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%);
|
||
}
|
||
|
||
.action-card.warning:hover {
|
||
border-color: #E6A23C;
|
||
background: linear-gradient(135deg, #fdf6ec 0%, #ffffff 100%);
|
||
}
|
||
|
||
.action-icon {
|
||
margin-right: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 8px;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.action-card.primary .action-icon {
|
||
color: #409EFF;
|
||
background: #ecf5ff;
|
||
}
|
||
|
||
.action-card.success .action-icon {
|
||
color: #67C23A;
|
||
background: #f0f9ff;
|
||
}
|
||
|
||
.action-card.warning .action-icon {
|
||
color: #E6A23C;
|
||
background: #fdf6ec;
|
||
}
|
||
|
||
.action-text {
|
||
flex: 1;
|
||
}
|
||
|
||
.action-name {
|
||
display: block;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
font-size: 16px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.action-desc {
|
||
color: #909399;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.welcome-tips {
|
||
margin-top: 30px;
|
||
}
|
||
|
||
.welcome-tips :deep(.el-alert) {
|
||
border-radius: 8px;
|
||
background: #f0f9ff;
|
||
border-color: #b3d8ff;
|
||
}
|
||
|
||
.tips-list {
|
||
margin: 0;
|
||
padding-left: 20px;
|
||
color: #606266;
|
||
}
|
||
|
||
.tips-list li {
|
||
margin-bottom: 8px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.tips-list li:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.breadcrumb-clickable {
|
||
cursor: pointer;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.breadcrumb-clickable:hover {
|
||
color: white;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
.el-upload__text em {
|
||
color: #409EFF;
|
||
}
|
||
|
||
/* 上传相关样式 */
|
||
.upload-container {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.upload-path-info {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.upload-area {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.upload-actions {
|
||
margin-bottom: 20px;
|
||
padding: 12px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
}
|
||
|
||
.upload-progress {
|
||
margin-top: 20px;
|
||
padding: 16px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.upload-progress h4 {
|
||
margin: 0 0 16px 0;
|
||
color: #303133;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.file-progress {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.file-progress:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.file-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.file-name {
|
||
color: #303133;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.file-status {
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.file-status.waiting {
|
||
background: #f4f4f5;
|
||
color: #909399;
|
||
}
|
||
|
||
.file-status.uploading {
|
||
background: #e6f7ff;
|
||
color: #1890ff;
|
||
}
|
||
|
||
.file-status.success {
|
||
background: #f6ffed;
|
||
color: #52c41a;
|
||
}
|
||
|
||
.file-status.error {
|
||
background: #fff1f0;
|
||
color: #ff4d4f;
|
||
}
|
||
|
||
/* 右键菜单样式 */
|
||
.context-menu-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 2000;
|
||
}
|
||
|
||
.context-menu {
|
||
position: fixed;
|
||
background: #ffffff;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 6px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||
padding: 6px 0;
|
||
min-width: 120px;
|
||
z-index: 2001;
|
||
}
|
||
|
||
.context-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 16px;
|
||
cursor: pointer;
|
||
transition: background-color 0.3s;
|
||
color: #606266;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.context-menu-item:hover {
|
||
background-color: #f5f7fa;
|
||
color: #409EFF;
|
||
}
|
||
|
||
.context-menu-item.danger {
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.context-menu-item.danger:hover {
|
||
background-color: #fef0f0;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.context-menu-item .el-icon {
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.context-menu-divider {
|
||
height: 1px;
|
||
background-color: #ebeef5;
|
||
margin: 6px 0;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media screen and (max-width: 1200px) {
|
||
.file-sidebar {
|
||
width: 250px;
|
||
}
|
||
}
|
||
|
||
@media screen and (max-width: 768px) {
|
||
.container-file-manager {
|
||
padding: 16px;
|
||
}
|
||
|
||
.page-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.header-left {
|
||
width: 100%;
|
||
}
|
||
|
||
.header-actions {
|
||
width: 100%;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.header-actions .el-button {
|
||
flex: 1;
|
||
min-width: auto;
|
||
}
|
||
|
||
.path-navigation {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.path-breadcrumb {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.file-manager-container {
|
||
flex-direction: column;
|
||
height: calc(100vh - 150px); /* 移动端调整高度 */
|
||
}
|
||
|
||
.file-sidebar {
|
||
width: 100%;
|
||
height: 250px; /* 固定高度,不随内容增长 */
|
||
max-height: 100vh; /* 移动端最大高度限制为一倍屏幕高度 */
|
||
border-right: none;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.tree-scrollbar {
|
||
padding: 4px;
|
||
}
|
||
|
||
.tree-scrollbar :deep(.el-scrollbar__view) {
|
||
max-height: calc(100vh - 100px); /* 移动端限制为一倍屏幕高度 */
|
||
}
|
||
|
||
.content-area {
|
||
height: calc(100vh - 400px);
|
||
min-height: 300px;
|
||
flex: 1;
|
||
}
|
||
|
||
.grid-view {
|
||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||
gap: 12px;
|
||
padding: 16px;
|
||
}
|
||
}
|
||
|
||
/* 标签页样式 */
|
||
.modified-indicator {
|
||
color: #F56C6C;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
/* 文件名验证样式 */
|
||
.error-message {
|
||
color: #f56c6c;
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.file-name-hint {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.file-name-hint small {
|
||
color: #909399;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* 解压对话框样式 */
|
||
.decompress-container {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.decompress-info {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.decompress-info :deep(.el-alert__content) p {
|
||
margin: 4px 0;
|
||
color: #606266;
|
||
}
|
||
|
||
.form-hint {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.form-hint small {
|
||
color: #909399;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.decompress-preview {
|
||
margin-top: 20px;
|
||
padding: 16px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
border: 1px solid #e9ecef;
|
||
}
|
||
|
||
.decompress-preview :deep(.el-alert) {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.decompress-preview :deep(.el-alert__title) {
|
||
color: #67C23A;
|
||
font-weight: 500;
|
||
}
|
||
|
||
|
||
|
||
|
||
</style>
|