云文件管理系统上传组件优化
This commit is contained in:
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -8,5 +8,5 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="JDK17" project-jdk-type="JavaSDK" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="java-17" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
@@ -307,4 +307,52 @@ public class FileController {
|
||||
List<FileEntity> folders = fileService.getMovableFolders(principal.getUserId(), excludeIds, currentFolderId);
|
||||
return ResponseEntity.ok(Map.of("data", folders));
|
||||
}
|
||||
|
||||
@PostMapping("/batchCancelShare")
|
||||
public ResponseEntity<?> batchCancelShare(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
Object idsObj = request.get("ids");
|
||||
if (idsObj == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "请选择要取消共享的文件"));
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
if (idsObj instanceof List) {
|
||||
for (Object id : (List<?>) idsObj) {
|
||||
if (id instanceof Number) {
|
||||
ids.add(((Number) id).longValue());
|
||||
} else if (id instanceof String) {
|
||||
ids.add(Long.parseLong((String) id));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Long fileId : ids) {
|
||||
fileService.cancelShare(fileId, principal.getUserId());
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("message", "批量取消共享成功"));
|
||||
}
|
||||
|
||||
@PostMapping("/batchRestore")
|
||||
public ResponseEntity<?> batchRestore(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
Object idsObj = request.get("ids");
|
||||
if (idsObj == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "请选择要还原的文件"));
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
if (idsObj instanceof List) {
|
||||
for (Object id : (List<?>) idsObj) {
|
||||
if (id instanceof Number) {
|
||||
ids.add(((Number) id).longValue());
|
||||
} else if (id instanceof String) {
|
||||
ids.add(Long.parseLong((String) id));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Long fileId : ids) {
|
||||
fileService.restoreFile(fileId, principal.getUserId());
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("message", "批量还原成功"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName(value = "sys_file", autoResultMap = true)
|
||||
@@ -31,4 +33,10 @@ public class FileEntity extends BaseEntity {
|
||||
|
||||
@TableField("deleted_at")
|
||||
private String deletedAt;
|
||||
|
||||
/**
|
||||
* 子文件夹列表(非数据库字段,用于树形结构)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private List<FileEntity> children;
|
||||
}
|
||||
|
||||
@@ -67,18 +67,8 @@ public class FileService {
|
||||
public List<FileEntity> getTrashFiles(Long userId, Long folderId) {
|
||||
LambdaQueryWrapper<FileEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(FileEntity::getUserId, userId)
|
||||
.eq(FileEntity::getIsDeleted, 1);
|
||||
|
||||
if (folderId != null) {
|
||||
// 查询指定文件夹下的已删除文件
|
||||
wrapper.eq(FileEntity::getFolderId, folderId);
|
||||
} else {
|
||||
// 查询根目录下已删除的文件和文件夹
|
||||
wrapper.isNull(FileEntity::getFolderId)
|
||||
.or(w -> w.eq(FileEntity::getFolderId, 0));
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(FileEntity::getDeletedAt);
|
||||
.eq(FileEntity::getIsDeleted, 1)
|
||||
.orderByDesc(FileEntity::getDeletedAt);
|
||||
return fileMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
@@ -90,6 +80,19 @@ public class FileService {
|
||||
public void moveToTrash(Long id, Long userId) {
|
||||
FileEntity file = fileMapper.selectById(id);
|
||||
if (file != null && file.getUserId().equals(userId)) {
|
||||
// 如果是文件夹,检查是否有子文件
|
||||
if (file.getIsFolder() != null && file.getIsFolder() == 1) {
|
||||
Long childCount = fileMapper.selectCount(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getFolderId, id)
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
);
|
||||
if (childCount != null && childCount > 0) {
|
||||
throw new RuntimeException("请删除该目录下文件后重试");
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 LambdaUpdateWrapper 明确指定要更新的字段
|
||||
LambdaUpdateWrapper<FileEntity> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(FileEntity::getId, id)
|
||||
@@ -509,18 +512,30 @@ public class FileService {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos);
|
||||
|
||||
// 用于跟踪已使用的文件名,避免重复
|
||||
java.util.Set<String> usedNames = new java.util.HashSet<>();
|
||||
|
||||
for (Long fileId : fileIds) {
|
||||
FileEntity file = fileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId) || file.getIsDeleted() == 1) {
|
||||
if (file == null || file.getIsDeleted() == 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查权限:文件所有者 或 文件被共享给当前用户(包括文件夹内的子文件)
|
||||
boolean hasPermission = file.getUserId().equals(userId) || canAccessFile(fileId, userId);
|
||||
if (!hasPermission) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 确定文件夹内容的查询用户ID(如果是共享文件,用文件所有者的ID查询子文件)
|
||||
Long ownerUserId = file.getUserId();
|
||||
|
||||
if (file.getIsFolder() == 1) {
|
||||
// 递归添加文件夹内容
|
||||
addFolderToZip(zos, file, "", userId);
|
||||
addFolderToZip(zos, file, "", ownerUserId, usedNames);
|
||||
} else {
|
||||
// 添加单个文件
|
||||
addFileToZip(zos, file, "");
|
||||
addFileToZip(zos, file, "", usedNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +543,44 @@ public class FileService {
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private void addFolderToZip(java.util.zip.ZipOutputStream zos, FileEntity folder, String parentPath, Long userId) throws IOException {
|
||||
/**
|
||||
* 检查用户是否有权限访问文件(包括共享文件夹内的子文件)
|
||||
*/
|
||||
private boolean canAccessFile(Long fileId, Long userId) {
|
||||
FileEntity file = fileMapper.selectById(fileId);
|
||||
if (file == null) return false;
|
||||
|
||||
// 直接共享给用户
|
||||
if (isFileSharedToUser(fileId, userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查父文件夹是否被共享
|
||||
Long parentId = file.getFolderId();
|
||||
while (parentId != null) {
|
||||
if (isFileSharedToUser(parentId, userId)) {
|
||||
return true;
|
||||
}
|
||||
FileEntity parent = fileMapper.selectById(parentId);
|
||||
parentId = parent != null ? parent.getFolderId() : null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否被共享给指定用户
|
||||
*/
|
||||
private boolean isFileSharedToUser(Long fileId, Long userId) {
|
||||
Long count = fileShareMapper.selectCount(
|
||||
new LambdaQueryWrapper<FileShare>()
|
||||
.eq(FileShare::getFileId, fileId)
|
||||
.eq(FileShare::getShareToUserId, userId)
|
||||
);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private void addFolderToZip(java.util.zip.ZipOutputStream zos, FileEntity folder, String parentPath, Long userId, java.util.Set<String> usedNames) throws IOException {
|
||||
String folderPath = parentPath + folder.getName() + "/";
|
||||
|
||||
// 获取文件夹下的所有内容
|
||||
@@ -541,21 +593,41 @@ public class FileService {
|
||||
|
||||
for (FileEntity child : children) {
|
||||
if (child.getIsFolder() == 1) {
|
||||
addFolderToZip(zos, child, folderPath, userId);
|
||||
addFolderToZip(zos, child, folderPath, userId, usedNames);
|
||||
} else {
|
||||
addFileToZip(zos, child, folderPath);
|
||||
addFileToZip(zos, child, folderPath, usedNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addFileToZip(java.util.zip.ZipOutputStream zos, FileEntity file, String parentPath) throws IOException {
|
||||
private void addFileToZip(java.util.zip.ZipOutputStream zos, FileEntity file, String parentPath, java.util.Set<String> usedNames) throws IOException {
|
||||
if (file.getPath() == null) return;
|
||||
|
||||
Path filePath = Paths.get(storagePath).toAbsolutePath().resolve("files").resolve(file.getPath());
|
||||
if (!Files.exists(filePath)) return;
|
||||
|
||||
// 生成唯一的 ZIP 条目名称
|
||||
String zipEntryName = parentPath + file.getName();
|
||||
java.util.zip.ZipEntry zipEntry = new java.util.zip.ZipEntry(zipEntryName);
|
||||
String finalName = zipEntryName;
|
||||
int counter = 1;
|
||||
|
||||
// 如果名称已存在,添加序号
|
||||
while (usedNames.contains(finalName)) {
|
||||
// 处理文件名和扩展名
|
||||
String baseName = zipEntryName;
|
||||
String extension = "";
|
||||
int dotIndex = zipEntryName.lastIndexOf(".");
|
||||
if (dotIndex > 0 && dotIndex > parentPath.length()) {
|
||||
baseName = zipEntryName.substring(0, dotIndex);
|
||||
extension = zipEntryName.substring(dotIndex);
|
||||
}
|
||||
finalName = baseName + " (" + counter + ")" + extension;
|
||||
counter++;
|
||||
}
|
||||
|
||||
usedNames.add(finalName);
|
||||
|
||||
java.util.zip.ZipEntry zipEntry = new java.util.zip.ZipEntry(finalName);
|
||||
zos.putNextEntry(zipEntry);
|
||||
|
||||
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||
@@ -569,17 +641,35 @@ public class FileService {
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.eq(FileEntity::getIsFolder, 1);
|
||||
|
||||
// 只查询当前目录下的文件夹(同级)
|
||||
if (currentFolderId == null) {
|
||||
wrapper.isNull(FileEntity::getFolderId);
|
||||
} else {
|
||||
wrapper.eq(FileEntity::getFolderId, currentFolderId);
|
||||
}
|
||||
|
||||
if (excludeIds != null && !excludeIds.isEmpty()) {
|
||||
wrapper.notIn(FileEntity::getId, excludeIds);
|
||||
}
|
||||
|
||||
return fileMapper.selectList(wrapper);
|
||||
// 查询用户所有文件夹
|
||||
List<FileEntity> allFolders = fileMapper.selectList(wrapper);
|
||||
|
||||
// 构建树形结构
|
||||
return buildFolderTree(allFolders, null, excludeIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建文件夹树形结构
|
||||
*/
|
||||
private List<FileEntity> buildFolderTree(List<FileEntity> allFolders, Long parentId, List<Long> excludeIds) {
|
||||
List<FileEntity> tree = new ArrayList<>();
|
||||
for (FileEntity folder : allFolders) {
|
||||
// 匹配父级关系
|
||||
boolean isChildOfParent = (parentId == null && folder.getFolderId() == null)
|
||||
|| (parentId != null && parentId.equals(folder.getFolderId()));
|
||||
|
||||
if (isChildOfParent) {
|
||||
// 递归构建子文件夹
|
||||
List<FileEntity> children = buildFolderTree(allFolders, folder.getId(), excludeIds);
|
||||
// 设置子文件夹列表(通过 transient 字段)
|
||||
folder.setChildren(children.isEmpty() ? null : children);
|
||||
tree.add(folder);
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
}
|
||||
|
||||
9
web-vue/package-lock.json
generated
9
web-vue/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import request from './request'
|
||||
|
||||
export const login = (data) => request.post('/auth/login', data)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import request from './request'
|
||||
|
||||
export const getMessages = (params) => request.get('/messages', { params })
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import request from './request'
|
||||
import request from './request'
|
||||
|
||||
export const getUsers = () => request.get('/users')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="消息"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 容器 (图片、PDF、Office、文本都用) -->
|
||||
<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 initViewer = async () => {
|
||||
if (!viewerContainer.value) return
|
||||
|
||||
// 加载 jit-viewer 脚本
|
||||
const loadJitViewer = () => {
|
||||
if (window.jitView) {
|
||||
jitViewerLoaded.value = true
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="个人信息"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="共享文件"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const routes = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ChatService {
|
||||
class ChatService {
|
||||
constructor() {
|
||||
this.ws = null
|
||||
this.listeners = []
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { useUserStore } from './user'
|
||||
export { useUserStore } from './user'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="desktop-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<TopNavbar />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧 60% -->
|
||||
<div class="login-left">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="not-found-content">
|
||||
<el-icon :size="120" color="#c0c4cc"><Warning /></el-icon>
|
||||
|
||||
Reference in New Issue
Block a user