- 文件管理:列表高度自适应、文件大小展示、文件夹递归总大小 - 菜单/权限管理:树表默认折叠、菜单列宽自适应 - 菜单编辑弹窗:加宽、移除允许角色/组件路径/重定向字段 - 工作流:节点选中框位置偏移修复 - 弹窗:统一默认宽度 640px,修正 width prop 失效 - 图标选择:Popover 小浮层改为居中 Dialog 弹窗 Co-Authored-By: Claude <noreply@anthropic.com>
1273 lines
34 KiB
Vue
1273 lines
34 KiB
Vue
<script lang="ts" setup>
|
||
import type { FileManagerRow } from './data';
|
||
|
||
import type {
|
||
OnActionClickParams,
|
||
VxeTableGridOptions,
|
||
} from '#/adapter/vxe-table';
|
||
import type { FolderApi, ShareApi } from '#/api/core/file';
|
||
|
||
import { computed, nextTick, reactive, ref } from 'vue';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
import { IconifyIcon } from '@vben/icons';
|
||
|
||
import {
|
||
Breadcrumb as ABreadcrumb,
|
||
BreadcrumbItem as ABreadcrumbItem,
|
||
Button as AButton,
|
||
Checkbox as ACheckbox,
|
||
DatePicker as ADatePicker,
|
||
Empty as AEmpty,
|
||
Form as AForm,
|
||
FormItem as AFormItem,
|
||
Input as AInput,
|
||
InputNumber as AInputNumber,
|
||
Modal as AModal,
|
||
Progress as AProgress,
|
||
Space as ASpace,
|
||
Spin as ASpin,
|
||
Tag as ATag,
|
||
TreeSelect as ATreeSelect,
|
||
Upload as AUpload,
|
||
message,
|
||
} from 'antdv-next';
|
||
|
||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||
import {
|
||
calcFileHash,
|
||
checkFile,
|
||
createFolder,
|
||
createShare,
|
||
deleteFile,
|
||
deleteFolder,
|
||
downloadFile,
|
||
getFileContent,
|
||
getFilePreviewUrl,
|
||
getFolder,
|
||
getFolderTree,
|
||
getOfficePreview,
|
||
getRootFiles,
|
||
moveFile,
|
||
renameFolder,
|
||
uploadFile,
|
||
uploadToFolder,
|
||
} from '#/api/core/file';
|
||
import { uploadFileWithResume } from '#/api/core/multipart-uploader';
|
||
|
||
import {
|
||
buildFileManagerRows,
|
||
useFileManagerColumns,
|
||
useGridFormSchema,
|
||
} from './data';
|
||
import {
|
||
canMoveFileToFolder,
|
||
getDraggedFileId,
|
||
setDraggedFileId,
|
||
} from './drag';
|
||
|
||
defineOptions({ name: 'FileManagerPage' });
|
||
|
||
// ===== 视图状态:null=根目录,字符串=文件夹内 =====
|
||
const currentFolderId = ref<null | string>(null);
|
||
// 当前层级数据(根目录和子文件夹统一用这两个,数据源都是 DB)
|
||
const subFolders = ref<FolderApi.FolderDto[]>([]);
|
||
const folderFiles = ref<FolderApi.FileMetaDto[]>([]);
|
||
const breadcrumbs = ref<FolderApi.FolderDto[]>([]);
|
||
const allFolders = ref<FolderApi.FolderDto[]>([]);
|
||
const currentSearchKeyword = ref('');
|
||
|
||
/** 当前是否在根目录 */
|
||
const isRoot = computed(() => currentFolderId.value === null);
|
||
|
||
// ===== 数据加载 =====
|
||
async function loadCurrentRows(keyword?: string) {
|
||
if (isRoot.value) {
|
||
// 根目录:顶层文件夹 + 根级文件(都从 DB 查,统一数据源)
|
||
const [rootFilesRes, treeRes] = await Promise.all([
|
||
getRootFiles(),
|
||
getFolderTree().catch(() => [] as FolderApi.FolderDto[]),
|
||
]);
|
||
allFolders.value = treeRes;
|
||
subFolders.value = treeRes.filter((f) => !f.parentId);
|
||
folderFiles.value = rootFilesRes;
|
||
breadcrumbs.value = [];
|
||
} else {
|
||
// 子文件夹:结构化数据(子文件夹 + 该层文件)
|
||
const [folderRes, treeRes] = await Promise.all([
|
||
getFolder(currentFolderId.value!),
|
||
getFolderTree().catch(() => allFolders.value),
|
||
]);
|
||
allFolders.value = treeRes;
|
||
// 子文件夹从 tree 派生(tree 里的 FolderDto 带 size;getFolder.subFolders 不带 size)
|
||
subFolders.value = treeRes.filter(
|
||
(f) => f.parentId === currentFolderId.value,
|
||
);
|
||
folderFiles.value = folderRes.files;
|
||
rebuildBreadcrumbs(currentFolderId.value!);
|
||
}
|
||
return buildFileManagerRows(subFolders.value, folderFiles.value, keyword);
|
||
}
|
||
|
||
function rebuildBreadcrumbs(folderId: string) {
|
||
const path: FolderApi.FolderDto[] = [];
|
||
let current = allFolders.value.find((f) => f.id === folderId);
|
||
while (current) {
|
||
path.unshift(current);
|
||
const parentId = current.parentId;
|
||
current = parentId
|
||
? allFolders.value.find((f) => f.id === parentId)
|
||
: undefined;
|
||
}
|
||
breadcrumbs.value = path;
|
||
}
|
||
|
||
function enterFolder(folder: FolderApi.FolderDto) {
|
||
currentFolderId.value = folder.id;
|
||
refreshGrid();
|
||
}
|
||
|
||
function goToRoot() {
|
||
currentFolderId.value = null;
|
||
refreshGrid();
|
||
}
|
||
|
||
function goToCrumb(index: number) {
|
||
if (index < 0) {
|
||
goToRoot();
|
||
} else {
|
||
currentFolderId.value = breadcrumbs.value[index]!.id;
|
||
refreshGrid();
|
||
}
|
||
}
|
||
|
||
// ===== 上传 =====
|
||
const uploading = ref(false);
|
||
const uploadProgressVisible = ref(false);
|
||
const uploadProgressPercent = ref(0);
|
||
const uploadProgressText = ref('');
|
||
const uploadAbortFlag = ref(false);
|
||
|
||
const MULTIPART_THRESHOLD = 5 * 1024 * 1024;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
async function customUpload(options: any) {
|
||
const { file } = options;
|
||
if (!(file instanceof File)) {
|
||
message.error('无效的文件');
|
||
return;
|
||
}
|
||
uploading.value = true;
|
||
uploadAbortFlag.value = false;
|
||
try {
|
||
const hash = await calcFileHash(file);
|
||
const checkRes = await checkFile(hash, file.size);
|
||
if (checkRes.exists) {
|
||
message.success(`秒传成功:${file.name}`);
|
||
await refreshGrid();
|
||
return;
|
||
}
|
||
|
||
if (file.size >= MULTIPART_THRESHOLD) {
|
||
// 大文件:分片上传 + 断点续传
|
||
uploadProgressVisible.value = true;
|
||
uploadProgressPercent.value = 0;
|
||
uploadProgressText.value = `分片上传中:${file.name}`;
|
||
await uploadFileWithResume(file, {
|
||
onProgress: (_u, _t, percent) => {
|
||
uploadProgressPercent.value = percent;
|
||
},
|
||
shouldAbort: () => uploadAbortFlag.value,
|
||
});
|
||
uploadProgressVisible.value = false;
|
||
message.success(`上传成功:${file.name}`);
|
||
} else if (currentFolderId.value) {
|
||
// 小文件 + 在文件夹内:上传到文件夹
|
||
await uploadToFolder({
|
||
folderId: currentFolderId.value,
|
||
fileName: file.name,
|
||
file,
|
||
});
|
||
message.success(`上传成功:${file.name}`);
|
||
} else {
|
||
// 小文件 + 根目录:直传
|
||
await uploadFile({
|
||
contentHash: hash,
|
||
file,
|
||
objectKey: file.name,
|
||
onProgress: (percent) => {
|
||
message.loading({ content: `上传中 ${percent}%`, key: 'upload' });
|
||
},
|
||
});
|
||
message.success({ content: `上传成功:${file.name}`, key: 'upload' });
|
||
}
|
||
await refreshGrid();
|
||
} catch (error) {
|
||
uploadProgressVisible.value = false;
|
||
if (error instanceof Error && error.message === 'UPLOAD_ABORTED') {
|
||
message.info('已取消上传');
|
||
} else {
|
||
message.error(`上传失败:${file.name}`);
|
||
}
|
||
} finally {
|
||
uploading.value = false;
|
||
}
|
||
}
|
||
|
||
function cancelUpload() {
|
||
uploadAbortFlag.value = true;
|
||
}
|
||
|
||
function beforeUpload(file: File) {
|
||
if (file.size > 500 * 1024 * 1024) {
|
||
message.error('文件不能超过 500MB');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ===== 预览 / 下载 / 删除(根目录扁平文件用 S3 key,文件夹文件用 s3Key)=====
|
||
|
||
// 统一的文件预览/下载/删除适配器:根目录用 FileInfo.key,文件夹用 FileMetaDto.s3Key
|
||
function getFileKey(record: FolderApi.FileMetaDto): string {
|
||
return record.s3Key;
|
||
}
|
||
|
||
function getFileName(record: FolderApi.FileMetaDto): string {
|
||
return record.name;
|
||
}
|
||
|
||
// 图片预览状态
|
||
const previewVisible = ref(false);
|
||
const previewSrc = ref('');
|
||
const previewTitle = ref('图片预览');
|
||
const previewError = ref(false);
|
||
const previewLoading = ref(false);
|
||
const previewWidth = ref('60%');
|
||
|
||
function closeImagePreview() {
|
||
previewVisible.value = false;
|
||
previewSrc.value = '';
|
||
previewError.value = false;
|
||
previewLoading.value = false;
|
||
}
|
||
|
||
function onImageLoad(e: Event) {
|
||
const img = e.target as HTMLImageElement;
|
||
previewLoading.value = false;
|
||
// 1px 说明加载了但不是有效图片(损坏/占位)
|
||
if (img.naturalWidth <= 1) {
|
||
previewError.value = true;
|
||
}
|
||
}
|
||
|
||
const textPreviewVisible = ref(false);
|
||
const textPreviewTitle = ref('');
|
||
const textPreviewContent = ref('');
|
||
const textPreviewLoading = ref(false);
|
||
|
||
function getFileType(
|
||
key: string,
|
||
): 'image' | 'office' | 'other' | 'pdf' | 'text' | 'video' {
|
||
const ext = key.split('.').pop()?.toLowerCase() ?? '';
|
||
if (['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext))
|
||
return 'image';
|
||
if (['pdf'].includes(ext)) return 'pdf';
|
||
if (['doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx'].includes(ext))
|
||
return 'office';
|
||
if (['avi', 'mkv', 'mov', 'mp4', 'webm'].includes(ext)) return 'video';
|
||
if (
|
||
[
|
||
'c',
|
||
'cpp',
|
||
'cs',
|
||
'css',
|
||
'csv',
|
||
'go',
|
||
'html',
|
||
'java',
|
||
'js',
|
||
'json',
|
||
'log',
|
||
'md',
|
||
'py',
|
||
'rs',
|
||
'scss',
|
||
'sh',
|
||
'sql',
|
||
'ts',
|
||
'txt',
|
||
'vue',
|
||
'xml',
|
||
'yaml',
|
||
'yml',
|
||
].includes(ext)
|
||
)
|
||
return 'text';
|
||
return 'other';
|
||
}
|
||
|
||
async function handlePreview(record: FolderApi.FileMetaDto) {
|
||
const key = getFileKey(record);
|
||
const type = getFileType(getFileName(record));
|
||
try {
|
||
switch (type) {
|
||
case 'image': {
|
||
// 代理 URL(后端流式,不暴露 S3 凭据),用自建 Modal 展示
|
||
previewError.value = false;
|
||
previewLoading.value = true;
|
||
previewTitle.value = getFileName(record);
|
||
previewSrc.value = getFilePreviewUrl(key);
|
||
previewVisible.value = true;
|
||
break;
|
||
}
|
||
case 'office': {
|
||
const res = await getOfficePreview(key);
|
||
window.open(res.previewUrl, '_blank');
|
||
break;
|
||
}
|
||
case 'text': {
|
||
textPreviewLoading.value = true;
|
||
textPreviewVisible.value = true;
|
||
textPreviewTitle.value = getFileName(record);
|
||
textPreviewContent.value = '';
|
||
const res = await getFileContent(key);
|
||
textPreviewContent.value = res.content;
|
||
break;
|
||
}
|
||
default: {
|
||
// PDF/视频等:代理 URL,浏览器内联渲染
|
||
window.open(getFilePreviewUrl(key), '_blank');
|
||
}
|
||
}
|
||
} catch {
|
||
message.error('获取预览链接失败');
|
||
} finally {
|
||
textPreviewLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleDownload(record: FolderApi.FileMetaDto) {
|
||
try {
|
||
const blob = await downloadFile(getFileKey(record));
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = getFileName(record);
|
||
document.body.append(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
message.error('下载失败');
|
||
}
|
||
}
|
||
|
||
async function handleDeleteFile(record: FolderApi.FileMetaDto) {
|
||
try {
|
||
await deleteFile(getFileKey(record));
|
||
message.success('已删除');
|
||
await refreshGrid();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
}
|
||
|
||
// ===== 文件夹操作 =====
|
||
const createFolderVisible = ref(false);
|
||
const newFolderName = ref('');
|
||
|
||
function openCreateFolder() {
|
||
newFolderName.value = '';
|
||
createFolderVisible.value = true;
|
||
}
|
||
|
||
async function handleCreateFolder() {
|
||
if (!newFolderName.value.trim()) {
|
||
message.warning('请输入文件夹名称');
|
||
return;
|
||
}
|
||
try {
|
||
await createFolder({
|
||
name: newFolderName.value.trim(),
|
||
parentId: currentFolderId.value || undefined,
|
||
});
|
||
message.success('文件夹已创建');
|
||
createFolderVisible.value = false;
|
||
await refreshGrid();
|
||
} catch {
|
||
message.error('创建失败');
|
||
}
|
||
}
|
||
|
||
const renameVisible = ref(false);
|
||
const renameTarget = ref<FolderApi.FolderDto | null>(null);
|
||
const renameValue = ref('');
|
||
|
||
function openRename(folder: FolderApi.FolderDto) {
|
||
renameTarget.value = folder;
|
||
renameValue.value = folder.name;
|
||
renameVisible.value = true;
|
||
}
|
||
|
||
async function handleRename() {
|
||
if (!renameTarget.value || !renameValue.value.trim()) return;
|
||
try {
|
||
await renameFolder(renameTarget.value.id, renameValue.value.trim());
|
||
message.success('已重命名');
|
||
renameVisible.value = false;
|
||
await refreshGrid();
|
||
} catch {
|
||
message.error('重命名失败');
|
||
}
|
||
}
|
||
|
||
async function handleDeleteFolder(folder: FolderApi.FolderDto) {
|
||
try {
|
||
await deleteFolder(folder.id);
|
||
message.success('文件夹已删除');
|
||
await refreshGrid();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
}
|
||
|
||
// 移动文件
|
||
const moveVisible = ref(false);
|
||
const moveTargetFile = ref<FolderApi.FileMetaDto | null>(null);
|
||
const moveTargetFolderId = ref<string | undefined>();
|
||
|
||
interface TreeNode {
|
||
children: TreeNode[];
|
||
title: string;
|
||
value: string;
|
||
}
|
||
|
||
const treeData = computed<TreeNode[]>(() => buildTree(allFolders.value, null));
|
||
|
||
function buildTree(
|
||
list: FolderApi.FolderDto[],
|
||
parentId: null | string,
|
||
): TreeNode[] {
|
||
return list
|
||
.filter((f) => f.parentId === parentId)
|
||
.map((f) => ({
|
||
value: f.id,
|
||
title: f.name,
|
||
children: buildTree(list, f.id),
|
||
}));
|
||
}
|
||
|
||
function openMove(file: FolderApi.FileMetaDto) {
|
||
moveTargetFile.value = file;
|
||
moveTargetFolderId.value = undefined;
|
||
moveVisible.value = true;
|
||
}
|
||
|
||
async function handleMove() {
|
||
if (!moveTargetFile.value) return;
|
||
try {
|
||
await moveFile(moveTargetFile.value.id, moveTargetFolderId.value);
|
||
message.success('已移动');
|
||
moveVisible.value = false;
|
||
await refreshGrid();
|
||
} catch {
|
||
message.error('移动失败');
|
||
}
|
||
}
|
||
|
||
// ===== 拖拽移动文件到文件夹 / 根目录 =====
|
||
const draggingFileId = ref<null | string>(null);
|
||
const activeDropTarget = ref<null | string>(null);
|
||
|
||
function findFileInCurrentList(fileId: string) {
|
||
return folderFiles.value.find((file) => file.id === fileId) ?? null;
|
||
}
|
||
|
||
function getDragFile(e: DragEvent) {
|
||
const fileId = getDraggedFileId(e.dataTransfer, draggingFileId.value);
|
||
return fileId ? findFileInCurrentList(fileId) : null;
|
||
}
|
||
|
||
function canDropFileTo(e: DragEvent, targetFolderId?: null | string) {
|
||
const file = getDragFile(e);
|
||
return !!file && canMoveFileToFolder(file, targetFolderId);
|
||
}
|
||
|
||
function clearDragState() {
|
||
draggingFileId.value = null;
|
||
activeDropTarget.value = null;
|
||
syncGridDropClasses();
|
||
}
|
||
|
||
function handleDropTargetLeave(e: DragEvent, targetKey: string) {
|
||
const currentTarget = e.currentTarget as HTMLElement | null;
|
||
const relatedTarget = e.relatedTarget as Node | null;
|
||
if (currentTarget?.contains(relatedTarget)) return;
|
||
if (activeDropTarget.value === targetKey) {
|
||
activeDropTarget.value = null;
|
||
}
|
||
}
|
||
|
||
function handleDragOver(e: DragEvent, targetFolderId?: null | string) {
|
||
if (!canDropFileTo(e, targetFolderId)) return;
|
||
e.preventDefault();
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}
|
||
}
|
||
|
||
async function moveDraggedFileTo(
|
||
e: DragEvent,
|
||
targetFolderId: null | string,
|
||
targetName: string,
|
||
) {
|
||
e.preventDefault();
|
||
const file = getDragFile(e);
|
||
if (!file || !canMoveFileToFolder(file, targetFolderId)) {
|
||
clearDragState();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await moveFile(file.id, targetFolderId || undefined);
|
||
message.success(`已移动到「${targetName}」`);
|
||
await refreshGrid();
|
||
} catch {
|
||
message.error('移动失败');
|
||
} finally {
|
||
clearDragState();
|
||
}
|
||
}
|
||
|
||
function handleRootDragEnter(e: DragEvent) {
|
||
if (!canDropFileTo(e, null)) return;
|
||
e.preventDefault();
|
||
activeDropTarget.value = 'root';
|
||
syncGridDropClasses();
|
||
}
|
||
|
||
function handleRootDragOver(e: DragEvent) {
|
||
handleDragOver(e, null);
|
||
}
|
||
|
||
async function handleRootDrop(e: DragEvent) {
|
||
await moveDraggedFileTo(e, null, '根目录');
|
||
}
|
||
|
||
function handleCrumbDragEnter(e: DragEvent, folder: FolderApi.FolderDto) {
|
||
if (!canDropFileTo(e, folder.id)) return;
|
||
e.preventDefault();
|
||
activeDropTarget.value = folder.id;
|
||
syncGridDropClasses();
|
||
}
|
||
|
||
function handleCrumbDragOver(e: DragEvent, folderId: string) {
|
||
handleDragOver(e, folderId);
|
||
}
|
||
|
||
async function handleCrumbDrop(e: DragEvent, folder: FolderApi.FolderDto) {
|
||
await moveDraggedFileTo(e, folder.id, folder.name);
|
||
}
|
||
|
||
function handleFileDragStart(e: DragEvent, row: FileManagerRow) {
|
||
if (!row.file) return;
|
||
draggingFileId.value = row.file.id;
|
||
setDraggedFileId(e.dataTransfer, row.file.id);
|
||
}
|
||
|
||
function handleFolderDragEnter(e: DragEvent, row: FileManagerRow) {
|
||
if (!row.folder || !canDropFileTo(e, row.folder.id)) return;
|
||
e.preventDefault();
|
||
activeDropTarget.value = row.folder.id;
|
||
syncGridDropClasses();
|
||
}
|
||
|
||
function handleFolderDragOver(e: DragEvent, row: FileManagerRow) {
|
||
if (!row.folder) return;
|
||
handleDragOver(e, row.folder.id);
|
||
}
|
||
|
||
function handleFolderDragLeave(e: DragEvent, row: FileManagerRow) {
|
||
if (!row.folder) return;
|
||
handleDropTargetLeave(e, row.folder.id);
|
||
syncGridDropClasses();
|
||
}
|
||
|
||
async function handleFolderDrop(e: DragEvent, row: FileManagerRow) {
|
||
if (!row.folder) return;
|
||
await moveDraggedFileTo(e, row.folder.id, row.folder.name);
|
||
}
|
||
|
||
function tableRowClassName({ row }: { row: FileManagerRow }) {
|
||
const classes: string[] = [];
|
||
if (row.itemType === 'file') {
|
||
classes.push('file-row-draggable');
|
||
}
|
||
if (row.itemType === 'folder' && activeDropTarget.value === row.folder?.id) {
|
||
classes.push('folder-row-drop-active');
|
||
}
|
||
return classes.join(' ');
|
||
}
|
||
|
||
function getGridBodyRows(): HTMLTableRowElement[] {
|
||
if (typeof document === 'undefined') return [];
|
||
return [
|
||
...document.querySelectorAll(
|
||
'.file-manager .vxe-table--body-wrapper tbody tr',
|
||
),
|
||
] as HTMLTableRowElement[];
|
||
}
|
||
|
||
function getGridVisibleRows(): FileManagerRow[] {
|
||
return (gridApi as any).grid?.getTableData?.()?.visibleData ?? [];
|
||
}
|
||
|
||
function resetNativeDragHandlers(rowEl: HTMLTableRowElement) {
|
||
rowEl.draggable = false;
|
||
rowEl.removeAttribute('title');
|
||
rowEl.ondragstart = null;
|
||
rowEl.ondragend = null;
|
||
rowEl.ondragenter = null;
|
||
rowEl.ondragover = null;
|
||
rowEl.ondragleave = null;
|
||
rowEl.ondrop = null;
|
||
}
|
||
|
||
function syncGridDropClasses() {
|
||
const visibleRows = getGridVisibleRows();
|
||
getGridBodyRows().forEach((rowEl, index) => {
|
||
const row = visibleRows[index];
|
||
rowEl.classList.toggle(
|
||
'folder-row-drop-active',
|
||
row?.itemType === 'folder' && activeDropTarget.value === row.folder?.id,
|
||
);
|
||
});
|
||
}
|
||
|
||
async function bindGridDragRows() {
|
||
await nextTick();
|
||
const visibleRows = getGridVisibleRows();
|
||
getGridBodyRows().forEach((rowEl, index) => {
|
||
const row = visibleRows[index];
|
||
resetNativeDragHandlers(rowEl);
|
||
if (!row) return;
|
||
|
||
rowEl.dataset.fileManagerRowKey = row.rowKey;
|
||
if (row.itemType === 'file') {
|
||
rowEl.draggable = true;
|
||
rowEl.title = '拖拽到文件夹可移动';
|
||
rowEl.ondragstart = (event) => handleFileDragStart(event, row);
|
||
rowEl.ondragend = () => clearDragState();
|
||
return;
|
||
}
|
||
|
||
rowEl.ondragenter = (event) => handleFolderDragEnter(event, row);
|
||
rowEl.ondragover = (event) => handleFolderDragOver(event, row);
|
||
rowEl.ondragleave = (event) => handleFolderDragLeave(event, row);
|
||
rowEl.ondrop = (event) => handleFolderDrop(event, row);
|
||
});
|
||
syncGridDropClasses();
|
||
}
|
||
|
||
function queueBindGridDragRows() {
|
||
nextTick(() => {
|
||
globalThis.setTimeout(() => {
|
||
bindGridDragRows();
|
||
}, 0);
|
||
});
|
||
}
|
||
|
||
// ===== 分享 =====
|
||
const shareVisible = ref(false);
|
||
const shareForm = reactive({
|
||
resourceId: '',
|
||
enablePassword: false,
|
||
password: '',
|
||
enableExpiry: false,
|
||
expiresAt: undefined as any,
|
||
enableLimit: false,
|
||
maxDownloads: 10,
|
||
});
|
||
const shareResultVisible = ref(false);
|
||
const shareLinkUrl = ref('');
|
||
const shareLoading = ref(false);
|
||
|
||
function openShare(record: FolderApi.FileMetaDto) {
|
||
// 文件夹内文件有 id,可作为 resourceId;根目录扁平文件无 id,用 key 占位
|
||
shareForm.resourceId = record.id;
|
||
shareForm.enablePassword = false;
|
||
shareForm.password = '';
|
||
shareForm.enableExpiry = false;
|
||
shareForm.expiresAt = undefined;
|
||
shareForm.enableLimit = false;
|
||
shareForm.maxDownloads = 10;
|
||
shareVisible.value = true;
|
||
}
|
||
|
||
async function handleCreateShare() {
|
||
shareLoading.value = true;
|
||
try {
|
||
const res: ShareApi.ShareLinkDto = await createShare({
|
||
resourceType: 'file',
|
||
resourceId: shareForm.resourceId,
|
||
password: shareForm.enablePassword ? shareForm.password : undefined,
|
||
expiresAt:
|
||
shareForm.enableExpiry && shareForm.expiresAt
|
||
? shareForm.expiresAt.toISOString()
|
||
: undefined,
|
||
maxDownloads: shareForm.enableLimit ? shareForm.maxDownloads : undefined,
|
||
});
|
||
shareLinkUrl.value = `${window.location.origin}${window.location.pathname}#/share/${res.token}`;
|
||
shareVisible.value = false;
|
||
shareResultVisible.value = true;
|
||
} catch {
|
||
message.error('创建分享失败');
|
||
} finally {
|
||
shareLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function copyShareLink() {
|
||
try {
|
||
await navigator.clipboard.writeText(shareLinkUrl.value);
|
||
message.success('链接已复制');
|
||
} catch {
|
||
message.warning('复制失败,请手动复制');
|
||
}
|
||
}
|
||
|
||
function onActionClick({ code, row }: OnActionClickParams<FileManagerRow>) {
|
||
switch (code) {
|
||
case 'delete': {
|
||
if (row.folder) {
|
||
void handleDeleteFolder(row.folder);
|
||
} else if (row.file) {
|
||
void handleDeleteFile(row.file);
|
||
}
|
||
break;
|
||
}
|
||
case 'download': {
|
||
if (row.file) void handleDownload(row.file);
|
||
break;
|
||
}
|
||
case 'move': {
|
||
if (row.file) openMove(row.file);
|
||
break;
|
||
}
|
||
case 'preview': {
|
||
if (row.file) void handlePreview(row.file);
|
||
break;
|
||
}
|
||
case 'rename': {
|
||
if (row.folder) openRename(row.folder);
|
||
break;
|
||
}
|
||
case 'share': {
|
||
if (row.file) openShare(row.file);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 工具函数 =====
|
||
function formatSize(bytes: number) {
|
||
if (!bytes) return '-';
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
let i = 0;
|
||
let n = bytes;
|
||
while (n >= 1024 && i < units.length - 1) {
|
||
n /= 1024;
|
||
i++;
|
||
}
|
||
return `${n.toFixed(1)} ${units[i]}`;
|
||
}
|
||
|
||
function typeTagStyle(key: string): { color: string; text: string } {
|
||
const type = getFileType(key);
|
||
const map: Record<string, { color: string; text: string }> = {
|
||
image: { color: 'blue', text: '图片' },
|
||
office: { color: 'orange', text: '文档' },
|
||
pdf: { color: 'red', text: 'PDF' },
|
||
video: { color: 'purple', text: '视频' },
|
||
text: { color: 'green', text: '文本' },
|
||
other: { color: 'default', text: '文件' },
|
||
};
|
||
return map[type] ?? { color: 'default', text: '文件' };
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
if (!iso) return '-';
|
||
return new Date(iso).toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
const [Grid, gridApi] = useVbenVxeGrid({
|
||
formOptions: {
|
||
resetButtonOptions: { content: '重置' },
|
||
schema: useGridFormSchema(),
|
||
submitButtonOptions: { content: '搜索' },
|
||
submitOnChange: true,
|
||
},
|
||
gridOptions: {
|
||
// 用于 :deep(.fm-grid) 定位到 Grid 的包装 div,让其 flex 填充剩余高度
|
||
class: 'fm-grid',
|
||
columns: useFileManagerColumns(onActionClick),
|
||
height: 'auto',
|
||
keepSource: true,
|
||
pagerConfig: { enabled: false },
|
||
proxyConfig: {
|
||
ajax: {
|
||
query: async (_params, formValues) => {
|
||
currentSearchKeyword.value =
|
||
typeof formValues?.search === 'string' ? formValues.search : '';
|
||
try {
|
||
const rows = await loadCurrentRows(currentSearchKeyword.value);
|
||
queueBindGridDragRows();
|
||
return { items: rows, total: rows.length };
|
||
} catch {
|
||
queueBindGridDragRows();
|
||
return { items: [], total: 0 };
|
||
}
|
||
},
|
||
},
|
||
},
|
||
rowClassName: tableRowClassName,
|
||
rowConfig: { keyField: 'rowKey' },
|
||
toolbarConfig: {
|
||
custom: true,
|
||
refresh: true,
|
||
zoom: true,
|
||
},
|
||
} as VxeTableGridOptions<FileManagerRow>,
|
||
gridEvents: {
|
||
proxyQuery() {
|
||
queueBindGridDragRows();
|
||
},
|
||
},
|
||
});
|
||
|
||
function refreshGrid() {
|
||
return gridApi.query();
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Page auto-content-height>
|
||
<div class="file-manager">
|
||
<!-- 面包屑导航:仅在进入文件夹后显示(根目录无需导航) -->
|
||
<ABreadcrumb v-if="breadcrumbs.length > 0" class="nav-breadcrumb">
|
||
<ABreadcrumbItem
|
||
:class="{ 'breadcrumb-drop-active': activeDropTarget === 'root' }"
|
||
class="breadcrumb-drop-target"
|
||
@click="goToRoot"
|
||
@dragenter="handleRootDragEnter"
|
||
@dragover="handleRootDragOver"
|
||
@dragleave="handleDropTargetLeave($event, 'root')"
|
||
@drop="handleRootDrop"
|
||
>
|
||
<IconifyIcon icon="lucide:hard-drive" /> 根目录
|
||
</ABreadcrumbItem>
|
||
<ABreadcrumbItem
|
||
v-for="(crumb, idx) in breadcrumbs"
|
||
:key="crumb.id"
|
||
:class="{ 'breadcrumb-drop-active': activeDropTarget === crumb.id }"
|
||
class="breadcrumb-drop-target"
|
||
@click="goToCrumb(idx)"
|
||
@dragenter="handleCrumbDragEnter($event, crumb)"
|
||
@dragover="handleCrumbDragOver($event, crumb.id)"
|
||
@dragleave="handleDropTargetLeave($event, crumb.id)"
|
||
@drop="handleCrumbDrop($event, crumb)"
|
||
>
|
||
{{ crumb.name }}
|
||
</ABreadcrumbItem>
|
||
</ABreadcrumb>
|
||
|
||
<!-- 分片上传进度条 -->
|
||
<div v-if="uploadProgressVisible" class="upload-progress">
|
||
<div class="upload-progress-header">
|
||
<span class="upload-progress-text">{{ uploadProgressText }}</span>
|
||
<AButton danger size="small" type="link" @click="cancelUpload">
|
||
取消
|
||
</AButton>
|
||
</div>
|
||
<AProgress :percent="uploadProgressPercent" status="active" />
|
||
</div>
|
||
|
||
<!-- 统一列表:文件夹 + 文件合并在一个 Vxe Grid,按类型区分渲染。支持拖拽文件到文件夹移动 -->
|
||
<Grid table-title="文件管理">
|
||
<template #toolbar-tools>
|
||
<ASpace>
|
||
<AUpload
|
||
:before-upload="beforeUpload"
|
||
:custom-request="customUpload"
|
||
:disabled="uploading"
|
||
:show-upload-list="false"
|
||
multiple
|
||
>
|
||
<AButton :loading="uploading" type="primary">
|
||
<template #icon>
|
||
<IconifyIcon icon="lucide:upload" />
|
||
</template>
|
||
上传文件
|
||
</AButton>
|
||
</AUpload>
|
||
|
||
<AButton @click="openCreateFolder">
|
||
<template #icon>
|
||
<IconifyIcon icon="lucide:folder-plus" />
|
||
</template>
|
||
新建文件夹
|
||
</AButton>
|
||
|
||
<AButton @click="refreshGrid">
|
||
<template #icon>
|
||
<IconifyIcon icon="lucide:refresh-cw" />
|
||
</template>
|
||
刷新
|
||
</AButton>
|
||
</ASpace>
|
||
</template>
|
||
|
||
<template #name="{ row }">
|
||
<span class="name-cell">
|
||
<IconifyIcon
|
||
v-if="row.itemType === 'file'"
|
||
class="drag-handle-icon"
|
||
icon="lucide:grip-vertical"
|
||
/>
|
||
<IconifyIcon
|
||
:icon="
|
||
row.itemType === 'folder' ? 'lucide:folder' : 'lucide:file'
|
||
"
|
||
:class="
|
||
row.itemType === 'folder'
|
||
? 'name-icon-folder'
|
||
: 'name-icon-file'
|
||
"
|
||
/>
|
||
<a
|
||
v-if="row.folder"
|
||
class="name-link"
|
||
@click="enterFolder(row.folder)"
|
||
>
|
||
{{ row.name }}
|
||
</a>
|
||
<span v-else class="name-text">{{ row.name }}</span>
|
||
</span>
|
||
</template>
|
||
|
||
<template #size="{ row }">
|
||
{{ formatSize(row.size ?? 0) }}
|
||
</template>
|
||
|
||
<template #type="{ row }">
|
||
<ATag v-if="row.itemType === 'folder'" color="gold">文件夹</ATag>
|
||
<ATag v-else :color="typeTagStyle(row.name).color">
|
||
{{ typeTagStyle(row.name).text }}
|
||
</ATag>
|
||
</template>
|
||
|
||
<template #time="{ row }">
|
||
{{ formatDate(row.createdAt) }}
|
||
</template>
|
||
|
||
<template #empty>
|
||
<AEmpty
|
||
:description="
|
||
currentSearchKeyword.trim() ? '没有匹配的文件' : '暂无文件'
|
||
"
|
||
/>
|
||
</template>
|
||
</Grid>
|
||
|
||
<!-- 图片预览弹窗(自建 Modal + img,支持加载失败提示) -->
|
||
<AModal
|
||
:footer="null"
|
||
:open="previewVisible"
|
||
:title="previewTitle"
|
||
:width="previewWidth"
|
||
@cancel="closeImagePreview"
|
||
>
|
||
<div class="image-preview-container">
|
||
<img
|
||
v-if="previewSrc && !previewError"
|
||
:src="previewSrc"
|
||
class="image-preview-img"
|
||
@error="previewError = true"
|
||
@load="onImageLoad"
|
||
/>
|
||
<AEmpty
|
||
v-if="previewError"
|
||
description="图片加载失败,文件可能已损坏或不存在"
|
||
/>
|
||
<ASpin v-if="previewLoading" class="image-preview-spin" />
|
||
</div>
|
||
</AModal>
|
||
|
||
<!-- 文本预览弹窗 -->
|
||
<AModal
|
||
:open="textPreviewVisible"
|
||
:title="textPreviewTitle"
|
||
:footer="null"
|
||
width="80%"
|
||
@cancel="textPreviewVisible = false"
|
||
>
|
||
<ASpin :spinning="textPreviewLoading">
|
||
<pre class="text-preview-content">{{ textPreviewContent }}</pre>
|
||
</ASpin>
|
||
</AModal>
|
||
|
||
<!-- 新建文件夹弹窗 -->
|
||
<AModal
|
||
:open="createFolderVisible"
|
||
title="新建文件夹"
|
||
@ok="handleCreateFolder"
|
||
@cancel="createFolderVisible = false"
|
||
>
|
||
<AForm layout="vertical">
|
||
<AFormItem label="文件夹名称">
|
||
<AInput v-model:value="newFolderName" placeholder="请输入名称" />
|
||
</AFormItem>
|
||
</AForm>
|
||
</AModal>
|
||
|
||
<!-- 重命名弹窗 -->
|
||
<AModal
|
||
:open="renameVisible"
|
||
title="重命名文件夹"
|
||
@ok="handleRename"
|
||
@cancel="renameVisible = false"
|
||
>
|
||
<AInput v-model:value="renameValue" placeholder="请输入新名称" />
|
||
</AModal>
|
||
|
||
<!-- 移动文件弹窗 -->
|
||
<AModal
|
||
:open="moveVisible"
|
||
title="移动文件到..."
|
||
@ok="handleMove"
|
||
@cancel="moveVisible = false"
|
||
>
|
||
<ATreeSelect
|
||
v-model:value="moveTargetFolderId"
|
||
:tree-data="treeData"
|
||
:tree-default-expand-all="true"
|
||
allow-clear
|
||
placeholder="选择目标文件夹(不选=移到根目录)"
|
||
style="width: 100%"
|
||
/>
|
||
</AModal>
|
||
|
||
<!-- 创建分享弹窗 -->
|
||
<AModal
|
||
:confirm-loading="shareLoading"
|
||
:open="shareVisible"
|
||
title="创建分享链接"
|
||
@ok="handleCreateShare"
|
||
@cancel="shareVisible = false"
|
||
>
|
||
<AForm layout="vertical">
|
||
<AFormItem label="设置密码">
|
||
<ASpace>
|
||
<ACheckbox v-model:checked="shareForm.enablePassword">
|
||
需要密码
|
||
</ACheckbox>
|
||
<AInput
|
||
v-if="shareForm.enablePassword"
|
||
v-model:value="shareForm.password"
|
||
placeholder="访问密码"
|
||
style="width: 200px"
|
||
/>
|
||
</ASpace>
|
||
</AFormItem>
|
||
<AFormItem label="有效期">
|
||
<ASpace>
|
||
<ACheckbox v-model:checked="shareForm.enableExpiry">
|
||
设置过期时间
|
||
</ACheckbox>
|
||
<ADatePicker
|
||
v-if="shareForm.enableExpiry"
|
||
v-model:value="shareForm.expiresAt"
|
||
show-time
|
||
format="YYYY-MM-DD HH:mm"
|
||
/>
|
||
</ASpace>
|
||
</AFormItem>
|
||
<AFormItem label="下载次数限制">
|
||
<ASpace>
|
||
<ACheckbox v-model:checked="shareForm.enableLimit">
|
||
限制次数
|
||
</ACheckbox>
|
||
<AInputNumber
|
||
v-if="shareForm.enableLimit"
|
||
v-model:value="shareForm.maxDownloads"
|
||
:min="1"
|
||
/>
|
||
</ASpace>
|
||
</AFormItem>
|
||
</AForm>
|
||
</AModal>
|
||
|
||
<!-- 分享结果弹窗 -->
|
||
<AModal
|
||
:footer="null"
|
||
:open="shareResultVisible"
|
||
title="分享链接已生成"
|
||
@cancel="shareResultVisible = false"
|
||
>
|
||
<p style="margin-bottom: 12px">复制以下链接分享给他人:</p>
|
||
<ASpace style="width: 100%">
|
||
<AInput :value="shareLinkUrl" read-only style="flex: 1" />
|
||
<AButton type="primary" @click="copyShareLink">复制</AButton>
|
||
</ASpace>
|
||
</AModal>
|
||
</div>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.file-manager {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
|
||
/* 填满 Page(auto-content-height) 内容区,让内部 Vxe Grid 的 height:'auto'
|
||
能拿到确定的父级高度(否则首屏表格体高度计算异常,需点全屏再还原才恢复) */
|
||
height: 100%;
|
||
}
|
||
|
||
/* Grid 包装 div:占据面包屑/上传进度条之外的剩余高度,
|
||
min-height:0 允许表格体按 height:'auto' 正确收缩计算 */
|
||
.file-manager :deep(.fm-grid) {
|
||
flex: 1 1 0%;
|
||
height: auto;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 名称列:图标 + 文字水平排列 */
|
||
.name-cell {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.drag-handle-icon {
|
||
flex-shrink: 0;
|
||
font-size: 14px;
|
||
color: hsl(var(--muted-foreground));
|
||
cursor: grab;
|
||
}
|
||
|
||
.name-icon-folder {
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
color: hsl(var(--primary));
|
||
}
|
||
|
||
.name-icon-file {
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
color: hsl(var(--muted-foreground));
|
||
}
|
||
|
||
/* 文件夹名称:可点击链接样式 */
|
||
.name-link {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: hsl(var(--primary));
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.name-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.name-text {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 图片预览弹窗 */
|
||
.image-preview-container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 300px;
|
||
}
|
||
|
||
.image-preview-img {
|
||
max-width: 100%;
|
||
max-height: 70vh;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.image-preview-spin {
|
||
position: absolute;
|
||
}
|
||
|
||
.nav-breadcrumb {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.breadcrumb-drop-target {
|
||
padding: 2px 6px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.breadcrumb-drop-active {
|
||
color: hsl(var(--primary));
|
||
background-color: hsl(var(--primary) / 12%);
|
||
}
|
||
|
||
.upload-progress {
|
||
padding: 12px 16px;
|
||
background-color: hsl(var(--muted) / 30%);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.upload-progress-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.upload-progress-text {
|
||
font-size: 13px;
|
||
color: hsl(var(--muted-foreground));
|
||
}
|
||
|
||
.text-preview-content {
|
||
max-height: 70vh;
|
||
padding: 16px;
|
||
margin: 0;
|
||
overflow: auto;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
word-break: break-all;
|
||
white-space: pre-wrap;
|
||
background-color: hsl(var(--muted) / 30%);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
:deep(.file-row-draggable) {
|
||
cursor: grab;
|
||
}
|
||
|
||
:deep(.file-row-draggable:active) {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
:deep(.folder-row-drop-active > td) {
|
||
background-color: hsl(var(--primary) / 10%) !important;
|
||
}
|
||
</style>
|