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

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

@@ -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>