<script lang="ts" setup>
|
import type { ContextMenuItem } from '@jnpf/ui';
|
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
|
|
import type { Component } from 'vue';
|
|
import type { FolderClipboard } from './folderMenuRules';
|
|
import type { DmsPermissionResource } from '#/api/x/dms/permission';
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicTree } from '@jnpf/ui';
|
import { useModal } from '@jnpf/ui/modal';
|
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
|
import { formatToDateTime } from '@jnpf/utils';
|
|
import {
|
FileExcelOutlined,
|
FileImageOutlined,
|
FileOutlined,
|
FilePdfOutlined,
|
FileTextOutlined,
|
FileWordOutlined,
|
FolderFilled,
|
FolderOutlined,
|
MenuFoldOutlined,
|
MenuUnfoldOutlined,
|
RightOutlined,
|
SearchOutlined,
|
} from '@ant-design/icons-vue';
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { copyDmsFolder, deleteDmsFolder, getPermissionResourceTree, moveDmsFolder, reorderDmsFolder } from '#/api/x/dms/permission';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import FileOperationLogModal from '../file-center/FileOperationLogModal.vue';
|
import { dmsPreviewErrorMessage, openDmsFilePreview } from '../shared/dmsFilePreview';
|
import FileViewModeSwitch from '../shared/FileViewModeSwitch.vue';
|
import { compareFolders, folderMenuAvailability } from './folderMenuRules';
|
import { permissionResourceDisplayName } from './permissionRules';
|
import RenameFolderModal from './RenameFolderModal.vue';
|
|
interface ExplorerLocation {
|
folder: DmsPermissionResource;
|
path: DmsPermissionResource[];
|
}
|
|
interface FolderTreeNode {
|
children?: FolderTreeNode[];
|
isLeaf?: boolean;
|
key: string;
|
resourceType: 'DOCUMENT' | 'FOLDER';
|
title: string;
|
}
|
|
interface FolderTreeLoadNode {
|
key: number | string;
|
}
|
|
interface FolderSignData {
|
biz_sign?: string;
|
}
|
|
const emit = defineEmits<{
|
createFolder: [folder: DmsPermissionResource];
|
folderChange: [folder: DmsPermissionResource];
|
select: [resource: DmsPermissionResource];
|
treeCollapse: [collapsed: boolean];
|
}>();
|
|
const { createConfirm, createMessage } = useMessage();
|
const [registerRenameFolderModal, { openModal: openRenameFolderModal }] = useModal();
|
const [registerOperationLogModal, { openModal: openOperationLogModal }] = useModal();
|
const loading = ref(false);
|
const folderOperationPending = ref(false);
|
const roots = ref<DmsPermissionResource[]>([]);
|
const entries = ref<DmsPermissionResource[]>([]);
|
const currentFolder = ref<DmsPermissionResource>();
|
const currentPath = ref<DmsPermissionResource[]>([]);
|
const selectedId = ref('');
|
const keyword = ref('');
|
const viewMode = ref<'details' | 'tiles'>('details');
|
const folderTreeData = ref<FolderTreeNode[]>([]);
|
const expandedFolderKeys = ref<string[]>([]);
|
const treeCollapsed = ref(false);
|
const contextFolderId = ref('');
|
const clipboard = ref<FolderClipboard>();
|
const treeResourceById = new Map<string, DmsPermissionResource>();
|
const treePathById = new Map<string, DmsPermissionResource[]>();
|
let selectTimer: number | undefined;
|
|
const selectedFolderKeys = computed(() => {
|
const id = contextFolderId.value || currentFolder.value?.id;
|
return id ? [id] : [];
|
});
|
const emptyDescription = computed(() => {
|
if (keyword.value) return '没有匹配的项目';
|
return '此文件夹为空';
|
});
|
const visibleEntries = computed(() => {
|
return [...entries.value].sort((left, right) => {
|
const typeDiff = resourceRank(left) - resourceRank(right);
|
if (typeDiff) return typeDiff;
|
if (left.resourceType === 'FOLDER' && right.resourceType === 'FOLDER') return compareFolders(left, right);
|
return displayName(left).localeCompare(displayName(right), 'zh-CN') || left.id.localeCompare(right.id);
|
});
|
});
|
const selectedEntry = computed(() => entries.value.find((entry) => entry.id === selectedId.value));
|
|
const detailColumns: BasicColumn[] = [
|
{ title: '标题', dataIndex: 'name', minWidth: 280, slots: { default: 'entryName' } },
|
{ title: '文件编号', dataIndex: 'documentNo', minWidth: 140, slots: { default: 'documentNo' } },
|
{ title: '版本号', dataIndex: 'versionLabel', width: 100, slots: { default: 'versionLabel' } },
|
{ title: '上传日期', dataIndex: 'createdAt', width: 120, slots: { default: 'createdAt' } },
|
{ title: '有效期至', dataIndex: 'effectiveTo', width: 120, slots: { default: 'effectiveTo' } },
|
{ title: '文件大小', dataIndex: 'sizeBytes', width: 110, slots: { default: 'sizeBytes' } },
|
{ title: '生效日期', dataIndex: 'effectiveFrom', width: 120, slots: { default: 'effectiveFrom' } },
|
];
|
const [registerDetailTable] = useVxeTable({
|
actionColumn: { title: '操作', dataIndex: 'action', fixed: 'right', width: 90 },
|
columns: detailColumns,
|
dataSource: visibleEntries,
|
emptyText: emptyDescription,
|
immediate: false,
|
pagination: false,
|
rowKey: 'id',
|
showIndexColumn: false,
|
showTableSetting: false,
|
});
|
|
function resourceRank(resource: DmsPermissionResource) {
|
if (resource.resourceType === 'FOLDER') return 0;
|
if (resource.resourceType === 'DOCUMENT') return 1;
|
return 2;
|
}
|
|
function displayName(resource: DmsPermissionResource) {
|
return permissionResourceDisplayName(resource);
|
}
|
|
function resourceTypeLabel(resource: DmsPermissionResource) {
|
if (resource.resourceType === 'FOLDER') return '文件夹';
|
if (resource.resourceType === 'DOCUMENT') return '文件目录';
|
if (resource.resourceType === 'FILE_VERSION') return `文件版本 v${resource.versionNo ?? '-'}`;
|
return '文件';
|
}
|
|
function resourceIcon(resource: DmsPermissionResource): Component {
|
if (isDirectory(resource)) return FolderFilled;
|
const name = displayName(resource).toLowerCase();
|
if (name.endsWith('.pdf')) return FilePdfOutlined;
|
if (/\.(?:doc|docx)$/.test(name)) return FileWordOutlined;
|
if (/\.(?:xls|xlsx|csv)$/.test(name)) return FileExcelOutlined;
|
if (/\.(?:gif|jpe?g|png|webp)$/.test(name)) return FileImageOutlined;
|
if (/\.(?:md|rtf|txt)$/.test(name)) return FileTextOutlined;
|
return FileOutlined;
|
}
|
|
function resourceIconTone(resource: DmsPermissionResource) {
|
if (isDirectory(resource)) return 'folder';
|
const name = displayName(resource).toLowerCase();
|
if (name.endsWith('.pdf')) return 'pdf';
|
if (/\.(?:doc|docx)$/.test(name)) return 'word';
|
if (/\.(?:xls|xlsx|csv)$/.test(name)) return 'excel';
|
if (/\.(?:gif|jpe?g|png|webp)$/.test(name)) return 'image';
|
return 'file';
|
}
|
|
function displaySize(resource: DmsPermissionResource) {
|
if (isDirectory(resource)) return '—';
|
const bytes = resource.sizeBytes;
|
if (bytes === undefined || bytes === null) return '—';
|
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
}
|
|
function displayDate(value?: string) {
|
return value ? formatToDateTime(value, 'YYYY-MM-DD') : '—';
|
}
|
|
function fileValue(resource: DmsPermissionResource, value: string) {
|
return isDirectory(resource) ? '—' : value;
|
}
|
|
function validUntil(resource: DmsPermissionResource) {
|
if (isDirectory(resource)) return '—';
|
if (resource.effectiveTo) return displayDate(resource.effectiveTo);
|
return resource.lifecycleStatus === 'EFFECTIVE' ? '长期有效' : '—';
|
}
|
|
function isDirectory(resource: DmsPermissionResource) {
|
return resource.resourceType === 'FOLDER' || resource.resourceType === 'DOCUMENT';
|
}
|
|
async function fetchEntries(folder: DmsPermissionResource, searchKeyword?: string) {
|
const response = await getPermissionResourceTree({ keyword: searchKeyword, parentId: folder.id, pageSize: 100 });
|
return response.data || [];
|
}
|
|
function toTreeNode(resource: DmsPermissionResource, current?: FolderTreeNode): FolderTreeNode {
|
const isDocument = resource.resourceType === 'DOCUMENT';
|
return {
|
children: isDocument ? undefined : current?.children,
|
isLeaf: isDocument || (current?.isLeaf ?? false),
|
key: resource.id,
|
resourceType: isDocument ? 'DOCUMENT' : 'FOLDER',
|
title: displayName(resource),
|
};
|
}
|
|
function findTreeNode(nodes: FolderTreeNode[], key: string): FolderTreeNode | undefined {
|
for (const node of nodes) {
|
if (node.key === key) return node;
|
const child = node.children ? findTreeNode(node.children, key) : undefined;
|
if (child) return child;
|
}
|
}
|
|
function replaceTreeNode(nodes: FolderTreeNode[], key: string, replacement: FolderTreeNode): FolderTreeNode[] {
|
return nodes.map((node) => {
|
if (node.key === key) return replacement;
|
if (!node.children) return node;
|
return { ...node, children: replaceTreeNode(node.children, key, replacement) };
|
});
|
}
|
|
function syncTreeChildren(parent: DmsPermissionResource, resources: DmsPermissionResource[], path: DmsPermissionResource[]) {
|
treeResourceById.set(parent.id, parent);
|
treePathById.set(parent.id, path);
|
|
const parentNode = findTreeNode(folderTreeData.value, parent.id);
|
if (!parentNode) return;
|
const currentChildren = new Map((parentNode.children || []).map((node) => [node.key, node]));
|
const folderChildren = resources.filter((resource) => resource.resourceType === 'FOLDER').sort(compareFolders);
|
const documentChildren = resources
|
.filter((resource) => resource.resourceType === 'DOCUMENT')
|
.sort((left, right) => displayName(left).localeCompare(displayName(right), 'zh-CN') || left.id.localeCompare(right.id));
|
const children = [...folderChildren, ...documentChildren].map((resource) => {
|
treeResourceById.set(resource.id, resource);
|
treePathById.set(resource.id, [...path, resource]);
|
return toTreeNode(resource, currentChildren.get(resource.id));
|
});
|
|
folderTreeData.value = replaceTreeNode(folderTreeData.value, parent.id, {
|
...parentNode,
|
children,
|
isLeaf: children.length === 0,
|
});
|
}
|
|
function expandFolderPath(path: DmsPermissionResource[]) {
|
expandedFolderKeys.value = [...new Set([...expandedFolderKeys.value, ...path.map((folder) => folder.id)])];
|
}
|
|
async function applyLocation(location: ExplorerLocation) {
|
loading.value = true;
|
try {
|
const loadedEntries = await fetchEntries(location.folder);
|
entries.value = loadedEntries;
|
syncTreeChildren(location.folder, loadedEntries, location.path);
|
expandFolderPath(location.path);
|
currentFolder.value = location.folder;
|
currentPath.value = location.path;
|
contextFolderId.value = location.folder.id;
|
selectedId.value = location.folder.id;
|
emit('folderChange', location.folder);
|
emit('select', location.folder);
|
} catch {
|
entries.value = [];
|
createMessage.error('目录内容加载失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function navigate(location: ExplorerLocation) {
|
keyword.value = '';
|
await applyLocation(location);
|
}
|
|
async function loadRoots(preferredPathIds: string[] = []) {
|
loading.value = true;
|
try {
|
const response = await getPermissionResourceTree({ pageSize: 100 });
|
roots.value = (response.data || []).filter((resource) => resource.resourceType === 'FOLDER').sort(compareFolders);
|
treeResourceById.clear();
|
treePathById.clear();
|
folderTreeData.value = roots.value.map((folder) => {
|
treeResourceById.set(folder.id, folder);
|
treePathById.set(folder.id, [folder]);
|
return toTreeNode(folder);
|
});
|
expandedFolderKeys.value = [];
|
let resource = roots.value.find((item) => item.id === preferredPathIds[0]) || roots.value[0];
|
if (!resource) {
|
entries.value = [];
|
currentFolder.value = undefined;
|
currentPath.value = [];
|
contextFolderId.value = '';
|
selectedId.value = '';
|
return;
|
}
|
let path = [resource];
|
for (const preferredId of preferredPathIds.slice(1)) {
|
const loadedEntries = await fetchEntries(resource);
|
syncTreeChildren(resource, loadedEntries, path);
|
const child = loadedEntries.find((item) => isDirectory(item) && item.id === preferredId);
|
if (!child) break;
|
resource = child;
|
path = [...path, child];
|
if (resource.resourceType === 'DOCUMENT') break;
|
}
|
await navigate({ folder: resource, path });
|
} catch {
|
roots.value = [];
|
entries.value = [];
|
createMessage.error('文件根目录加载失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function siblingFolders(folder: DmsPermissionResource) {
|
if (!folder.parentId) return roots.value;
|
if (currentFolder.value?.id === folder.parentId) {
|
return entries.value.filter((item) => item.resourceType === 'FOLDER').sort(compareFolders);
|
}
|
const parentNode = findTreeNode(folderTreeData.value, folder.parentId);
|
return (parentNode?.children || [])
|
.map((node) => treeResourceById.get(node.key))
|
.filter((item): item is DmsPermissionResource => item?.resourceType === 'FOLDER')
|
.sort(compareFolders);
|
}
|
|
function folderErrorMessage(error: unknown, fallback: string) {
|
return error instanceof Error && error.message ? error.message : fallback;
|
}
|
|
function revisionOf(folder: DmsPermissionResource) {
|
if (folder.revision === undefined) {
|
throw new Error('目录版本信息缺失,请刷新后重试');
|
}
|
return folder.revision;
|
}
|
|
async function reorderFolder(folder: DmsPermissionResource, path: DmsPermissionResource[], direction: 'DOWN' | 'UP', bizSign?: string) {
|
folderOperationPending.value = true;
|
try {
|
await reorderDmsFolder(folder.id, { direction, revision: revisionOf(folder) }, bizSign);
|
await loadRoots(path.map((item) => item.id));
|
createMessage.success(direction === 'UP' ? '目录已上移' : '目录已下移');
|
} catch (error) {
|
createMessage.error(folderErrorMessage(error, '目录排序失败'));
|
} finally {
|
folderOperationPending.value = false;
|
}
|
}
|
|
function deleteFolder(folder: DmsPermissionResource, path: DmsPermissionResource[]) {
|
createConfirm({
|
content: `删除目录“${folder.name}”?只能删除不包含子目录和文件的空目录。`,
|
iconType: 'warning',
|
title: '删除目录',
|
onOk: () =>
|
runWithFolderSign('删除', folder, (signData) => submitDeleteFolder(folder, path, signData.biz_sign), [
|
{ folderId: folder.id, name: folder.name, revision: folder.revision },
|
]),
|
});
|
}
|
|
async function submitDeleteFolder(folder: DmsPermissionResource, path: DmsPermissionResource[], bizSign?: string) {
|
folderOperationPending.value = true;
|
try {
|
await deleteDmsFolder(folder.id, revisionOf(folder), bizSign);
|
if (clipboard.value?.source.id === folder.id) clipboard.value = undefined;
|
await loadRoots(path.slice(0, -1).map((item) => item.id));
|
createMessage.success('目录已删除');
|
} catch (error) {
|
createMessage.error(folderErrorMessage(error, '目录删除失败'));
|
} finally {
|
folderOperationPending.value = false;
|
}
|
}
|
|
async function pasteFolder(target: DmsPermissionResource, targetPath: DmsPermissionResource[], bizSign?: string) {
|
const value = clipboard.value;
|
if (!value) return;
|
folderOperationPending.value = true;
|
try {
|
if (value.mode === 'cut') {
|
await moveDmsFolder(value.source.id, { parentId: target.id, revision: revisionOf(value.source) }, bizSign);
|
clipboard.value = undefined;
|
createMessage.success(`目录已移动到“${target.name}”`);
|
} else {
|
await copyDmsFolder(value.source.id, { parentId: target.id, requestId: uuidv4() }, bizSign);
|
createMessage.success(`目录已复制到“${target.name}”`);
|
}
|
await loadRoots(targetPath.map((item) => item.id));
|
} catch (error) {
|
createMessage.error(folderErrorMessage(error, value.mode === 'cut' ? '目录移动失败' : '目录复制失败'));
|
} finally {
|
folderOperationPending.value = false;
|
}
|
}
|
|
async function handleFolderRenamed(folder: DmsPermissionResource) {
|
if (clipboard.value?.source.id === folder.id) clipboard.value = { ...clipboard.value, source: folder };
|
await loadRoots(currentPath.value.map((item) => item.id));
|
createMessage.success('目录已修改');
|
}
|
|
function runWithFolderSign(
|
action: string,
|
folder: DmsPermissionResource,
|
onSubmit: (signData: FolderSignData) => Promise<void> | void,
|
bizData: object[] = [folder],
|
) {
|
onlineUtils.sign({
|
title: '电子签名',
|
isFaceToFace: false,
|
metaData: {
|
biz_button: action,
|
biz_data: bizData,
|
biz_module: '文件夹权限管理',
|
biz_title: folder.name,
|
is_biz_form: false,
|
is_review_button: false,
|
},
|
onCancel: () => {},
|
onSubmit,
|
});
|
}
|
|
function buildFolderTreeContextMenu(node: FolderTreeLoadNode, event: MouseEvent): ContextMenuItem[] {
|
const resource = treeResourceById.get(String(node.key));
|
const path = resource ? treePathById.get(resource.id) : undefined;
|
if (!resource || !path) return [];
|
if (resource.resourceType === 'DOCUMENT') {
|
event.preventDefault();
|
contextFolderId.value = resource.id;
|
if (resource.id === currentFolder.value?.id) selectEntry(resource);
|
else void navigate({ folder: resource, path });
|
return [];
|
}
|
const folder = resource;
|
|
contextFolderId.value = folder.id;
|
selectEntry(folder);
|
if (folder.id !== currentFolder.value?.id) void navigate({ folder, path });
|
|
const availability = folderMenuAvailability(folder, siblingFolders(folder), path, clipboard.value);
|
const disabled = folderOperationPending.value;
|
return [
|
{
|
disabled,
|
handler: () => emit('createFolder', folder),
|
icon: 'lucide:folder-plus',
|
label: '新增',
|
},
|
{
|
disabled,
|
handler: () => openRenameFolderModal(true, { folder }),
|
icon: 'lucide:pencil',
|
label: '修改',
|
},
|
{
|
disabled: disabled || !availability.canDelete,
|
divider: true,
|
handler: () => deleteFolder(folder, path),
|
icon: 'lucide:trash-2',
|
label: '删除',
|
},
|
{
|
disabled: disabled || !availability.canMoveUp,
|
handler: () => runWithFolderSign('上移', folder, (signData) => reorderFolder(folder, path, 'UP', signData.biz_sign), [{ direction: 'UP', folder }]),
|
icon: 'lucide:arrow-up',
|
label: '上移',
|
},
|
{
|
disabled: disabled || !availability.canMoveDown,
|
divider: true,
|
handler: () => runWithFolderSign('下移', folder, (signData) => reorderFolder(folder, path, 'DOWN', signData.biz_sign), [{ direction: 'DOWN', folder }]),
|
icon: 'lucide:arrow-down',
|
label: '下移',
|
},
|
{
|
disabled: disabled || !folder.parentId,
|
handler: () => {
|
clipboard.value = { mode: 'cut', source: folder };
|
createMessage.success(`已剪切“${folder.name}”`);
|
},
|
icon: 'lucide:scissors',
|
label: '剪切',
|
},
|
{
|
disabled,
|
handler: () => {
|
clipboard.value = { mode: 'copy', source: folder };
|
createMessage.success(`已复制“${folder.name}”`);
|
},
|
icon: 'lucide:copy',
|
label: '复制',
|
},
|
{
|
disabled: disabled || !availability.canPaste,
|
divider: true,
|
handler: () => {
|
const value = clipboard.value;
|
if (!value) return;
|
runWithFolderSign('粘贴', folder, (signData) => pasteFolder(folder, path, signData.biz_sign), [
|
{ mode: value.mode, source: value.source, target: folder },
|
]);
|
},
|
icon: 'lucide:clipboard-paste',
|
label: '粘贴',
|
},
|
{
|
disabled,
|
handler: () => loadRoots(path.map((item) => item.id)),
|
icon: 'lucide:refresh-cw',
|
label: '刷新',
|
},
|
];
|
}
|
|
async function loadFolderTreeChildren(node: FolderTreeLoadNode) {
|
const folder = treeResourceById.get(String(node.key));
|
if (!folder || folder.resourceType !== 'FOLDER') return;
|
try {
|
const loadedEntries = await fetchEntries(folder);
|
syncTreeChildren(folder, loadedEntries, treePathById.get(folder.id) || [folder]);
|
} catch {
|
createMessage.error('子目录加载失败');
|
}
|
}
|
|
function handleFolderTreeExpand(keys: (number | string)[]) {
|
expandedFolderKeys.value = keys.map(String);
|
}
|
|
function handleFolderTreeSelect(keys: (number | string)[]) {
|
const key = keys[0];
|
if (key === undefined) return;
|
const resource = treeResourceById.get(String(key));
|
const path = resource ? treePathById.get(resource.id) : undefined;
|
if (!resource || !path) return;
|
if (resource.id === currentFolder.value?.id) {
|
selectEntry(resource);
|
return;
|
}
|
void navigate({ folder: resource, path });
|
}
|
|
function toggleFolderTree() {
|
treeCollapsed.value = !treeCollapsed.value;
|
emit('treeCollapse', treeCollapsed.value);
|
}
|
|
async function openEntry(resource: DmsPermissionResource) {
|
if (selectedId.value !== resource.id) selectEntry(resource);
|
if (isDirectory(resource)) {
|
await navigate({ folder: resource, path: [...currentPath.value, resource] });
|
return;
|
}
|
try {
|
await openDmsFilePreview({
|
fileName: displayName(resource),
|
mimeType: resource.mimeType,
|
resourceId: resource.id,
|
resourceType: 'FILE_VERSION',
|
});
|
} catch (error) {
|
createMessage.error(dmsPreviewErrorMessage(error));
|
}
|
}
|
|
function showOperationLogs(resource: DmsPermissionResource) {
|
if (resource.resourceType !== 'FILE_VERSION') return;
|
clearSelectTimer();
|
selectEntry(resource);
|
openOperationLogModal(true, { item: resource });
|
}
|
|
function selectEntry(resource: DmsPermissionResource) {
|
selectedId.value = resource.id;
|
emit('select', resource);
|
}
|
|
function handleEntryClick(resource: DmsPermissionResource) {
|
clearSelectTimer();
|
selectTimer = window.setTimeout(() => {
|
selectTimer = undefined;
|
selectEntry(resource);
|
}, 250);
|
}
|
|
function handleEntryDoubleClick(resource: DmsPermissionResource) {
|
clearSelectTimer();
|
void openEntry(resource);
|
}
|
|
function handleDetailCellClick({ row }: { row: DmsPermissionResource }) {
|
handleEntryClick(row);
|
}
|
|
function handleDetailCellDoubleClick({ column, row }: { column?: { field?: string; property?: string }; row: DmsPermissionResource }) {
|
if (column?.field === 'action' || column?.property === 'action') return;
|
handleEntryDoubleClick(row);
|
}
|
|
function getEntryRowClass(context: unknown) {
|
const { record, row } = context as { record?: DmsPermissionResource; row?: DmsPermissionResource };
|
const resource = row || record;
|
return resource?.id === selectedId.value ? 'is-selected' : '';
|
}
|
|
function getEntryActions(resource: DmsPermissionResource): ActionItem[] {
|
if (resource.resourceType !== 'FILE_VERSION') return [];
|
return [{ label: '日志', onClick: () => showOperationLogs(resource) }];
|
}
|
|
function clearSelectTimer() {
|
if (selectTimer === undefined) return;
|
window.clearTimeout(selectTimer);
|
selectTimer = undefined;
|
}
|
|
async function openBreadcrumb(index: number) {
|
const folder = currentPath.value[index];
|
if (!folder || folder.id === currentFolder.value?.id) return;
|
await navigate({ folder, path: currentPath.value.slice(0, index + 1) });
|
}
|
|
async function refresh() {
|
if (!currentFolder.value) return loadRoots();
|
if (keyword.value.trim()) return searchFiles();
|
await applyLocation({ folder: currentFolder.value, path: currentPath.value });
|
}
|
|
async function searchFiles() {
|
const folder = currentFolder.value;
|
if (!folder) return;
|
loading.value = true;
|
selectedId.value = '';
|
try {
|
entries.value = await fetchEntries(folder, keyword.value.trim() || undefined);
|
} catch {
|
entries.value = [];
|
createMessage.error('文件搜索失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function reload() {
|
return loadRoots(currentPath.value.map((item) => item.id));
|
}
|
|
onMounted(() => loadRoots());
|
onBeforeUnmount(clearSelectTimer);
|
|
defineExpose({ refresh, reload });
|
</script>
|
|
<template>
|
<section class="resource-explorer">
|
<aside class="folder-navigation" :class="{ collapsed: treeCollapsed }">
|
<div class="folder-navigation-header">
|
<strong v-if="!treeCollapsed">目录</strong>
|
<a-tooltip :title="treeCollapsed ? '展开目录树' : '收起目录树'">
|
<a-button class="folder-navigation-toggle" :aria-label="treeCollapsed ? '展开目录树' : '收起目录树'" type="text" @click="toggleFolderTree">
|
<MenuUnfoldOutlined v-if="treeCollapsed" />
|
<MenuFoldOutlined v-else />
|
</a-button>
|
</a-tooltip>
|
</div>
|
<div v-if="!treeCollapsed" class="folder-tree-scroll">
|
<BasicTree
|
class="folder-tree"
|
:before-right-click="buildFolderTreeContextMenu"
|
:expanded-keys="expandedFolderKeys"
|
:field-names="{ key: 'key', title: 'title', children: 'children' }"
|
:load-data="loadFolderTreeChildren"
|
:selected-keys="selectedFolderKeys"
|
:tree-data="folderTreeData"
|
@update:expanded-keys="handleFolderTreeExpand"
|
@select="handleFolderTreeSelect">
|
<template #title="{ key, resourceType, title }">
|
<span class="folder-tree-title" :title="resourceType === 'DOCUMENT' ? `${title}(ID:${key})` : title">
|
<FolderOutlined v-if="resourceType === 'FOLDER'" />
|
<FileTextOutlined v-else />
|
<span>{{ title }}</span>
|
</span>
|
</template>
|
</BasicTree>
|
</div>
|
</aside>
|
|
<div class="explorer-main">
|
<div class="navigation-bar">
|
<div class="address-bar" aria-label="当前位置">
|
<template v-for="(folder, index) in currentPath" :key="folder.id">
|
<button type="button" :title="displayName(folder)" @click="openBreadcrumb(index)">{{ displayName(folder) }}</button>
|
<RightOutlined v-if="index < currentPath.length - 1" />
|
</template>
|
</div>
|
|
<jnpf-input
|
v-model:value="keyword"
|
class="entry-search"
|
allow-clear
|
:maxlength="200"
|
placeholder="搜索文件"
|
@change="!keyword && searchFiles()"
|
@press-enter="searchFiles">
|
<template #prefix><SearchOutlined /></template>
|
</jnpf-input>
|
|
<div class="navigation-actions"><slot name="actions"></slot></div>
|
</div>
|
|
<div class="file-pane">
|
<div class="view-command-bar">
|
<strong>{{ currentFolder?.name || '文件' }}</strong>
|
<div class="view-command-actions">
|
<span>{{ visibleEntries.length }} 个项目</span>
|
<FileViewModeSwitch v-model:value="viewMode" />
|
</div>
|
</div>
|
|
<div class="entry-viewport" :class="`view-${viewMode}`" v-loading="viewMode === 'tiles' && loading">
|
<BasicVxeTable
|
v-if="viewMode === 'details'"
|
class="resource-table"
|
:loading="loading"
|
:row-class-name="getEntryRowClass"
|
@cell-click="handleDetailCellClick"
|
@cell-dblclick="handleDetailCellDoubleClick"
|
@register="registerDetailTable">
|
<template #entryName="{ record }">
|
<span
|
class="entry-name"
|
:aria-selected="selectedId === record.id"
|
:title="displayName(record)"
|
tabindex="0"
|
@keydown.enter.stop="openEntry(record)">
|
<span class="entry-icon" :class="resourceIconTone(record)"><component :is="resourceIcon(record)" /></span>
|
<span>{{ displayName(record) }}</span>
|
</span>
|
</template>
|
<template #documentNo="{ record }">{{ fileValue(record, record.documentNo || '—') }}</template>
|
<template #versionLabel="{ record }">{{ fileValue(record, record.versionLabel || `v${record.versionNo ?? '-'}`) }}</template>
|
<template #createdAt="{ record }">{{ fileValue(record, displayDate(record.createdAt)) }}</template>
|
<template #effectiveTo="{ record }">{{ validUntil(record) }}</template>
|
<template #sizeBytes="{ record }">{{ fileValue(record, displaySize(record)) }}</template>
|
<template #effectiveFrom="{ record }">{{ fileValue(record, displayDate(record.effectiveFrom)) }}</template>
|
<template #action="{ record }">
|
<span class="entry-action" @dblclick.stop>
|
<TableAction v-if="record.resourceType === 'FILE_VERSION'" :actions="getEntryActions(record)" stop-button-propagation />
|
<span v-else>—</span>
|
</span>
|
</template>
|
</BasicVxeTable>
|
|
<template v-else>
|
<div v-if="visibleEntries.length" class="tile-grid" role="listbox" aria-label="目录内容">
|
<button
|
v-for="entry in visibleEntries"
|
:key="entry.id"
|
class="entry-tile"
|
:class="{ selected: selectedId === entry.id }"
|
type="button"
|
:aria-selected="selectedId === entry.id"
|
:title="displayName(entry)"
|
@click="handleEntryClick(entry)"
|
@dblclick.stop.prevent="handleEntryDoubleClick(entry)"
|
@keydown.enter="openEntry(entry)">
|
<span class="tile-icon" :class="resourceIconTone(entry)"><component :is="resourceIcon(entry)" /></span>
|
<span class="tile-name">{{ displayName(entry) }}</span>
|
<span class="tile-meta">{{ resourceTypeLabel(entry) }}</span>
|
</button>
|
</div>
|
<jnpf-empty v-else-if="!loading" class="entry-empty" :description="emptyDescription" />
|
</template>
|
</div>
|
|
<footer class="explorer-status-bar">
|
<span>{{ entries.length }} 个项目</span>
|
<span v-if="selectedEntry" :title="displayName(selectedEntry)">已选择:{{ displayName(selectedEntry) }}</span>
|
</footer>
|
</div>
|
</div>
|
|
<RenameFolderModal @register="registerRenameFolderModal" @renamed="handleFolderRenamed" />
|
<FileOperationLogModal @register="registerOperationLogModal" />
|
</section>
|
</template>
|
|
<style lang="scss" scoped>
|
.resource-explorer {
|
display: contents;
|
color: var(--text-color);
|
}
|
|
.explorer-main {
|
container: explorer-main / inline-size;
|
display: flex;
|
flex-direction: column;
|
grid-row: 1;
|
grid-column: 2;
|
min-width: 0;
|
min-height: 0;
|
overflow: hidden;
|
background: var(--component-background);
|
}
|
|
.folder-navigation {
|
display: flex;
|
flex-direction: column;
|
grid-row: 1;
|
grid-column: 1;
|
min-width: 0;
|
min-height: 0;
|
overflow: hidden;
|
background: color-mix(in srgb, var(--body-background) 62%, var(--component-background));
|
border-right: 1px solid var(--border-color-base1);
|
}
|
|
.folder-navigation-header {
|
display: flex;
|
flex: 0 0 42px;
|
align-items: center;
|
justify-content: space-between;
|
min-width: 0;
|
padding: 0 6px 0 12px;
|
border-bottom: 1px solid var(--border-color-base1);
|
|
strong {
|
overflow: hidden;
|
text-overflow: ellipsis;
|
font-size: 13px;
|
white-space: nowrap;
|
}
|
|
.collapsed & {
|
justify-content: center;
|
padding: 0;
|
}
|
}
|
|
.folder-navigation-toggle {
|
width: 30px;
|
min-width: 30px;
|
height: 30px;
|
padding: 0;
|
}
|
|
.folder-tree-scroll {
|
flex: 1;
|
min-height: 0;
|
padding: 6px;
|
overflow: auto;
|
}
|
|
.folder-tree {
|
min-width: 100%;
|
background: transparent;
|
}
|
|
.folder-tree-title {
|
display: flex;
|
gap: 7px;
|
align-items: center;
|
min-width: 0;
|
|
> .anticon {
|
flex: 0 0 auto;
|
}
|
|
svg {
|
flex: 0 0 auto;
|
color: #d99c00;
|
}
|
|
> span:last-child {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.folder-tree :deep(.ant-tree-treenode) {
|
width: 100%;
|
padding-block: 1px;
|
}
|
|
.folder-tree :deep(.ant-tree-node-content-wrapper) {
|
min-width: 0;
|
overflow: hidden;
|
}
|
|
.file-pane {
|
display: flex;
|
flex: 1;
|
flex-direction: column;
|
min-width: 0;
|
min-height: 0;
|
}
|
|
.navigation-bar {
|
display: grid;
|
flex: 0 0 auto;
|
grid-template-columns: minmax(180px, 1fr) 180px auto;
|
gap: 10px;
|
align-items: center;
|
min-height: 52px;
|
padding: 8px 12px;
|
border-bottom: 1px solid var(--border-color-base1);
|
}
|
|
.address-bar {
|
display: flex;
|
align-items: center;
|
min-width: 0;
|
height: 34px;
|
padding: 0 8px;
|
overflow: hidden;
|
background: var(--body-background);
|
border: 1px solid var(--border-color-base1);
|
border-radius: 4px;
|
|
button {
|
min-width: 0;
|
max-width: 180px;
|
padding: 3px 7px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
color: inherit;
|
white-space: nowrap;
|
cursor: pointer;
|
background: transparent;
|
border: 0;
|
border-radius: 3px;
|
|
&:hover {
|
background: var(--item-hover-bg);
|
}
|
}
|
|
svg {
|
flex: 0 0 auto;
|
font-size: 10px;
|
color: var(--text-color-secondary);
|
}
|
}
|
|
.entry-search {
|
width: 100%;
|
max-width: 180px;
|
}
|
|
.navigation-actions {
|
display: flex;
|
justify-content: flex-end;
|
min-width: 0;
|
|
:deep(.workspace-actions) {
|
flex: 1 1 auto;
|
justify-content: flex-end;
|
min-width: 0;
|
max-width: 100%;
|
}
|
}
|
|
.view-command-bar {
|
display: flex;
|
flex: 0 0 auto;
|
align-items: center;
|
justify-content: space-between;
|
min-height: 42px;
|
padding: 6px 14px;
|
border-bottom: 1px solid var(--border-color-base1);
|
|
strong {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
font-size: 14px;
|
white-space: nowrap;
|
}
|
}
|
|
.view-command-actions {
|
display: flex;
|
flex: 0 0 auto;
|
gap: 4px;
|
align-items: center;
|
font-size: 12px;
|
color: var(--text-color-secondary);
|
}
|
|
.entry-viewport {
|
flex: 1;
|
min-height: 0;
|
padding: 8px 12px;
|
overflow: auto;
|
|
&.view-details {
|
padding: 0;
|
overflow: hidden;
|
}
|
}
|
|
.resource-table {
|
height: 100%;
|
|
:deep(.vxe-body--row) {
|
cursor: default;
|
}
|
|
:deep(.vxe-body--row.is-selected > .vxe-body--column) {
|
background: color-mix(in srgb, var(--primary-color) 13%, var(--component-background));
|
}
|
|
:deep(.vxe-body--row.is-selected:hover > .vxe-body--column) {
|
background: color-mix(in srgb, var(--primary-color) 17%, var(--component-background));
|
}
|
}
|
|
.entry-name {
|
display: flex;
|
gap: 10px;
|
align-items: center;
|
min-width: 0;
|
|
&:focus-visible {
|
outline: 2px solid var(--primary-color);
|
outline-offset: 2px;
|
}
|
|
> span:last-child {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.entry-icon {
|
display: inline-flex;
|
flex: 0 0 24px;
|
align-items: center;
|
justify-content: center;
|
font-size: 20px;
|
}
|
|
.folder {
|
color: #e6a700;
|
}
|
|
.pdf {
|
color: #d14343;
|
}
|
|
.word {
|
color: #2468c9;
|
}
|
|
.excel {
|
color: #20845b;
|
}
|
|
.image {
|
color: #8b5bb7;
|
}
|
|
.file {
|
color: #6b7280;
|
}
|
|
.tile-grid {
|
display: grid;
|
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
gap: 8px;
|
align-content: start;
|
}
|
|
.entry-tile {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
min-width: 0;
|
height: 132px;
|
padding: 10px;
|
color: inherit;
|
cursor: default;
|
background: transparent;
|
border: 1px solid transparent;
|
border-radius: 6px;
|
|
&:hover {
|
background: var(--item-hover-bg);
|
border-color: var(--border-color-base1);
|
}
|
|
&.selected {
|
background: color-mix(in srgb, var(--primary-color) 13%, transparent);
|
border-color: color-mix(in srgb, var(--primary-color) 45%, transparent);
|
}
|
|
&:focus-visible {
|
outline: 2px solid var(--primary-color);
|
}
|
}
|
|
.tile-icon {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
width: 58px;
|
height: 58px;
|
font-size: 46px;
|
}
|
|
.tile-name {
|
width: 100%;
|
margin-top: 6px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
font-size: 13px;
|
white-space: nowrap;
|
}
|
|
.tile-meta {
|
margin-top: 2px;
|
font-size: 11px;
|
color: var(--text-color-secondary);
|
}
|
|
.entry-empty {
|
margin-top: min(20vh, 140px);
|
}
|
|
.explorer-status-bar {
|
display: flex;
|
flex: 0 0 30px;
|
align-items: center;
|
justify-content: space-between;
|
min-width: 0;
|
padding: 0 12px;
|
font-size: 12px;
|
color: var(--text-color-secondary);
|
border-top: 1px solid var(--border-color-base1);
|
|
span {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
@container explorer-main (max-width: 1120px) {
|
.navigation-bar {
|
grid-template-columns: minmax(0, 1fr) 180px;
|
}
|
|
.address-bar {
|
grid-row: 1;
|
grid-column: 1;
|
}
|
|
.entry-search {
|
grid-row: 1;
|
grid-column: 2;
|
}
|
|
.navigation-actions {
|
grid-row: 2;
|
grid-column: 1 / 3;
|
width: 100%;
|
}
|
}
|
|
@container explorer-main (max-width: 480px) {
|
.navigation-bar {
|
grid-template-columns: minmax(0, 1fr);
|
}
|
|
.address-bar {
|
grid-row: 1;
|
grid-column: 1;
|
}
|
|
.entry-search {
|
grid-row: 2;
|
grid-column: 1;
|
}
|
|
.navigation-actions {
|
grid-row: 3;
|
grid-column: 1;
|
|
:deep(.workspace-actions) {
|
justify-content: flex-start;
|
}
|
}
|
}
|
</style>
|