725 lines
22 KiB
Vue
725 lines
22 KiB
Vue
|
|
<template>
|
|||
|
|
<a-modal
|
|||
|
|
v-model:open="innerOpen"
|
|||
|
|
title="模板预览"
|
|||
|
|
width="1240px"
|
|||
|
|
:footer="null"
|
|||
|
|
:body-style="{ padding: '16px 20px 22px' }"
|
|||
|
|
destroy-on-close
|
|||
|
|
wrap-class-name="native-template-list-preview-modal"
|
|||
|
|
@cancel="onClose"
|
|||
|
|
>
|
|||
|
|
<a-spin :spinning="loading">
|
|||
|
|
<div v-if="errorText" class="native-preview-error">{{ errorText }}</div>
|
|||
|
|
<a-row v-else-if="schema" :gutter="20" class="native-preview-row">
|
|||
|
|
<a-col :xs="24" :lg="11" class="native-preview-left">
|
|||
|
|
<a-tabs v-model:activeKey="jsonTabKey" size="small" class="json-tabs">
|
|||
|
|
<a-tab-pane key="template" tab="模板JSON">
|
|||
|
|
<div class="json-template-pane">
|
|||
|
|
<div class="json-box-title">画布实际 JSON(模板样式)</div>
|
|||
|
|
<a-textarea v-model:value="canvasJsonText" :rows="16" class="json-textarea" placeholder="模板 JSON" />
|
|||
|
|
</div>
|
|||
|
|
</a-tab-pane>
|
|||
|
|
<a-tab-pane key="params" tab="参数JSON">
|
|||
|
|
<div class="params-json-pane">
|
|||
|
|
<div class="params-json-head">
|
|||
|
|
<div class="json-sub-tabs json-sub-tabs--segmented" role="tablist" aria-label="参数数据来源">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
role="tab"
|
|||
|
|
:class="['capsule-tab', { 'is-active': dataSourceType === 'manual' }]"
|
|||
|
|
@click="dataSourceType = 'manual'"
|
|||
|
|
>
|
|||
|
|
手动JSON
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
role="tab"
|
|||
|
|
:class="['capsule-tab', { 'is-active': dataSourceType === 'mock' }]"
|
|||
|
|
@click="dataSourceType = 'mock'"
|
|||
|
|
>
|
|||
|
|
模拟JSON
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<a-button
|
|||
|
|
v-show="dataSourceType === 'mock'"
|
|||
|
|
size="small"
|
|||
|
|
type="primary"
|
|||
|
|
ghost
|
|||
|
|
class="json-capsule-btn"
|
|||
|
|
@click="onGenerateMock"
|
|||
|
|
>
|
|||
|
|
根据画布生成
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
<a-textarea
|
|||
|
|
v-show="dataSourceType === 'manual'"
|
|||
|
|
v-model:value="previewDataText"
|
|||
|
|
:rows="14"
|
|||
|
|
class="json-textarea json-textarea--main"
|
|||
|
|
placeholder="手动输入预览数据 JSON"
|
|||
|
|
/>
|
|||
|
|
<template v-if="dataSourceType === 'mock'">
|
|||
|
|
<a-textarea
|
|||
|
|
v-model:value="mockDataText"
|
|||
|
|
:rows="12"
|
|||
|
|
class="json-textarea json-textarea--main"
|
|||
|
|
placeholder="点击「根据画布生成」自动填充模拟数据"
|
|||
|
|
/>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
</a-tab-pane>
|
|||
|
|
</a-tabs>
|
|||
|
|
</a-col>
|
|||
|
|
<a-col :xs="24" :lg="13" class="native-preview-right">
|
|||
|
|
<div class="preview-header-row">
|
|||
|
|
<div class="preview-title">预览样式</div>
|
|||
|
|
<a-space v-if="previewHtml" size="small" align="center" wrap class="preview-header-actions" @click.stop>
|
|||
|
|
<a-tooltip title="缩小">
|
|||
|
|
<a-button type="default" size="small" class="preview-zoom-btn" @click="zoomPreviewOut">
|
|||
|
|
<Icon icon="ant-design:zoom-out-outlined" />
|
|||
|
|
</a-button>
|
|||
|
|
</a-tooltip>
|
|||
|
|
<span class="preview-zoom-label">{{ zoomPercentLabel }}</span>
|
|||
|
|
<a-tooltip title="放大">
|
|||
|
|
<a-button type="default" size="small" class="preview-zoom-btn" @click="zoomPreviewIn">
|
|||
|
|
<Icon icon="ant-design:zoom-in-outlined" />
|
|||
|
|
</a-button>
|
|||
|
|
</a-tooltip>
|
|||
|
|
<a-tooltip title="恢复按窗口适应">
|
|||
|
|
<a-button type="default" size="small" class="preview-zoom-fit-btn" @click="resetPreviewZoom">适应</a-button>
|
|||
|
|
</a-tooltip>
|
|||
|
|
<a-tooltip title="使用浏览器打印当前预览内容">
|
|||
|
|
<a-button type="primary" size="small" class="preview-print-btn" @click="handleBrowserPrint">
|
|||
|
|
<Icon icon="ant-design:printer-outlined" />
|
|||
|
|
打印
|
|||
|
|
</a-button>
|
|||
|
|
</a-tooltip>
|
|||
|
|
</a-space>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="layoutPaperPx" ref="previewHostRef" class="preview-frame-wrap">
|
|||
|
|
<template v-if="previewHtml">
|
|||
|
|
<!-- 内层用 margin:auto,放大后父级可四向滚动,避免 flex 居中导致裁切 -->
|
|||
|
|
<div class="preview-scroll-flex">
|
|||
|
|
<div class="preview-zoom-slot">
|
|||
|
|
<div
|
|||
|
|
class="preview-scale-shim"
|
|||
|
|
:style="{
|
|||
|
|
width: `${layoutPaperPx.wPx * previewDisplayScale}px`,
|
|||
|
|
height: `${layoutPaperPx.hPx * previewDisplayScale}px`,
|
|||
|
|
}"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
class="preview-scale-inner"
|
|||
|
|
:style="{
|
|||
|
|
width: `${layoutPaperPx.wPx}px`,
|
|||
|
|
height: `${layoutPaperPx.hPx}px`,
|
|||
|
|
transform: `scale(${previewDisplayScale})`,
|
|||
|
|
}"
|
|||
|
|
>
|
|||
|
|
<iframe
|
|||
|
|
ref="previewIframeRef"
|
|||
|
|
class="preview-iframe"
|
|||
|
|
title="原生模板预览"
|
|||
|
|
:srcdoc="previewHtml"
|
|||
|
|
:style="{
|
|||
|
|
width: `${layoutPaperPx.wPx}px`,
|
|||
|
|
height: `${layoutPaperPx.hPx}px`,
|
|||
|
|
}"
|
|||
|
|
@load="onPreviewIframeLoad"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
<a-empty v-else description="正在生成预览…" />
|
|||
|
|
</div>
|
|||
|
|
</a-col>
|
|||
|
|
</a-row>
|
|||
|
|
</a-spin>
|
|||
|
|
</a-modal>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script lang="ts" setup>
|
|||
|
|
import { computed, nextTick, ref, watch } from 'vue';
|
|||
|
|
import { useDebounceFn, useResizeObserver } from '@vueuse/core';
|
|||
|
|
import { Icon } from '/@/components/Icon';
|
|||
|
|
import { useMessage } from '/@/hooks/web/useMessage';
|
|||
|
|
import { queryById } from '../printTemplate.api';
|
|||
|
|
import { renderNativePrintHtml, resolvePrintPageCount } from '../native/core/printRenderer';
|
|||
|
|
import { stringifyNativeTemplateStyle } from '../native/core/nativeTemplateStyleSerialize';
|
|||
|
|
import { generateNativeMockDataObject } from '../native/core/nativeMockData';
|
|||
|
|
import { normalizeImportedNativeSchema } from '../native/core/nativeSchemaNormalize';
|
|||
|
|
import type { NativeTemplateSchema } from '../native/core/types';
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
open: boolean;
|
|||
|
|
/** 打印模板主键 id */
|
|||
|
|
templateId: string | null;
|
|||
|
|
}>();
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
(e: 'update:open', v: boolean): void;
|
|||
|
|
}>();
|
|||
|
|
|
|||
|
|
const { createMessage } = useMessage();
|
|||
|
|
|
|||
|
|
const innerOpen = computed({
|
|||
|
|
get: () => props.open,
|
|||
|
|
set: (v: boolean) => emit('update:open', v),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const loading = ref(false);
|
|||
|
|
const errorText = ref('');
|
|||
|
|
const schema = ref<NativeTemplateSchema | null>(null);
|
|||
|
|
const canvasJsonText = ref('{}');
|
|||
|
|
const jsonTabKey = ref<'template' | 'params'>('template');
|
|||
|
|
const dataSourceType = ref<'manual' | 'mock'>('mock');
|
|||
|
|
const previewDataText = ref('{}');
|
|||
|
|
const mockDataText = ref('{}');
|
|||
|
|
const previewHtml = ref('');
|
|||
|
|
/** 预览区容器,用于根据可用空间计算缩放比 */
|
|||
|
|
const previewHostRef = ref<HTMLElement | null>(null);
|
|||
|
|
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
|
|||
|
|
/** 自动适应窗口时的缩放系数(相对纸张像素,通常 ≤1) */
|
|||
|
|
const autoFitScale = ref(1);
|
|||
|
|
/** 用户在「适应」基础上的缩放倍数,1 表示与自动适应一致 */
|
|||
|
|
const zoomMultiplier = ref(1);
|
|||
|
|
const ZOOM_STEP = 1.15;
|
|||
|
|
const ZOOM_MULT_MIN = 0.25;
|
|||
|
|
const ZOOM_MULT_MAX = 4;
|
|||
|
|
|
|||
|
|
/** 最终用于 transform 的缩放 = 自动适应 × 手动倍数,并做安全上下限 */
|
|||
|
|
const previewDisplayScale = computed(() => {
|
|||
|
|
const raw = autoFitScale.value * zoomMultiplier.value;
|
|||
|
|
if (!Number.isFinite(raw) || raw <= 0) {
|
|||
|
|
return 1;
|
|||
|
|
}
|
|||
|
|
return Math.min(4, Math.max(0.08, raw));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 相对「适应窗口」的百分比,便于用户理解当前放大程度 */
|
|||
|
|
const zoomPercentLabel = computed(() => `${Math.round(zoomMultiplier.value * 100)}%`);
|
|||
|
|
|
|||
|
|
/** CSS mm 转 px(与浏览器常规 96dpi 一致) */
|
|||
|
|
const MM_TO_CSS_PX = 96 / 25.4;
|
|||
|
|
|
|||
|
|
const activeDataText = computed(() => (dataSourceType.value === 'mock' ? mockDataText.value : previewDataText.value));
|
|||
|
|
|
|||
|
|
const previewData = computed(() => {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(activeDataText.value || '{}');
|
|||
|
|
} catch (_e) {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 按纸张 mm × 页数换算为像素(理论下限;auto 表格等实际可能更高,见 contentMeasurePx) */
|
|||
|
|
const paperPixelSize = computed(() => {
|
|||
|
|
if (!schema.value) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const pageCount = Math.max(1, resolvePrintPageCount(schema.value, previewData.value));
|
|||
|
|
const wMm = Number(schema.value.page?.width || 210);
|
|||
|
|
const hMm = Number(schema.value.page?.height || 297);
|
|||
|
|
const wPx = wMm * MM_TO_CSS_PX;
|
|||
|
|
const hPx = hMm * pageCount * MM_TO_CSS_PX;
|
|||
|
|
return { wPx, hPx, pageCount };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** iframe 内实际排版测量(解决 autoPage 表格超出单页高度后仍被裁切) */
|
|||
|
|
const contentMeasurePx = ref({ w: 0, h: 0 });
|
|||
|
|
|
|||
|
|
/** 预览/打印用的版面像素:取理论纸张与实测内容的较大值 */
|
|||
|
|
const layoutPaperPx = computed(() => {
|
|||
|
|
const ps = paperPixelSize.value;
|
|||
|
|
if (!ps) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const m = contentMeasurePx.value;
|
|||
|
|
return {
|
|||
|
|
...ps,
|
|||
|
|
wPx: Math.max(ps.wPx, m.w || 0),
|
|||
|
|
hPx: Math.max(ps.hPx, m.h || 0),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 遍历 iframe 文档与 scrollHeight,估算真实内容宽高 */
|
|||
|
|
function measureIframeContentBox(doc: Document): { w: number; h: number } {
|
|||
|
|
const body = doc.body;
|
|||
|
|
let minTop = Infinity;
|
|||
|
|
let maxBottom = 0;
|
|||
|
|
let minLeft = Infinity;
|
|||
|
|
let maxRight = 0;
|
|||
|
|
const visit = (el: Element) => {
|
|||
|
|
if (el instanceof HTMLElement) {
|
|||
|
|
const r = el.getBoundingClientRect();
|
|||
|
|
if (r.height > 0.5 && r.width > 0.5) {
|
|||
|
|
minTop = Math.min(minTop, r.top);
|
|||
|
|
maxBottom = Math.max(maxBottom, r.bottom);
|
|||
|
|
minLeft = Math.min(minLeft, r.left);
|
|||
|
|
maxRight = Math.max(maxRight, r.right);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for (let i = 0; i < el.children.length; i++) {
|
|||
|
|
visit(el.children[i]);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
visit(body);
|
|||
|
|
const byRect =
|
|||
|
|
Number.isFinite(minTop) && maxBottom > 0
|
|||
|
|
? { w: Math.ceil(maxRight - minLeft + 6), h: Math.ceil(maxBottom - minTop + 12) }
|
|||
|
|
: { w: 0, h: 0 };
|
|||
|
|
const sh = Math.max(doc.documentElement.scrollHeight, body.scrollHeight, byRect.h);
|
|||
|
|
const sw = Math.max(doc.documentElement.scrollWidth, body.scrollWidth, byRect.w);
|
|||
|
|
return { w: Math.ceil(sw), h: Math.ceil(sh) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateContentMeasure() {
|
|||
|
|
void nextTick(() => {
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
const doc = previewIframeRef.value?.contentDocument;
|
|||
|
|
if (!doc?.body) {
|
|||
|
|
contentMeasurePx.value = { w: 0, h: 0 };
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
contentMeasurePx.value = measureIframeContentBox(doc);
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refreshPreview() {
|
|||
|
|
if (!schema.value) {
|
|||
|
|
previewHtml.value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
previewHtml.value = await renderNativePrintHtml(schema.value, previewData.value);
|
|||
|
|
} catch (e: any) {
|
|||
|
|
previewHtml.value = '';
|
|||
|
|
createMessage.error(`预览渲染失败:${e?.message || '未知错误'}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const debouncedRefreshPreview = useDebounceFn(() => void refreshPreview(), 320);
|
|||
|
|
|
|||
|
|
function computePreviewScale() {
|
|||
|
|
const host = previewHostRef.value;
|
|||
|
|
const ps = layoutPaperPx.value;
|
|||
|
|
if (!host || !ps) {
|
|||
|
|
autoFitScale.value = 1;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const pad = 20;
|
|||
|
|
const availW = Math.max(0, host.clientWidth - pad);
|
|||
|
|
const availH = Math.max(0, host.clientHeight - pad);
|
|||
|
|
if (availW <= 0 || availH <= 0 || ps.wPx <= 0 || ps.hPx <= 0) {
|
|||
|
|
autoFitScale.value = 1;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const raw = Math.min(availW / ps.wPx, availH / ps.hPx, 1) * 0.96;
|
|||
|
|
autoFitScale.value = Number.isFinite(raw) && raw > 0 ? raw : 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function zoomPreviewIn() {
|
|||
|
|
zoomMultiplier.value = Math.min(ZOOM_MULT_MAX, zoomMultiplier.value * ZOOM_STEP);
|
|||
|
|
scheduleScrollPreviewCenter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function zoomPreviewOut() {
|
|||
|
|
zoomMultiplier.value = Math.max(ZOOM_MULT_MIN, zoomMultiplier.value / ZOOM_STEP);
|
|||
|
|
scheduleScrollPreviewCenter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 缩放后把可视区域滚到预览块大致居中,便于看到全貌 */
|
|||
|
|
function scheduleScrollPreviewCenter() {
|
|||
|
|
void nextTick(() => {
|
|||
|
|
const host = previewHostRef.value;
|
|||
|
|
if (!host) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const slot = host.querySelector('.preview-zoom-slot') as HTMLElement | null;
|
|||
|
|
if (!slot) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const sr = slot.getBoundingClientRect();
|
|||
|
|
const hr = host.getBoundingClientRect();
|
|||
|
|
const nextLeft = host.scrollLeft + (sr.left - hr.left) + sr.width / 2 - host.clientWidth / 2;
|
|||
|
|
const nextTop = host.scrollTop + (sr.top - hr.top) + sr.height / 2 - host.clientHeight / 2;
|
|||
|
|
host.scrollTo({
|
|||
|
|
left: Math.max(0, nextLeft),
|
|||
|
|
top: Math.max(0, nextTop),
|
|||
|
|
behavior: 'smooth',
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 恢复为仅按窗口自动适应(清除手动缩放) */
|
|||
|
|
function resetPreviewZoom() {
|
|||
|
|
zoomMultiplier.value = 1;
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
scheduleScrollPreviewCenter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onPreviewIframeLoad() {
|
|||
|
|
updateContentMeasure();
|
|||
|
|
// 二维码等异步绘制后再测一次,避免首次高度偏小
|
|||
|
|
window.setTimeout(() => updateContentMeasure(), 400);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 调用浏览器打印预览 iframe 内文档(与当前预览 HTML 一致) */
|
|||
|
|
function handleBrowserPrint() {
|
|||
|
|
const win = previewIframeRef.value?.contentWindow;
|
|||
|
|
if (!win) {
|
|||
|
|
createMessage.warning('预览未就绪,请稍后再试');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
win.focus();
|
|||
|
|
win.print();
|
|||
|
|
} catch (_e) {
|
|||
|
|
createMessage.error('无法唤起打印,请检查浏览器是否拦截弹窗');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scheduleComputeScale() {
|
|||
|
|
void nextTick(() => computePreviewScale());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useResizeObserver(previewHostRef, () => {
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
watch([paperPixelSize, previewHtml, () => props.open], () => {
|
|||
|
|
contentMeasurePx.value = { w: 0, h: 0 };
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
watch([layoutPaperPx, () => props.open], () => {
|
|||
|
|
if (props.open) {
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function onClose() {
|
|||
|
|
errorText.value = '';
|
|||
|
|
schema.value = null;
|
|||
|
|
previewHtml.value = '';
|
|||
|
|
zoomMultiplier.value = 1;
|
|||
|
|
autoFitScale.value = 1;
|
|||
|
|
contentMeasurePx.value = { w: 0, h: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onGenerateMock() {
|
|||
|
|
if (!schema.value) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const mockObj = generateNativeMockDataObject(schema.value.elements, canvasJsonText.value);
|
|||
|
|
mockDataText.value = JSON.stringify(mockObj, null, 2);
|
|||
|
|
createMessage.success('已根据画布组件生成模拟数据 JSON');
|
|||
|
|
} catch (e: any) {
|
|||
|
|
createMessage.error(`生成失败:${e?.message || '未知错误'}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => [props.open, props.templateId] as const,
|
|||
|
|
async ([isOpen, id]) => {
|
|||
|
|
if (!isOpen || !id) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
loading.value = true;
|
|||
|
|
errorText.value = '';
|
|||
|
|
schema.value = null;
|
|||
|
|
previewHtml.value = '';
|
|||
|
|
zoomMultiplier.value = 1;
|
|||
|
|
autoFitScale.value = 1;
|
|||
|
|
contentMeasurePx.value = { w: 0, h: 0 };
|
|||
|
|
try {
|
|||
|
|
const record = (await queryById(id)) as Record<string, any>;
|
|||
|
|
const rawText = String(record?.templateJson || '').trim();
|
|||
|
|
if (!rawText) {
|
|||
|
|
errorText.value = '该记录没有模板 JSON';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const parsed = JSON.parse(rawText);
|
|||
|
|
if (parsed?.engine !== 'native') {
|
|||
|
|
errorText.value = '仅原生模板(engine=native)支持此预览';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const normalized = normalizeImportedNativeSchema(parsed);
|
|||
|
|
const pw = Number(record?.paperWidthMm);
|
|||
|
|
const ph = Number(record?.paperHeightMm);
|
|||
|
|
if (pw > 0 && ph > 0) {
|
|||
|
|
normalized.page.width = pw;
|
|||
|
|
normalized.page.height = ph;
|
|||
|
|
}
|
|||
|
|
schema.value = normalized;
|
|||
|
|
canvasJsonText.value = stringifyNativeTemplateStyle(normalized);
|
|||
|
|
const mockObj = generateNativeMockDataObject(normalized.elements, canvasJsonText.value);
|
|||
|
|
mockDataText.value = JSON.stringify(mockObj, null, 2);
|
|||
|
|
previewDataText.value = mockDataText.value;
|
|||
|
|
dataSourceType.value = 'mock';
|
|||
|
|
jsonTabKey.value = 'params';
|
|||
|
|
await refreshPreview();
|
|||
|
|
scheduleComputeScale();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
errorText.value = e?.message || '加载模板失败';
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ immediate: false },
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
watch([activeDataText, schema, () => props.open], () => {
|
|||
|
|
if (!props.open || !schema.value) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
void debouncedRefreshPreview();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => props.open,
|
|||
|
|
(v) => {
|
|||
|
|
if (!v) {
|
|||
|
|
onClose();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="less">
|
|||
|
|
.native-preview-error {
|
|||
|
|
padding: 12px 4px;
|
|||
|
|
color: #cf1322;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.native-preview-row {
|
|||
|
|
min-height: 440px;
|
|||
|
|
align-items: stretch;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.native-preview-left {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.native-preview-right {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: 440px;
|
|||
|
|
max-height: 72vh;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-tabs {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
min-height: 0;
|
|||
|
|
border: 1px solid #f0f0f0;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: #fff;
|
|||
|
|
padding: 10px 12px 12px;
|
|||
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-tabs :deep(.ant-tabs-content-holder) {
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-tabs :deep(.ant-tabs-content),
|
|||
|
|
.json-tabs :deep(.ant-tabs-tabpane) {
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-template-pane,
|
|||
|
|
.params-json-pane {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 12px;
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-box-title {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: rgba(0, 0, 0, 0.55);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-textarea {
|
|||
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
|||
|
|
font-size: 12px;
|
|||
|
|
line-height: 1.45;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.json-textarea--main {
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 200px;
|
|||
|
|
resize: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.params-json-head {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 手动 / 模拟:胶囊分段(与设计器视觉一致) */
|
|||
|
|
.json-sub-tabs.json-sub-tabs--segmented {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 3px 5px;
|
|||
|
|
background: #f0f2f5;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid #e8e8e8;
|
|||
|
|
gap: 3px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.capsule-tab {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 4px 14px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: transparent;
|
|||
|
|
font-size: 12px;
|
|||
|
|
line-height: 1.45;
|
|||
|
|
color: rgba(0, 0, 0, 0.65);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition:
|
|||
|
|
background 0.2s,
|
|||
|
|
color 0.2s,
|
|||
|
|
box-shadow 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.capsule-tab:hover:not(.is-active) {
|
|||
|
|
color: rgba(0, 0, 0, 0.88);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.capsule-tab.is-active {
|
|||
|
|
background: #fff;
|
|||
|
|
color: #1677ff;
|
|||
|
|
font-weight: 500;
|
|||
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.params-json-head :deep(.json-capsule-btn.ant-btn) {
|
|||
|
|
height: 28px;
|
|||
|
|
padding: 0 16px;
|
|||
|
|
line-height: 26px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-header-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-title {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: rgba(0, 0, 0, 0.85);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-header-actions {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-print-btn {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-zoom-label {
|
|||
|
|
min-width: 40px;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: rgba(0, 0, 0, 0.55);
|
|||
|
|
user-select: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-zoom-btn,
|
|||
|
|
.preview-zoom-fit-btn {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-zoom-fit-btn {
|
|||
|
|
padding: 0 10px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-frame-wrap {
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
overflow: auto;
|
|||
|
|
border: 1px solid #f0f0f0;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
padding: 12px;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 至少占满可视区;内容超出时由外层 preview-frame-wrap 滚动,避免 flex 居中裁切放大内容 */
|
|||
|
|
.preview-scroll-flex {
|
|||
|
|
min-height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-zoom-slot {
|
|||
|
|
margin: auto;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-scale-shim {
|
|||
|
|
overflow: visible;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-scale-inner {
|
|||
|
|
transform-origin: top left;
|
|||
|
|
will-change: transform;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-iframe {
|
|||
|
|
display: block;
|
|||
|
|
border: none;
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
|
|||
|
|
<!-- 弹窗在窄屏下不超出视口 -->
|
|||
|
|
<style lang="less">
|
|||
|
|
.native-template-list-preview-modal .ant-modal {
|
|||
|
|
max-width: calc(100vw - 24px) !important;
|
|||
|
|
}
|
|||
|
|
</style>
|