|
|
@@ -0,0 +1,220 @@
|
|
|
+<template>
|
|
|
+ <div class="web-preview-panel">
|
|
|
+ <!-- 地址栏:面板里已经在浏览的真实地址(同源,可读 location),随时能复制或跳回新窗口 -->
|
|
|
+ <div class="web-toolbar">
|
|
|
+ <div class="web-url" :title="url">{{ displayUrl }}</div>
|
|
|
+ <a-select v-model:value="sizeKey" class="web-size-select" size="small" :options="sizeOptions" :dropdown-match-select-width="false" />
|
|
|
+ <div class="web-actions">
|
|
|
+ <a-button size="small" type="link" @click="reload">刷新</a-button>
|
|
|
+ <a-button size="small" type="link" @click="openInBrowser">新窗口打开</a-button>
|
|
|
+ <a-button size="small" type="link" @click="copyUrl">复制链接</a-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!--
|
|
|
+ 舞台:iframe 按选定分辨率(默认 1280×720)作为「真实视口」渲染,再整体等比缩放到面板里显示。
|
|
|
+ 内网系统基本是按固定设计稿尺寸做的、不响应式,直接铺满窄面板会被挤变形,所以要这样缩。
|
|
|
+ 只缩不放(放大会糊),四周留白居中;选「自适应面板」时退回铺满、不缩放。
|
|
|
+ -->
|
|
|
+ <div ref="stageRef" class="web-stage">
|
|
|
+ <div v-if="loading" class="web-loading">正在加载页面…</div>
|
|
|
+ <div class="web-viewport" :class="{ 'is-loading': loading }" :style="viewportStyle">
|
|
|
+ <iframe ref="frameRef" class="web-frame" :src="url" frameborder="0" @load="loading = false"></iframe>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
|
+ import { message } from 'ant-design-vue';
|
|
|
+ import { copyToClipboard } from '/@/views/ventAI/manageAssistent/lib/index';
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 右侧面板的网页预览:http(s) 地址直接 iframe 嵌入(判定见 lib/webLink.ts,当前不区分站内站外)。
|
|
|
+ *
|
|
|
+ * 不加 sandbox:地址多为内网系统,加了(尤其不带 allow-same-origin 时)会让它变成不透明来源,
|
|
|
+ * 登录态、下载、弹窗等能力都受影响。要收紧的话给 iframe 加
|
|
|
+ * sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads" 即可。
|
|
|
+ */
|
|
|
+ const props = defineProps<{ url: string }>();
|
|
|
+
|
|
|
+ const frameRef = ref<HTMLIFrameElement>();
|
|
|
+ const stageRef = ref<HTMLElement>();
|
|
|
+ const loading = ref(true);
|
|
|
+
|
|
|
+ // 预设视口尺寸:内网系统按设计稿分辨率渲染才不会变形
|
|
|
+ const SIZE_PRESETS = [
|
|
|
+ { value: '1280x720', label: '1280 × 720', width: 1280, height: 720 },
|
|
|
+ { value: '1440x900', label: '1440 × 900', width: 1440, height: 900 },
|
|
|
+ { value: '1920x1080', label: '1920 × 1080', width: 1920, height: 1080 },
|
|
|
+ { value: 'fit', label: '自适应面板', width: 0, height: 0 },
|
|
|
+ ];
|
|
|
+ const sizeOptions = SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label }));
|
|
|
+ const sizeKey = ref('1280x720');
|
|
|
+ const preset = computed(() => SIZE_PRESETS.find((p) => p.value === sizeKey.value) || SIZE_PRESETS[0]);
|
|
|
+
|
|
|
+ // 舞台可用尺寸:缩放比要跟着面板大小变(全屏/收起面板时都要重算)
|
|
|
+ const stageSize = ref({ width: 0, height: 0 });
|
|
|
+ const scale = computed(() => {
|
|
|
+ const p = preset.value;
|
|
|
+ const { width, height } = stageSize.value;
|
|
|
+ if (!p.width || !p.height || !width || !height) return 1;
|
|
|
+ return Math.min(1, width / p.width, height / p.height);
|
|
|
+ });
|
|
|
+ const viewportStyle = computed(() => {
|
|
|
+ const p = preset.value;
|
|
|
+ if (!p.width || !p.height) return { top: '0', left: '0', width: '100%', height: '100%', transform: 'none' };
|
|
|
+ return {
|
|
|
+ top: '50%',
|
|
|
+ left: '50%',
|
|
|
+ width: `${p.width}px`,
|
|
|
+ height: `${p.height}px`,
|
|
|
+ transform: `translate(-50%, -50%) scale(${scale.value})`,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ let resizeObserver: ResizeObserver | null = null;
|
|
|
+ onMounted(() => {
|
|
|
+ const el = stageRef.value;
|
|
|
+ if (!el) return;
|
|
|
+ const measure = () => {
|
|
|
+ stageSize.value = { width: el.clientWidth, height: el.clientHeight };
|
|
|
+ };
|
|
|
+ resizeObserver = new ResizeObserver(measure);
|
|
|
+ resizeObserver.observe(el);
|
|
|
+ measure();
|
|
|
+ });
|
|
|
+
|
|
|
+ onBeforeUnmount(() => {
|
|
|
+ resizeObserver?.disconnect();
|
|
|
+ resizeObserver = null;
|
|
|
+ });
|
|
|
+
|
|
|
+ // 标签栏里显示短路径,完整地址放在 title 上
|
|
|
+ const displayUrl = computed(() => {
|
|
|
+ try {
|
|
|
+ const u = new URL(props.url, window.location.href);
|
|
|
+ return u.pathname + u.search || u.href;
|
|
|
+ } catch {
|
|
|
+ return props.url;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 换地址(同一标签页复用实例时)重新进加载态
|
|
|
+ watch(
|
|
|
+ () => props.url,
|
|
|
+ () => {
|
|
|
+ loading.value = true;
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ /** iframe 里当前真实地址:可读时用它;跳到读不到的页面(跨域)时退回标签原始地址 */
|
|
|
+ const currentHref = (): string => {
|
|
|
+ try {
|
|
|
+ return frameRef.value?.contentWindow?.location.href || props.url;
|
|
|
+ } catch {
|
|
|
+ return props.url;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const reload = () => {
|
|
|
+ const win = frameRef.value?.contentWindow;
|
|
|
+ try {
|
|
|
+ // 刷新 iframe 内的当前页,而不是回到打开时的初始地址
|
|
|
+ loading.value = true;
|
|
|
+ win?.location.reload();
|
|
|
+ } catch {
|
|
|
+ // 跨域读不到时退回重新加载 src
|
|
|
+ if (frameRef.value) frameRef.value.src = props.url;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const openInBrowser = () => {
|
|
|
+ window.open(currentHref(), '_blank', 'noopener,noreferrer');
|
|
|
+ };
|
|
|
+
|
|
|
+ const copyUrl = () => {
|
|
|
+ copyToClipboard(currentHref()).then(() => message.success('链接已复制'));
|
|
|
+ };
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="less">
|
|
|
+ .web-preview-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 8px 10px;
|
|
|
+ border-bottom: 1px solid rgb(63 80 106 / 25%);
|
|
|
+
|
|
|
+ .web-url {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8b949e;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: rgb(255 255 255 / 5%);
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-size-select {
|
|
|
+ width: 116px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-stage {
|
|
|
+ position: relative;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ // 缩放后四周的留白底色,跟面板区分开,看起来像「一块屏幕」
|
|
|
+ background: rgb(0 0 0 / 22%);
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-viewport {
|
|
|
+ position: absolute;
|
|
|
+ transform-origin: center center;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 6px 22px rgb(0 0 0 / 40%);
|
|
|
+
|
|
|
+ &.is-loading {
|
|
|
+ visibility: hidden;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-frame {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ border: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .web-loading {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #8b949e;
|
|
|
+ }
|
|
|
+</style>
|