ljw559 d5084cdf70 fix: 修复文件管理/菜单/权限/工作流多处 UI 问题并优化弹窗
- 文件管理:列表高度自适应、文件大小展示、文件夹递归总大小
- 菜单/权限管理:树表默认折叠、菜单列宽自适应
- 菜单编辑弹窗:加宽、移除允许角色/组件路径/重定向字段
- 工作流:节点选中框位置偏移修复
- 弹窗:统一默认宽度 640px,修正 width prop 失效
- 图标选择:Popover 小浮层改为居中 Dialog 弹窗

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 00:45:34 +08:00

1273 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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