2263 lines
75 KiB
Vue
2263 lines
75 KiB
Vue
<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);
|
||
/** 分析过程假进度(0–100),接口无真实进度,仅用于缓解长时间无反馈 */
|
||
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());
|
||
}
|
||
},
|
||
);
|
||
|
||
/** 左侧工具栏宽度(px),0 表示隐藏 */
|
||
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>
|