<script lang="ts" setup>
|
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
|
|
import type { Component } from 'vue';
|
|
import type { DmsCompareSession } from '#/api/x/dms/compare';
|
import type { DmsFileCenterItem, DmsFileCenterMode } from '#/api/x/dms/fileCenter';
|
|
import { computed, h, onMounted, ref, watch } 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,
|
FolderAddOutlined,
|
FolderFilled,
|
FolderOutlined,
|
InfoCircleOutlined,
|
MenuFoldOutlined,
|
MenuUnfoldOutlined,
|
ReloadOutlined,
|
RightOutlined,
|
SearchOutlined,
|
} from '@ant-design/icons-vue';
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { evaluateFolderCreate, getChildFolders, getDocumentVersions, getFolderFiles, getRootFolders } from '#/api/x/dms/fileCenter';
|
import { addFavoriteFile } from '#/api/x/dms/personalFolder';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import { dmsPreviewErrorMessage, openDmsFilePreview } from '../shared/dmsFilePreview';
|
import FilePropertiesModal from '../shared/FilePropertiesModal.vue';
|
import FileViewModeSwitch from '../shared/FileViewModeSwitch.vue';
|
import { canCompareFile } from './compareRules';
|
import CreateFolderModal from './CreateFolderModal.vue';
|
import FileCompareModal from './FileCompareModal.vue';
|
import FileCompareSelectModal from './FileCompareSelectModal.vue';
|
import FileOperationLogModal from './FileOperationLogModal.vue';
|
|
interface FolderTreeNode {
|
children?: FolderTreeNode[];
|
isLeaf?: boolean;
|
key: string;
|
resourceType?: 'DOCUMENT' | 'FOLDER';
|
title: string;
|
}
|
|
interface FolderTreeLoadNode {
|
key: number | string;
|
}
|
|
interface MoreTreeEntries {
|
cursor: string;
|
parentId?: string;
|
resourceType: 'DOCUMENT' | 'FOLDER';
|
}
|
|
const props = defineProps<{
|
mode: DmsFileCenterMode;
|
targetFolderId?: string;
|
targetFolderPath?: string;
|
}>();
|
|
const detailColumns: BasicColumn[] = [
|
{ dataIndex: 'name', minWidth: 280, slots: { default: 'entryName' }, title: '标题' },
|
{ dataIndex: 'documentNo', minWidth: 130, slots: { default: 'documentNo' }, title: '文件编号' },
|
{ dataIndex: 'versionLabel', slots: { default: 'versionLabel' }, title: '版本号', width: 90 },
|
{ dataIndex: 'createdAt', slots: { default: 'createdAt' }, title: '上传日期', width: 112 },
|
{ dataIndex: 'effectiveTo', slots: { default: 'effectiveTo' }, title: '有效期至', width: 112 },
|
{ dataIndex: 'sizeBytes', slots: { default: 'sizeBytes' }, title: '文件大小', width: 100 },
|
{ dataIndex: 'effectiveFrom', slots: { default: 'effectiveFrom' }, title: '生效日期', width: 112 },
|
...(props.mode === 'HISTORICAL'
|
? [
|
{ dataIndex: 'lifecycleStatus', slots: { default: 'lifecycleStatus' }, title: '文件状态', width: 100 },
|
{ dataIndex: 'invalidatedAt', slots: { default: 'invalidatedAt' }, title: '失效日期', width: 112 },
|
{ dataIndex: 'revokedAt', slots: { default: 'revokedAt' }, title: '撤销日期', width: 112 },
|
]
|
: []),
|
];
|
|
const PAGE_SIZE = 50;
|
const compareSession = ref<DmsCompareSession>();
|
const [registerCompareModal, { openModal: openCompareModal }] = useModal();
|
const { createMessage } = useMessage();
|
const [registerCreateFolderModal, { openModal: openCreateFolderModal }] = useModal();
|
const [registerOperationLogModal, { openModal: openOperationLogModal }] = useModal();
|
const [registerFilePropertiesModal, { openModal: openFilePropertiesModal }] = useModal();
|
const treeResourceById = new Map<string, DmsFileCenterItem>();
|
const treePathById = new Map<string, DmsFileCenterItem[]>();
|
const moreTreeEntriesByKey = new Map<string, MoreTreeEntries>();
|
const treeData = ref<FolderTreeNode[]>([]);
|
const expandedKeys = ref<string[]>([]);
|
const selectedKeys = ref<string[]>([]);
|
const treeCollapsed = ref(false);
|
const currentFolder = ref<DmsFileCenterItem>();
|
const currentPath = ref<DmsFileCenterItem[]>([]);
|
const folderEntries = ref<DmsFileCenterItem[]>([]);
|
const fileEntries = ref<DmsFileCenterItem[]>([]);
|
const folderCursor = ref<string>();
|
const fileCursor = ref<string>();
|
const hasMoreFolders = ref(false);
|
const hasMoreFiles = ref(false);
|
const loading = ref(false);
|
const loadingMoreFolders = ref(false);
|
const loadingMoreFiles = ref(false);
|
const canCreate = ref(false);
|
const checkingCreate = ref(false);
|
const favoritingFileId = ref('');
|
const propertiesLoading = ref(false);
|
const keyword = ref('');
|
const selectedId = ref('');
|
const viewMode = ref<'details' | 'tiles'>('details');
|
let initialized = false;
|
|
const historical = computed(() => props.mode === 'HISTORICAL');
|
const centerName = computed(() => (historical.value ? '旧版文件中心' : '现行版文件中心'));
|
const visibleFolderEntries = computed(() => (keyword.value.trim() ? [] : folderEntries.value));
|
const entries = computed(() => [...visibleFolderEntries.value, ...fileEntries.value]);
|
const selectedEntry = computed(() => entries.value.find((entry) => entry.id === selectedId.value));
|
const selectedPropertyTarget = computed(() => {
|
const item = selectedEntry.value;
|
if (item?.resourceType === 'FILE_VERSION') return item;
|
return item?.resourceType === 'DOCUMENT' ? item : undefined;
|
});
|
const emptyDescription = computed(() => {
|
if (keyword.value) return '没有匹配的文件';
|
return historical.value ? '此目录没有可见的旧版文件' : '此目录没有可见的现行文件';
|
});
|
const detailActionColumn = computed<BasicColumn>(() => ({
|
align: 'left',
|
dataIndex: 'action',
|
fixed: 'right',
|
title: '操作',
|
width: historical.value ? 160 : 300,
|
}));
|
function detailRowClassName(params: any) {
|
const row = params?.row || params;
|
return row?.id === selectedId.value ? 'file-center-row-selected' : '';
|
}
|
const [registerDetailTable] = useVxeTable({
|
actionColumn: detailActionColumn,
|
columns: detailColumns,
|
emptyText: emptyDescription,
|
pagination: false,
|
rowKey: 'id',
|
showIndexColumn: false,
|
showTableSetting: true,
|
});
|
|
function displayName(item: DmsFileCenterItem) {
|
const name = item.fileName || item.name;
|
if (item.resourceType === 'DOCUMENT') {
|
const extensionIndex = name.lastIndexOf('.');
|
const title = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
if (item.lifecycleStatus === 'REVOKED') return `${title}(已撤销)`;
|
return item.lifecycleStatus === 'INVALIDATED' ? `${title}(已失效)` : title;
|
}
|
if (historical.value && item.resourceType === 'FILE_VERSION') {
|
return `${name} (${item.versionLabel || `v${item.versionNo ?? '-'}`})`;
|
}
|
return name;
|
}
|
|
function lifecycleLabel(status?: DmsFileCenterItem['lifecycleStatus']) {
|
return (
|
{
|
EFFECTIVE: '现行有效',
|
EXPIRED: '已过期',
|
INVALIDATED: '已失效',
|
OBSOLETE: '已废止',
|
REVOKED: '已撤销',
|
SUPERSEDED: '已被替代',
|
}[status || ''] || '旧版'
|
);
|
}
|
|
function typeLabel(item: DmsFileCenterItem) {
|
if (item.resourceType === 'FOLDER') return '文件夹';
|
if (item.resourceType === 'DOCUMENT') return '文件目录';
|
return historical.value ? lifecycleLabel(item.lifecycleStatus) : '现行文件';
|
}
|
|
function itemIcon(item: DmsFileCenterItem): Component {
|
if (isDirectory(item)) return FolderFilled;
|
const name = displayName(item).toLocaleLowerCase();
|
if (name.endsWith('.pdf')) return FilePdfOutlined;
|
if (/\.(?:doc|docx)(?:\s|\(|$)/.test(name)) return FileWordOutlined;
|
if (/\.(?:xls|xlsx|csv)(?:\s|\(|$)/.test(name)) return FileExcelOutlined;
|
if (/\.(?:gif|jpe?g|png|webp)(?:\s|\(|$)/.test(name)) return FileImageOutlined;
|
if (/\.(?:md|rtf|txt)(?:\s|\(|$)/.test(name)) return FileTextOutlined;
|
return FileOutlined;
|
}
|
|
function iconTone(item: DmsFileCenterItem) {
|
if (isDirectory(item)) return 'folder';
|
const name = displayName(item).toLocaleLowerCase();
|
if (name.includes('.pdf')) return 'pdf';
|
if (/\.docx?/.test(name)) return 'word';
|
if (/\.(?:csv|xlsx?)/.test(name)) return 'excel';
|
if (/\.(?:gif|jpe?g|png|webp)/.test(name)) return 'image';
|
return 'file';
|
}
|
|
function displaySize(item: DmsFileCenterItem) {
|
if (isDirectory(item) || item.sizeBytes === undefined || item.sizeBytes === null) return '—';
|
const bytes = item.sizeBytes;
|
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(item: DmsFileCenterItem, value: string) {
|
return item.resourceType === 'FILE_VERSION' ? value : '—';
|
}
|
|
function validUntil(item: DmsFileCenterItem) {
|
if (item.resourceType !== 'FILE_VERSION') return '—';
|
if (item.effectiveTo) return displayDate(item.effectiveTo);
|
return item.lifecycleStatus === 'EFFECTIVE' ? '长期有效' : '—';
|
}
|
|
function isDirectory(item: DmsFileCenterItem) {
|
return item.resourceType === 'FOLDER' || item.resourceType === 'DOCUMENT';
|
}
|
|
function treeNode(resource: DmsFileCenterItem, 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 moreNode(parentId: string | undefined, cursor: string, resourceType: 'DOCUMENT' | 'FOLDER') {
|
const key = `__more__:${resourceType.toLowerCase()}:${parentId || 'root'}:${uuidv4()}`;
|
moreTreeEntriesByKey.set(key, { cursor, parentId, resourceType });
|
return { isLeaf: true, key, title: resourceType === 'FOLDER' ? '加载更多目录' : '加载更多逻辑文件' };
|
}
|
|
function findNode(nodes: FolderTreeNode[], key: string): FolderTreeNode | undefined {
|
for (const node of nodes) {
|
if (node.key === key) return node;
|
const child = node.children ? findNode(node.children, key) : undefined;
|
if (child) return child;
|
}
|
}
|
|
function replaceNode(nodes: FolderTreeNode[], key: string, replacement: FolderTreeNode): FolderTreeNode[] {
|
return nodes.map((node) => {
|
if (node.key === key) return replacement;
|
return node.children ? { ...node, children: replaceNode(node.children, key, replacement) } : node;
|
});
|
}
|
|
function registerTreeResources(resources: DmsFileCenterItem[], path: DmsFileCenterItem[]) {
|
for (const resource of resources) {
|
treeResourceById.set(resource.id, resource);
|
treePathById.set(resource.id, [...path, resource]);
|
}
|
}
|
|
function treeNodeRank(node: FolderTreeNode) {
|
const more = moreTreeEntriesByKey.get(node.key);
|
const resourceType = more?.resourceType || node.resourceType;
|
if (resourceType === 'FOLDER') return more ? 1 : 0;
|
return more ? 3 : 2;
|
}
|
|
function syncTreeSegment(parent: DmsFileCenterItem, resourceType: 'DOCUMENT' | 'FOLDER', resources: DmsFileCenterItem[], nextCursor?: string, append = false) {
|
const current = findNode(treeData.value, parent.id);
|
if (!current) return;
|
|
const childrenWithoutPreviousMore = (current.children || []).filter((node) => {
|
const more = moreTreeEntriesByKey.get(node.key);
|
if (more?.resourceType !== resourceType) return true;
|
moreTreeEntriesByKey.delete(node.key);
|
return false;
|
});
|
const existing = append ? childrenWithoutPreviousMore.filter((node) => node.resourceType === resourceType) : [];
|
const preserved = childrenWithoutPreviousMore.filter((node) => node.resourceType !== resourceType);
|
const existingById = new Map(existing.map((node) => [node.key, node]));
|
const segmentById = new Map(existing.map((node) => [node.key, node]));
|
resources.forEach((resource) => segmentById.set(resource.id, treeNode(resource, existingById.get(resource.id))));
|
const segment = [...segmentById.values()];
|
if (nextCursor) segment.push(moreNode(parent.id, nextCursor, resourceType));
|
const children = [...preserved, ...segment].sort((left, right) => treeNodeRank(left) - treeNodeRank(right));
|
treeData.value = replaceNode(treeData.value, parent.id, {
|
...current,
|
children,
|
isLeaf: children.length === 0,
|
});
|
}
|
|
async function loadRootFolders(append = false, cursor?: string) {
|
const response = await getRootFolders({ cursor, pageSize: PAGE_SIZE });
|
const folders = response.data.list || [];
|
registerTreeResources(folders, []);
|
const existing = append ? treeData.value.filter((node) => !moreTreeEntriesByKey.has(node.key)) : [];
|
const nodes = folders.map((folder) => treeNode(folder));
|
treeData.value = [...existing, ...nodes];
|
if (response.data.hasMore && response.data.nextCursor) treeData.value.push(moreNode(undefined, response.data.nextCursor, 'FOLDER'));
|
if (!append) {
|
const first = folders[0];
|
if (first) {
|
await openFolder(first, [first]);
|
} else {
|
currentFolder.value = undefined;
|
currentPath.value = [];
|
folderEntries.value = [];
|
fileEntries.value = [];
|
folderCursor.value = undefined;
|
fileCursor.value = undefined;
|
hasMoreFolders.value = false;
|
hasMoreFiles.value = false;
|
canCreate.value = false;
|
selectedKeys.value = [];
|
}
|
}
|
}
|
|
async function loadTreeChildren(node: FolderTreeLoadNode) {
|
const key = String(node.key);
|
if (moreTreeEntriesByKey.has(key)) return;
|
const folder = treeResourceById.get(key);
|
if (!folder || folder.resourceType !== 'FOLDER') return;
|
const [folderResponse, documentResponse] = await Promise.all([
|
getChildFolders(folder.id, { pageSize: PAGE_SIZE }),
|
getFolderFiles(props.mode, folder.id, { pageSize: PAGE_SIZE }),
|
]);
|
const folders = folderResponse.data.list || [];
|
const documents = documentResponse.data.list || [];
|
const path = treePathById.get(folder.id) || [folder];
|
registerTreeResources(folders, path);
|
registerTreeResources(documents, path);
|
syncTreeSegment(folder, 'FOLDER', folders, folderResponse.data.hasMore ? folderResponse.data.nextCursor : undefined);
|
syncTreeSegment(folder, 'DOCUMENT', documents, documentResponse.data.hasMore ? documentResponse.data.nextCursor : undefined);
|
}
|
|
async function loadMoreTreeEntries(key: string) {
|
const more = moreTreeEntriesByKey.get(key);
|
if (!more) return;
|
if (!more.parentId) {
|
if (more.resourceType === 'FOLDER') {
|
await loadRootFolders(true, more.cursor);
|
moreTreeEntriesByKey.delete(key);
|
}
|
return;
|
}
|
const parent = treeResourceById.get(more.parentId);
|
if (!parent || parent.resourceType !== 'FOLDER') return;
|
const response =
|
more.resourceType === 'FOLDER'
|
? await getChildFolders(parent.id, { cursor: more.cursor, pageSize: PAGE_SIZE })
|
: await getFolderFiles(props.mode, parent.id, { cursor: more.cursor, pageSize: PAGE_SIZE });
|
const resources = response.data.list || [];
|
registerTreeResources(resources, treePathById.get(parent.id) || [parent]);
|
syncTreeSegment(parent, more.resourceType, resources, response.data.hasMore ? response.data.nextCursor : undefined, true);
|
}
|
|
async function loadCreatePermission(folder: DmsFileCenterItem) {
|
if (historical.value || folder.resourceType !== 'FOLDER') {
|
canCreate.value = false;
|
return;
|
}
|
checkingCreate.value = true;
|
try {
|
const response = await evaluateFolderCreate(folder.id);
|
canCreate.value = !!response.data.allowed;
|
} catch {
|
canCreate.value = false;
|
} finally {
|
checkingCreate.value = false;
|
}
|
}
|
|
async function openFolder(folder: DmsFileCenterItem, path: DmsFileCenterItem[]) {
|
loading.value = true;
|
keyword.value = '';
|
selectedId.value = '';
|
try {
|
const [folderResponse, fileResponse] = await Promise.all([
|
folder.resourceType === 'FOLDER' ? getChildFolders(folder.id, { pageSize: PAGE_SIZE }) : Promise.resolve(undefined),
|
getContainerFiles(folder, { pageSize: PAGE_SIZE }),
|
]);
|
const children = folderResponse?.data.list || [];
|
if (folder.resourceType === 'FOLDER') {
|
const documents = fileResponse.data.list || [];
|
registerTreeResources(children, path);
|
registerTreeResources(documents, path);
|
syncTreeSegment(folder, 'FOLDER', children, folderResponse?.data.hasMore ? folderResponse.data.nextCursor : undefined);
|
syncTreeSegment(folder, 'DOCUMENT', documents, fileResponse.data.hasMore ? fileResponse.data.nextCursor : undefined);
|
}
|
currentFolder.value = folder;
|
currentPath.value = path;
|
selectedKeys.value = [folder.id];
|
expandedKeys.value = [...new Set([...expandedKeys.value, ...path.map((item) => item.id)])];
|
folderEntries.value = children;
|
folderCursor.value = folderResponse?.data.nextCursor;
|
hasMoreFolders.value = !!folderResponse?.data.hasMore;
|
fileEntries.value = fileResponse.data.list || [];
|
fileCursor.value = fileResponse.data.nextCursor;
|
hasMoreFiles.value = fileResponse.data.hasMore;
|
void loadCreatePermission(folder);
|
} catch {
|
folderEntries.value = [];
|
fileEntries.value = [];
|
createMessage.error(`${centerName.value}加载失败`);
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function handleTreeSelect(keys: (number | string)[]) {
|
const key = String(keys[0] ?? '');
|
if (!key) return;
|
if (moreTreeEntriesByKey.has(key)) {
|
const currentKey = currentFolder.value?.id;
|
try {
|
await loadMoreTreeEntries(key);
|
} finally {
|
selectedKeys.value = currentKey ? [currentKey] : [];
|
}
|
return;
|
}
|
const resource = treeResourceById.get(key);
|
const path = resource ? treePathById.get(resource.id) : undefined;
|
if (resource && path) await openFolder(resource, path);
|
}
|
|
function handleTreeExpand(keys: (number | string)[]) {
|
expandedKeys.value = keys.map(String);
|
}
|
|
async function loadMoreDirectoryFolders() {
|
if (!currentFolder.value || !folderCursor.value || loadingMoreFolders.value) return;
|
loadingMoreFolders.value = true;
|
try {
|
const response = await getChildFolders(currentFolder.value.id, { cursor: folderCursor.value, pageSize: PAGE_SIZE });
|
const values = response.data.list || [];
|
registerTreeResources(values, currentPath.value);
|
folderEntries.value.push(...values);
|
folderCursor.value = response.data.nextCursor;
|
hasMoreFolders.value = response.data.hasMore;
|
syncTreeSegment(currentFolder.value, 'FOLDER', values, response.data.hasMore ? response.data.nextCursor : undefined, true);
|
} finally {
|
loadingMoreFolders.value = false;
|
}
|
}
|
|
async function loadMoreFileEntries() {
|
const folder = currentFolder.value;
|
if (!folder || !fileCursor.value || loadingMoreFiles.value) return;
|
loadingMoreFiles.value = true;
|
try {
|
const response = await getContainerFiles(folder, {
|
cursor: fileCursor.value,
|
keyword: keyword.value.trim() || undefined,
|
pageSize: PAGE_SIZE,
|
});
|
const values = response.data.list || [];
|
fileEntries.value.push(...values);
|
fileCursor.value = response.data.nextCursor;
|
hasMoreFiles.value = response.data.hasMore;
|
if (folder.resourceType === 'FOLDER' && !keyword.value.trim()) {
|
registerTreeResources(values, currentPath.value);
|
syncTreeSegment(folder, 'DOCUMENT', values, response.data.hasMore ? response.data.nextCursor : undefined, true);
|
}
|
} finally {
|
loadingMoreFiles.value = false;
|
}
|
}
|
|
async function searchFiles() {
|
const folder = currentFolder.value;
|
if (!folder) return;
|
loading.value = true;
|
try {
|
const response = await getContainerFiles(folder, { keyword: keyword.value.trim() || undefined, pageSize: PAGE_SIZE });
|
fileEntries.value = response.data.list || [];
|
fileCursor.value = response.data.nextCursor;
|
hasMoreFiles.value = response.data.hasMore;
|
} catch {
|
createMessage.error('文件搜索失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function refresh() {
|
if (currentFolder.value) return openFolder(currentFolder.value, currentPath.value);
|
await loadRootFolders();
|
}
|
|
function openCreateFolder() {
|
if (currentFolder.value?.resourceType === 'FOLDER' && canCreate.value) openCreateFolderModal(true, { parent: currentFolder.value });
|
}
|
|
async function handleFolderCreated() {
|
await refresh();
|
createMessage.success('目录已创建');
|
}
|
|
function selectEntry(item: DmsFileCenterItem) {
|
selectedId.value = item.id;
|
}
|
|
function handleEntryClick(item: DmsFileCenterItem) {
|
selectEntry(item);
|
if (isDirectory(item)) void openFolder(item, [...currentPath.value, item]);
|
else void previewFile(item);
|
}
|
|
function getContainerFiles(container: DmsFileCenterItem, params: { cursor?: string; keyword?: string; pageSize?: number }) {
|
return container.resourceType === 'DOCUMENT' ? getDocumentVersions(props.mode, container.id, params) : getFolderFiles(props.mode, container.id, params);
|
}
|
|
async function previewFile(item: DmsFileCenterItem) {
|
try {
|
onlineUtils.showLoading('文件预览加载中');
|
await openDmsFilePreview({
|
fileName: item.fileName || item.name,
|
mimeType: item.mimeType,
|
resourceId: item.id,
|
resourceType: 'FILE_VERSION',
|
watermarkBusinessTypes: ['xianxingAndJiubanWenjianZhongxin'],
|
});
|
} catch (error) {
|
createMessage.error(dmsPreviewErrorMessage(error));
|
} finally {
|
onlineUtils.hideLoading();
|
}
|
}
|
|
function showOperationLogs(item: DmsFileCenterItem) {
|
if (item.resourceType !== 'FILE_VERSION') return;
|
selectEntry(item);
|
openOperationLogModal(true, { item });
|
}
|
|
async function favoriteFile(item: DmsFileCenterItem) {
|
if (historical.value || item.resourceType !== 'FILE_VERSION' || !item.documentId || favoritingFileId.value) return;
|
selectEntry(item);
|
favoritingFileId.value = item.id;
|
try {
|
const response = await addFavoriteFile({
|
documentId: item.documentId,
|
fileVersionId: item.id,
|
operationId: uuidv4(),
|
});
|
createMessage.success(response.data.addedCount ? '已加入个人文件夹的收藏栏' : '该文件已在收藏栏中');
|
} catch (error) {
|
createMessage.error(error instanceof Error && error.message ? error.message : '文件收藏失败');
|
} finally {
|
favoritingFileId.value = '';
|
}
|
}
|
|
async function showFileProperties(target = selectedPropertyTarget.value) {
|
if (!target || propertiesLoading.value) return;
|
selectEntry(target);
|
if (target.resourceType === 'FILE_VERSION') {
|
openFilePropertiesModal(true, { item: target });
|
return;
|
}
|
propertiesLoading.value = true;
|
try {
|
const response = await getDocumentVersions(props.mode, target.id, { pageSize: 1 });
|
const item = response.data.list?.[0];
|
if (!item) {
|
createMessage.error(historical.value ? '未找到该文件的历史版本' : '未找到该文件的现行有效版本');
|
return;
|
}
|
openFilePropertiesModal(true, { item });
|
} catch {
|
createMessage.error('文件属性加载失败');
|
} finally {
|
propertiesLoading.value = false;
|
}
|
}
|
|
function showVersionCompare(item: DmsFileCenterItem) {
|
if (historical.value || !canCompareFile(item)) return;
|
selectEntry(item);
|
openCompareModal(true, { item });
|
}
|
|
function withTestId(testId: string, action: ActionItem) {
|
return { ...action, 'data-test': testId } as ActionItem;
|
}
|
|
function openRevocationApplication() {
|
onlineUtils.openFlowForm({ flowId: '859064410424006213', title: '文件/记录撤销申请', onSuccess: refresh });
|
}
|
|
function getTableActions(item: DmsFileCenterItem): ActionItem[] {
|
const actions: ActionItem[] = [];
|
if (item.resourceType === 'FILE_VERSION') {
|
actions.push({ label: '日志', onClick: () => showOperationLogs(item) });
|
if (!historical.value) {
|
actions.push({
|
disabled: !item.documentId || (!!favoritingFileId.value && favoritingFileId.value !== item.id),
|
label: '收藏',
|
loading: favoritingFileId.value === item.id,
|
onClick: () => favoriteFile(item),
|
});
|
}
|
}
|
if (!historical.value && (item.resourceType === 'DOCUMENT' || item.resourceType === 'FILE_VERSION')) {
|
actions.push(
|
withTestId('file-properties-action', {
|
label: '属性',
|
loading: propertiesLoading.value && selectedId.value === item.id,
|
onClick: () => showFileProperties(item),
|
}),
|
);
|
}
|
if (!historical.value && canCompareFile(item)) {
|
actions.push(withTestId('file-compare-action', { label: '版本比较', onClick: () => showVersionCompare(item) }));
|
}
|
return actions;
|
}
|
|
function handleDetailCellClick({ column, row }: { column?: { field?: string; property?: string }; row?: DmsFileCenterItem }) {
|
if (!row || column?.field === 'action' || column?.property === 'action') return;
|
handleEntryClick(row);
|
}
|
|
async function openBreadcrumb(index: number) {
|
const folder = currentPath.value[index];
|
if (folder) await openFolder(folder, currentPath.value.slice(0, index + 1));
|
}
|
|
async function findFolder(parentId: string | undefined, name: string, targetId?: string) {
|
let cursor: string | undefined;
|
do {
|
const params = { cursor, pageSize: PAGE_SIZE };
|
const response = parentId ? await getChildFolders(parentId, params) : await getRootFolders(params);
|
const folders = response.data.list || [];
|
const match = folders.find((folder) => (targetId ? folder.id === targetId : folder.name === name));
|
if (match) return match;
|
cursor = response.data.hasMore ? response.data.nextCursor : undefined;
|
} while (cursor);
|
}
|
|
function ensureFolderPathInTree(path: DmsFileCenterItem[]) {
|
path.forEach((folder, index) => {
|
const folderPath = path.slice(0, index + 1);
|
treeResourceById.set(folder.id, folder);
|
treePathById.set(folder.id, folderPath);
|
if (index === 0) {
|
const current = treeData.value.find((node) => node.key === folder.id);
|
if (!current) treeData.value = [...treeData.value, treeNode(folder)];
|
return;
|
}
|
const parent = path[index - 1];
|
if (!parent) return;
|
const parentNode = findNode(treeData.value, parent.id);
|
if (!parentNode) return;
|
const children = parentNode.children || [];
|
const current = children.find((node) => node.key === folder.id);
|
if (current) return;
|
treeData.value = replaceNode(treeData.value, parent.id, {
|
...parentNode,
|
children: [...children, treeNode(folder)].sort((left, right) => treeNodeRank(left) - treeNodeRank(right)),
|
isLeaf: false,
|
});
|
});
|
}
|
|
async function resolveFolderPath(folderId: string, folderPath: string) {
|
const names = folderPath
|
.split('/')
|
.map((name) => name.trim())
|
.filter(Boolean);
|
const path: DmsFileCenterItem[] = [];
|
let parentId: string | undefined;
|
for (let index = 0; index < names.length; index++) {
|
const name = names[index];
|
if (!name) continue;
|
const folder = await findFolder(parentId, name, index === names.length - 1 ? folderId : undefined);
|
if (!folder) return [];
|
path.push(folder);
|
parentId = folder.id;
|
}
|
return path.at(-1)?.id === folderId ? path : [];
|
}
|
|
async function openRequestedFolder() {
|
if (!initialized || !props.targetFolderId || !props.targetFolderPath) return;
|
try {
|
const path = await resolveFolderPath(props.targetFolderId, props.targetFolderPath);
|
const folder = path.at(-1);
|
if (!folder) {
|
createMessage.error('对应目录不存在或当前用户无权访问');
|
return;
|
}
|
ensureFolderPathInTree(path);
|
treeCollapsed.value = false;
|
await openFolder(folder, path);
|
} catch {
|
createMessage.error('目标目录加载失败');
|
}
|
}
|
|
watch(
|
() => [props.targetFolderId, props.targetFolderPath],
|
() => void openRequestedFolder(),
|
);
|
|
onMounted(async () => {
|
loading.value = true;
|
try {
|
await loadRootFolders();
|
initialized = true;
|
await openRequestedFolder();
|
} catch {
|
createMessage.error('目录加载失败');
|
} finally {
|
initialized = true;
|
loading.value = false;
|
}
|
});
|
</script>
|
|
<template>
|
<section class="jnpf-content-wrapper file-center" :class="{ 'historical-center': historical, 'tree-collapsed': treeCollapsed }">
|
<aside class="jnpf-content-wrapper-left 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="treeCollapsed = !treeCollapsed">
|
<MenuUnfoldOutlined v-if="treeCollapsed" />
|
<MenuFoldOutlined v-else />
|
</a-button>
|
</a-tooltip>
|
</div>
|
<div v-if="!treeCollapsed" class="folder-tree-scroll">
|
<BasicTree
|
class="folder-tree"
|
:default-expand-all="false"
|
:expanded-keys="expandedKeys"
|
:field-names="{ children: 'children', key: 'key', title: 'title' }"
|
:load-data="loadTreeChildren"
|
:loading="loading && treeData.length === 0"
|
:selected-keys="selectedKeys"
|
:tree-data="treeData"
|
@update:expanded-keys="handleTreeExpand"
|
@select="handleTreeSelect">
|
<template #title="{ key, resourceType, title }">
|
<span
|
class="folder-tree-title"
|
:class="{ more: moreTreeEntriesByKey.has(String(key)) }"
|
:title="resourceType === 'DOCUMENT' ? `${title}(ID:${key})` : title">
|
<RightOutlined v-if="moreTreeEntriesByKey.has(String(key))" />
|
<FolderOutlined v-else-if="resourceType === 'FOLDER'" />
|
<FileTextOutlined v-else />
|
<span>{{ title }}</span>
|
</span>
|
</template>
|
</BasicTree>
|
</div>
|
</aside>
|
|
<main class="jnpf-content-wrapper-center file-workspace">
|
<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
|
:disabled="!currentFolder"
|
:maxlength="200"
|
placeholder="搜索文件"
|
@press-enter="searchFiles"
|
@change="!keyword && searchFiles()">
|
<template #prefix><SearchOutlined /></template>
|
</jnpf-input>
|
|
<div class="workspace-actions">
|
<a-button v-if="!historical" data-testid="revocation-application" @click="openRevocationApplication">撤销申请</a-button>
|
<template v-if="!historical && canCreate">
|
<a-button :icon="h(FolderAddOutlined)" @click="openCreateFolder">新建目录</a-button>
|
</template>
|
<a-tooltip v-else-if="!historical && checkingCreate" title="正在检查 CREATE 权限">
|
<a-button loading aria-label="检查创建权限" />
|
</a-tooltip>
|
<a-tooltip v-if="historical" :title="selectedPropertyTarget ? '查看选中文件的属性' : '请先选择一个文件'">
|
<span>
|
<a-button :disabled="!selectedPropertyTarget" :icon="h(InfoCircleOutlined)" :loading="propertiesLoading" @click="showFileProperties()">
|
属性
|
</a-button>
|
</span>
|
</a-tooltip>
|
<a-tooltip title="刷新当前列表">
|
<a-button aria-label="刷新当前列表" :icon="h(ReloadOutlined)" @click="refresh" />
|
</a-tooltip>
|
</div>
|
</div>
|
|
<div class="view-command-bar">
|
<strong>{{ currentFolder ? displayName(currentFolder) : centerName }}</strong>
|
<div class="view-command-actions">
|
<span>{{ entries.length }} 个已加载项目</span>
|
<FileViewModeSwitch v-model:value="viewMode" />
|
</div>
|
</div>
|
|
<div class="jnpf-content-wrapper-content entry-viewport" :class="`view-${viewMode}`" v-loading="viewMode === 'tiles' && loading">
|
<BasicVxeTable
|
v-if="viewMode === 'details'"
|
class="file-detail-table"
|
:data-source="entries"
|
:loading="loading"
|
:row-class-name="detailRowClassName"
|
@cell-click="handleDetailCellClick"
|
@register="registerDetailTable">
|
<template #entryName="{ record }">
|
<button
|
class="entry-name-button"
|
:class="{ selected: selectedId === record.id }"
|
type="button"
|
:aria-label="`${isDirectory(record) ? '打开目录' : '预览文件'}:${displayName(record)}`"
|
:title="displayName(record)"
|
@click.stop="handleEntryClick(record)">
|
<span class="entry-icon" :class="iconTone(record)"><component :is="itemIcon(record)" /></span>
|
<span>{{ displayName(record) }}</span>
|
</button>
|
</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 #lifecycleStatus="{ record }">{{ record.lifecycleStatus ? lifecycleLabel(record.lifecycleStatus) : '—' }}</template>
|
<template #invalidatedAt="{ record }">{{ displayDate(record.invalidatedAt) }}</template>
|
<template #revokedAt="{ record }">{{ displayDate(record.revokedAt) }}</template>
|
<template #sizeBytes="{ record }">{{ fileValue(record, displaySize(record)) }}</template>
|
<template #effectiveFrom="{ record }">{{ fileValue(record, displayDate(record.effectiveFrom)) }}</template>
|
<template #action="{ record }">
|
<div class="operation-cell" :class="{ selected: selectedId === record.id }" @click.stop>
|
<TableAction v-if="getTableActions(record).length" :actions="getTableActions(record)" stop-button-propagation />
|
<span v-else>—</span>
|
</div>
|
</template>
|
</BasicVxeTable>
|
|
<template v-else>
|
<div v-if="entries.length" class="tile-grid" role="listbox" aria-label="文件列表">
|
<button
|
v-for="item in entries"
|
:key="item.id"
|
class="entry-tile"
|
:class="{ selected: selectedId === item.id }"
|
type="button"
|
:aria-selected="selectedId === item.id"
|
:title="displayName(item)"
|
@click="handleEntryClick(item)">
|
<span class="tile-icon" :class="iconTone(item)"><component :is="itemIcon(item)" /></span>
|
<span class="tile-name">{{ displayName(item) }}</span>
|
<span class="tile-meta">{{ typeLabel(item) }}</span>
|
</button>
|
</div>
|
<jnpf-empty v-else-if="!loading" class="entry-empty" :description="emptyDescription" />
|
</template>
|
|
<div v-if="hasMoreFolders || hasMoreFiles" class="load-more-actions">
|
<a-button v-if="hasMoreFolders" :loading="loadingMoreFolders" @click="loadMoreDirectoryFolders">加载更多目录</a-button>
|
<a-button v-if="hasMoreFiles" :loading="loadingMoreFiles" @click="loadMoreFileEntries">加载更多文件</a-button>
|
</div>
|
</div>
|
|
<footer class="status-bar">
|
<span>{{ entries.length }} 个已加载项目</span>
|
<span v-if="selectedEntry" :title="displayName(selectedEntry)">已选择:{{ displayName(selectedEntry) }}</span>
|
</footer>
|
</main>
|
|
<CreateFolderModal @register="registerCreateFolderModal" @created="handleFolderCreated" />
|
<FileCompareSelectModal @register="registerCompareModal" @compare="compareSession = $event" />
|
<FileCompareModal :session="compareSession" @close="compareSession = undefined" />
|
<FileOperationLogModal @register="registerOperationLogModal" />
|
<FilePropertiesModal @register="registerFilePropertiesModal" />
|
</section>
|
</template>
|
|
<style lang="scss" scoped>
|
.file-center {
|
--folder-navigation-width: 236px;
|
|
display: grid;
|
grid-template-columns: var(--folder-navigation-width) minmax(0, 1fr);
|
width: 100%;
|
height: 100%;
|
min-height: 520px;
|
overflow: hidden;
|
color: var(--text-color);
|
background: var(--component-background);
|
|
&.tree-collapsed {
|
--folder-navigation-width: 40px;
|
}
|
}
|
|
.folder-navigation {
|
display: flex;
|
flex-direction: column;
|
width: 100%;
|
height: 100%;
|
min-width: 0;
|
min-height: 0;
|
margin-right: 0;
|
overflow: hidden;
|
background: color-mix(in srgb, var(--body-background) 62%, var(--component-background));
|
border-right: 1px solid var(--border-color-base1);
|
border-radius: 0;
|
}
|
|
.folder-navigation-header {
|
display: flex;
|
flex: 0 0 52px;
|
align-items: center;
|
justify-content: space-between;
|
padding: 0 6px 0 12px;
|
border-bottom: 1px solid var(--border-color-base1);
|
|
.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;
|
}
|
|
&.more,
|
&.more svg {
|
color: var(--primary-color);
|
}
|
|
> span:last-child {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.folder-tree :deep(.ant-tree-treenode) {
|
width: 100%;
|
}
|
|
.folder-tree :deep(.ant-tree-node-content-wrapper) {
|
min-width: 0;
|
overflow: hidden;
|
}
|
|
.file-workspace {
|
display: flex;
|
flex-direction: column;
|
min-width: 0;
|
min-height: 0;
|
overflow: hidden;
|
}
|
|
.navigation-bar {
|
display: grid;
|
flex: 0 0 auto;
|
grid-template-columns: minmax(0, 1fr) clamp(140px, 25%, 320px) auto;
|
gap: 10px;
|
align-items: center;
|
min-height: 52px;
|
padding: 8px 12px;
|
border-bottom: 1px solid var(--border-color-base1);
|
}
|
|
.entry-search {
|
min-width: 0;
|
width: 100%;
|
}
|
|
.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;
|
}
|
|
svg {
|
flex: 0 0 auto;
|
font-size: 10px;
|
color: var(--text-color-secondary);
|
}
|
}
|
|
.workspace-actions {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 7px;
|
justify-content: flex-end;
|
}
|
|
.view-command-bar {
|
display: flex;
|
flex: 0 0 42px;
|
align-items: center;
|
justify-content: space-between;
|
padding: 6px 14px;
|
border-bottom: 1px solid var(--border-color-base1);
|
}
|
|
.view-command-actions {
|
display: flex;
|
gap: 7px;
|
align-items: center;
|
font-size: 12px;
|
color: var(--text-color-secondary);
|
}
|
|
.file-center .entry-viewport {
|
display: flex;
|
flex: 1;
|
flex-direction: column;
|
min-height: 0;
|
padding: 8px 12px;
|
overflow: auto;
|
|
&.view-details {
|
padding: 0;
|
overflow: hidden;
|
}
|
}
|
|
.file-detail-table {
|
flex: 1;
|
min-height: 0;
|
height: auto !important;
|
|
:deep(.vxe-body--row) {
|
cursor: pointer;
|
}
|
|
:deep(.file-center-row-selected .vxe-body--column) {
|
background: var(--item-active-bg);
|
}
|
}
|
|
.operation-cell {
|
display: flex;
|
align-items: center;
|
min-width: 0;
|
min-height: 28px;
|
overflow: hidden;
|
white-space: nowrap;
|
}
|
|
.entry-name-button {
|
display: flex;
|
gap: 10px;
|
align-items: center;
|
width: 100%;
|
min-width: 0;
|
padding: 0;
|
overflow: hidden;
|
color: inherit;
|
text-align: left;
|
cursor: pointer;
|
background: transparent;
|
border: 0;
|
|
> span:last-child {
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.entry-icon {
|
flex: 0 0 auto;
|
font-size: 22px;
|
color: #667085;
|
|
&.folder {
|
color: #e5a600;
|
}
|
|
&.word {
|
color: #2468c9;
|
}
|
|
&.pdf {
|
color: #d92d20;
|
}
|
|
&.excel {
|
color: #18864b;
|
}
|
|
&.image {
|
color: #8b5cf6;
|
}
|
}
|
|
.tile-grid {
|
display: grid;
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
gap: 8px;
|
}
|
|
.entry-tile {
|
display: flex;
|
flex-direction: column;
|
gap: 6px;
|
align-items: center;
|
justify-content: center;
|
min-width: 0;
|
min-height: 138px;
|
padding: 12px;
|
color: inherit;
|
cursor: pointer;
|
background: transparent;
|
border: 1px solid transparent;
|
border-radius: 4px;
|
|
&:hover {
|
background: var(--item-hover-bg);
|
}
|
|
&.selected {
|
background: var(--item-active-bg);
|
border-color: color-mix(in srgb, var(--primary-color) 55%, transparent);
|
}
|
}
|
|
.tile-icon {
|
font-size: 54px;
|
color: #667085;
|
|
&.folder {
|
color: #e5a600;
|
}
|
|
&.word {
|
color: #2468c9;
|
}
|
|
&.pdf {
|
color: #d92d20;
|
}
|
|
&.excel {
|
color: #18864b;
|
}
|
|
&.image {
|
color: #8b5cf6;
|
}
|
}
|
|
.tile-name,
|
.tile-meta {
|
width: 100%;
|
overflow: hidden;
|
text-align: center;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.tile-meta {
|
font-size: 12px;
|
color: var(--text-color-secondary);
|
}
|
|
.entry-empty {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
min-height: 320px;
|
}
|
|
.load-more-actions {
|
display: flex;
|
gap: 8px;
|
justify-content: center;
|
padding: 18px 0 8px;
|
}
|
|
.status-bar {
|
display: flex;
|
flex: 0 0 30px;
|
justify-content: space-between;
|
padding: 6px 12px;
|
font-size: 12px;
|
color: var(--text-color-secondary);
|
border-top: 1px solid var(--border-color-base1);
|
}
|
|
@media (max-width: 900px) {
|
.file-center {
|
--folder-navigation-width: 200px;
|
}
|
|
.navigation-bar {
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
}
|
|
.workspace-actions {
|
grid-row: 2;
|
grid-column: 1 / -1;
|
}
|
}
|
</style>
|