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

725 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>