<script lang="ts" setup>
|
import type { DmsFolderTreeItem } from '#/api/x/dms/fileCenter';
|
import type { OpenDmsFolderSelectorConfig } from '#/utils/jnpf';
|
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicModal, useModal } from '@jnpf/ui/modal';
|
|
import { FolderOpenOutlined, FolderOutlined } from '@ant-design/icons-vue';
|
import { Tree as ATree } from 'ant-design-vue';
|
|
import { getDmsFolderTree } from '#/api/x/dms/fileCenter';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
interface FolderTreeNode {
|
children?: FolderTreeNode[];
|
isLeaf: boolean;
|
key: string;
|
title: string;
|
}
|
|
interface FolderTreeLoadNode {
|
key: number | string;
|
}
|
|
const PAGE_SIZE = 100;
|
const PATH_SEPARATOR = '/';
|
const emitter = onlineUtils.getEmitter();
|
const { createMessage } = useMessage();
|
const [registerModal, { closeModal, openModal }] = useModal();
|
const config = ref<null | OpenDmsFolderSelectorConfig>(null);
|
const loading = ref(false);
|
const treeData = ref<FolderTreeNode[]>([]);
|
const treeVisible = ref(false);
|
const expandedKeys = ref<string[]>([]);
|
const selectedKeys = ref<string[]>([]);
|
const foldersById = new Map<string, DmsFolderTreeItem>();
|
const pathsById = new Map<string, DmsFolderTreeItem[]>();
|
let requestId = 0;
|
|
const title = computed(() => config.value?.title?.trim() || '选择目录');
|
const selectedFolder = computed(() => foldersById.get(selectedKeys.value[0] || ''));
|
const selectedPath = computed(() => (selectedFolder.value ? pathsById.get(selectedFolder.value.id) || [selectedFolder.value] : []));
|
const selectedFolderPath = computed(() => selectedPath.value.map((folder) => folder.name).join(PATH_SEPARATOR));
|
|
function toTreeNode(folder: DmsFolderTreeItem, current?: FolderTreeNode): FolderTreeNode {
|
return {
|
children: current?.children,
|
isLeaf: !folder.hasChildren,
|
key: folder.id,
|
title: folder.name,
|
};
|
}
|
|
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: DmsFolderTreeItem[], parentPath: DmsFolderTreeItem[]) {
|
for (const folder of folders) {
|
foldersById.set(folder.id, folder);
|
pathsById.set(folder.id, [...parentPath, folder]);
|
}
|
}
|
|
function appendChildren(parentId: string | undefined, folders: DmsFolderTreeItem[]) {
|
if (!parentId) {
|
const existing = new Map(treeData.value.map((node) => [node.key, node]));
|
treeData.value = folders.map((folder) => toTreeNode(folder, existing.get(folder.id)));
|
return;
|
}
|
|
const parent = findNode(treeData.value, parentId);
|
if (!parent) return;
|
const existing = new Map((parent.children || []).map((node) => [node.key, node]));
|
treeData.value = replaceNode(treeData.value, parentId, {
|
...parent,
|
children: folders.map((folder) => toTreeNode(folder, existing.get(folder.id))),
|
});
|
}
|
|
async function fetchFolderLevel(parentId?: string) {
|
const folders: DmsFolderTreeItem[] = [];
|
let cursor: string | undefined;
|
do {
|
const response = await getDmsFolderTree({ cursor, pageSize: PAGE_SIZE, parentId });
|
folders.push(...(response.data.list || []));
|
cursor = response.data.hasMore ? response.data.nextCursor : undefined;
|
} while (cursor);
|
return folders;
|
}
|
|
async function loadFolderLevel(parentId?: string, currentRequestId = requestId) {
|
const folders = await fetchFolderLevel(parentId);
|
if (currentRequestId !== requestId) return [];
|
const parentPath = parentId ? pathsById.get(parentId) || [] : [];
|
registerFolders(folders, parentPath);
|
appendChildren(parentId, folders);
|
return folders;
|
}
|
|
async function loadTreeChildren(node: FolderTreeLoadNode) {
|
const folder = foldersById.get(String(node.key));
|
if (!folder?.hasChildren || findNode(treeData.value, folder.id)?.children) return;
|
await loadFolderLevel(folder.id);
|
}
|
|
async function locateInitialFolder(folderId: string, currentRequestId: number) {
|
if (foldersById.has(folderId)) return;
|
const queue = treeData.value.map((node) => node.key);
|
for (let index = 0; index < queue.length; index++) {
|
if (foldersById.has(folderId) || currentRequestId !== requestId) break;
|
const parentId = queue[index]!;
|
const parent = foldersById.get(parentId);
|
if (!parent?.hasChildren) continue;
|
const children = await loadFolderLevel(parentId, currentRequestId);
|
queue.push(...children.filter((folder) => folder.hasChildren).map((folder) => folder.id));
|
}
|
}
|
|
async function locateAndSelectInitialFolder(folderId: string, currentRequestId: number) {
|
try {
|
await locateInitialFolder(folderId, currentRequestId);
|
if (currentRequestId !== requestId || selectedKeys.value.length) return;
|
const folder = foldersById.get(folderId);
|
if (!folder) return;
|
selectedKeys.value = [folder.id];
|
expandedKeys.value = (pathsById.get(folder.id) || []).slice(0, -1).map((item) => item.id);
|
} catch {
|
if (currentRequestId === requestId) createMessage.error('目录树加载失败');
|
}
|
}
|
|
function resetTree() {
|
foldersById.clear();
|
pathsById.clear();
|
treeData.value = [];
|
expandedKeys.value = [];
|
selectedKeys.value = [];
|
}
|
|
async function close(cancelled = true) {
|
const closingConfig = config.value;
|
requestId++;
|
closeModal();
|
// Let the modal and Ant Tree finish their current update before any callback can open another selector.
|
await nextTick();
|
if (config.value === closingConfig) config.value = null;
|
if (cancelled) closingConfig?.onCancel?.();
|
return true;
|
}
|
|
async function handleOpen(openConfig: OpenDmsFolderSelectorConfig) {
|
const currentRequestId = ++requestId;
|
const folderId = openConfig?.folderId?.trim();
|
let shouldLocateInitialFolder = false;
|
config.value = openConfig || {};
|
resetTree();
|
treeVisible.value = false;
|
loading.value = true;
|
await nextTick();
|
if (currentRequestId !== requestId) return;
|
treeVisible.value = true;
|
openModal(true);
|
|
try {
|
await loadFolderLevel(undefined, currentRequestId);
|
if (currentRequestId !== requestId) return;
|
if (folderId) {
|
const folder = foldersById.get(folderId);
|
if (folder) {
|
selectedKeys.value = [folder.id];
|
expandedKeys.value = (pathsById.get(folder.id) || []).slice(0, -1).map((item) => item.id);
|
} else {
|
shouldLocateInitialFolder = true;
|
}
|
}
|
} catch {
|
if (currentRequestId === requestId) createMessage.error('目录树加载失败');
|
} finally {
|
if (currentRequestId === requestId) loading.value = false;
|
}
|
|
if (shouldLocateInitialFolder && currentRequestId === requestId && folderId) {
|
void locateAndSelectInitialFolder(folderId, currentRequestId);
|
}
|
}
|
|
function handleSelect(keys: (number | string)[]) {
|
selectedKeys.value = keys.slice(0, 1).map(String);
|
}
|
|
function handleExpand(keys: (number | string)[]) {
|
expandedKeys.value = keys.map(String);
|
}
|
|
function handleConfirm() {
|
const folder = selectedFolder.value;
|
if (!folder || loading.value) return;
|
const onConfirm = config.value?.onConfirm;
|
const folderPath = selectedFolderPath.value;
|
|
// Wait for the current modal's DOM update before user code can open another selector.
|
void close(false).then(() => {
|
try {
|
onConfirm?.(folder.id, folderPath);
|
} catch (error) {
|
console.error('[onlineUtils.openDmsFolderSelector] onConfirm error:', error);
|
createMessage.error('目录选择回调执行失败');
|
}
|
});
|
}
|
|
onMounted(() => {
|
emitter.on('OPEN_DMS_FOLDER_SELECTOR', handleOpen);
|
});
|
|
onUnmounted(() => {
|
emitter.off('OPEN_DMS_FOLDER_SELECTOR', handleOpen);
|
});
|
</script>
|
|
<template>
|
<BasicModal
|
@register="registerModal"
|
:close-func="() => close(true)"
|
:confirm-loading="loading"
|
:ok-button-props="{ disabled: !selectedFolder || loading }"
|
:title="title"
|
:width="560"
|
class="global-dms-folder-selector"
|
ok-text="确定"
|
@ok="handleConfirm">
|
<div v-loading="loading" class="dms-folder-selector__body">
|
<ATree
|
v-if="treeVisible"
|
block-node
|
class="dms-folder-selector__tree"
|
:expanded-keys="expandedKeys"
|
:load-data="loadTreeChildren"
|
:selected-keys="selectedKeys"
|
:tree-data="treeData"
|
@expand="handleExpand"
|
@select="handleSelect">
|
<template #title="{ key, title: nodeTitle }">
|
<span class="dms-folder-selector__node" :title="nodeTitle">
|
<FolderOpenOutlined v-if="expandedKeys.includes(String(key))" />
|
<FolderOutlined v-else />
|
<span>{{ nodeTitle }}</span>
|
</span>
|
</template>
|
</ATree>
|
<div v-if="!loading && !treeData.length" class="dms-folder-selector__empty">暂无可选择的目录</div>
|
<div v-else class="dms-folder-selector__path" :title="selectedFolderPath">
|
{{ selectedFolderPath || '请选择目录' }}
|
</div>
|
</div>
|
</BasicModal>
|
</template>
|
|
<style scoped>
|
.dms-folder-selector__body {
|
display: flex;
|
flex-direction: column;
|
height: min(58vh, 520px);
|
min-height: 280px;
|
}
|
|
.dms-folder-selector__tree {
|
flex: 1;
|
min-height: 0;
|
padding: 8px;
|
overflow: auto;
|
}
|
|
.dms-folder-selector__node {
|
display: flex;
|
gap: 7px;
|
align-items: center;
|
min-height: 32px;
|
min-width: 0;
|
|
> svg {
|
flex: 0 0 auto;
|
color: currentcolor;
|
}
|
|
> span {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.dms-folder-selector__tree :deep(.ant-tree-treenode) {
|
display: flex;
|
align-items: center;
|
min-height: 32px;
|
width: 100%;
|
}
|
|
.dms-folder-selector__tree :deep(.ant-tree-switcher),
|
.dms-folder-selector__tree :deep(.ant-tree-node-content-wrapper) {
|
display: inline-flex;
|
align-items: center;
|
min-height: 32px;
|
line-height: 32px;
|
min-width: 0;
|
border-radius: 4px;
|
}
|
|
.dms-folder-selector__path {
|
min-height: 38px;
|
padding: 9px 12px;
|
overflow: hidden;
|
color: var(--text-color-secondary);
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
background: var(--body-background);
|
border-top: 1px solid var(--border-color-base1);
|
}
|
|
.dms-folder-selector__empty {
|
padding: 36px 12px;
|
color: var(--text-color-secondary);
|
text-align: center;
|
}
|
|
@media (width <= 640px) {
|
.dms-folder-selector__body {
|
height: 56vh;
|
min-height: 240px;
|
}
|
}
|
</style>
|