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