Files
qhmes/jeecgboot-vue3/src/views/print/template/native/NativePrintDesigner.vue

2263 lines
75 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>
<div class="native-designer-page">
<div class="designer-toolbar">
<div class="toolbar-left">
<span class="title">原生打印模板设计器</span>
<span v-if="templateId" class="tip">ID: {{ templateId }}</span>
</div>
<a-space>
<a-button @click="backToList">返回列表</a-button>
<a-button @click="handleDelete">删除选中</a-button>
<a-button @click="duplicateElement">复制元素</a-button>
<a-button @click="bringForward">上移图层</a-button>
<a-button @click="sendBackward">下移图层</a-button>
<a-button @click="previewTemplate">即时预览</a-button>
<a-button @click="printTemplate">打印</a-button>
<a-button :loading="printDotLoading" @click="printTemplateViaPrintDot">PrintDot 打印</a-button>
<a-button type="primary" :loading="saving" @click="saveTemplate">保存模板</a-button>
</a-space>
</div>
<div class="designer-meta">
<div class="designer-meta__grid">
<a-input v-model:value="meta.templateCode" placeholder="模板编码" allow-clear />
<a-input v-model:value="meta.templateName" placeholder="模板名称" allow-clear />
<a-input v-model:value="meta.category" placeholder="分类默认form" allow-clear />
<a-input v-model:value="meta.remark" placeholder="备注" allow-clear />
</div>
<div class="designer-meta__actions">
<a-input-number
:value="state.scale"
:min="0.2"
:max="2"
:step="0.1"
addon-before="按比例缩放"
class="meta-scale-input"
@change="setScale(Number($event || 1))"
/>
<a-button class="meta-scale-btn" @click="pageConfigModalOpen = true">页面配置</a-button>
<a-button class="meta-scale-btn" @click="openImageAnalyzeModal">上传图片分析模板</a-button>
</div>
</div>
<div class="designer-main">
<div
class="left-panel-wrap"
:class="{ 'is-collapsed': leftPanelWidth <= 0 }"
:style="leftPanelWrapStyle"
>
<div class="left-panel">
<ToolbarPalette
v-model:insert-region="insertRegion"
:data-binding="state.schema.dataBinding"
@add="handleAddElement"
@update-data-binding="handleUpdateDataBinding"
/>
<a-divider />
<a-space direction="vertical" style="width: 100%">
<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-space class="json-action-row json-action-row--capsule" size="small" :wrap="true">
<a-button size="small" class="json-capsule-btn" @click="generateCanvasJson">从画布生成</a-button>
<a-button size="small" type="primary" ghost class="json-capsule-btn" @click="applyCanvasJsonToCanvas">应用到画布</a-button>
</a-space>
<a-textarea
v-model:value="canvasJsonText"
:rows="18"
class="json-textarea"
placeholder="记录画布实际JSON可反向应用到画布并还原模板样式"
/>
</div>
</a-tab-pane>
<a-tab-pane key="params" tab="参数JSON">
<a-tabs v-model:activeKey="dataSourceType" size="small" class="json-sub-tabs json-sub-tabs--segmented">
<a-tab-pane key="manual" tab="手动JSON">
<a-textarea
v-model:value="previewDataText"
:rows="16"
class="json-textarea"
placeholder="手动输入预览/打印数据 JSON"
/>
</a-tab-pane>
<a-tab-pane key="mock" tab="模拟JSON">
<div class="json-mock-toolbar">
<a-button size="small" type="primary" ghost class="json-capsule-btn" @click="generateMockData">根据画布生成</a-button>
</div>
<a-textarea
v-model:value="mockDataText"
:rows="14"
class="json-textarea"
placeholder="点击「根据画布生成」自动填充模拟数据"
/>
</a-tab-pane>
</a-tabs>
</a-tab-pane>
</a-tabs>
</a-space>
</div>
</div>
<div
class="left-panel-resizer"
role="separator"
aria-orientation="vertical"
:title="leftResizerTitle"
@mousedown="startLeftPanelResize"
@dblclick.prevent="onLeftResizerDblClick"
/>
<div class="center-panel">
<DesignerCanvas
:schema="state.schema"
:selected-id="state.selectedId"
:scale="state.scale"
:preview-data="previewData"
:selected-table-column="selectedTableColumn"
:selected-free-table-cell="selectedFreeTableCell"
:selected-free-table-merge-corner="selectedFreeTableRangeCorner"
@select="handleSelectElement"
@update="handleElementUpdate"
@select-table-column="handleSelectTableColumn"
@select-free-table-cell="handleSelectFreeTableCell"
@edit-free-table-cell="handleEditFreeTableCell"
/>
</div>
<div class="right-panel">
<PropertiesPanel
:schema="state.schema"
:selected-element="selectedElement"
:selected-table-column-key="resolveSelectedTableColumnKey()"
:selected-free-table-cell="resolveSelectedFreeTableCell()"
:selected-free-table-merge-rect="resolveSelectedFreeTableMergeRect()"
@update-element="handleElementUpdate"
/>
</div>
</div>
<PageConfigModal v-model:open="pageConfigModalOpen" :schema="state.schema" @update-page="updatePage" />
<a-drawer
v-model:open="previewVisible"
title="即时预览"
width="70%"
placement="right"
:body-style="{ padding: '12px 16px 20px', overflow: 'hidden' }"
>
<div class="native-instant-preview-host">
<iframe
class="preview-frame"
title="原生模板即时预览"
:srcdoc="previewHtml"
:style="{ height: `${previewDrawerIframeHeightPx}px` }"
/>
</div>
</a-drawer>
<FreeTableCellEditModal
v-model:open="freeTableCellModalOpen"
:element-id="freeTableCellModalTarget?.elementId || ''"
:row="freeTableCellModalTarget?.row ?? 0"
:col="freeTableCellModalTarget?.col ?? 0"
:schema="state.schema"
@save="handleFreeTableCellModalSave"
/>
<a-modal
v-model:open="imageAnalyzeVisible"
destroy-on-close
:width="imageAnalyzeModalWidth"
:centered="false"
wrap-class-name="native-print-image-analyze-modal"
:body-style="imageAnalyzeModalBodyStyle"
:closable="!imageAnalyzeLoading"
:mask-closable="!imageAnalyzeLoading"
@cancel="resetImageAnalyzeModal"
>
<template #title>
<div class="image-analyze-modal-title">
<span class="image-analyze-modal-title-text">上传图片分析模板</span>
<span class="image-analyze-modal-title-tip">按住标题栏可拖动 · 右下角可拉大窗口</span>
</div>
</template>
<div class="image-analyze-body">
<a-alert type="info" show-icon style="margin-bottom: 12px">
<template #message>
<span>选择或拖拽图片到下方区域系统将生成画布 JSON 与模拟数据先预览渲染效果确认后再应用到画布</span>
</template>
</a-alert>
<div
class="image-analyze-drop"
:class="{ 'is-dragover': imageAnalyzeDragover, 'is-busy': imageAnalyzeLoading }"
@dragenter.prevent="onImageAnalyzeDragEnter"
@dragover.prevent="onImageAnalyzeDragOver"
@dragleave.prevent="onImageAnalyzeDragLeave"
@drop.prevent="onImageAnalyzeDrop"
>
<input
ref="imageAnalyzeFileInputRef"
type="file"
accept="image/*"
class="image-analyze-file-input"
:disabled="imageAnalyzeLoading"
@change="onImageAnalyzeFileInputChange"
/>
<p class="image-analyze-drop-title">拖拽图片到此处</p>
<a-button type="link" :disabled="imageAnalyzeLoading" @click="triggerImageAnalyzeFilePick">点击选择文件</a-button>
</div>
<div v-if="imageAnalyzeLoading" class="image-analyze-progress">
<a-progress
:percent="Math.min(100, Math.round(imageAnalyzeProgress))"
:status="imageAnalyzeProgress >= 100 ? 'success' : 'active'"
:stroke-width="10"
/>
<p class="image-analyze-progress-tip">{{ imageAnalyzeProgressTip }}</p>
<p class="image-analyze-progress-sub">大模型分析可能需要数十秒至两分钟请耐心等待勿关闭窗口</p>
</div>
<div v-if="imageAnalyzeHint" class="image-analyze-hint">{{ imageAnalyzeHint }}</div>
<a-divider v-if="imageAnalyzePreviewHtml" plain orientation="left">效果预览应用前</a-divider>
<div v-if="imageAnalyzePreviewHtml" class="image-analyze-preview-wrap">
<div v-if="imageAnalyzeThumbUrl" class="image-analyze-thumb">
<div class="thumb-label">原图</div>
<img :src="imageAnalyzeThumbUrl" alt="上传原图" />
</div>
<div class="image-analyze-preview-frame-wrap">
<div class="image-analyze-preview-toolbar">
<div class="thumb-label">按生成模板渲染</div>
<a-space v-if="imageAnalyzeLayoutPaperPx" size="small" wrap @click.stop>
<a-tooltip title="缩小">
<a-button type="default" size="small" class="image-analyze-zoom-btn" @click="imageAnalyzeZoomOut">
<Icon icon="ant-design:zoom-out-outlined" />
</a-button>
</a-tooltip>
<span class="image-analyze-zoom-pct">{{ imageAnalyzeZoomPercentLabel }}</span>
<a-tooltip title="放大">
<a-button type="default" size="small" class="image-analyze-zoom-btn" @click="imageAnalyzeZoomIn">
<Icon icon="ant-design:zoom-in-outlined" />
</a-button>
</a-tooltip>
<a-tooltip title="按预览区大小自动适应">
<a-button type="default" size="small" @click="imageAnalyzeZoomFit">适应</a-button>
</a-tooltip>
</a-space>
</div>
<div
ref="imageAnalyzePreviewHostRef"
class="image-analyze-preview-host"
:style="{ maxHeight: `${imageAnalyzePreviewHostMaxHeight}px` }"
>
<template v-if="imageAnalyzeLayoutPaperPx">
<div class="image-analyze-preview-scroll">
<div class="image-analyze-zoom-slot">
<div
class="image-analyze-scale-shim"
:style="{
width: `${imageAnalyzeLayoutPaperPx.wPx * imageAnalyzeDisplayScale}px`,
height: `${imageAnalyzeLayoutPaperPx.hPx * imageAnalyzeDisplayScale}px`,
}"
>
<div
class="image-analyze-scale-inner"
:style="{
width: `${imageAnalyzeLayoutPaperPx.wPx}px`,
height: `${imageAnalyzeLayoutPaperPx.hPx}px`,
transform: `scale(${imageAnalyzeDisplayScale})`,
}"
>
<iframe
ref="imageAnalyzeIframeRef"
class="image-analyze-preview-frame"
:srcdoc="imageAnalyzePreviewHtml"
@load="onImageAnalyzeIframeLoad"
/>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div
v-if="imageAnalyzeVisible"
class="image-analyze-resize-handle"
aria-hidden="true"
@mousedown.prevent="onImageAnalyzeModalResizeStart"
/>
</div>
<template #footer>
<a-button @click="resetImageAnalyzeModal">取消</a-button>
<a-button type="primary" :disabled="!imageAnalyzePendingSchema || imageAnalyzeLoading" @click="confirmApplyImageAnalyze">
应用到画布
</a-button>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useDebounceFn, useResizeObserver } from '@vueuse/core';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { add, analyzeImageForNativeFile, edit, queryByCode, queryById } from '../printTemplate.api';
import DesignerCanvas from './components/DesignerCanvas.vue';
import FreeTableCellEditModal from './components/FreeTableCellEditModal.vue';
import PageConfigModal from './components/PageConfigModal.vue';
import PropertiesPanel from './components/PropertiesPanel.vue';
import ToolbarPalette from './components/ToolbarPalette.vue';
import { printHtml } from './core/printService';
import { printNativeSchemaViaPrintDot } from '../utils/printNativeViaPrintDot';
import { renderNativePrintHtml, resolvePrintPageCount } from './core/printRenderer';
import { generateNativeMockDataObject } from './core/nativeMockData';
import { buildNativeTemplateStylePayload } from './core/nativeTemplateStyleSerialize';
import {
applyDetailTableMultiHeaderFalsePositiveFix,
applyStackedHeaderBandLayoutFix,
normalizeImportedNativeSchema,
} from './core/nativeSchemaNormalize';
import { normalizeFreeTableAnchors } from './core/freeTableGrid';
import { scaleFreeTableTracks } from './core/freeTableTracks';
import { useDesignerStore } from './core/useDesignerStore';
import type { NativeElement, NativeElementType, NativeTemplateSchema } from './core/types';
defineOptions({ name: 'NativePrintDesigner' });
const router = useRouter();
const route = useRoute();
const { createMessage } = useMessage();
const {
state,
selectedElement,
setSchema,
patchDataBinding,
addElement,
updateElement,
setSelected,
removeSelected,
duplicateSelected,
bringForward,
sendBackward,
setScale,
serialize,
} = useDesignerStore();
const templateId = ref('');
const saving = ref(false);
const printDotLoading = ref(false);
const previewVisible = ref(false);
const previewHtml = ref('');
const selectedTableColumn = ref<{ elementId: string; columnKey: string } | null>(null);
const selectedFreeTableCell = ref<{ elementId: string; row: number; col: number } | null>(null);
/** Shift 点选第二角,与 selectedFreeTableCell 组成合并矩形 */
const selectedFreeTableRangeCorner = ref<{ elementId: string; row: number; col: number } | null>(null);
/** 自由表格单元格双击编辑弹窗 */
const freeTableCellModalOpen = ref(false);
const freeTableCellModalTarget = ref<{ elementId: string; row: number; col: number } | null>(null);
/** 页面尺寸、边距、网格(从第二行工具区打开) */
const pageConfigModalOpen = ref(false);
/** 上传图片分析模板 */
const imageAnalyzeVisible = ref(false);
const imageAnalyzeLoading = ref(false);
/** 分析过程假进度0100接口无真实进度仅用于缓解长时间无反馈 */
const imageAnalyzeProgress = ref(0);
const imageAnalyzeProgressTip = ref('正在准备上传…');
let imageAnalyzeProgressTimer: ReturnType<typeof setInterval> | null = null;
/** 关闭弹窗或发起新任务时递增,用于丢弃在途请求结果 */
let imageAnalyzeSeq = 0;
const imageAnalyzeHint = ref('');
const imageAnalyzePreviewHtml = ref('');
const imageAnalyzeThumbUrl = ref('');
const imageAnalyzePendingSchema = ref<NativeTemplateSchema | null>(null);
const imageAnalyzeMockJson = ref('');
const imageAnalyzeDragover = ref(false);
const imageAnalyzeFileInputRef = ref<HTMLInputElement | null>(null);
/** 图片分析弹窗:宽度与内容区最大高度(可拖动右下角调整) */
const IMAGE_ANALYZE_MODAL_W_MIN = 680;
const IMAGE_ANALYZE_MODAL_W_MAX = 1680;
const IMAGE_ANALYZE_BODY_H_MIN = 400;
const IMAGE_ANALYZE_BODY_H_MAX = 960;
const IMAGE_ANALYZE_MM_TO_CSS_PX = 96 / 25.4;
const IMAGE_ANALYZE_ZOOM_STEP = 1.12;
const IMAGE_ANALYZE_ZOOM_MULT_MIN = 0.25;
const IMAGE_ANALYZE_ZOOM_MULT_MAX = 4;
const imageAnalyzeModalWidth = ref(1000);
const imageAnalyzeModalBodyMaxHeight = ref(760);
const imageAnalyzeIframeRef = ref<HTMLIFrameElement | null>(null);
const imageAnalyzePreviewHostRef = ref<HTMLElement | null>(null);
/** iframe 内实测内容宽高,用于 autoPage 等超出理论纸张时撑开预览 */
const imageAnalyzeContentMeasurePx = ref({ w: 0, h: 0 });
/** 相对「适应预览区」的倍数1 表示与自动适应一致 */
const imageAnalyzeZoomMult = ref(1);
const imageAnalyzeAutoFitScale = ref(1);
const imageAnalyzeModalBodyStyle = computed(() => ({
padding: '12px 16px 22px',
maxHeight: `${imageAnalyzeModalBodyMaxHeight.value}px`,
overflow: 'auto',
position: 'relative',
}));
const imageAnalyzePreviewHostMaxHeight = computed(() =>
Math.max(200, imageAnalyzeModalBodyMaxHeight.value - 280),
);
function getImageAnalyzeMockObject(): Record<string, any> {
try {
return JSON.parse(imageAnalyzeMockJson.value || '{}');
} catch (_e) {
return {};
}
}
/** 遍历 iframe 文档估算真实内容包围盒(与列表预览一致思路) */
function measureImageAnalyzeIframeContentBox(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) };
}
const imageAnalyzePaperPixelSize = computed(() => {
const schema = imageAnalyzePendingSchema.value;
if (!schema) {
return null;
}
const mock = getImageAnalyzeMockObject();
const pageCount = Math.max(1, resolvePrintPageCount(schema, mock));
const wMm = Number(schema.page?.width || 210);
const hMm = Number(schema.page?.height || 297);
const wPx = wMm * IMAGE_ANALYZE_MM_TO_CSS_PX;
const hPx = hMm * pageCount * IMAGE_ANALYZE_MM_TO_CSS_PX;
return { wPx, hPx, pageCount };
});
const imageAnalyzeLayoutPaperPx = computed(() => {
const ps = imageAnalyzePaperPixelSize.value;
if (!ps) {
return null;
}
const m = imageAnalyzeContentMeasurePx.value;
return {
wPx: Math.max(ps.wPx, m.w || 0),
hPx: Math.max(ps.hPx, m.h || 0),
};
});
const imageAnalyzeDisplayScale = computed(() => {
const raw = imageAnalyzeAutoFitScale.value * imageAnalyzeZoomMult.value;
if (!Number.isFinite(raw) || raw <= 0) {
return 1;
}
return Math.min(4, Math.max(0.08, raw));
});
const imageAnalyzeZoomPercentLabel = computed(() => `${Math.round(imageAnalyzeZoomMult.value * 100)}%`);
function computeImageAnalyzeAutoFitScale() {
const host = imageAnalyzePreviewHostRef.value;
const ps = imageAnalyzeLayoutPaperPx.value;
if (!host || !ps) {
imageAnalyzeAutoFitScale.value = 1;
return;
}
const pad = 16;
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) {
imageAnalyzeAutoFitScale.value = 1;
return;
}
const raw = Math.min(availW / ps.wPx, availH / ps.hPx, 1) * 0.96;
imageAnalyzeAutoFitScale.value = Number.isFinite(raw) && raw > 0 ? raw : 1;
}
const debouncedComputeImageAnalyzeScale = useDebounceFn(() => {
computeImageAnalyzeAutoFitScale();
}, 120);
function onImageAnalyzeIframeLoad() {
void nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const doc = imageAnalyzeIframeRef.value?.contentDocument;
if (!doc?.body) {
imageAnalyzeContentMeasurePx.value = { w: 0, h: 0 };
debouncedComputeImageAnalyzeScale();
return;
}
imageAnalyzeContentMeasurePx.value = measureImageAnalyzeIframeContentBox(doc);
debouncedComputeImageAnalyzeScale();
});
});
});
}
function imageAnalyzeZoomIn() {
imageAnalyzeZoomMult.value = Math.min(IMAGE_ANALYZE_ZOOM_MULT_MAX, imageAnalyzeZoomMult.value * IMAGE_ANALYZE_ZOOM_STEP);
}
function imageAnalyzeZoomOut() {
imageAnalyzeZoomMult.value = Math.max(IMAGE_ANALYZE_ZOOM_MULT_MIN, imageAnalyzeZoomMult.value / IMAGE_ANALYZE_ZOOM_STEP);
}
function imageAnalyzeZoomFit() {
imageAnalyzeZoomMult.value = 1;
debouncedComputeImageAnalyzeScale();
}
function resetImageAnalyzeModalLayout() {
const vh = typeof window !== 'undefined' ? window.innerHeight : 900;
imageAnalyzeModalWidth.value = 1000;
imageAnalyzeModalBodyMaxHeight.value = Math.min(
IMAGE_ANALYZE_BODY_H_MAX,
Math.max(IMAGE_ANALYZE_BODY_H_MIN, vh - 140),
);
imageAnalyzeZoomMult.value = 1;
imageAnalyzeAutoFitScale.value = 1;
imageAnalyzeContentMeasurePx.value = { w: 0, h: 0 };
}
function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n));
}
function onImageAnalyzeModalResizeStart(e: MouseEvent) {
if (e.button !== 0) {
return;
}
const startX = e.clientX;
const startY = e.clientY;
const startW = imageAnalyzeModalWidth.value;
const startBh = imageAnalyzeModalBodyMaxHeight.value;
const vh = typeof window !== 'undefined' ? window.innerHeight : 900;
const maxBody = Math.min(IMAGE_ANALYZE_BODY_H_MAX, Math.max(IMAGE_ANALYZE_BODY_H_MIN, vh - 100));
function move(ev: MouseEvent) {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
imageAnalyzeModalWidth.value = Math.round(clamp(startW + dx, IMAGE_ANALYZE_MODAL_W_MIN, IMAGE_ANALYZE_MODAL_W_MAX));
imageAnalyzeModalBodyMaxHeight.value = Math.round(clamp(startBh + dy, IMAGE_ANALYZE_BODY_H_MIN, maxBody));
debouncedComputeImageAnalyzeScale();
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
}
let imageAnalyzeDragTeardown: (() => void) | null = null;
function teardownImageAnalyzeModalDrag() {
imageAnalyzeDragTeardown?.();
imageAnalyzeDragTeardown = null;
}
function findImageAnalyzeModalWrap(): HTMLElement | null {
const list = document.querySelectorAll('.ant-modal-wrap.native-print-image-analyze-modal');
for (let i = list.length - 1; i >= 0; i--) {
const el = list[i] as HTMLElement;
if (getComputedStyle(el).display !== 'none') {
return el;
}
}
return null;
}
/** 标题栏拖动整个弹窗(仅作用于本弹窗 wrap */
function setupImageAnalyzeModalDrag() {
teardownImageAnalyzeModalDrag();
const wrap = findImageAnalyzeModalWrap();
if (!wrap) {
return;
}
const dialogHeaderEl = wrap.querySelector('.ant-modal-header') as HTMLElement | null;
const dragDom = wrap.querySelector('.ant-modal') as HTMLElement | null;
if (!dialogHeaderEl || !dragDom) {
return;
}
dialogHeaderEl.style.cursor = 'move';
const getStyle = (dom: HTMLElement, attr: string) => getComputedStyle(dom)[attr as any] as string;
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) {
return;
}
const disX = e.clientX;
const disY = e.clientY;
const screenWidth = document.body.clientWidth;
const screenHeight = document.documentElement.clientHeight;
const dragDomWidth = dragDom.offsetWidth;
const dragDomheight = dragDom.offsetHeight;
const minDragDomLeft = dragDom.offsetLeft;
const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth;
const minDragDomTop = dragDom.offsetTop;
let maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight;
if (maxDragDomTop < 0) {
maxDragDomTop = screenHeight - dragDom.offsetTop;
}
const domLeft = getStyle(dragDom, 'left');
const domTop = getStyle(dragDom, 'top');
let styL = +domLeft;
let styT = +domTop;
if (domLeft.includes('%')) {
styL = +document.body.clientWidth * (+domLeft.replace(/%/g, '') / 100);
styT = +document.body.clientHeight * (+domTop.replace(/%/g, '') / 100);
} else {
styL = +String(domLeft).replace(/px/g, '');
styT = +String(domTop).replace(/px/g, '');
}
if (!Number.isFinite(styL)) {
styL = dragDom.offsetLeft || 0;
}
if (!Number.isFinite(styT)) {
styT = dragDom.offsetTop || 0;
}
function onMove(ev: MouseEvent) {
let left = ev.clientX - disX;
let top = ev.clientY - disY;
if (-left > minDragDomLeft) {
left = -minDragDomLeft;
} else if (left > maxDragDomLeft) {
left = maxDragDomLeft;
}
if (-top > minDragDomTop) {
top = -minDragDomTop;
} else if (top > maxDragDomTop) {
top = maxDragDomTop;
}
dragDom.style.left = `${left + styL}px`;
dragDom.style.top = `${top + styT}px`;
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
dialogHeaderEl.addEventListener('mousedown', onMouseDown);
imageAnalyzeDragTeardown = () => {
dialogHeaderEl.removeEventListener('mousedown', onMouseDown);
dialogHeaderEl.style.cursor = '';
};
}
useResizeObserver(imageAnalyzePreviewHostRef, () => {
if (imageAnalyzePreviewHtml.value) {
debouncedComputeImageAnalyzeScale();
}
});
watch(
() => imageAnalyzeVisible.value,
(v) => {
if (v) {
void nextTick(() => {
setTimeout(() => setupImageAnalyzeModalDrag(), 40);
setTimeout(() => debouncedComputeImageAnalyzeScale(), 100);
});
} else {
teardownImageAnalyzeModalDrag();
}
},
);
watch(
() => [imageAnalyzePreviewHtml.value, imageAnalyzeLayoutPaperPx.value?.wPx] as const,
() => {
if (imageAnalyzePreviewHtml.value && imageAnalyzeLayoutPaperPx.value) {
void nextTick(() => debouncedComputeImageAnalyzeScale());
}
},
);
/** 左侧工具栏宽度px0 表示隐藏 */
const LS_LEFT_PANEL_KEY = 'qhmes-native-print-left-panel-w';
const LEFT_PANEL_MIN = 260;
const LEFT_PANEL_MAX = 640;
const LEFT_PANEL_DEFAULT = 360;
/** 松手时宽度小于此值则收拢为隐藏 */
const LEFT_PANEL_COLLAPSE_SNAP = 96;
const leftPanelWidth = ref(LEFT_PANEL_DEFAULT);
const lastLeftPanelWidth = ref(LEFT_PANEL_DEFAULT);
let leftResizeActive = false;
let leftResizeStartX = 0;
let leftResizeStartW = 0;
const leftPanelWrapStyle = computed(() => ({
width: leftPanelWidth.value <= 0 ? '0px' : `${leftPanelWidth.value}px`,
}));
const leftResizerTitle = '拖动调整左侧工具栏宽度;拖至极窄可隐藏。双击收起或恢复。';
function startLeftPanelResize(e: MouseEvent) {
if (e.button !== 0) return;
e.preventDefault();
leftResizeActive = true;
leftResizeStartX = e.clientX;
leftResizeStartW = leftPanelWidth.value;
window.addEventListener('mousemove', onLeftPanelResizeMove);
window.addEventListener('mouseup', stopLeftPanelResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
function onLeftPanelResizeMove(e: MouseEvent) {
if (!leftResizeActive) return;
const delta = e.clientX - leftResizeStartX;
let next = leftResizeStartW + delta;
next = Math.max(0, Math.min(LEFT_PANEL_MAX, next));
leftPanelWidth.value = next;
}
function stopLeftPanelResize() {
if (!leftResizeActive) return;
leftResizeActive = false;
window.removeEventListener('mousemove', onLeftPanelResizeMove);
window.removeEventListener('mouseup', stopLeftPanelResize);
document.body.style.cursor = '';
document.body.style.userSelect = '';
let w = leftPanelWidth.value;
if (w > 0 && w < LEFT_PANEL_COLLAPSE_SNAP) {
w = 0;
} else if (w >= LEFT_PANEL_COLLAPSE_SNAP && w < LEFT_PANEL_MIN) {
w = LEFT_PANEL_MIN;
}
if (w > 0) {
lastLeftPanelWidth.value = w;
}
leftPanelWidth.value = w;
try {
localStorage.setItem(LS_LEFT_PANEL_KEY, String(w));
} catch (_e) {
/* 忽略存储异常 */
}
}
function onLeftResizerDblClick() {
if (leftPanelWidth.value <= 0) {
const restore = Math.min(
LEFT_PANEL_MAX,
Math.max(LEFT_PANEL_MIN, lastLeftPanelWidth.value || LEFT_PANEL_DEFAULT),
);
leftPanelWidth.value = restore;
} else {
lastLeftPanelWidth.value = Math.max(leftPanelWidth.value, LEFT_PANEL_MIN);
leftPanelWidth.value = 0;
}
try {
localStorage.setItem(LS_LEFT_PANEL_KEY, String(leftPanelWidth.value));
} catch (_e) {
/* 忽略 */
}
}
watch(freeTableCellModalOpen, (v) => {
if (!v) {
freeTableCellModalTarget.value = null;
}
});
const insertRegion = ref<'header' | 'body'>('body');
const jsonTabKey = ref<'template' | 'params'>('template');
const dataSourceType = ref<'manual' | 'mock'>('manual');
const previewDataText = ref('{}');
const mockDataText = ref('{}');
const canvasJsonText = ref('{}');
const meta = reactive({
templateCode: `NATIVE_${Date.now()}`,
templateName: '原生打印模板',
category: 'form',
remark: '原生设计器模板',
});
const activeDataText = computed(() => (dataSourceType.value === 'mock' ? mockDataText.value : previewDataText.value));
const previewData = computed(() => {
try {
return JSON.parse(activeDataText.value || '{}');
} catch (_error) {
return {};
}
});
/** 与列表预览逻辑一致:按 resolvePrintPageCount 估算 iframe 高度,避免多页内容被单页裁切 */
const PREVIEW_MM_TO_CSS_PX = 96 / 25.4;
const previewDrawerIframeHeightPx = computed(() => {
const sch = state?.schema;
const hMm = Number(sch?.page?.heightMm);
if (!sch?.page || !Number.isFinite(hMm) || hMm <= 0) {
return 900;
}
const count = Math.max(1, resolvePrintPageCount(sch, previewData.value));
const gapPx = 12;
const padY = 24 * 2;
return Math.round(count * hMm * PREVIEW_MM_TO_CSS_PX + (count - 1) * gapPx + padY);
});
function generateMockData(options: { syncManual?: boolean; showMessage?: boolean } = {}) {
const { syncManual = false, showMessage = true } = options;
const mock = generateNativeMockDataObject(state.schema.elements, canvasJsonText.value);
const nextText = JSON.stringify(mock, null, 2);
mockDataText.value = nextText;
if (syncManual || !String(previewDataText.value || '').trim() || previewDataText.value === '{}') {
previewDataText.value = nextText;
}
if (showMessage) {
createMessage.success('已根据画布组件生成模拟数据 JSON');
}
}
function mapTemplateStyleToElement(item: any): NativeElement {
const type = String(item?.component || 'text') as NativeElementType;
const rect = item?.rect || {};
const base: any = {
id: String(item?.id || `${type}_${Math.random().toString(36).slice(2, 8)}`),
type,
bindField: String(item?.bindField || ''),
region: item?.region || '',
bandId: String(item?.bandId || ''),
x: Number(rect?.x || 0),
y: Number(rect?.y || 0),
w: Number(rect?.w || 40),
h: Number(rect?.h || 12),
zIndex: Number(rect?.zIndex || 1),
style: item?.style || {},
};
const payload = item?.payload || {};
if (type === 'image') {
base.src = payload.src || '';
base.fit = payload.fit || 'contain';
return base as NativeElement;
}
if (type === 'table' || type === 'detailTable') {
base.source = payload.source || (type === 'detailTable' ? 'detailList' : 'mainTable');
base.mergeColumnKeys = Array.isArray(payload?.mergeColumnKeys) ? payload.mergeColumnKeys.map((item: any) => String(item || '')) : [];
base.strictGrouping = payload?.strictGrouping !== false;
base.enableMultiHeader = payload.enableMultiHeader === true;
base.tableHeightMode = payload.tableHeightMode || 'autoPage';
base.fixedRows = Number(payload.fixedRows || 5);
base.showHeader = payload.showHeader !== false;
base.rowHeight = Number(payload.rowHeight || 8);
base.headerHeight = Number(payload.headerHeight || 10);
base.headerFontSize = Number(payload.headerFontSize || 12);
base.bodyFontSize = Number(payload.bodyFontSize || 12);
base.headerBgColor = payload.headerBgColor || '#f5f5f5';
base.headerTextColor = payload.headerTextColor || '#111111';
base.footerLabelColumnKey = payload.footerLabelColumnKey || '';
base.footerLabelText = payload.footerLabelText || '合计';
base.footerLabelCenter = payload.footerLabelCenter !== false;
base.footerShowTotal = payload.footerShowTotal !== false;
base.footerTotalMode = payload.footerTotalMode === 'page' ? 'page' : 'overall';
base.headerConfig =
payload?.headerConfig && Array.isArray(payload.headerConfig?.cells)
? {
rowCount: Math.max(1, Number(payload.headerConfig.rowCount || 1)),
colCount: Math.max(1, Number(payload.headerConfig.colCount || 1)),
cells: payload.headerConfig.cells.map((cell: any, idx: number) => ({
id: String(cell?.id || `h_${idx}`),
row: Math.max(0, Number(cell?.row || 0)),
col: Math.max(0, Number(cell?.col || 0)),
rowspan: Math.max(1, Number(cell?.rowspan || 1)),
colspan: Math.max(1, Number(cell?.colspan || 1)),
title: String(cell?.title || ''),
align: String(cell?.align || 'center'),
})),
}
: undefined;
base.columns = Array.isArray(payload.columns)
? payload.columns.map((col: any, index: number) => ({
key: col?.key || `col_${index + 1}`,
title: col?.title || `${index + 1}`,
field: col?.field || `field${index + 1}`,
bindField: col?.bindField || col?.field || `field${index + 1}`,
width: Number(col?.width || 30),
align: col?.align || 'left',
contentType: col?.contentType || 'text',
fontFamily: col?.fontFamily || '',
fontSize: Number(col?.fontSize || 12),
useCustomFontSize: !!col?.useCustomFontSize,
fontColor: col?.fontColor || '#111111',
autoFitFont: !!col?.autoFitFont,
autoWrap: col?.autoWrap !== false,
fillCell: col?.fillCell !== false,
contentScale: Number(col?.contentScale || 100),
imageFit: col?.imageFit || 'contain',
qrLevel: col?.qrLevel || 'M',
qrRenderType: col?.qrRenderType || 'image/png',
barcodeFormat: col?.barcodeFormat || 'CODE128',
decimalPlaces: Number(col?.decimalPlaces ?? 2),
roundHalfUp: col?.roundHalfUp !== false,
amountType: col?.amountType || 'CNY',
enableFooterTotal: !!col?.enableFooterTotal,
mergeByValue: !!col?.mergeByValue,
}))
: [];
if (!base.mergeColumnKeys?.length) {
base.mergeColumnKeys = (base.columns || []).filter((col: any) => col?.mergeByValue).map((col: any) => String(col?.key || ''));
}
return base as NativeElement;
}
if (type === 'freeTable') {
const rowCount = Math.max(1, Number(payload?.rowCount || 3));
const colCount = Math.max(1, Number(payload?.colCount || 3));
base.rowCount = rowCount;
base.colCount = colCount;
base.borderColor = String(payload?.borderColor || '#d9d9d9');
base.borderWidth = Math.max(1, Number(payload?.borderWidth || 1));
base.outerBorderLineStyle = payload?.outerBorderLineStyle || 'solid';
base.innerBorderHorizontalLineStyle = payload?.innerBorderHorizontalLineStyle || 'solid';
base.innerBorderVerticalLineStyle = payload?.innerBorderVerticalLineStyle || 'solid';
if (Array.isArray(payload?.colWidths)) {
base.colWidths = payload.colWidths.map((n: any) => Number(n));
}
if (Array.isArray(payload?.rowHeights)) {
base.rowHeights = payload.rowHeights.map((n: any) => Number(n));
}
if (payload?.outerBorder && typeof payload.outerBorder === 'object') {
base.outerBorder = { ...payload.outerBorder };
}
if (payload?.innerBorder && typeof payload.innerBorder === 'object') {
base.innerBorder = { ...payload.innerBorder };
}
base.printRepeated = payload.printRepeated === true;
base.cells = Array.isArray(payload?.cells)
? payload.cells
.map((cell: any) => ({
row: Math.max(0, Number(cell?.row || 0)),
col: Math.max(0, Number(cell?.col || 0)),
rowspan: Math.max(1, Number(cell?.rowspan || 1)),
colspan: Math.max(1, Number(cell?.colspan || 1)),
text: String(cell?.text || ''),
bindField: String(cell?.bindField || ''),
contentType: cell?.contentType || 'text',
fillCell: cell?.fillCell,
contentScale: cell?.contentScale != null ? Number(cell.contentScale) : undefined,
imageFit: cell?.imageFit,
qrLevel: cell?.qrLevel,
qrRenderType: cell?.qrRenderType,
barcodeFormat: cell?.barcodeFormat,
decimalPlaces: cell?.decimalPlaces != null ? Number(cell.decimalPlaces) : undefined,
roundHalfUp: cell?.roundHalfUp,
amountType: cell?.amountType,
autoWrap: cell?.autoWrap,
autoFitFont: cell?.autoFitFont,
align: String(cell?.align || 'left'),
verticalAlign: String(cell?.verticalAlign || 'middle'),
fontSize: Math.max(8, Number(cell?.fontSize || 12)),
color: String(cell?.color || '#111111'),
backgroundColor: String(cell?.backgroundColor || '#ffffff'),
hideBorderTop: cell?.hideBorderTop === true,
hideBorderRight: cell?.hideBorderRight === true,
hideBorderBottom: cell?.hideBorderBottom === true,
hideBorderLeft: cell?.hideBorderLeft === true,
}))
.filter(
(cell: any) =>
cell.row < rowCount &&
cell.col < colCount &&
cell.row + cell.rowspan <= rowCount &&
cell.col + cell.colspan <= colCount,
)
: [];
return base as NativeElement;
}
if (type === 'qrcode' || type === 'barcode') {
base.value = payload.value || '';
return base as NativeElement;
}
if (type === 'reportHeader' || type === 'reportFooter') {
base.region = item?.region || (type === 'reportHeader' ? 'header' : 'footer');
base.text = payload.text || '';
base.bookmarkText = payload.bookmarkText || '';
base.keepTogether = payload.keepTogether !== false;
base.centerWithDetail = payload.centerWithDetail !== false;
base.refreshPage = payload.refreshPage || 'none';
base.visible = payload.visible !== false;
base.stretch = payload.stretch === true;
base.shrink = payload.shrink === true;
base.printRepeated = payload.printRepeated === true;
base.printAtPageBottom = payload.printAtPageBottom === true;
base.removeBlankWhenNoData = payload.removeBlankWhenNoData === true;
return base as NativeElement;
}
base.text = payload.text || '';
if (payload.format) {
base.format = payload.format;
}
return base as NativeElement;
}
function defaultDataBinding() {
return {
fieldMap: {} as Record<string, string>,
tableSources: ['mainTable', 'detailList'],
params: [] as any[],
detailTables: [] as any[],
};
}
function generateCanvasJson() {
const payload = buildNativeTemplateStylePayload(state.schema);
canvasJsonText.value = JSON.stringify(payload, null, 2);
}
function handleUpdateDataBinding(value: Partial<NonNullable<NativeTemplateSchema['dataBinding']>>) {
patchDataBinding(value);
generateCanvasJson();
}
function applyCanvasJsonToCanvas() {
try {
const parsed = JSON.parse(canvasJsonText.value || '{}');
if (!Array.isArray(parsed?.elements) || !parsed?.page) {
createMessage.warning('实际 JSON 格式无效,缺少 page 或 elements');
return;
}
const dbIn = parsed.dataBinding || {};
const curDb = state.schema.dataBinding || defaultDataBinding();
const schema: NativeTemplateSchema = {
engine: 'native',
version: '1.0.0',
page: {
width: Number(parsed.page?.width || 210),
height: Number(parsed.page?.height || 297),
unit: 'mm',
margin: Array.isArray(parsed.page?.margin) ? parsed.page.margin : [10, 10, 10, 10],
gridSize: Number(parsed.page?.gridSize || 2),
},
elements: parsed.elements.map((item: any) => mapTemplateStyleToElement(item)),
dataBinding: {
fieldMap: { ...(curDb.fieldMap || {}), ...(dbIn.fieldMap || {}) },
tableSources: Array.isArray(dbIn.tableSources) && dbIn.tableSources.length
? [...dbIn.tableSources]
: curDb.tableSources || ['mainTable', 'detailList'],
params: Array.isArray(dbIn.params) ? [...dbIn.params] : curDb.params || [],
detailTables: Array.isArray(dbIn.detailTables)
? dbIn.detailTables.map((t: any) => ({
tableKey: String(t.tableKey || ''),
label: t.label ? String(t.label) : undefined,
fields: Array.isArray(t.fields)
? t.fields.map((f: any) => ({
key: String(f.key || ''),
label: f.label ? String(f.label) : undefined,
}))
: [],
}))
: curDb.detailTables || [],
},
};
setSchema(schema);
generateCanvasJson();
createMessage.success('已按实际 JSON 还原画布模板样式');
} catch (error: any) {
createMessage.error(`应用失败:${error?.message || 'JSON格式错误'}`);
}
}
async function loadTemplate() {
const id = String(route.query.id || '');
if (!id) {
return;
}
const record = await queryById(id);
templateId.value = String(record.id || id);
meta.templateCode = String(record.templateCode || meta.templateCode);
meta.templateName = String(record.templateName || meta.templateName);
meta.category = String(record.category || 'form');
meta.remark = String(record.remark || '');
if (record.templateJson) {
const parsed = JSON.parse(record.templateJson);
if (parsed?.engine === 'native') {
// 列表「纸张(mm)」来自实体字段若仅在编辑弹窗改过纸张JSON 内 page 可能未同步,以实体为准
const pw = Number(record.paperWidthMm);
const ph = Number(record.paperHeightMm);
if (pw > 0 && ph > 0) {
if (!parsed.page || typeof parsed.page !== 'object') {
parsed.page = { unit: 'mm', margin: [10, 10, 10, 10], gridSize: 2 };
}
parsed.page.width = pw;
parsed.page.height = ph;
}
setSchema(parsed);
}
}
generateCanvasJson();
}
function handleElementUpdate(payload: { id: string; patch: Partial<NativeElement> }) {
const before = state.schema.elements.find((item) => item.id === payload.id) as any;
const prevW = Number(before?.w);
const prevH = Number(before?.h);
const wasFreeTable = before?.type === 'freeTable';
const patch = payload.patch || ({} as Partial<NativeElement>);
updateElement(payload.id, patch);
const current = state.schema.elements.find((item) => item.id === payload.id) as any;
if (wasFreeTable && current?.type === 'freeTable') {
const rowCount = Math.max(1, Number(current.rowCount || 1));
const colCount = Math.max(1, Number(current.colCount || 1));
const cw = Array.isArray(current.colWidths) ? current.colWidths.map((x: any) => Number(x)) : [];
const rh = Array.isArray(current.rowHeights) ? current.rowHeights.map((x: any) => Number(x)) : [];
const touchedTracks =
Object.prototype.hasOwnProperty.call(patch, 'colWidths') || Object.prototype.hasOwnProperty.call(patch, 'rowHeights');
const bboxChanged =
(Object.prototype.hasOwnProperty.call(patch, 'w') || Object.prototype.hasOwnProperty.call(patch, 'h')) && !touchedTracks;
const nw = Number(current.w);
const nh = Number(current.h);
if (
bboxChanged &&
cw.length === colCount &&
rh.length === rowCount &&
prevW > 0 &&
prevH > 0 &&
(Math.abs(nw - prevW) > 0.001 || Math.abs(nh - prevH) > 0.001)
) {
const scaled = scaleFreeTableTracks(cw, rh, prevW, prevH, nw, nh);
updateElement(payload.id, { colWidths: scaled.colWidths, rowHeights: scaled.rowHeights } as any);
}
}
if (current?.type === 'reportHeader') {
updateElement(payload.id, {
x: 0,
y: 0,
w: state.schema.page.width,
region: 'header',
bandId: payload.id,
} as any);
} else if (current?.type === 'reportFooter') {
updateElement(payload.id, {
x: 0,
y: Math.max(0, state.schema.page.height - current.h),
w: state.schema.page.width,
region: 'footer',
bandId: payload.id,
} as any);
}
if (selectedTableColumn.value?.elementId === payload.id) {
const columns = Array.isArray(current?.columns) ? current.columns : [];
if (!columns.some((col: any) => col?.key === selectedTableColumn.value?.columnKey)) {
selectedTableColumn.value = null;
}
}
if (selectedFreeTableCell.value?.elementId === payload.id) {
const rowCount = Math.max(1, Number(current?.rowCount || 1));
const colCount = Math.max(1, Number(current?.colCount || 1));
const row = Number(selectedFreeTableCell.value.row || 0);
const col = Number(selectedFreeTableCell.value.col || 0);
if (row >= rowCount || col >= colCount) {
selectedFreeTableCell.value = null;
}
}
if (payload.patch && 'cells' in payload.patch && selectedFreeTableRangeCorner.value?.elementId === payload.id) {
selectedFreeTableRangeCorner.value = null;
}
generateCanvasJson();
}
function updatePage(patch: any) {
state.schema.page = { ...state.schema.page, ...patch };
state.schema.elements.forEach((item: any) => {
if (item.type === 'reportHeader') {
item.x = 0;
item.y = 0;
item.w = state.schema.page.width;
item.region = 'header';
item.bandId = item.id;
} else if (item.type === 'reportFooter') {
item.x = 0;
item.y = Math.max(0, state.schema.page.height - item.h);
item.w = state.schema.page.width;
item.region = 'footer';
item.bandId = item.id;
}
});
generateCanvasJson();
}
function handleAddElement(type: NativeElementType) {
if (type === 'reportHeader') {
const exists = state.schema.elements.find((item) => item.type === 'reportHeader');
if (exists) {
setSelected(exists.id);
createMessage.warning('报表头已存在,可直接调整其高度');
return;
}
}
if (type === 'reportFooter') {
const exists = state.schema.elements.find((item) => item.type === 'reportFooter');
if (exists) {
setSelected(exists.id);
createMessage.warning('报表尾已存在,可直接调整其高度');
return;
}
}
addElement(type);
const current = selectedElement.value as any;
if (current) {
const layout = resolveBandLayout();
if (type === 'reportHeader') {
updateElement(current.id, {
x: 0,
y: 0,
w: state.schema.page.width,
region: 'header',
bandId: current.id,
} as any);
} else if (type === 'reportFooter') {
updateElement(current.id, {
x: 0,
y: Math.max(0, state.schema.page.height - current.h),
w: state.schema.page.width,
region: 'footer',
bandId: current.id,
} as any);
} else {
let targetRegion = insertRegion.value;
if (targetRegion === 'header' && layout.headerHeight <= 0) {
targetRegion = 'body';
createMessage.warning('尚未设置报表头区域,已自动放入报表主体');
}
const minY = targetRegion === 'header' ? 0 : layout.headerHeight;
const maxY = targetRegion === 'header' ? Math.max(0, layout.headerHeight - current.h) : Math.max(layout.headerHeight, layout.bodyBottom - current.h);
const nextY = Math.min(maxY, Math.max(minY, current.y));
const headerBand = state.schema.elements.find((item) => item.type === 'reportHeader') as any;
updateElement(current.id, {
y: nextY,
region: targetRegion,
bandId: targetRegion === 'header' ? String(headerBand?.id || '') : '',
} as any);
}
}
selectedTableColumn.value = null;
selectedFreeTableCell.value = null;
selectedFreeTableRangeCorner.value = null;
generateCanvasJson();
}
function resolveBandLayout() {
const pageHeight = state.schema.page.height;
const header = state.schema.elements.find((item) => item.type === 'reportHeader') as any;
const footer = state.schema.elements.find((item) => item.type === 'reportFooter') as any;
const headerHeight = Number(header?.h || 0);
const footerHeight = Number(footer?.h || 0);
const bodyBottom = Math.max(headerHeight, pageHeight - footerHeight);
return { headerHeight, footerHeight, bodyBottom };
}
function buildSavePayload() {
const orientation = state.schema.page.width > state.schema.page.height ? 'landscape' : 'portrait';
return {
id: templateId.value || undefined,
templateCode: meta.templateCode,
templateName: meta.templateName,
category: meta.category || 'form',
paperWidthMm: state.schema.page.width,
paperHeightMm: state.schema.page.height,
paperOrientation: orientation,
remark: meta.remark,
templateJson: serialize(),
};
}
async function saveTemplate() {
if (!meta.templateCode || !meta.templateName) {
createMessage.warning('请先填写模板编码和模板名称');
return;
}
saving.value = true;
try {
const payload = buildSavePayload();
if (templateId.value) {
await edit(payload);
} else {
await add(payload);
const queryResult = await queryByCode(meta.templateCode);
if (queryResult?.id) {
templateId.value = String(queryResult.id);
}
}
createMessage.success('原生模板保存成功');
} catch (error: any) {
createMessage.error(error?.message || '保存失败');
} finally {
saving.value = false;
}
}
async function previewTemplate() {
previewHtml.value = await renderNativePrintHtml(state.schema, previewData.value);
previewVisible.value = true;
}
async function printTemplate() {
const html = await renderNativePrintHtml(state.schema, previewData.value);
previewHtml.value = html;
printHtml(html);
}
async function printTemplateViaPrintDot() {
printDotLoading.value = true;
try {
await printNativeSchemaViaPrintDot({
schema: state.schema,
data: previewData.value,
jobName: String(meta.templateCode || '').trim() || 'native-print',
});
createMessage.success('已通过 PrintDot 提交打印');
} catch (error: any) {
createMessage.error(error?.message || 'PrintDot 打印失败');
} finally {
printDotLoading.value = false;
}
}
function handleDelete() {
removeSelected();
selectedTableColumn.value = null;
selectedFreeTableCell.value = null;
selectedFreeTableRangeCorner.value = null;
generateCanvasJson();
}
function duplicateElement() {
duplicateSelected();
selectedTableColumn.value = null;
selectedFreeTableCell.value = null;
selectedFreeTableRangeCorner.value = null;
generateCanvasJson();
}
function handleSelectElement(id: string) {
setSelected(id);
if (!id || selectedTableColumn.value?.elementId !== id) {
selectedTableColumn.value = null;
}
if (!id || selectedFreeTableCell.value?.elementId !== id) {
selectedFreeTableCell.value = null;
}
if (!id || selectedFreeTableRangeCorner.value?.elementId !== id) {
selectedFreeTableRangeCorner.value = null;
}
}
function handleSelectTableColumn(payload: { elementId: string; columnKey: string }) {
setSelected(payload.elementId);
selectedTableColumn.value = payload;
selectedFreeTableCell.value = null;
selectedFreeTableRangeCorner.value = null;
}
function handleSelectFreeTableCell(payload: { elementId: string; row: number; col: number; shiftKey?: boolean }) {
setSelected(payload.elementId);
selectedTableColumn.value = null;
if (payload.shiftKey === true && selectedFreeTableCell.value?.elementId === payload.elementId) {
selectedFreeTableRangeCorner.value = {
elementId: payload.elementId,
row: payload.row,
col: payload.col,
};
return;
}
selectedFreeTableCell.value = {
elementId: payload.elementId,
row: payload.row,
col: payload.col,
};
selectedFreeTableRangeCorner.value = null;
}
/** 画布内双击自由表格单元格:打开仅针对该表的编辑弹窗 */
function handleEditFreeTableCell(payload: { elementId: string; row: number; col: number }) {
setSelected(payload.elementId);
selectedTableColumn.value = null;
selectedFreeTableRangeCorner.value = null;
selectedFreeTableCell.value = {
elementId: payload.elementId,
row: payload.row,
col: payload.col,
};
freeTableCellModalTarget.value = {
elementId: payload.elementId,
row: payload.row,
col: payload.col,
};
freeTableCellModalOpen.value = true;
}
function handleFreeTableCellModalSave(payload: {
elementId: string;
row: number;
col: number;
patch: Record<string, unknown>;
}) {
const el = state.schema.elements.find((e) => e.id === payload.elementId) as any;
if (!el || el.type !== 'freeTable') {
freeTableCellModalOpen.value = false;
return;
}
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, el.cells || []);
const next = anchors.map((item: any) =>
item.row === payload.row && item.col === payload.col ? { ...item, ...payload.patch } : { ...item },
);
handleElementUpdate({ id: payload.elementId, patch: { cells: next } as any });
freeTableCellModalOpen.value = false;
}
function resolveSelectedTableColumnKey() {
if (!selectedElement.value || (selectedElement.value.type !== 'table' && selectedElement.value.type !== 'detailTable')) {
return '';
}
if (selectedTableColumn.value?.elementId === selectedElement.value.id) {
return selectedTableColumn.value.columnKey;
}
return '';
}
function resolveSelectedFreeTableCell() {
if (!selectedElement.value || selectedElement.value.type !== 'freeTable') {
return null;
}
if (selectedFreeTableCell.value?.elementId === selectedElement.value.id) {
return {
row: Number(selectedFreeTableCell.value.row || 0),
col: Number(selectedFreeTableCell.value.col || 0),
};
}
return null;
}
function resolveSelectedFreeTableMergeRect() {
if (!selectedFreeTableCell.value || !selectedFreeTableRangeCorner.value) {
return null;
}
if (
selectedFreeTableCell.value.elementId !== selectedFreeTableRangeCorner.value.elementId ||
!selectedElement.value ||
selectedElement.value.type !== 'freeTable' ||
selectedFreeTableCell.value.elementId !== selectedElement.value.id
) {
return null;
}
const a = selectedFreeTableCell.value;
const b = selectedFreeTableRangeCorner.value;
return {
r0: Math.min(a.row, b.row),
r1: Math.max(a.row, b.row),
c0: Math.min(a.col, b.col),
c1: Math.max(a.col, b.col),
};
}
function invalidatePendingImageAnalyze() {
imageAnalyzeSeq += 1;
}
function clearImageAnalyzeProgressTimer() {
if (imageAnalyzeProgressTimer != null) {
clearInterval(imageAnalyzeProgressTimer);
imageAnalyzeProgressTimer = null;
}
}
function resetImageAnalyzeProgressUi() {
clearImageAnalyzeProgressTimer();
imageAnalyzeProgress.value = 0;
imageAnalyzeProgressTip.value = '正在准备上传…';
}
function startImageAnalyzeFakeProgress() {
resetImageAnalyzeProgressUi();
imageAnalyzeProgress.value = 8;
imageAnalyzeProgressTip.value = '正在上传图片…';
imageAnalyzeProgressTimer = setInterval(() => {
imageAnalyzeProgress.value = Math.min(86, imageAnalyzeProgress.value + (Math.random() * 4 + 0.6));
if (imageAnalyzeProgress.value >= 28 && imageAnalyzeProgress.value < 48) {
imageAnalyzeProgressTip.value = '正在调用大模型识别版式与字段…';
} else if (imageAnalyzeProgress.value >= 48) {
imageAnalyzeProgressTip.value = '正在生成模板 JSON请稍候…';
}
}, 420);
}
function completeImageAnalyzeProgressSuccess() {
clearImageAnalyzeProgressTimer();
imageAnalyzeProgress.value = 100;
imageAnalyzeProgressTip.value = '分析完成';
}
function clearImageAnalyzeState() {
if (imageAnalyzeThumbUrl.value) {
URL.revokeObjectURL(imageAnalyzeThumbUrl.value);
}
imageAnalyzeThumbUrl.value = '';
imageAnalyzePreviewHtml.value = '';
imageAnalyzeHint.value = '';
imageAnalyzePendingSchema.value = null;
imageAnalyzeMockJson.value = '';
imageAnalyzeDragover.value = false;
imageAnalyzeLoading.value = false;
imageAnalyzeContentMeasurePx.value = { w: 0, h: 0 };
imageAnalyzeZoomMult.value = 1;
resetImageAnalyzeProgressUi();
}
function resetImageAnalyzeModal() {
invalidatePendingImageAnalyze();
clearImageAnalyzeState();
imageAnalyzeVisible.value = false;
}
function openImageAnalyzeModal() {
clearImageAnalyzeState();
resetImageAnalyzeModalLayout();
imageAnalyzeVisible.value = true;
}
function onImageAnalyzeDragEnter(e: DragEvent) {
e.preventDefault();
if (imageAnalyzeLoading.value) {
return;
}
imageAnalyzeDragover.value = true;
}
function onImageAnalyzeDragOver(e: DragEvent) {
e.preventDefault();
if (imageAnalyzeLoading.value) {
return;
}
imageAnalyzeDragover.value = true;
}
function onImageAnalyzeDragLeave(e: DragEvent) {
e.preventDefault();
if (imageAnalyzeLoading.value) {
return;
}
imageAnalyzeDragover.value = false;
}
function triggerImageAnalyzeFilePick() {
if (imageAnalyzeLoading.value) {
return;
}
imageAnalyzeFileInputRef.value?.click();
}
function onImageAnalyzeFileInputChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) {
void processImageAnalyzeFile(file);
}
}
function onImageAnalyzeDrop(e: DragEvent) {
e.preventDefault();
imageAnalyzeDragover.value = false;
if (imageAnalyzeLoading.value) {
return;
}
const file = e.dataTransfer?.files?.[0];
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
createMessage.warning('请拖拽图片文件');
return;
}
void processImageAnalyzeFile(file);
}
async function processImageAnalyzeFile(file: File) {
if (imageAnalyzeLoading.value) {
createMessage.warning('正在分析中,请稍候');
return;
}
const mySeq = ++imageAnalyzeSeq;
imageAnalyzeLoading.value = true;
startImageAnalyzeFakeProgress();
try {
const res = await analyzeImageForNativeFile(file);
if (mySeq !== imageAnalyzeSeq) {
return;
}
imageAnalyzeProgress.value = Math.max(imageAnalyzeProgress.value, 44);
imageAnalyzeProgressTip.value = '正在解析返回结果…';
const parsed = applyDetailTableMultiHeaderFalsePositiveFix(
applyStackedHeaderBandLayoutFix(normalizeImportedNativeSchema(JSON.parse(res.templateJson))),
);
imageAnalyzePendingSchema.value = parsed;
imageAnalyzeMockJson.value = res.mockDataJson || '{}';
const extra = res.aiUsed ? '(已调用视觉大模型)' : '(未调用大模型或已回退占位)';
imageAnalyzeHint.value = `${res.hint || ''}${extra}`.trim();
if (imageAnalyzeThumbUrl.value) {
URL.revokeObjectURL(imageAnalyzeThumbUrl.value);
}
imageAnalyzeThumbUrl.value = URL.createObjectURL(file);
let mock: Record<string, any> = {};
try {
mock = JSON.parse(imageAnalyzeMockJson.value || '{}');
} catch (_e) {
mock = {};
}
if (mySeq !== imageAnalyzeSeq) {
return;
}
imageAnalyzeProgress.value = Math.max(imageAnalyzeProgress.value, 72);
imageAnalyzeProgressTip.value = '正在生成本地预览…';
imageAnalyzePreviewHtml.value = await renderNativePrintHtml(parsed, mock);
if (mySeq !== imageAnalyzeSeq) {
return;
}
completeImageAnalyzeProgressSuccess();
await new Promise<void>((r) => setTimeout(r, 360));
if (mySeq !== imageAnalyzeSeq) {
return;
}
createMessage.success('已生成预览,请确认后应用到画布');
} catch (err: any) {
if (mySeq === imageAnalyzeSeq) {
createMessage.error(err?.message || '图片分析失败');
}
} finally {
if (mySeq === imageAnalyzeSeq) {
imageAnalyzeLoading.value = false;
resetImageAnalyzeProgressUi();
}
}
}
function confirmApplyImageAnalyze() {
const pending = imageAnalyzePendingSchema.value;
if (!pending) {
createMessage.warning('请先生成预览');
return;
}
setSchema(pending);
const mockText = imageAnalyzeMockJson.value || '{}';
mockDataText.value = mockText;
previewDataText.value = mockText;
dataSourceType.value = 'mock';
selectedTableColumn.value = null;
selectedFreeTableCell.value = null;
selectedFreeTableRangeCorner.value = null;
setSelected('');
generateCanvasJson();
resetImageAnalyzeModal();
createMessage.success('已应用到画布');
}
function backToList() {
router.push('/print/template');
}
onMounted(async () => {
try {
const raw = localStorage.getItem(LS_LEFT_PANEL_KEY);
if (raw != null && raw !== '') {
const n = Number.parseInt(raw, 10);
if (!Number.isNaN(n) && n >= 0 && n <= LEFT_PANEL_MAX) {
leftPanelWidth.value = n;
if (n >= LEFT_PANEL_MIN) {
lastLeftPanelWidth.value = n;
}
}
}
} catch (_e) {
/* 忽略 */
}
await loadTemplate();
generateCanvasJson();
generateMockData({ syncManual: true, showMessage: false });
});
onUnmounted(() => {
stopLeftPanelResize();
teardownImageAnalyzeModalDrag();
invalidatePendingImageAnalyze();
clearImageAnalyzeProgressTimer();
imageAnalyzeLoading.value = false;
if (imageAnalyzeThumbUrl.value) {
URL.revokeObjectURL(imageAnalyzeThumbUrl.value);
}
});
</script>
<style scoped lang="less">
.native-designer-page {
background: #fff;
min-height: 100%;
}
.designer-toolbar {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.title {
font-size: 16px;
font-weight: 600;
}
.tip {
color: #666;
font-size: 12px;
}
}
.designer-meta {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
/** 模板编码/名称等:均分列宽,避免单行 24 栅格在窄宽度下挤压重叠 */
.designer-meta__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px 12px;
width: 100%;
min-width: 0;
}
@media (max-width: 1200px) {
.designer-meta__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.designer-meta__grid {
grid-template-columns: minmax(0, 1fr);
}
}
.designer-meta__grid :deep(.ant-input) {
min-width: 0;
}
/** 缩放 + 按钮单独一行,保证「按比例缩放」与按钮完整展示 */
.designer-meta__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
width: 100%;
min-width: 0;
}
.meta-scale-input {
flex: 1 1 220px;
min-width: 0;
max-width: 100%;
}
.meta-scale-input :deep(.ant-input-number) {
width: 100%;
min-width: 0;
}
.meta-scale-input :deep(.ant-input-number-group-addon) {
white-space: nowrap;
}
.meta-scale-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.designer-main {
display: flex;
flex-direction: row;
align-items: stretch;
min-height: calc(100vh - 132px);
}
.left-panel-wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border-right: 1px solid #f0f0f0;
background: #fafbfc;
&.is-collapsed {
border-right: none;
}
}
.left-panel-resizer {
flex-shrink: 0;
width: 6px;
cursor: col-resize;
align-self: stretch;
position: relative;
z-index: 2;
background: transparent;
user-select: none;
&:hover {
background: rgba(22, 119, 255, 0.08);
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 18%;
bottom: 18%;
width: 2px;
transform: translateX(-50%);
border-radius: 1px;
background: #d9d9d9;
}
}
.left-panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 12px;
.json-box-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
line-height: 20px;
}
.json-box-title-space {
margin-top: 10px;
}
.json-action-row {
margin-bottom: 8px;
}
/* 模板 JSON纵向 flex + gap避免 margin 合并导致按钮与文本框仍贴在一起 */
.json-template-pane {
display: flex;
flex-direction: column;
gap: 20px;
}
.json-template-pane > .json-box-title {
margin-bottom: 0;
}
.json-template-pane > .json-action-row.json-action-row--capsule {
margin-bottom: 0;
}
/* 模板 JSON操作按钮胶囊形 */
.json-action-row--capsule,
.json-mock-toolbar {
:deep(.json-capsule-btn.ant-btn) {
height: 28px;
padding: 0 16px;
line-height: 26px;
border-radius: 999px;
}
}
.json-tabs {
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
padding: 8px;
}
/* 参数 JSON子级「分段胶囊」灰色轨道随内容收缩不铺满整行 */
.json-sub-tabs.json-sub-tabs--segmented {
margin-top: 4px;
:deep(.ant-tabs-nav) {
margin-bottom: 10px;
width: fit-content;
max-width: 100%;
align-self: flex-start;
&::before {
display: none;
}
}
:deep(.ant-tabs-nav-wrap) {
border-bottom: none;
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
padding: 3px 5px;
background: #f0f2f5;
border-radius: 999px;
border: 1px solid #e8e8e8;
}
:deep(.ant-tabs-nav-list) {
display: flex;
align-items: center;
gap: 3px;
}
:deep(.ant-tabs-tab) {
margin: 0 !important;
padding: 4px 12px !important;
border: none !important;
border-radius: 999px !important;
background: transparent;
font-size: 12px;
line-height: 1.4;
color: rgba(0, 0, 0, 0.65);
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s;
}
:deep(.ant-tabs-tab-active) {
background: #fff !important;
color: #1677ff !important;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
:deep(.ant-tabs-tab:hover:not(.ant-tabs-tab-active)) {
color: rgba(0, 0, 0, 0.85);
}
:deep(.ant-tabs-ink-bar) {
display: none !important;
}
:deep(.ant-tabs-content-holder) {
border-top: none;
}
:deep(.ant-tabs-content) {
padding-top: 4px;
}
}
.json-mock-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.json-textarea {
:deep(textarea) {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
}
}
}
.center-panel {
flex: 1;
min-width: 0;
min-height: 0;
}
.right-panel {
width: 320px;
flex-shrink: 0;
min-width: 0;
min-height: 0;
border-left: 1px solid #f0f0f0;
}
/* 高度由 :style 绑定 previewDrawerIframeHeightPx避免与多页内容冲突 */
.native-instant-preview-host {
max-height: calc(100vh - 120px);
overflow: auto;
padding: 12px;
box-sizing: border-box;
background: #f0f0f0;
border-radius: 6px;
}
.preview-frame {
display: block;
width: 100%;
min-height: 320px;
border: 1px solid #f0f0f0;
background: #fff;
box-sizing: border-box;
}
.image-analyze-modal-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
min-width: 0;
}
.image-analyze-modal-title-text {
font-weight: 600;
font-size: 16px;
}
.image-analyze-modal-title-tip {
font-size: 12px;
font-weight: 400;
color: rgba(0, 0, 0, 0.45);
}
.image-analyze-body {
position: relative;
min-height: 120px;
}
.image-analyze-file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
left: -100px;
}
.image-analyze-drop {
position: relative;
border: 1px dashed #d9d9d9;
border-radius: 8px;
padding: 24px 16px;
text-align: center;
background: #fafafa;
transition: border-color 0.2s, background 0.2s;
&.is-dragover {
border-color: #1677ff;
background: #f0f7ff;
}
&.is-busy {
pointer-events: none;
opacity: 0.72;
}
}
.image-analyze-progress {
margin-top: 16px;
padding: 14px 16px;
border-radius: 8px;
background: #f0f7ff;
border: 1px solid #d6e4ff;
}
.image-analyze-progress-tip {
margin: 10px 0 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.78);
font-weight: 500;
}
.image-analyze-progress-sub {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.image-analyze-drop-title {
margin: 0 0 4px;
color: rgba(0, 0, 0, 0.65);
}
.image-analyze-hint {
margin-top: 10px;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
line-height: 1.6;
}
.image-analyze-preview-wrap {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.image-analyze-thumb {
flex: 0 0 200px;
max-width: 100%;
.thumb-label {
margin-bottom: 6px;
}
img {
max-width: 100%;
max-height: 280px;
object-fit: contain;
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fff;
}
}
.image-analyze-preview-frame-wrap {
flex: 1;
min-width: 260px;
min-height: 0;
}
.image-analyze-preview-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.image-analyze-zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.image-analyze-zoom-pct {
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
min-width: 40px;
text-align: center;
}
.image-analyze-preview-host {
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
min-height: 160px;
}
.image-analyze-preview-scroll {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 10px;
min-width: 100%;
min-height: 100%;
box-sizing: border-box;
}
.image-analyze-zoom-slot {
flex: 0 0 auto;
}
.image-analyze-scale-shim {
position: relative;
flex-shrink: 0;
}
.image-analyze-scale-inner {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
will-change: transform;
}
.thumb-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 0;
}
.image-analyze-preview-toolbar .thumb-label {
margin-bottom: 0;
}
.image-analyze-preview-frame {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border: 0;
background: #fff;
pointer-events: auto;
}
.image-analyze-resize-handle {
position: absolute;
right: 2px;
bottom: 2px;
width: 18px;
height: 18px;
cursor: se-resize;
z-index: 5;
border-radius: 0 0 6px 0;
background: linear-gradient(135deg, transparent 50%, rgba(0, 0, 0, 0.12) 50%);
}
.image-analyze-resize-handle:hover {
background: linear-gradient(135deg, transparent 45%, rgba(22, 119, 255, 0.35) 45%);
}
</style>
<style lang="less">
/* 弹窗挂到 body需非 scoped 才能命中 */
.native-print-image-analyze-modal.ant-modal-wrap .ant-modal-header {
user-select: none;
}
</style>