云文件管理系统上传组件优化

This commit is contained in:
2026-04-02 12:06:51 +08:00
parent 61a675b4de
commit 3710e98eb3
34 changed files with 1102 additions and 306 deletions

View File

@@ -886,6 +886,7 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1421,13 +1422,15 @@
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -1634,6 +1637,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1693,6 +1697,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
"integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.31",
"@vue/compiler-sfc": "3.5.31",

View File

@@ -1,4 +1,4 @@
<template>
<template>
<router-view />
</template>

View File

@@ -1,4 +1,4 @@
import request from './request'
import request from './request'
export const login = (data) => request.post('/auth/login', data)

View File

@@ -1,4 +1,4 @@
import request from './request'
import request from './request'
export const getFiles = (params) => request.get('/files', { params })
export const getTrashFiles = (params) => request.get('/files/trashFiles', { params })
@@ -25,6 +25,19 @@ export const shareFileApi = (id, data) => request.post(`/files/${id}/shareFile`,
export const cancelShare = (id) => request.delete(`/files/${id}/cancelShare`)
export const renameFile = (id, name) => request.put(`/files/${id}/rename`, { name })
export const moveFile = (id, folderId) => request.put(`/files/${id}/move`, { folderId })
export const batchDownload = (ids) => request.post('/files/batchDownload', { ids }, { responseType: 'blob' })
export const batchDownload = (ids) => {
return request.post('/files/batchDownload', { ids }, {
responseType: 'blob',
validateStatus: (status) => status < 500 // 允许 4xx 错误也返回
}).catch(error => {
// 如果是错误响应,将 blob 包装后抛出
if (error.response && error.response.data instanceof Blob) {
return Promise.reject(error)
}
return Promise.reject(error)
})
}
export const getMovableFolders = (excludeIds, currentFolderId) => request.get('/files/movableFolders', { params: { excludeIds, currentFolderId } })
export const getFilePreview = (id) => request.get(`/files/${id}/preview`, { responseType: 'blob' })
export const batchCancelShare = (ids) => request.post('/files/batchCancelShare', { ids })
export const batchRestore = (ids) => request.post('/files/batchRestore', { ids })

View File

@@ -1,4 +1,4 @@
import request from './request'
import request from './request'
export const getMessages = (params) => request.get('/messages', { params })

View File

@@ -1,4 +1,4 @@
import axios from 'axios'
import axios from 'axios'
const request = axios.create({
baseURL: '/api',

View File

@@ -1,3 +1,3 @@
import request from './request'
import request from './request'
export const getUsers = () => request.get('/users')

View File

@@ -1,24 +1,24 @@
<template>
<template>
<el-dialog
v-model="visible"
title="批量移动"
width="400px"
width="500px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="目标文件夹">
<el-select v-model="targetFolderId" placeholder="请选择目标文件夹" style="width: 100%" clearable>
<el-option :label="parentFolderName" :value="'parent'" v-if="canGoParent" />
<el-option label="根目录" :value="'root'" />
<el-option
v-for="folder in folders"
:key="folder.id"
:label="folder.name"
:value="folder.id"
/>
</el-select>
</el-form-item>
</el-form>
<div class="form-item">
<span class="label">目标目录</span>
<el-tree-select
v-model="targetFolderId"
:data="folderTreeData"
:props="treeProps"
placeholder="请选择目标目录"
clearable
check-strictly
:render-after-expand="false"
style="flex: 1"
node-key="id"
/>
</div>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>
@@ -33,7 +33,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { Close, FolderOpened } from '@element-plus/icons-vue'
const props = defineProps({
@@ -50,18 +50,77 @@ const visible = computed({
set: (val) => emit('update:modelValue', val)
})
const targetFolderId = ref('')
const targetFolderId = ref(null)
// 树形配置
const treeProps = {
label: 'name',
children: 'children',
value: 'id'
}
// 转换目录数据为树形结构
const folderTreeData = computed(() => {
// 根节点
const rootNodes = [
{
id: 'root',
name: '根目录',
children: []
}
]
// 递归转换后端返回的树形数据
const convertFolderTree = (folders) => {
if (!folders || folders.length === 0) return []
return folders.map(folder => ({
id: folder.id,
name: folder.name,
children: folder.children && folder.children.length > 0
? convertFolderTree(folder.children)
: undefined
}))
}
// 将后端返回的目录树挂载到根目录下
rootNodes[0].children = convertFolderTree(props.folders)
return rootNodes
})
const handleConfirm = () => {
emit('confirm', targetFolderId.value)
visible.value = false
targetFolderId.value = ''
targetFolderId.value = null
}
const open = () => {
targetFolderId.value = ''
targetFolderId.value = null
visible.value = true
}
// 重置选中状态
watch(visible, (val) => {
if (!val) {
targetFolderId.value = null
}
})
defineExpose({ open })
</script>
<style scoped>
.form-item {
display: flex;
align-items: center;
gap: 12px;
}
.label {
flex-shrink: 0;
width: 80px;
font-size: 14px;
color: #606266;
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-dialog
v-model="visible"
title="消息"

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="sidebar">
<el-menu
:default-active="activeMenu"
@@ -6,7 +6,7 @@
@select="handleSelect"
>
<el-menu-item index="my-files">
<el-icon><Document /></el-icon>
<el-icon><FolderOpened /></el-icon>
<span>我的文档</span>
</el-menu-item>
<el-menu-item index="my-share">
@@ -33,7 +33,7 @@
<script setup>
import { ref, defineEmits, defineProps } from 'vue'
import { Document, Share, User, Delete } from '@element-plus/icons-vue'
import { FolderOpened, Share, User, Delete } from '@element-plus/icons-vue'
const props = defineProps({
activeMenu: { type: String, default: 'my-files' },

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="file-table">
<el-table
:data="files"
@@ -8,14 +8,12 @@
@selection-change="$emit('selection-change', $event)"
@row-dblclick="$emit('row-dblclick', $event)"
>
<el-table-column type="selection" width="50" />
<el-table-column type="selection" width="50" :selectable="checkSelectable" />
<el-table-column label="文件名" min-width="200">
<template #default="{ row }">
<div class="file-name-cell">
<el-icon :size="20" :color="getFileIconColor(row)">
<component :is="getFileIcon(row)" />
</el-icon>
<FileIcon :icon="getFileIconType(row)" :color="getFileIconColor(row)" :size="24" />
<span class="file-name">{{ row.name }}</span>
</div>
</template>
@@ -50,32 +48,24 @@
<el-icon><View /></el-icon>
<span style="margin-left: 2px">预览</span>
</el-button>
<el-button link @click="$emit('download', row)" v-else-if="row.type !== 'folder'">
<el-icon><Download /></el-icon>
<span style="margin-left: 2px">下载</span>
</el-button>
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, row)">
<el-button link>
<span>更多</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename">
<el-icon><Edit /></el-icon>
<span>重命名</span>
</el-dropdown-item>
<el-dropdown-item command="share">
<el-icon><Share /></el-icon>
<span>共享</span>
</el-dropdown-item>
<el-dropdown-item command="download" v-if="row.type !== 'folder' && canPreview(row)">
<el-dropdown-item command="rename">
<el-icon><Edit /></el-icon>
<span>重命名</span>
</el-dropdown-item>
<el-dropdown-item command="download" v-if="row.type !== 'folder'">
<el-icon><Download /></el-icon>
<span>下载</span>
</el-dropdown-item>
<el-dropdown-item command="preview" v-if="!canPreview(row) && row.type !== 'folder'">
<el-icon><View /></el-icon>
<span>预览</span>
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>
<span style="color: #f56c6c">删除</span>
@@ -115,7 +105,11 @@
</template>
<script setup>
import { Document, Folder, Picture, VideoPlay, Headset, RefreshLeft, Delete, View, Share, Download, CloseBold, Edit } from '@element-plus/icons-vue'
import {
Document, Folder, Picture, VideoPlay, Headset, RefreshLeft, Delete, View, Share, Download, CloseBold, Edit,
Tickets, Notebook, Reading, Files, DocumentCopy
} from '@element-plus/icons-vue'
import FileIcon from './FileIcon.vue'
const props = defineProps({
files: { type: Array, default: () => [] },
@@ -126,13 +120,25 @@ const props = defineProps({
const emit = defineEmits(['selection-change', 'row-dblclick', 'preview', 'share', 'download', 'delete', 'restore', 'delete-permanent', 'cancel-share', 'rename'])
// 判断行是否可勾选
const checkSelectable = (row) => {
// 共享给我的页面,目录不可勾选
if (props.menuType === 'shared-to-me' && row.type === 'folder') {
return false
}
return true
}
const handleCommand = (command, row) => {
switch (command) {
case 'share':
emit('share', row)
break
case 'rename':
emit('rename', row)
break
case 'share':
emit('share', row)
case 'download':
emit('download', row)
break
case 'delete':
emit('delete', row)
@@ -140,19 +146,84 @@ const handleCommand = (command, row) => {
}
}
const getFileIcon = (file) => {
if (file.type === 'folder') return Folder
if (file.type === 'image') return Picture
if (file.type === 'video') return VideoPlay
if (file.type === 'audio') return Headset
return Document
// 获取文件图标类型
const getFileIconType = (file) => {
if (file.type === 'folder') return 'folder'
if (file.type === 'image') return 'image'
if (file.type === 'video') return 'video'
if (file.type === 'audio') return 'audio'
const ext = file.name.split('.').pop()?.toLowerCase()
// PDF
if (ext === 'pdf') return 'pdf'
// Word 文档
if (['docx', 'doc'].includes(ext)) return 'word'
// Excel 表格
if (['xlsx', 'xls'].includes(ext)) return 'excel'
// PPT 演示文稿
if (['pptx', 'ppt'].includes(ext)) return 'ppt'
// 代码文件
if (['js', 'ts', 'vue', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml'].includes(ext)) return 'code'
// 文本文件
if (['txt', 'md', 'markdown', 'log', 'sql', 'yaml', 'yml', 'sh', 'bat', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(ext)) return 'text'
// OFD
if (ext === 'ofd') return 'pdf'
// 压缩文件
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'zip'
return 'document'
}
// 根据文件扩展名获取图标颜色
const getFileIconColor = (file) => {
if (file.type === 'folder') return '#f7b32b'
if (file.type === 'image') return '#67c23a'
if (file.type === 'video') return '#409eff'
if (file.type === 'audio') return '#e6a23c'
const ext = file.name.split('.').pop()?.toLowerCase()
// PDF - 红色
if (ext === 'pdf') return '#f56c6c'
// Word - 蓝色
if (['docx', 'doc'].includes(ext)) return '#2b579a'
// Excel - 绿色
if (['xlsx', 'xls'].includes(ext)) return '#217346'
// PPT - 橙红色
if (['pptx', 'ppt'].includes(ext)) return '#d24726'
// 代码文件 - 紫色
if (['js', 'ts', 'vue', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml'].includes(ext)) return '#9c27b0'
// 文本文件 - 灰色
if (['txt', 'md', 'markdown', 'log', 'yaml', 'yml', 'sh', 'bat'].includes(ext)) return '#606266'
// SQL - 深蓝色
if (ext === 'sql') return '#1976d2'
// Python - 蓝黄色
if (ext === 'py') return '#3776ab'
// Java - 红棕色
if (ext === 'java') return '#f89820'
// OFD - 红色
if (ext === 'ofd') return '#f56c6c'
// 压缩文件 - 棕色
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return '#8d6e63'
return '#909399'
}
@@ -175,7 +246,11 @@ const formatDate = (date) => {
const canPreview = (file) => {
if (file.type === 'folder') return false
const ext = file.name.split('.').pop()?.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'md', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html', 'docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt', 'ofd'].includes(ext)
// 支持预览的格式注意doc 旧格式不支持)
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
'pdf',
'txt', 'md', 'markdown', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html', 'htm', 'yaml', 'yml', 'sql', 'sh', 'bat',
'docx', 'xlsx', 'xls', 'pptx', 'ppt', 'ofd'].includes(ext)
}
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="file-toolbar">
<!-- 第一行视图切换 + 搜索框 + 上传/新建 -->
<div class="toolbar-top">
@@ -29,7 +29,7 @@
</el-button>
<el-button @click="$emit('newFolder')">
<el-icon><FolderAdd /></el-icon>
<span style="margin-left: 4px">新建文件夹</span>
<span style="margin-left: 4px">新建目录</span>
</el-button>
</el-button-group>
@@ -39,28 +39,40 @@
</el-button>
</div>
<!-- 第二行前进后退 + 路径 -->
<div class="toolbar-bottom">
<div class="path-controls">
<el-button v-if="showBack" @click="$emit('goBack')" circle title="返回上级">
<el-icon><Back /></el-icon>
</el-button>
<span class="current-path">{{ currentPath }}</span>
</div>
<!-- 第二行面包屑导航 -->
<div class="toolbar-bottom" v-if="menuType !== 'trash'">
<el-icon class="breadcrumb-icon"><Folder /></el-icon>
<el-breadcrumb separator="/" v-if="folderStack.length > 0">
<el-breadcrumb-item>
<span class="breadcrumb-link" @click="$emit('goBack', -1)">根目录</span>
</el-breadcrumb-item>
<el-breadcrumb-item
v-for="(folder, index) in folderStack"
:key="index"
>
<span
class="breadcrumb-link"
:class="{ 'is-last': index === folderStack.length - 1 }"
@click="handleBreadcrumbClick(index)"
>{{ folder.name }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
<span v-else class="current-path">根目录</span>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { List, Grid, Upload, FolderAdd, Delete, Back, Search } from '@element-plus/icons-vue'
import { List, Grid, Upload, FolderAdd, Delete, Search, Folder } from '@element-plus/icons-vue'
const props = defineProps({
viewMode: { type: String, default: 'table' },
searchKeyword: { type: String, default: '' },
menuType: { type: String, default: 'my-files' },
currentPath: { type: String, default: '/' },
showBack: { type: Boolean, default: false }
showBack: { type: Boolean, default: false },
folderStack: { type: Array, default: () => [] }
})
const emit = defineEmits([
@@ -97,6 +109,12 @@ const onSearchInput = (val) => {
const doSearch = () => {
emit('search')
}
const handleBreadcrumbClick = (index) => {
// 点击当前项不跳转
if (index === props.folderStack.length - 1) return
emit('goBack', index)
}
</script>
<style scoped>
@@ -105,7 +123,6 @@ const doSearch = () => {
flex-direction: column;
background: #fff;
border-bottom: 1px solid #e4e7ed;
gap: 12px;
padding: 12px 16px;
}
@@ -122,17 +139,56 @@ const doSearch = () => {
.toolbar-bottom {
display: flex;
align-items: center;
min-height: 32px;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid #ebeef5;
}
.path-controls {
display: flex;
align-items: center;
gap: 8px;
.breadcrumb-icon {
margin-right: 8px;
color: #f7b32b;
font-size: 18px;
}
.current-path {
font-size: 13px;
font-size: 14px;
color: #606266;
word-break: break-all;
}
.breadcrumb-link {
cursor: pointer;
color: #409eff;
}
.breadcrumb-link:hover {
color: #66b1ff;
}
.breadcrumb-link.is-last {
color: #303133;
cursor: default;
font-weight: 500;
}
:deep(.el-breadcrumb) {
display: flex;
align-items: center;
}
:deep(.el-breadcrumb__item) {
display: flex;
align-items: center;
}
:deep(.el-breadcrumb__inner) {
color: #606266;
display: flex;
align-items: center;
}
:deep(.el-breadcrumb__separator) {
display: flex;
align-items: center;
}
</style>

View File

@@ -1,13 +1,13 @@
<template>
<template>
<el-dialog
v-model="visible"
title="新建文件夹"
title="新建目录"
width="400px"
class="custom-dialog"
>
<el-input
v-model="folderName"
placeholder="请输入文件夹名称"
placeholder="请输入目录名称"
clearable
@keyup.enter="handleCreate"
autofocus

View File

@@ -1,36 +1,52 @@
<template>
<template>
<el-dialog
v-model="visible"
:title="previewFile?.name || '预览'"
width="80%"
width="60%"
top="5vh"
class="custom-dialog preview-dialog"
class="preview-dialog-center"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="preview-content">
<img v-if="isImage" :src="previewUrl" class="preview-image" />
<iframe v-else-if="isPdf" :src="previewUrl" class="preview-iframe" />
<pre v-else-if="isText" class="preview-text">{{ previewContent }}</pre>
<div v-else-if="isOffice" ref="officeViewer" class="preview-office">
<div v-if="!jitViewerLoaded" class="preview-loading">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在加载预览组件...</p>
</div>
<div class="preview-container">
<!-- 加载中 -->
<div v-if="loading" class="preview-loading">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在加载预览...</p>
</div>
<div v-else class="preview-unsupported">
<el-icon :size="64"><Document /></el-icon>
<p>此文件类型暂不支持预览</p>
<el-button type="primary" @click="$emit('download', previewFile)">
<!-- 错误 -->
<div v-else-if="error" class="preview-error">
<el-icon :size="48"><Warning /></el-icon>
<p>{{ error }}</p>
<el-button type="primary" @click="handleDownload">
<el-icon><Download /></el-icon>
<span style="margin-left: 4px">下载文件</span>
</el-button>
</div>
<!-- 不支持的 Office 格式 -->
<div v-else-if="isUnsupportedFormat" class="preview-unsupported">
<el-icon :size="64"><Document /></el-icon>
<p>此文件格式暂不支持在线预览</p>
<p class="tip" v-if="previewFile?.name?.endsWith('.doc')">建议将文件转换为 .docx 格式后上传</p>
<el-button type="primary" @click="handleDownload">
<el-icon><Download /></el-icon>
<span style="margin-left: 4px">下载文件</span>
</el-button>
</div>
<!-- jit-viewer 容器 (图片PDFOffice文本都用) -->
<div v-show="!loading && !error && !isUnsupportedFormat" ref="viewerContainer" class="viewer-container"></div>
</div>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import { Document, Download, Loading } from '@element-plus/icons-vue'
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { Document, Download, Loading, Warning } from '@element-plus/icons-vue'
import { createViewer } from 'jit-viewer'
import 'jit-viewer/style.css'
const props = defineProps({
modelValue: Boolean,
@@ -46,139 +62,244 @@ const visible = computed({
set: (v) => emit('update:modelValue', v)
})
const officeViewer = ref(null)
const jitViewerLoaded = ref(false)
const viewerContainer = ref(null)
const loading = ref(false)
const error = ref('')
let viewerInstance = null
const isImage = computed(() => {
// 不支持的格式
const isUnsupportedFormat = computed(() => {
if (!props.previewFile) return false
const ext = props.previewFile.name.split('.').pop()?.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)
return ['doc'].includes(ext)
})
const isPdf = computed(() => {
if (!props.previewFile) return false
return props.previewFile.name.toLowerCase().endsWith('.pdf')
})
// 获取文件类型
const getFileType = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase()
const typeMap = {
'docx': 'docx',
'xlsx': 'xlsx',
'xls': 'xls',
'pptx': 'pptx',
'ppt': 'ppt',
'ofd': 'ofd',
'pdf': 'pdf',
'txt': 'txt',
'md': 'md',
'markdown': 'markdown',
'json': 'txt',
'xml': 'txt',
'log': 'txt',
'js': 'txt',
'ts': 'txt',
'vue': 'txt',
'java': 'txt',
'py': 'txt',
'css': 'txt',
'html': 'html',
'htm': 'html',
'yaml': 'txt',
'yml': 'txt',
'sql': 'txt',
'sh': 'txt',
'bat': 'txt',
'jpg': 'image',
'jpeg': 'image',
'png': 'image',
'gif': 'image',
'webp': 'image',
'svg': 'image',
'bmp': 'image',
'ico': 'image'
}
return typeMap[ext] || ext
}
const isText = computed(() => {
if (!props.previewFile) return false
const ext = props.previewFile.name.split('.').pop()?.toLowerCase()
return ['txt', 'md', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html'].includes(ext)
})
// 获取文件源
const getFileSource = () => {
// 如果有 URL用 URL
if (props.previewUrl) {
return props.previewUrl
}
// 如果有文本内容,返回文本
if (props.previewContent) {
return new Blob([props.previewContent], { type: 'text/plain' })
}
return null
}
const isOffice = computed(() => {
if (!props.previewFile) return false
const ext = props.previewFile.name.split('.').pop()?.toLowerCase()
return ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt', 'ofd'].includes(ext)
})
// 加载 jit-viewer 脚本
const loadJitViewer = () => {
if (window.jitView) {
jitViewerLoaded.value = true
// 初始化 jit-viewer
const initViewer = async () => {
if (!viewerContainer.value) return
const fileSource = getFileSource()
if (!fileSource && !isUnsupportedFormat.value) {
error.value = '无法加载文件'
return
}
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/jit-viewer@latest/dist/jit-viewer.min.js'
script.onload = () => {
jitViewerLoaded.value = true
initOfficeViewer()
}
script.onerror = () => {
jitViewerLoaded.value = false
}
document.head.appendChild(script)
}
// 初始化 Office 预览
const initOfficeViewer = async () => {
if (!officeViewer.value || !props.previewUrl) return
loading.value = true
error.value = ''
await nextTick()
// 使用 jit-viewer 预览
if (window.jitView) {
officeViewer.value.innerHTML = ''
const viewer = document.createElement('div')
viewer.style.width = '100%'
viewer.style.height = '70vh'
officeViewer.value.appendChild(viewer)
try {
window.jitView(viewer, {
url: props.previewUrl,
fileType: props.previewFile?.name.split('.').pop()?.toLowerCase()
})
} catch (e) {
officeViewer.value.innerHTML = '<div class="preview-error">预览加载失败,请下载后查看</div>'
try {
// 销毁旧实例
if (viewerInstance) {
viewerInstance.destroy()
viewerInstance = null
}
// 清空容器
viewerContainer.value.innerHTML = ''
// 创建新实例
viewerInstance = createViewer({
target: viewerContainer.value,
file: fileSource,
filename: props.previewFile?.name,
type: getFileType(props.previewFile?.name || ''),
toolbar: true,
theme: 'light',
locale: 'zh-CN',
width: '100%',
height: '100%',
pdfRender: 'inset',
onError: (err) => {
console.error('jit-viewer error:', err)
error.value = '预览加载失败,请尝试下载后查看'
loading.value = false
},
onLoad: () => {
loading.value = false
},
onReady: () => {
loading.value = false
}
})
viewerInstance.mount()
} catch (e) {
console.error('Failed to init jit-viewer:', e)
error.value = '预览组件加载失败,请尝试下载后查看'
loading.value = false
}
}
// 监听弹窗显示和文件变化
// 销毁 viewer 实例
const destroyViewer = () => {
if (viewerInstance) {
try {
viewerInstance.destroy()
} catch (e) {
console.error('Failed to destroy viewer:', e)
}
viewerInstance = null
}
}
// 处理下载
const handleDownload = () => {
emit('download', props.previewFile)
}
// 关闭弹窗时清理
const handleClose = () => {
destroyViewer()
loading.value = false
error.value = ''
}
// 监听弹窗显示
watch(() => props.modelValue, (val) => {
if (val && isOffice.value) {
jitViewerLoaded.value = false
loadJitViewer()
if (val) {
loading.value = true
nextTick(() => {
initViewer()
})
} else {
destroyViewer()
}
})
watch(() => props.previewUrl, (val) => {
if (val && isOffice.value && props.modelValue) {
initOfficeViewer()
// 监听文件变化
watch([() => props.previewUrl, () => props.previewContent], () => {
if (props.modelValue) {
nextTick(() => {
initViewer()
})
}
})
// 组件卸载时清理
onBeforeUnmount(() => {
destroyViewer()
})
</script>
<style scoped>
.preview-content {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
.preview-dialog-center {
:deep(.el-dialog) {
display: flex;
flex-direction: column;
max-height: 90vh;
}
:deep(.el-dialog__header) {
padding: 12px 20px;
border-bottom: 1px solid #e4e7ed;
margin-right: 0;
flex-shrink: 0;
}
:deep(.el-dialog__body) {
flex: 1;
padding: 0;
overflow: hidden;
}
}
.preview-container {
width: 100%;
height: calc(90vh - 100px);
background: #f5f7fa;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.preview-iframe {
.viewer-container {
width: 100%;
height: 70vh;
border: none;
height: 100%;
overflow: hidden;
}
.preview-text {
max-width: 100%;
max-height: 70vh;
overflow: auto;
background: #fff;
padding: 16px;
border-radius: 4px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
/* 确保 jit-viewer 容器铺满 */
.viewer-container :deep(> div) {
width: 100% !important;
height: 100% !important;
}
.preview-office {
width: 100%;
height: 70vh;
background: #fff;
.viewer-container :deep(.jit-viewer-container),
.viewer-container :deep(.jit-viewer),
.viewer-container :deep([class*="viewer"]) {
width: 100% !important;
height: 100% !important;
}
.preview-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #fff;
color: #909399;
z-index: 10;
}
.preview-loading p {
@@ -187,17 +308,38 @@ watch(() => props.previewUrl, (val) => {
}
.preview-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #fff;
color: #f56c6c;
z-index: 10;
}
.preview-error p {
margin: 16px 0;
font-size: 14px;
}
.preview-unsupported {
text-align: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
color: #909399;
z-index: 10;
}
.preview-unsupported p {
@@ -205,6 +347,12 @@ watch(() => props.previewUrl, (val) => {
font-size: 14px;
}
.preview-unsupported .tip {
font-size: 12px;
color: #c0c4cc;
margin-top: -8px;
}
.is-loading {
animation: rotating 2s linear infinite;
}
@@ -213,4 +361,13 @@ watch(() => props.previewUrl, (val) => {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 隐藏 jit-viewer 版权信息 */
:deep(.jit-viewer-powered),
:deep(.jit-viewer-brand),
:deep([class*="powered"]),
:deep([class*="credit"]),
:deep([class*="brand"]) {
display: none !important;
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-dialog
v-model="visible"
title="个人信息"

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-dialog
v-model="visible"
title="重命名"
@@ -81,7 +81,7 @@ const handleConfirm = async () => {
let newName = form.name.trim()
// 如果是文件(不是文件夹),自动补回扩展名
// 如果是文件(不是目录),自动补回扩展名
if (props.file?.type !== 'folder') {
const originalExt = getExtension(originalName.value)
const newExt = getExtension(newName)

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-dialog
v-model="visible"
title="共享文件"

View File

@@ -1,7 +1,48 @@
<template>
<template>
<div class="top-navbar">
<div class="navbar-left">
<el-icon :size="22" color="#ffffff"><Folder /></el-icon>
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="hex-top" x1="24" y1="2" x2="24" y2="46" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4db6ac"/>
<stop offset="50%" stop-color="#009688"/>
<stop offset="100%" stop-color="#004d40"/>
</linearGradient>
<linearGradient id="hex-front" x1="10" y1="22" x2="38" y2="46" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#26a69a"/>
<stop offset="100%" stop-color="#00796b"/>
</linearGradient>
<linearGradient id="hex-side" x1="4" y1="14" x2="24" y2="44" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#00796b"/>
<stop offset="100%" stop-color="#004d40"/>
</linearGradient>
<linearGradient id="hex-highlight" x1="24" y1="4" x2="24" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="rgba(255,255,255,0.5)"/>
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="0.5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 3D Hexagon: side (left) -->
<polygon points="4,14 10,10 10,34 4,38" fill="url(#hex-side)" opacity="0.85"/>
<!-- 3D Hexagon: front face -->
<polygon points="10,10 38,10 44,14 44,38 38,42 10,42 4,38 4,14" fill="url(#hex-front)"/>
<!-- 3D Hexagon: top face -->
<polygon points="10,10 24,3 38,10 10,10" fill="url(#hex-top)" opacity="0.9"/>
<!-- Highlight on top -->
<polygon points="12,10 24,5 36,10" fill="url(#hex-highlight)" opacity="0.6"/>
<!-- Inner subtle line for depth -->
<polygon points="10,10 38,10 44,14 4,14" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="0.5"/>
<!-- Center icon: stylized folder/cloud -->
<path d="M19 22h-1a3 3 0 00-3 3v6a3 3 0 003 3h12a3 3 0 003-3v-4a3 3 0 00-3-3h-2l-1.5-2H19z" fill="rgba(255,255,255,0.9)" filter="url(#glow)"/>
</svg>
</div>
<span class="system-title">云文件管理系统</span>
</div>
<div class="navbar-right">
@@ -131,6 +172,17 @@ const handleUserCommand = (command) => {
gap: 10px;
}
.logo-icon {
width: 28px;
height: 28px;
color: #fff;
}
.logo-icon svg {
width: 100%;
height: 100%;
}
.system-title {
font-size: 16px;
font-weight: 600;

View File

@@ -1,12 +1,12 @@
<template>
<template>
<el-dialog
v-model="visible"
title="上传文件"
width="650px"
class="custom-dialog"
@open="handleDialogOpen"
>
<el-upload
ref="uploadRef"
drag
multiple
:limit="11"
@@ -20,6 +20,10 @@
<div class="el-upload__text">拖拽文件到此处<em>点击选择</em>最多10个</div>
</el-upload>
<div class="storage-info">
<span>剩余空间{{ formatSize(remainingStorage) }}</span>
</div>
<div class="file-list-preview" v-if="fileList.length > 0">
<div class="file-list-header">
<span>文件名</span>
@@ -37,14 +41,18 @@
</span>
</div>
</div>
<div class="file-list-footer">
<span> {{ fileList.length }} 个文件总大小{{ formatSize(totalSize) }}</span>
<span v-if="isOverLimit" class="warning-text">超出剩余空间</span>
</div>
</div>
<template #footer>
<el-button @click="visible = false">
<el-button @click="handleCancel">
<el-icon><Close /></el-icon>
<span style="margin-left: 4px">取消</span>
</el-button>
<el-button type="primary" @click="handleUpload" :loading="uploading">
<el-button type="primary" @click="handleUpload" :loading="uploading" :disabled="isOverLimit">
<el-icon><Upload /></el-icon>
<span style="margin-left: 4px">开始上传</span>
</el-button>
@@ -59,7 +67,8 @@ import { UploadFilled, Close, Upload, Delete } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: Boolean,
uploading: { type: Boolean, default: false }
uploading: { type: Boolean, default: false },
remainingStorage: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'upload'])
@@ -71,6 +80,16 @@ const visible = computed({
const fileList = ref([])
// 计算总大小
const totalSize = computed(() => {
return fileList.value.reduce((sum, f) => sum + (f.size || 0), 0)
})
// 是否超出剩余空间
const isOverLimit = computed(() => {
return totalSize.value > props.remainingStorage
})
const handleChange = (file, list) => {
if (list.length > 10) {
ElMessage.warning('最多一次上传10个文件')
@@ -104,9 +123,18 @@ const handleUpload = () => {
ElMessage.warning('请选择文件')
return
}
emit('upload', fileList.value.map(f => f.raw))
}
const handleCancel = () => {
visible.value = false
}
const handleDialogOpen = () => {
fileList.value = []
}
// 对外暴露方法
defineExpose({
clear: () => { fileList.value = [] },
@@ -115,6 +143,15 @@ defineExpose({
</script>
<style scoped>
.storage-info {
margin-top: 12px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 4px;
font-size: 13px;
color: #606266;
}
.file-list-preview {
margin-top: 16px;
border: 1px solid #e4e7ed;
@@ -165,4 +202,18 @@ defineExpose({
width: 40px;
text-align: center;
}
.file-list-footer {
padding: 10px 12px;
background: #f5f7fa;
border-top: 1px solid #e4e7ed;
font-size: 13px;
color: #606266;
}
.warning-text {
color: #f56c6c;
font-weight: 500;
margin-left: 4px;
}
</style>

View File

@@ -1,4 +1,4 @@
import { createApp } from 'vue'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/store/user'
const routes = [

View File

@@ -1,4 +1,4 @@
class ChatService {
class ChatService {
constructor() {
this.ws = null
this.listeners = []
@@ -102,4 +102,4 @@ class ChatService {
}
var chatService = new ChatService()
export { chatService }
export { chatService }

View File

@@ -1 +1 @@
export { useUserStore } from './user'
export { useUserStore } from './user'

View File

@@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="desktop-layout">
<!-- 顶部导航栏 -->
<TopNavbar />

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="app-layout">
<div class="main-wrapper">
<!-- 左侧菜单 -->
@@ -17,27 +17,39 @@
v-model:viewMode="viewMode"
v-model:searchKeyword="searchKeyword"
:menu-type="activeMenu"
:current-path="getCurrentPathName()"
:show-back="folderStack.length > 0"
:folder-stack="folderStack"
@search="loadFiles"
@refresh="loadFiles"
@upload="uploadVisible = true"
@upload="handleOpenUpload"
@newFolder="folderVisible = true"
@emptyTrash="handleEmptyTrash"
@goBack="goBack"
@goBack="handleBreadcrumbClick"
/>
<!-- 批量操作工具栏 -->
<div v-if="activeMenu === 'my-files'" class="batch-toolbar">
<div v-if="activeMenu === 'my-files' || activeMenu === 'shared-to-me' || activeMenu === 'my-share' || activeMenu === 'trash'" class="batch-toolbar">
<el-button-group>
<el-button @click="handleBatchDownload" :disabled="selectedFiles.length === 0">
<el-button v-if="activeMenu !== 'trash'" @click="handleBatchDownload" :disabled="selectedFiles.length === 0">
<el-icon><Download /></el-icon>
<span style="margin-left: 4px">批量下载</span>
</el-button>
<el-button @click="handleBatchMove" :disabled="selectedFiles.length === 0">
<el-button v-if="activeMenu === 'my-share'" type="warning" @click="handleBatchCancelShare" :disabled="selectedFiles.length === 0">
<el-icon><CloseBold /></el-icon>
<span style="margin-left: 4px">批量取消</span>
</el-button>
<el-button v-if="activeMenu === 'trash'" type="primary" @click="handleBatchRestore" :disabled="selectedFiles.length === 0">
<el-icon><RefreshLeft /></el-icon>
<span style="margin-left: 4px">批量还原</span>
</el-button>
<el-button v-if="activeMenu === 'my-files'" type="success" @click="handleBatchMove" :disabled="selectedFiles.length === 0">
<el-icon><FolderOpened /></el-icon>
<span style="margin-left: 4px">批量移动</span>
</el-button>
<el-button v-if="activeMenu === 'my-files'" type="danger" @click="handleBatchDelete" :disabled="selectedFiles.length === 0">
<el-icon><Delete /></el-icon>
<span style="margin-left: 4px">批量删除</span>
</el-button>
</el-button-group>
</div>
@@ -64,9 +76,7 @@
<div v-for="file in paginatedFiles" :key="file.id" class="file-card" @dblclick="handleRowDblClick(file)">
<div class="file-card-main">
<div class="file-card-icon">
<el-icon :size="36" :color="getFileIconColor(file)">
<component :is="getFileIcon(file)" />
</el-icon>
<FileIcon :icon="getFileIconType(file)" :color="getFileIconColor(file)" :size="36" />
</div>
<el-tooltip :content="file.name" placement="top" :show-after="300">
<div class="file-card-name">{{ file.name }}</div>
@@ -119,6 +129,7 @@
<UploadDialog
v-model="uploadVisible"
:uploading="uploading"
:remaining-storage="remainingStorage"
@upload="handleUpload"
/>
<FolderDialog
@@ -154,10 +165,11 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Document, Folder, Picture, VideoPlay, Headset, RefreshLeft, Delete, View, Share, Download, CloseBold, Edit, FolderOpened, Close } from '@element-plus/icons-vue'
import { RefreshLeft, Delete, View, Share, Download, CloseBold, Edit, FolderOpened, Close } from '@element-plus/icons-vue'
import FileSidebar from '@/components/FileSidebar.vue'
import FileToolbar from '@/components/FileToolbar.vue'
import FileTable from '@/components/FileTable.vue'
import FileIcon from '@/components/FileIcon.vue'
import UploadDialog from '@/components/UploadDialog.vue'
import FolderDialog from '@/components/FolderDialog.vue'
import ShareDialog from '@/components/ShareDialog.vue'
@@ -168,7 +180,8 @@ import {
getFiles, getTrashFiles, getSharedByMe, getSharedByMeFolderFiles, getSharedToMe, getSharedFolderFiles,
uploadFiles, downloadFile, deleteFile, restoreFile,
deletePermanently, emptyTrash, createFolder,
shareFileApi, cancelShare, getFilePreview, renameFile, moveFile, batchDownload, getMovableFolders
shareFileApi, cancelShare, getFilePreview, renameFile, moveFile, batchDownload, getMovableFolders,
batchCancelShare, batchRestore
} from '@/api/file'
import { getCurrentUser } from '@/api/auth'
import { useUserStore } from '@/store/user'
@@ -189,6 +202,14 @@ const folderStack = ref([])
// 弹窗
const uploadVisible = ref(false)
const uploading = ref(false)
const handleOpenUpload = () => {
if (remainingStorage.value <= 0) {
ElMessage.warning('存储空间已满,无法上传文件')
return
}
uploadVisible.value = true
}
const folderVisible = ref(false)
const shareVisible = ref(false)
const currentShareFile = ref(null)
@@ -220,6 +241,13 @@ const totalStorage = computed(() => {
return (limit / (1024 * 1024 * 1024)).toFixed(0) + ' GB'
})
// 剩余存储空间(字节)
const remainingStorage = computed(() => {
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
const used = userStore.storageUsed || 0
return Math.max(0, limit - used)
})
// 刷新存储数据(从后端精确重算)
const refreshStorage = async () => {
try {
@@ -237,22 +265,6 @@ const refreshStorage = async () => {
}
// 辅助函数
const getFileIcon = (file) => {
if (file.type === 'folder') return Folder
if (file.type === 'image') return Picture
if (file.type === 'video') return VideoPlay
if (file.type === 'audio') return Headset
return Document
}
const getFileIconColor = (file) => {
if (file.type === 'folder') return '#f7b32b'
if (file.type === 'image') return '#67c23a'
if (file.type === 'video') return '#409eff'
if (file.type === 'audio') return '#e6a23c'
return '#909399'
}
const formatSize = (size) => {
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
@@ -269,7 +281,11 @@ const formatDate = (date) => {
const canPreview = (file) => {
if (file.type === 'folder') return false
const ext = file.name.split('.').pop()?.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'md', 'docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt', 'ofd'].includes(ext)
// 支持预览的文件类型图片、PDF、文本、Office注意doc 旧格式不支持)
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
'pdf',
'txt', 'md', 'markdown', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html', 'htm', 'yaml', 'yml', 'sql',
'docx', 'xlsx', 'xls', 'pptx', 'ppt', 'ofd'].includes(ext)
}
// 分页
@@ -318,9 +334,14 @@ const handleMenuSelect = (index) => {
loadFiles()
}
// 进入文件夹
// 进入目录
const enterFolder = (folder) => {
folderStack.value.push({ id: currentFolderId.value, name: folder.name })
// 先把当前目录信息压入栈(用于返回)
folderStack.value.push({
id: folder.id,
name: folder.name
})
// 进入新目录
currentFolderId.value = folder.id
loadFiles()
}
@@ -328,8 +349,25 @@ const enterFolder = (folder) => {
// 返回上级
const goBack = () => {
if (!folderStack.value.length) return
const prev = folderStack.value.pop()
currentFolderId.value = prev.id
folderStack.value.pop()
// 获取上一个目录的ID
const lastFolder = folderStack.value[folderStack.value.length - 1]
currentFolderId.value = lastFolder?.id || null
loadFiles()
}
// 面包屑点击
const handleBreadcrumbClick = (index) => {
if (index === -1) {
// 点击根目录
folderStack.value = []
currentFolderId.value = null
} else {
// 点击某个目录,截取到该位置之后
folderStack.value = folderStack.value.slice(0, index + 1)
// 当前目录设为点击的目录
currentFolderId.value = folderStack.value[index]?.id || null
}
loadFiles()
}
@@ -340,16 +378,6 @@ const getCurrentPathName = () => {
}
const handleUpload = async (fileList) => {
// 上传前校验存储空间
const remaining = (userStore.storageLimit || 20 * 1024 * 1024 * 1024) - (userStore.storageUsed || 0)
const totalSize = fileList.reduce((sum, f) => sum + (f.size || 0), 0)
if (totalSize > remaining) {
const needGb = (totalSize / (1024 * 1024 * 1024)).toFixed(2)
const remainMb = (remaining / (1024 * 1024)).toFixed(2)
ElMessage.warning(`存储空间不足,需要 ${needGb} GB,剩余仅 ${remainMb} MB`)
return
}
uploading.value = true
try {
await uploadFiles(fileList, currentFolderId.value)
@@ -378,18 +406,29 @@ const handlePreview = async (file) => {
previewFile.value = file
previewUrl.value = ''
previewContent.value = ''
previewVisible.value = true // 先打开弹窗
try {
const blob = await getFilePreview(file.id)
const ext = file.name.split('.').pop()?.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'].includes(ext)) {
// 需要用 blob URL 的格式图片、PDF、Office
const blobUrlFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
'pdf', 'docx', 'xlsx', 'xls', 'pptx', 'ppt', 'ofd']
if (blobUrlFormats.includes(ext)) {
// 图片、PDF、Office 文件使用 blob URL
previewUrl.value = URL.createObjectURL(blob)
} else {
// 文本文件直接读取内容
previewContent.value = await blob.text()
console.log('Text content loaded:', previewContent.value?.substring(0, 100))
}
} catch (e) {
console.error('Preview error:', e)
ElMessage.error('预览失败')
previewVisible.value = false
}
previewVisible.value = true
}
const handleRename = (file) => {
@@ -428,7 +467,9 @@ const handleDelete = async (file) => {
ElMessage.success('已移至回收站')
loadFiles()
} catch (e) {
ElMessage.error('删除失败')
// 显示后端返回的错误信息
const errorMsg = e.response?.data?.message || e.message || '删除失败'
ElMessage.error(errorMsg)
}
}
@@ -516,6 +557,14 @@ const handleBatchDownload = async () => {
const ids = selectedFiles.value.map(f => f.id)
const blob = await batchDownload(ids)
// 检查返回的是否是错误信息JSON 格式)
if (blob.type && blob.type.includes('application/json')) {
const text = await blob.text()
const error = JSON.parse(text)
ElMessage.error(error.message || '下载失败')
return
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -524,7 +573,23 @@ const handleBatchDownload = async () => {
URL.revokeObjectURL(url)
selectedFiles.value = []
} catch (e) {
ElMessage.error('下载失败')
// 处理 HTTP 错误响应
if (e.response && e.response.data) {
// 如果是 blob 类型的错误响应
if (e.response.data instanceof Blob) {
try {
const text = await e.response.data.text()
const error = JSON.parse(text)
ElMessage.error(error.message || '下载失败')
} catch {
ElMessage.error('下载失败')
}
} else {
ElMessage.error(e.response.data.message || '下载失败')
}
} else {
ElMessage.error('下载失败')
}
}
}
@@ -540,39 +605,76 @@ const handleBatchMove = async () => {
movableFolders.value = res.data || []
batchMoveVisible.value = true
} catch (e) {
ElMessage.error('获取文件夹列表失败')
ElMessage.error('获取目录列表失败')
}
}
const handleConfirmBatchMove = async (targetFolderId) => {
// 处理各种选项
// Handle target folder ID
let finalFolderId = null
if (targetFolderId === 'parent') {
if (folderStack.value.length > 0) {
const parentFolder = folderStack.value[folderStack.value.length - 1]
finalFolderId = parentFolder.id
} else {
finalFolderId = null
}
} else if (targetFolderId === 'root' || targetFolderId === '' || targetFolderId === null || targetFolderId === undefined) {
// 根目录
if (targetFolderId === 'root' || targetFolderId === '' || targetFolderId === null || targetFolderId === undefined) {
finalFolderId = null
} else {
// 具体文件夹
finalFolderId = targetFolderId
}
console.log('targetFolderId:', targetFolderId, 'finalFolderId:', finalFolderId)
let successCount = 0
let failCount = 0
for (const file of selectedFiles.value) {
try {
await moveFile(file.id, finalFolderId)
successCount++
} catch (e) {
ElMessage.error("移动文件或目录失败")
failCount++
console.error('移动失败:', file.name, e)
}
}
ElMessage.success('批量移动完成')
if (successCount > 0) {
ElMessage.success(`成功移动 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`)
} else if (failCount > 0) {
ElMessage.error('移动失败')
}
selectedFiles.value = []
loadFiles()
}
const handleBatchDelete = async () => {
if (selectedFiles.value.length === 0) {
ElMessage.warning('请选择要删除的文件')
return
}
await ElMessageBox.confirm(`确定删除选中的 ${selectedFiles.value.length} 个文件吗?`, '提示', { type: 'warning' })
let successCount = 0
let failCount = 0
const errors = []
for (const file of selectedFiles.value) {
try {
await deleteFile(file.id)
successCount++
} catch (e) {
failCount++
const errorMsg = e.response?.data?.message || e.message || '删除失败'
if (!errors.includes(errorMsg)) {
errors.push(errorMsg)
}
}
}
if (successCount > 0) {
ElMessage.success(`成功删除 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`)
} else if (failCount > 0) {
ElMessage.error(errors[0] || '删除失败')
}
selectedFiles.value = []
loadFiles()
}
@@ -583,10 +685,90 @@ const handleRowDblClick = (row) => {
}
}
const handleBatchCancelShare = async () => {
if (selectedFiles.value.length === 0) {
ElMessage.warning('请选择要取消共享的文件')
return
}
await ElMessageBox.confirm(`确定取消选中 ${selectedFiles.value.length} 个文件的共享吗?`, '提示', { type: 'warning' })
try {
const ids = selectedFiles.value.map(f => f.id)
const res = await batchCancelShare(ids)
ElMessage.success('批量取消共享成功')
selectedFiles.value = []
loadFiles()
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleBatchRestore = async () => {
if (selectedFiles.value.length === 0) {
ElMessage.warning('请选择要还原的文件')
return
}
await ElMessageBox.confirm(`确定还原选中的 ${selectedFiles.value.length} 个文件吗?`, '提示', { type: 'info' })
try {
const ids = selectedFiles.value.map(f => f.id)
const res = await batchRestore(ids)
ElMessage.success('批量还原成功')
selectedFiles.value = []
loadFiles()
refreshStorage()
} catch (e) {
ElMessage.error('操作失败')
}
}
onMounted(async () => {
await refreshStorage()
loadFiles()
})
// 文件图标类型
const getFileIconType = (file) => {
if (file.type === 'folder') return 'folder'
if (file.type === 'image') return 'image'
if (file.type === 'video') return 'video'
if (file.type === 'audio') return 'audio'
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext === 'pdf') return 'pdf'
if (['docx', 'doc'].includes(ext)) return 'word'
if (['xlsx', 'xls'].includes(ext)) return 'excel'
if (['pptx', 'ppt'].includes(ext)) return 'ppt'
if (['js', 'ts', 'vue', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml'].includes(ext)) return 'code'
if (['txt', 'md', 'markdown', 'log', 'sql', 'yaml', 'yml', 'sh', 'bat', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(ext)) return 'text'
if (ext === 'ofd') return 'pdf'
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'zip'
return 'document'
}
// 文件图标颜色
const getFileIconColor = (file) => {
if (file.type === 'folder') return '#f7b32b'
if (file.type === 'image') return '#67c23a'
if (file.type === 'video') return '#409eff'
if (file.type === 'audio') return '#e6a23c'
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext === 'pdf') return '#f56c6c'
if (['docx', 'doc'].includes(ext)) return '#2b579a'
if (['xlsx', 'xls'].includes(ext)) return '#217346'
if (['pptx', 'ppt'].includes(ext)) return '#d24726'
if (['js', 'ts', 'vue', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml'].includes(ext)) return '#9c27b0'
if (['txt', 'md', 'markdown', 'log', 'yaml', 'yml', 'sh', 'bat'].includes(ext)) return '#606266'
if (ext === 'sql') return '#1976d2'
if (ext === 'py') return '#3776ab'
if (ext === 'java') return '#f89820'
if (ext === 'ofd') return '#f56c6c'
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return '#8d6e63'
return '#909399'
}
</script>
<style scoped>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
<el-form-item prop="username">
<el-input

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="login-container">
<!-- 左侧 60% -->
<div class="login-left">

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="not-found">
<div class="not-found-content">
<el-icon :size="120" color="#c0c4cc"><Warning /></el-icon>