Files
ApiServer-Web-admin_dashboa…/src/views/acs/nodes/containFile.vue
T
2025-09-11 22:57:54 +08:00

3054 lines
81 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="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>