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

2263 lines
75 KiB
Vue
Raw Normal View History

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