<script lang="ts" setup>
|
import type { Component } from 'vue';
|
|
import type { DmsFileCenterItem, DmsFileCenterMode } from '#/api/x/dms/fileCenter';
|
|
import { computed, h, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { useModal } from '@jnpf/ui/modal';
|
import { formatToDateTime } from '@jnpf/utils';
|
|
import {
|
AppstoreOutlined,
|
EditOutlined,
|
FileExcelOutlined,
|
FileImageOutlined,
|
FileOutlined,
|
FilePdfOutlined,
|
FileTextOutlined,
|
FileWordOutlined,
|
FolderAddOutlined,
|
FolderFilled,
|
FolderOutlined,
|
MenuFoldOutlined,
|
MenuUnfoldOutlined,
|
ReloadOutlined,
|
RightOutlined,
|
SearchOutlined,
|
ShareAltOutlined,
|
UnorderedListOutlined,
|
UploadOutlined,
|
} from '@ant-design/icons-vue';
|
import { Tree as ATree, Upload as AUpload, Empty } from 'ant-design-vue';
|
|
import { evaluateDocumentUpdate, evaluateFolderCreate, getChildFolders, getFolderFiles, getRootFolders, getSharedFiles } from '#/api/x/dms/fileCenter';
|
|
import { dmsPreviewErrorMessage, openDmsFilePreview } from '../shared/dmsFilePreview';
|
|
import CreateFolderModal from './CreateFolderModal.vue';
|
import FileRevisionModal from './FileRevisionModal.vue';
|
import UploadFileModal from './UploadFileModal.vue';
|
|
interface FolderTreeNode {
|
children?: FolderTreeNode[];
|
isLeaf?: boolean;
|
key: string;
|
title: string;
|
}
|
|
interface FolderTreeLoadNode {
|
key: number | string;
|
}
|
|
interface MoreFolders {
|
cursor: string;
|
parentId?: string;
|
}
|
|
const props = defineProps<{
|
mode: DmsFileCenterMode;
|
}>();
|
|
const PAGE_SIZE = 50;
|
const SHARED_KEY = '__shared__';
|
const { createMessage } = useMessage();
|
const [registerCreateFolderModal, { openModal: openCreateFolderModal }] = useModal();
|
const [registerUploadFileModal, { openModal: openUploadFileModal }] = useModal();
|
const [registerFileRevisionModal, { openModal: openFileRevisionModal }] = useModal();
|
const folderById = new Map<string, DmsFileCenterItem>();
|
const folderPathById = new Map<string, DmsFileCenterItem[]>();
|
const moreFoldersByKey = new Map<string, MoreFolders>();
|
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 sharedLocation = ref(false);
|
const canCreate = ref(false);
|
const checkingCreate = ref(false);
|
const checkingRevision = ref(false);
|
const keyword = ref('');
|
const selectedId = ref('');
|
const viewMode = ref<'details' | 'tiles'>('details');
|
let selectTimer: number | undefined;
|
|
const historical = computed(() => props.mode === 'HISTORICAL');
|
const centerName = computed(() => (historical.value ? '旧版文件中心' : '现行版文件中心'));
|
const visibleFolderEntries = computed(() => {
|
const search = keyword.value.trim().toLocaleLowerCase();
|
if (!search) return folderEntries.value;
|
return folderEntries.value.filter((item) => item.name.toLocaleLowerCase().startsWith(search));
|
});
|
const entries = computed(() => [...visibleFolderEntries.value, ...fileEntries.value]);
|
const selectedEntry = computed(() => entries.value.find((entry) => entry.id === selectedId.value));
|
const selectedCurrentFile = computed(() => {
|
const item = selectedEntry.value;
|
return !historical.value && item?.resourceType === 'FILE_VERSION' && !!item.documentId ? item : undefined;
|
});
|
const emptyDescription = computed(() => {
|
if (keyword.value) return '没有匹配的文件';
|
if (sharedLocation.value) return historical.value ? '没有直接共享的旧版文件' : '没有直接共享的现行文件';
|
return historical.value ? '此目录没有可见的旧版文件' : '此目录没有可见的现行文件';
|
});
|
|
function displayName(item: DmsFileCenterItem) {
|
const name = item.fileName || item.name;
|
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 '文件夹';
|
return historical.value ? lifecycleLabel(item.lifecycleStatus) : '现行文件';
|
}
|
|
function itemIcon(item: DmsFileCenterItem): Component {
|
if (item.resourceType === 'FOLDER') 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 (item.resourceType === 'FOLDER') 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 (item.resourceType === 'FOLDER' || 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 folderNode(folder: DmsFileCenterItem, current?: FolderTreeNode): FolderTreeNode {
|
return { children: current?.children, isLeaf: current?.isLeaf ?? false, key: folder.id, title: folder.name };
|
}
|
|
function moreNode(parentId: string | undefined, cursor: string) {
|
const key = `__more__:${parentId || 'root'}:${crypto.randomUUID()}`;
|
moreFoldersByKey.set(key, { cursor, parentId });
|
return { isLeaf: true, key, title: '加载更多目录' };
|
}
|
|
function sharedNode(): FolderTreeNode {
|
return { isLeaf: true, key: SHARED_KEY, title: '共享给我' };
|
}
|
|
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 registerFolders(folders: DmsFileCenterItem[], path: DmsFileCenterItem[]) {
|
for (const folder of folders) {
|
folderById.set(folder.id, folder);
|
folderPathById.set(folder.id, [...path, folder]);
|
}
|
}
|
|
function syncTreeChildren(parent: DmsFileCenterItem, folders: DmsFileCenterItem[], nextCursor?: string, append = false) {
|
const current = findNode(treeData.value, parent.id);
|
if (!current) return;
|
const existing = append ? (current.children || []).filter((node) => !moreFoldersByKey.has(node.key)) : [];
|
const existingById = new Map(existing.map((node) => [node.key, node]));
|
const children = folders.map((folder) => folderNode(folder, existingById.get(folder.id)));
|
if (nextCursor) children.push(moreNode(parent.id, nextCursor));
|
treeData.value = replaceNode(treeData.value, parent.id, {
|
...current,
|
children: [...existing, ...children],
|
isLeaf: !existing.length && !children.length,
|
});
|
}
|
|
async function loadRootFolders(append = false, cursor?: string) {
|
const response = await getRootFolders({ cursor, pageSize: PAGE_SIZE });
|
const folders = response.data.list || [];
|
registerFolders(folders, []);
|
const existing = append ? treeData.value.filter((node) => node.key !== SHARED_KEY && !moreFoldersByKey.has(node.key)) : [];
|
const nodes = folders.map((folder) => folderNode(folder));
|
treeData.value = [sharedNode(), ...existing, ...nodes];
|
if (response.data.hasMore && response.data.nextCursor) treeData.value.push(moreNode(undefined, response.data.nextCursor));
|
if (!append) {
|
const first = folders[0];
|
await (first ? openFolder(first, [first]) : openShared());
|
}
|
}
|
|
async function loadTreeChildren(node: FolderTreeLoadNode) {
|
const key = String(node.key);
|
if (key === SHARED_KEY || moreFoldersByKey.has(key)) return;
|
const folder = folderById.get(key);
|
if (!folder) return;
|
const response = await getChildFolders(folder.id, { pageSize: PAGE_SIZE });
|
const children = response.data.list || [];
|
const path = folderPathById.get(folder.id) || [folder];
|
registerFolders(children, path);
|
syncTreeChildren(folder, children, response.data.hasMore ? response.data.nextCursor : undefined);
|
}
|
|
async function loadMoreTreeFolders(key: string) {
|
const more = moreFoldersByKey.get(key);
|
if (!more) return;
|
moreFoldersByKey.delete(key);
|
if (!more.parentId) {
|
await loadRootFolders(true, more.cursor);
|
return;
|
}
|
const parent = folderById.get(more.parentId);
|
if (!parent) return;
|
const response = await getChildFolders(parent.id, { cursor: more.cursor, pageSize: PAGE_SIZE });
|
const children = response.data.list || [];
|
registerFolders(children, folderPathById.get(parent.id) || [parent]);
|
syncTreeChildren(parent, children, response.data.hasMore ? response.data.nextCursor : undefined, true);
|
}
|
|
async function loadCreatePermission(folder: DmsFileCenterItem) {
|
if (historical.value) {
|
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;
|
sharedLocation.value = false;
|
keyword.value = '';
|
selectedId.value = '';
|
try {
|
const [folderResponse, fileResponse] = await Promise.all([
|
getChildFolders(folder.id, { pageSize: PAGE_SIZE }),
|
getFolderFiles(props.mode, folder.id, { pageSize: PAGE_SIZE }),
|
]);
|
const children = folderResponse.data.list || [];
|
registerFolders(children, path);
|
syncTreeChildren(folder, children, folderResponse.data.hasMore ? folderResponse.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 openShared() {
|
loading.value = true;
|
sharedLocation.value = true;
|
currentFolder.value = undefined;
|
currentPath.value = [];
|
folderEntries.value = [];
|
folderCursor.value = undefined;
|
hasMoreFolders.value = false;
|
canCreate.value = false;
|
keyword.value = '';
|
selectedId.value = '';
|
selectedKeys.value = [SHARED_KEY];
|
try {
|
const response = await getSharedFiles(props.mode, { pageSize: PAGE_SIZE });
|
fileEntries.value = response.data.list || [];
|
fileCursor.value = response.data.nextCursor;
|
hasMoreFiles.value = response.data.hasMore;
|
} catch {
|
fileEntries.value = [];
|
createMessage.error('共享文件加载失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function handleTreeSelect(keys: (number | string)[]) {
|
const key = String(keys[0] ?? '');
|
if (!key) return;
|
if (key === SHARED_KEY) return openShared();
|
if (moreFoldersByKey.has(key)) return loadMoreTreeFolders(key);
|
const folder = folderById.get(key);
|
const path = folder ? folderPathById.get(folder.id) : undefined;
|
if (folder && path) await openFolder(folder, 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 || [];
|
registerFolders(values, currentPath.value);
|
folderEntries.value.push(...values);
|
folderCursor.value = response.data.nextCursor;
|
hasMoreFolders.value = response.data.hasMore;
|
syncTreeChildren(currentFolder.value, values, response.data.hasMore ? response.data.nextCursor : undefined, true);
|
} finally {
|
loadingMoreFolders.value = false;
|
}
|
}
|
|
async function loadMoreFileEntries() {
|
if (!fileCursor.value || loadingMoreFiles.value) return;
|
loadingMoreFiles.value = true;
|
try {
|
const response = sharedLocation.value
|
? await getSharedFiles(props.mode, { cursor: fileCursor.value, pageSize: PAGE_SIZE })
|
: await getFolderFiles(props.mode, currentFolder.value!.id, {
|
cursor: fileCursor.value,
|
keyword: keyword.value.trim() || undefined,
|
pageSize: PAGE_SIZE,
|
});
|
fileEntries.value.push(...(response.data.list || []));
|
fileCursor.value = response.data.nextCursor;
|
hasMoreFiles.value = response.data.hasMore;
|
} finally {
|
loadingMoreFiles.value = false;
|
}
|
}
|
|
async function searchFiles() {
|
if (sharedLocation.value) return;
|
const folder = currentFolder.value;
|
if (!folder) return;
|
loading.value = true;
|
try {
|
const response = await getFolderFiles(props.mode, folder.id, { 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 (sharedLocation.value) return openShared();
|
if (currentFolder.value) return openFolder(currentFolder.value, currentPath.value);
|
await loadRootFolders();
|
}
|
|
function openCreateFolder() {
|
if (currentFolder.value && canCreate.value) openCreateFolderModal(true, { parent: currentFolder.value });
|
}
|
|
async function handleFolderCreated() {
|
await refresh();
|
createMessage.success('目录已创建');
|
}
|
|
function prepareUpload(file: File) {
|
if (!currentFolder.value || !canCreate.value) return AUpload.LIST_IGNORE;
|
openUploadFileModal(true, { file, folder: currentFolder.value });
|
return AUpload.LIST_IGNORE;
|
}
|
|
async function handleUploaded() {
|
await refresh();
|
createMessage.success('文件已上传并关联文件编号');
|
}
|
|
async function openRevision() {
|
const item = selectedCurrentFile.value;
|
if (!item?.documentId || checkingRevision.value) return;
|
checkingRevision.value = true;
|
try {
|
const response = await evaluateDocumentUpdate(item.documentId);
|
if (!response.data.allowed) {
|
createMessage.error('当前用户没有该文件的 UPDATE 权限,不能发起修订');
|
return;
|
}
|
openFileRevisionModal(true, { item });
|
} catch {
|
createMessage.error('文件修订权限检查失败');
|
} finally {
|
checkingRevision.value = false;
|
}
|
}
|
|
async function handleRevised() {
|
await refresh();
|
createMessage.success('文件修订已直接生效,原版本已转入旧版文件中心');
|
}
|
|
function selectEntry(item: DmsFileCenterItem) {
|
selectedId.value = item.id;
|
}
|
|
function handleEntryClick(item: DmsFileCenterItem) {
|
clearSelectTimer();
|
selectTimer = window.setTimeout(() => {
|
selectTimer = undefined;
|
selectEntry(item);
|
}, 250);
|
}
|
|
function handleEntryDoubleClick(item: DmsFileCenterItem) {
|
clearSelectTimer();
|
if (item.resourceType === 'FOLDER') void openFolder(item, [...currentPath.value, item]);
|
else {
|
selectEntry(item);
|
void previewFile(item);
|
}
|
}
|
|
async function previewFile(item: DmsFileCenterItem) {
|
try {
|
await openDmsFilePreview({ fileName: item.fileName || item.name, resourceId: item.id, resourceType: 'FILE_VERSION' });
|
} catch (error) {
|
createMessage.error(dmsPreviewErrorMessage(error));
|
}
|
}
|
|
function clearSelectTimer() {
|
if (selectTimer === undefined) return;
|
window.clearTimeout(selectTimer);
|
selectTimer = undefined;
|
}
|
|
async function openBreadcrumb(index: number) {
|
const folder = currentPath.value[index];
|
if (folder) await openFolder(folder, currentPath.value.slice(0, index + 1));
|
}
|
|
onMounted(async () => {
|
loading.value = true;
|
try {
|
await loadRootFolders();
|
} catch {
|
createMessage.error('目录加载失败');
|
} finally {
|
loading.value = false;
|
}
|
});
|
onBeforeUnmount(clearSelectTimer);
|
</script>
|
|
<template>
|
<section class="file-center" :class="{ 'tree-collapsed': treeCollapsed }">
|
<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="treeCollapsed = !treeCollapsed">
|
<MenuUnfoldOutlined v-if="treeCollapsed" />
|
<MenuFoldOutlined v-else />
|
</a-button>
|
</a-tooltip>
|
</div>
|
<div v-if="!treeCollapsed" class="folder-tree-scroll">
|
<ATree
|
block-node
|
class="folder-tree"
|
:expanded-keys="expandedKeys"
|
:load-data="loadTreeChildren"
|
:selected-keys="selectedKeys"
|
:tree-data="treeData"
|
@expand="handleTreeExpand"
|
@select="handleTreeSelect">
|
<template #title="{ key, title }">
|
<span class="folder-tree-title" :class="{ more: moreFoldersByKey.has(String(key)) }" :title="title">
|
<ShareAltOutlined v-if="key === SHARED_KEY" />
|
<RightOutlined v-else-if="moreFoldersByKey.has(String(key))" />
|
<FolderOutlined v-else />
|
<span>{{ title }}</span>
|
</span>
|
</template>
|
</ATree>
|
</div>
|
</aside>
|
|
<main class="file-workspace">
|
<div class="navigation-bar">
|
<div class="address-bar" aria-label="当前位置">
|
<button v-if="sharedLocation" type="button">共享给我</button>
|
<template v-else v-for="(folder, index) in currentPath" :key="folder.id">
|
<button type="button" :title="folder.name" @click="openBreadcrumb(index)">{{ folder.name }}</button>
|
<RightOutlined v-if="index < currentPath.length - 1" />
|
</template>
|
</div>
|
|
<a-input
|
v-model:value="keyword"
|
class="entry-search"
|
allow-clear
|
:disabled="sharedLocation"
|
:placeholder="sharedLocation ? '共享文件' : `按名称前缀搜索 ${currentFolder?.name || ''}`"
|
@press-enter="searchFiles"
|
@change="!keyword && searchFiles()">
|
<template #prefix><SearchOutlined /></template>
|
</a-input>
|
|
<div class="workspace-actions">
|
<template v-if="!historical && !sharedLocation && canCreate">
|
<a-button :icon="h(FolderAddOutlined)" @click="openCreateFolder">新建目录</a-button>
|
<AUpload :before-upload="prepareUpload" :show-upload-list="false">
|
<a-button :icon="h(UploadOutlined)">上传</a-button>
|
</AUpload>
|
</template>
|
<a-tooltip v-else-if="!historical && !sharedLocation && checkingCreate" title="正在检查 CREATE 权限">
|
<a-button loading aria-label="检查创建权限" />
|
</a-tooltip>
|
<a-tooltip v-if="!historical" :title="selectedCurrentFile ? '修订选中的现行文件' : '请先选择一个现行文件'">
|
<span>
|
<a-button :disabled="!selectedCurrentFile" :icon="h(EditOutlined)" :loading="checkingRevision" @click="openRevision"> 文件修订 </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>{{ sharedLocation ? '共享给我' : currentFolder?.name || centerName }}</strong>
|
<div class="view-command-actions">
|
<span>{{ entries.length }} 个已加载项目</span>
|
<a-button-group size="small">
|
<a-tooltip title="详细信息">
|
<a-button aria-label="详细信息视图" :type="viewMode === 'details' ? 'primary' : 'default'" @click="viewMode = 'details'">
|
<UnorderedListOutlined />
|
</a-button>
|
</a-tooltip>
|
<a-tooltip title="大图标">
|
<a-button aria-label="大图标视图" :type="viewMode === 'tiles' ? 'primary' : 'default'" @click="viewMode = 'tiles'">
|
<AppstoreOutlined />
|
</a-button>
|
</a-tooltip>
|
</a-button-group>
|
</div>
|
</div>
|
|
<div class="entry-viewport" :class="`view-${viewMode}`" v-loading="loading">
|
<template v-if="entries.length">
|
<div v-if="viewMode === 'details'" class="detail-list" role="listbox" aria-label="文件列表">
|
<div class="detail-header" aria-hidden="true">
|
<span>标题</span>
|
<span>文件编号</span>
|
<span>版本号</span>
|
<span>上传日期</span>
|
<span>有效期至</span>
|
<span>文件大小</span>
|
<span>生效日期</span>
|
<span>操作记录</span>
|
</div>
|
<div
|
v-for="item in entries"
|
:key="item.id"
|
class="detail-row"
|
:class="{ selected: selectedId === item.id }"
|
role="option"
|
:aria-selected="selectedId === item.id"
|
tabindex="0"
|
@click="handleEntryClick(item)"
|
@dblclick.stop.prevent="handleEntryDoubleClick(item)"
|
@keydown.enter="handleEntryDoubleClick(item)">
|
<span class="entry-name" :title="displayName(item)">
|
<span class="entry-icon" :class="iconTone(item)"><component :is="itemIcon(item)" /></span>
|
<span>{{ displayName(item) }}</span>
|
</span>
|
<span :title="item.documentNo">{{ fileValue(item, item.documentNo || '—') }}</span>
|
<span>{{ fileValue(item, item.versionLabel || `v${item.versionNo ?? '-'}`) }}</span>
|
<span>{{ fileValue(item, displayDate(item.createdAt)) }}</span>
|
<span>{{ validUntil(item) }}</span>
|
<span>{{ fileValue(item, displaySize(item)) }}</span>
|
<span>{{ fileValue(item, displayDate(item.effectiveFrom)) }}</span>
|
<span>—</span>
|
</div>
|
</div>
|
|
<div v-else 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)"
|
@dblclick.stop.prevent="handleEntryDoubleClick(item)"
|
@keydown.enter="handleEntryDoubleClick(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>
|
</template>
|
<Empty v-else-if="!loading" class="entry-empty" :description="emptyDescription" />
|
|
<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" />
|
<UploadFileModal @register="registerUploadFileModal" @uploaded="handleUploaded" />
|
<FileRevisionModal @register="registerFileRevisionModal" @revised="handleRevised" />
|
</section>
|
</template>
|
|
<style lang="scss" scoped>
|
.file-center {
|
--folder-navigation-width: 236px;
|
|
display: grid;
|
grid-template-columns: var(--folder-navigation-width) minmax(520px, 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;
|
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 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;
|
|
svg {
|
flex: 0 0 auto;
|
color: #d99c00;
|
}
|
|
&.more,
|
&.more svg {
|
color: var(--primary-color);
|
}
|
|
span {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.folder-tree :deep(.ant-tree-treenode) {
|
width: 100%;
|
}
|
|
.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(200px, 1fr) minmax(200px, 320px) 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;
|
}
|
|
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);
|
}
|
|
.entry-viewport {
|
flex: 1;
|
min-height: 0;
|
padding: 8px 12px;
|
overflow: auto;
|
}
|
|
.detail-list {
|
min-width: 1240px;
|
}
|
|
.detail-header,
|
.detail-row {
|
display: grid;
|
grid-template-columns: minmax(280px, 1fr) minmax(130px, 0.55fr) 90px 112px 112px 100px 112px 100px;
|
align-items: center;
|
min-height: 44px;
|
border-bottom: 1px solid var(--border-color-base1);
|
}
|
|
.detail-header {
|
min-height: 38px;
|
font-size: 12px;
|
color: var(--text-color-secondary);
|
}
|
|
.detail-header > span,
|
.detail-row > span {
|
min-width: 0;
|
padding: 0 10px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.detail-row {
|
cursor: default;
|
border-radius: 3px;
|
|
&:hover {
|
background: var(--item-hover-bg);
|
}
|
|
&.selected {
|
background: var(--item-active-bg);
|
outline: 1px solid color-mix(in srgb, var(--primary-color) 52%, transparent);
|
}
|
}
|
|
.entry-name {
|
display: flex;
|
gap: 10px;
|
align-items: center;
|
}
|
|
.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: default;
|
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(180px, 1fr) auto;
|
}
|
|
.entry-search {
|
grid-row: 2;
|
grid-column: 1 / -1;
|
}
|
}
|
</style>
|