Files
qhmes/jeecgboot-vue3/src/views/print/template/components/NativeTemplateListPreviewModal.vue

725 lines
22 KiB
Vue
Raw Normal View History

<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>