<script lang="ts" setup>
|
import type { Component } from 'vue';
|
|
import { computed, defineAsyncComponent, markRaw, nextTick, onMounted, onUnmounted, reactive, shallowRef } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicPopup } from '@jnpf/ui/popup';
|
|
import { clearCustomViewParams } from '#/utils/custom-view-context';
|
import { onlineUtils, type OpenCustomViewConfig } from '#/utils/jnpf';
|
|
interface OpenCustomViewEvent extends OpenCustomViewConfig {
|
instanceId: number;
|
params: Record<string, any>;
|
}
|
|
type ViewLoader = () => Promise<{ default: Component }>;
|
|
const customViews = import.meta.glob('../../views/x/**/*.vue') as Record<string, ViewLoader>;
|
const contentTargetSelectors = ['.jnpf-layout-content > .app-main', '.jnpf-layout-content .app-main', '.app-main'];
|
const emitter = onlineUtils.getEmitter();
|
const { createMessage } = useMessage();
|
const viewComponent = shallowRef<Component | null>(null);
|
const teleportTarget = shallowRef<HTMLElement | null>(null);
|
|
const state = reactive({
|
config: null as null | OpenCustomViewEvent,
|
key: 0,
|
loading: false,
|
open: false,
|
title: '自定义页面',
|
});
|
|
const viewParams = computed(() => state.config?.params || {});
|
|
function normalizePagePath(page: string) {
|
const normalized = page
|
.trim()
|
.replaceAll('\\', '/')
|
.replace(/^\/+/, '')
|
.replace(/\.vue$/, '')
|
.replace(/\/index$/, '');
|
const segments = normalized.split('/');
|
if (!normalized.startsWith('x/') || segments.some((segment) => !segment || segment === '.' || segment === '..')) return '';
|
return normalized;
|
}
|
|
function getViewLoader(page: string) {
|
const normalized = normalizePagePath(page);
|
if (!normalized) return null;
|
const basePath = `../../views/${normalized}`;
|
return customViews[`${basePath}.vue`] || customViews[`${basePath}/index.vue`] || null;
|
}
|
|
function findContentTarget() {
|
for (const selector of contentTargetSelectors) {
|
const target = document.querySelector<HTMLElement>(selector);
|
if (target) return target;
|
}
|
return null;
|
}
|
|
async function ensureTeleportTarget() {
|
const currentTarget = findContentTarget();
|
if (!currentTarget) return false;
|
if (teleportTarget.value !== currentTarget) {
|
teleportTarget.value = currentTarget;
|
await nextTick();
|
}
|
return true;
|
}
|
|
function resetState() {
|
state.config = null;
|
state.loading = false;
|
state.open = false;
|
state.title = '自定义页面';
|
viewComponent.value = null;
|
}
|
|
function handleClose(result?: any) {
|
const config = state.config;
|
resetState();
|
if (!config) return Promise.resolve(true);
|
|
clearCustomViewParams(config.instanceId);
|
try {
|
config.onClose?.(result);
|
} catch (error) {
|
console.error('[onlineUtils.openCustomView] onClose error:', error);
|
}
|
return Promise.resolve(true);
|
}
|
|
async function handleOpen(config: OpenCustomViewEvent) {
|
if (state.config) await handleClose();
|
|
state.config = config;
|
state.loading = true;
|
state.title = config.title || '自定义页面';
|
viewComponent.value = null;
|
|
try {
|
if (!(await ensureTeleportTarget())) throw new Error('未找到当前页签内容区域');
|
const loader = getViewLoader(config.page);
|
if (!loader) throw new Error('未找到 src/views/x 下的自定义页面');
|
viewComponent.value = markRaw(defineAsyncComponent(loader));
|
state.key = config.instanceId;
|
state.open = true;
|
} catch (error: any) {
|
console.error('[onlineUtils.openCustomView] open custom view failed:', error);
|
clearCustomViewParams(config.instanceId);
|
resetState();
|
createMessage.warning(error?.message || '打开自定义页面失败');
|
} finally {
|
state.loading = false;
|
}
|
}
|
|
const popupContext = {
|
close: (result?: any) => handleClose(result),
|
};
|
|
onMounted(() => {
|
teleportTarget.value = findContentTarget();
|
emitter.on('OPEN_CUSTOM_VIEW_MODAL', handleOpen as any);
|
});
|
|
onUnmounted(() => {
|
emitter.off('OPEN_CUSTOM_VIEW_MODAL', handleOpen as any);
|
if (state.config) clearCustomViewParams(state.config.instanceId);
|
});
|
</script>
|
|
<template>
|
<Teleport v-if="teleportTarget" :to="teleportTarget">
|
<BasicPopup
|
:open="state.open"
|
:loading="state.loading"
|
destroy-on-close
|
:show-ok-btn="false"
|
:close-func="handleClose"
|
class="full-popup global-custom-view-popup">
|
<template #title>
|
<div class="text-[16px] font-medium">{{ state.title }}</div>
|
</template>
|
<div class="global-custom-view-popup-body">
|
<component :is="viewComponent" v-if="viewComponent" :key="state.key" :params="viewParams" :popup-context="popupContext" @close="handleClose" />
|
</div>
|
</BasicPopup>
|
</Teleport>
|
</template>
|
|
<style>
|
.global-custom-view-popup {
|
border-radius: var(--radius);
|
}
|
|
.global-custom-view-popup-body {
|
height: 100%;
|
min-height: 0;
|
overflow: hidden;
|
}
|
|
.global-custom-view-popup-body > .jnpf-content-wrapper {
|
height: 100%;
|
}
|
</style>
|