优化图片分析弹窗,新增可拖动和调整大小功能,改进预览区布局和缩放控制,确保用户体验流畅。同时,修复标题和日期对齐问题,提升模板生成的准确性。

This commit is contained in:
geht
2026-04-14 19:05:52 +08:00
parent 52ad06e28f
commit 2bd4c5584d
3 changed files with 857 additions and 22 deletions

View File

@@ -12,9 +12,11 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Base64;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -52,17 +54,57 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT
"detailTables":[{"tableKey":"List1","label":"明细","fields":[{"key":"字段键","label":"列标题"}]}]
}
}
规则:
========== 一、通用 ==========
1) 坐标 x,y,w,h 使用毫米相对整张纸左上角zIndex 从 1 递增。
2) elements.type 只能是title,subtitle,text,date,pageNo,image,qrcode,barcode,table,detailTable,freeTable,reportHeader,reportFooter。
3) **表格三选一(极其重要)**
- **detailTable** 或 **table**:多行**同构**明细数据(表头 + 多行重复记录如订单行、物料清单、BOM 行)。必须有 **source**(如 List1与 **columns**列宽、bindField数据按行重复
- **freeTable****套打表单/登记表**——左标签右值、多行多列**格子内容各不相同**、存在 **rowspan/colspan 合并**、或人员信息卡/申请单等非重复行列表。必须用 **freeTable**:含 **rowCount,colCount,borderWidth,borderColor,cells[]**;每个 cell 含 **row,col,rowspan,colspan,text,bindField**(可空)、align 等。**不要用 detailTable 模拟登记表**
判断:若图中是「姓名/性别/身高」等字段卡、合并单元格里写「二维码」→ **freeTable**;若是「序号+产品+规格+数量」多行清单 → **detailTable**
4) 从图片中读到的静态文字用 text/title可变字段用 bindField 或表格列 bindFieldparams/detailTables 要覆盖所有用到的绑定键
5) page 宽高与图片长宽比一致,可假定常见标签纸或 A4单位 mm数值合理
6) 版面上单独出现「二维码」文字标签、或黑白方块二维码图案:必须用 type「qrcode」表达value 可用示例 URLbindField 建议 qrCodeValue不要用 text 写「二维码」,也不要用空 src 的 image 占位方块码
7) 「日期」「打印日期」「制单日期」等标签或具体年月日:用 type「date」必须设 formatYYYY-MM-DD / YYYY年MM月DD日 等与版式一致);需要数据绑定则设 bindField 如 printDate
3) 从图片读到的**静态展示文字**用 text/title/subtitle需要数据绑定的用 bindFieldparams/detailTables 必须覆盖所有用到的绑定键。
4) page 宽高与图片长宽比一致(常见标签纸或 A4单位 mm数值合理
5) **坐标与版心(防错位,极其重要)**
- 原点为纸张**左上角****x 向右、y 向下**增大;禁止负坐标或颠倒轴向
- 排版前先在脑中确定**主表格块**freeTable / table / detailTable 中外接矩形最大者)的 **(x,y,w,h)**,再布置表外元素;**禁止**在未对齐主块的情况下随意写 x,y
- **title / subtitle**:若在表格**上方**,应满足 **y + h ≤ 表格.y 210mm**(留出空隙)。水平位置须与图中一致:若标题相对**整张纸**居中则用 **x ≈ (page.width w) / 2**;若标题明显相对**下方表格块**居中(表宽明显小于页宽、标题盖住表的中线),则用 **x ≈ table.x + (table.w w) / 2**,使标题与表格**左右对齐、视觉同一列宽区域**。**禁止**把标题写到表格**右缘以右**的空白区充当「居中」
- **date表格外右上角****x ≈ page.width margin[1] w****y** 与标题**同一水平带**(同排或略低 13mm且 **y + h ≤ 表格.y 2mm****禁止**把日期写到纸面竖直中部、表格下方或与标题横向严重错位
- 视觉自上而下为「大标题 → 日期 → 表格」时,必须 **y(标题) ≤ y(日期) + h(日期) + 小间隙**,且标题、日期元素的 **y + h** 须**明显小于**主表格的 **y**(整块落在表格外上方,与表顶至少留 2mm 间隙)
- 估算坐标时可按「图中该块占纸宽/纸高的比例 × page.width/page.height」换算为 mm使表格外标题、日期与表格的**相对位置**与截图一致。
========== 二、表格类型判定(极其重要,先分类再建模)==========
若图中存在「表格」区域,必须在心里完成分类,且**同一版式块只能三选一**
A) **detailTable**:业务**明细/清单**——多行**同构**记录(如序号、物料编码、规格、数量、单价、金额、税率等列,行结构重复)。通常有清晰表头,下方多行数据区。需 **source**(如 List1与 **columns**(每列 key/title/bindField/width/align/**contentType**)。
B) **table**:与 detailTable 类似但语义为主表/汇总表(也可用 source 如 mainTable列仍同构、多行重复。
C) **freeTable****套打格线表/登记表/信息卡**。满足**任一条**即优先 freeTable① 左「姓名/生日/性别」等**标签格**与右**空白填写格**成对出现且各行语义不同;② 存在 **rowspan/colspan** 大块合并(如某一角「二维码」占位或实码跨多行多列);③ 整表为固定格子套打而非「表头+多行重复数据行」。必须用 **rowCount,colCount,borderWidth,borderColor,cells[]**;每个 cell**row,col,rowspan,colspan,text,bindField**(可空),**contentType**,align,verticalAlign,fontSize,color,**backgroundColor** 等;可选 **colWidths**(长度=colCount、**rowHeights**(长度=rowCount
**反例(易错)**:仅有「姓名、年龄、生日、学历…」字段行 + 角上二维码合并格 → **绝不是** detailTable**绝不是**「单列竖向多行」伪明细表。
**禁止**用 detailTable/table 去模拟明显的 freeTable禁止把「人员信息卡」误建成横向多列明细表。
D) **混合版式**:上方为套打区(二维码/日期/说明格)、下方为**多行同构明细**时,必须拆成 **freeTable + detailTable/table** 两个元素,**禁止**只用一张 detailTable 吞掉上方版式,也**禁止**把整张明细误改成单行 freeTable。
========== 三、明细表 / 普通表table、detailTable==========
0) **勿与登记表混淆**:若首行是「姓名/年龄」等**字段名标签**且下方并非多行**同结构**重复记录,而是套打格线,则应归为 **freeTable**,不要建成 detailTable。
1) **默认单级表头(极其重要)****enableMultiHeader 默认为 false****不要输出**空的 **headerConfig** 或「占行无字」的假多级表头。仅当图中表头区域**肉眼可见至少两行不同的文字表头**(如上行大类、下行具体列名,且两行都有字)才设 **enableMultiHeader: true**。
2) **列名必须可读****columns** 中每一列的 **title** 须从图中表头**逐字抄录**(如「序号」「名称」「性别」),**禁止**留空或只写 field 键名。若只有一行表头,**禁止**臆造第二行空表头。
3) **多级表头(仅当真多级时)**:输出 **headerConfig** 时,**cells[] 里每个格子的 title 必须与图中可见文字一致**,禁止整行空白;**rowCount/colCount** 与格子数量、**columns** 叶子列数一致。若无法读清多级结构,宁可 **enableMultiHeader:false** 只做单行表头。
4) **合并表头**:若表头格存在跨行/跨列(视觉合并单元格),在 headerConfig.cells 中正确写 **rowspan/colspan**;列定义 columns 与表头叶子列一一对应bindField 对齐到数据字段。
5) **列 contentType**:纯文字列用 **"contentType":"text"**;金额/数值列用 **amount** 或 **number**;列内是图片用 **image**;列内是条码用 **barcode**。若业务是**普通字符串展示**,一律 **text**,不要用 qrcode 类型列除非该列整列都是二维码图。
6) **tableHeightMode**:长明细可用 **autoPage**;固定行分页用 **fixedRows** 并写 fixedRows 数字。
========== 四、自由表格freeTable==========
1) **网格与坐标****row、col 从 0 开始**,与 HTML 表格一致。**rowCount、colCount** = 按最外边框内**完整纵横格线**数出来的**逻辑行数、列数**与是否存在合并无关。例如常见「6 行 × 4 列」人员登记表,即 rowCount=6,colCount=4**不要**因第一行两列占位不同而把整表改成 3 列或更少列。
2) **合并单元格(锚点唯一)**:每个被合并的矩形区域在 cells 里**只输出一条**记录:取该区域**左上角**那一格的 (row,col),写 **rowspan、colspan** 覆盖所占行数与列数。**被合并覆盖到的从属格位不要再输出 cell**,否则会导致格位重叠、版式错乱。
3) **登记表 + 角区二维码合并格(典型)**:多行左侧为「性别/身高/体重」等标签、对侧为填写区时,若某一角有一块**跨多行多列**的合并格且内容为「二维码」字样或二维码图案,则该合并格**仅一个锚点**(左上角格坐标)+ **rowspan、colspan** **严格等于图中该码区所占格数****禁止**用超大 rowspan 吞掉左侧「性别/身高/体重」等独立标签列,也**禁止**用顶层 **type:"qrcode"** 大块叠在表上;格内用 **contentType:"qrcode"** + **bindField** 即可。
4) **标签格 vs 值格**:常见 **标签|值|标签|值** 四列交替(以图中列位置为准,勿机械套用奇偶列)。**固定标签**格写中文标签到 **text**、**contentType:"text"**、一般无 **bindField****待填值**格 **contentType:"text"** + **bindField**英文驼峰或下划线。图中「示例_xxx」「样例_xxx」等占位视为动态字段写入对应值格 **bindField**(可与 dataBinding.params 或 fieldMap 键一致)。
5) **表格外标题与日期**:若「标题」居中、日期在右上且**明显在表格边框线之外**(表格外上方),请用独立元素 **type:"title"** 与 **type:"date"**(带 format**不要**把它们强行做成 freeTable 的第 0 行(除非视觉上标题确实画在表内单元格里)。**x,y** 须满足第一节「坐标与版心」:标题与**主表格块水平居中对齐**(表宽小于页宽时 **title.x ≈ table.x + (table.w title.w)/2**),日期靠纸右缘,二者均在表格 **y** 之上。
6) **列宽、竖线与行高(防列线跑偏)****colWidths** 长度须等于 **colCount**,与图中**竖分割线间距比例**一致,**sum(colWidths) ≈ freeTable 元素 w**(允许 12mm**rowHeights** 长度须等于 **rowCount** 且与各行视觉高度成比例。勿少列、勿吞并视觉独立的窄列。
7) **填充色**:非白底格写 **backgroundColor**;白底可 **#ffffff** 或省略。
8) **表格内的「二维码」**(极其重要):
- 字样或图案**在格线内** → 该 cell **contentType:"qrcode"** + bindField**禁止**顶层再叠 **type:"qrcode"**。
- **仅在表格外**独立一块时 → 顶层 **type:"qrcode"**。
9) 自由表单元格 **contentType**text | image | qrcode | barcode | number | amount普通字符串绑定一律 **text**。
========== 五、文字与日期、独立二维码 ==========
1) 顶层「日期」「打印日期」或具体年月日:用 **type:"date"**,必须 **format**YYYY-MM-DD / YYYY年MM月DD日 等);可 **bindField**。
2) **独立**二维码图块(不在表格格内):**type:"qrcode"**value 可用示例 URLbindField 建议 qrCodeValue不要用空 src 的 image 冒充。
3) 普通说明文字不要用 qrcode 类型表达。
请严格按以上规则输出 JSON以提高与原生设计器table/detailTable/freeTable的一致性。
""";
private final ObjectMapper objectMapper;
@@ -265,6 +307,10 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT
tryConvertMisclassifiedListTableToFreeTable(e);
type = e.path("type").asText("text");
}
if ("freeTable".equals(type)) {
// 格内「二维码」文案须 contentType:qrcode先于 ensureFreeTableShape 写入
upgradeFreeTableCellsSemantics(e);
}
if ("table".equals(type) || "detailTable".equals(type)) {
ensureTableShape(e);
} else if ("freeTable".equals(type)) {
@@ -284,6 +330,8 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT
}
}
// 若 freeTable 格内已是二维码,去掉叠在同一区域内的顶层 qrcode避免渲染「吃掉」表格或丢格
removeStandaloneQrDateRedundantWithFreeTable(elements);
// AI 常同时输出「二维码/日期」文案 + 图形,升级后易重叠重复,此处按包围盒与绑定键去重
dedupeOverlappingSemanticElements(elements);
@@ -534,8 +582,12 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT
if (looksLikeCommerceDetailColumns(cols)) {
return;
}
// 典型清单/明细表头:勿把整张表误改成单行 freeTable否则下方多行体与二维码区会丢失
if (hasColumnTitleContaining(cols, "序号") || hasColumnTitleContaining(cols, "项次")) {
return;
}
int personalHits = countPersonalFormLabels(cols);
if (personalHits < 3) {
if (personalHits < 5) {
return;
}
ArrayNode colsCopy = cols.deepCopy();
@@ -608,6 +660,15 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT
return n;
}
private static boolean hasColumnTitleContaining(ArrayNode cols, String needle) {
for (JsonNode c : cols) {
if (c.isObject() && needle.equals(c.path("title").asText("").trim())) {
return true;
}
}
return false;
}
private static void stripTableOnlyKeys(ObjectNode el) {
for (String k : TABLE_ONLY_KEYS) {
el.remove(k);
@@ -685,6 +746,132 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT
}
}
/** 自由表单元格:格内「二维码」/日期类文案补全 contentType 与 bindField顶层 upgrade 扫不到 cells */
private void upgradeFreeTableCellsSemantics(ObjectNode ft) {
JsonNode cellsNode = ft.get("cells");
if (cellsNode == null || !cellsNode.isArray()) {
return;
}
for (JsonNode n : cellsNode) {
if (!n.isObject()) {
continue;
}
ObjectNode cell = (ObjectNode) n;
String text = cell.path("text").asText("").trim();
if (isQrCodeLabelText(text)) {
cell.put("contentType", "qrcode");
if (!cell.has("bindField") || StringUtils.isBlank(cell.path("bindField").asText())) {
cell.put("bindField", "qrCode");
}
cell.put("text", "");
if (!cell.has("verticalAlign") || StringUtils.isBlank(cell.path("verticalAlign").asText())) {
cell.put("verticalAlign", "middle");
}
if (!cell.has("align") || StringUtils.isBlank(cell.path("align").asText())) {
cell.put("align", "center");
}
} else if (StringUtils.isNotBlank(text) && isDateSemanticText(text)) {
if (!cell.has("bindField") || StringUtils.isBlank(cell.path("bindField").asText())) {
cell.put("bindField", "printDate");
}
if (!cell.has("contentType") || StringUtils.isBlank(cell.path("contentType").asText())) {
cell.put("contentType", "text");
}
}
}
}
/**
* 当 freeTable 格内已有二维码/日期语义时,删除叠在同一区域内的顶层 qrcode、date避免遮挡表格或造成「丢失格」错觉。
*/
private void removeStandaloneQrDateRedundantWithFreeTable(ArrayNode elements) {
List<ObjectNode> fts = new ArrayList<>();
for (JsonNode n : elements) {
if (n.isObject() && "freeTable".equals(n.path("type").asText(""))) {
fts.add((ObjectNode) n);
}
}
if (fts.isEmpty()) {
return;
}
for (int i = elements.size() - 1; i >= 0; i--) {
JsonNode n = elements.get(i);
if (!n.isObject()) {
continue;
}
ObjectNode e = (ObjectNode) n;
String t = e.path("type").asText("");
if (!"qrcode".equals(t) && !"date".equals(t)) {
continue;
}
for (ObjectNode ft : fts) {
boolean hasQr = freeTableHasQrLikeCell(ft);
boolean hasDate = freeTableHasDateLikeCell(ft);
if ("qrcode".equals(t) && hasQr) {
double overlap = rectOverlapRatio(e, ft);
if (overlap >= 0.04 || centerInsideFreeTableBounds(e, ft)) {
elements.remove(i);
break;
}
}
if ("date".equals(t) && hasDate) {
double overlap = rectOverlapRatio(e, ft);
if (overlap >= 0.08 || centerInsideFreeTableBounds(e, ft)) {
elements.remove(i);
break;
}
}
}
}
}
private static boolean freeTableHasQrLikeCell(ObjectNode ft) {
JsonNode cells = ft.get("cells");
if (cells == null || !cells.isArray()) {
return false;
}
for (JsonNode c : cells) {
if (!c.isObject()) {
continue;
}
if ("qrcode".equalsIgnoreCase(c.path("contentType").asText("").trim())) {
return true;
}
String tx = c.path("text").asText("");
if (StringUtils.isNotBlank(tx) && tx.contains("二维码")) {
return true;
}
}
return false;
}
private boolean freeTableHasDateLikeCell(ObjectNode ft) {
JsonNode cells = ft.get("cells");
if (cells == null || !cells.isArray()) {
return false;
}
for (JsonNode c : cells) {
if (!c.isObject()) {
continue;
}
String tx = c.path("text").asText("");
if (isDateSemanticText(tx)) {
return true;
}
}
return false;
}
private static boolean centerInsideFreeTableBounds(ObjectNode e, ObjectNode ft) {
double fx = ft.path("x").asDouble();
double fy = ft.path("y").asDouble();
double fw = Math.max(ft.path("w").asDouble(), 0.01);
double fh = Math.max(ft.path("h").asDouble(), 0.01);
double cx = e.path("x").asDouble() + e.path("w").asDouble() / 2.0;
double cy = e.path("y").asDouble() + e.path("h").asDouble() / 2.0;
return cx >= fx - 1 && cx <= fx + fw + 1 && cy >= fy - 1 && cy <= fy + fh + 1;
}
/** 文案为「二维码」或极短含二维码提示 */
private boolean isQrCodeLabelText(String text) {
if (StringUtils.isBlank(text)) {

View File

@@ -150,13 +150,21 @@
<a-modal
v-model:open="imageAnalyzeVisible"
title="上传图片分析模板"
width="920px"
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>
@@ -199,10 +207,68 @@
<img :src="imageAnalyzeThumbUrl" alt="上传原图" />
</div>
<div class="image-analyze-preview-frame-wrap">
<div class="thumb-label">按生成模板渲染</div>
<iframe class="image-analyze-preview-frame" :srcdoc="imageAnalyzePreviewHtml"></iframe>
<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>
@@ -215,8 +281,10 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
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';
@@ -225,10 +293,14 @@
import PropertiesPanel from './components/PropertiesPanel.vue';
import ToolbarPalette from './components/ToolbarPalette.vue';
import { printHtml } from './core/printService';
import { renderNativePrintHtml } from './core/printRenderer';
import { renderNativePrintHtml, resolvePrintPageCount } from './core/printRenderer';
import { generateNativeMockDataObject } from './core/nativeMockData';
import { buildNativeTemplateStylePayload } from './core/nativeTemplateStyleSerialize';
import { normalizeImportedNativeSchema } from './core/nativeSchemaNormalize';
import {
applyDetailTableMultiHeaderFalsePositiveFix,
applyStackedHeaderBandLayoutFix,
normalizeImportedNativeSchema,
} from './core/nativeSchemaNormalize';
import { normalizeFreeTableAnchors } from './core/freeTableGrid';
import { scaleFreeTableTracks } from './core/freeTableTracks';
import { useDesignerStore } from './core/useDesignerStore';
@@ -286,6 +358,333 @@
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;
@@ -1083,6 +1482,8 @@
imageAnalyzeMockJson.value = '';
imageAnalyzeDragover.value = false;
imageAnalyzeLoading.value = false;
imageAnalyzeContentMeasurePx.value = { w: 0, h: 0 };
imageAnalyzeZoomMult.value = 1;
resetImageAnalyzeProgressUi();
}
@@ -1094,6 +1495,7 @@
function openImageAnalyzeModal() {
clearImageAnalyzeState();
resetImageAnalyzeModalLayout();
imageAnalyzeVisible.value = true;
}
@@ -1170,7 +1572,9 @@
imageAnalyzeProgress.value = Math.max(imageAnalyzeProgress.value, 44);
imageAnalyzeProgressTip.value = '正在解析返回结果…';
const parsed = normalizeImportedNativeSchema(JSON.parse(res.templateJson));
const parsed = applyDetailTableMultiHeaderFalsePositiveFix(
applyStackedHeaderBandLayoutFix(normalizeImportedNativeSchema(JSON.parse(res.templateJson))),
);
imageAnalyzePendingSchema.value = parsed;
imageAnalyzeMockJson.value = res.mockDataJson || '{}';
const extra = res.aiUsed ? '(已调用视觉大模型)' : '(未调用大模型或已回退占位)';
@@ -1260,6 +1664,7 @@
onUnmounted(() => {
stopLeftPanelResize();
teardownImageAnalyzeModalDrag();
invalidatePendingImageAnalyze();
clearImageAnalyzeProgressTimer();
imageAnalyzeLoading.value = false;
@@ -1577,7 +1982,27 @@
border: 1px solid #f0f0f0;
}
.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;
}
@@ -1654,6 +2079,10 @@
flex: 0 0 200px;
max-width: 100%;
.thumb-label {
margin-bottom: 6px;
}
img {
max-width: 100%;
max-height: 280px;
@@ -1667,19 +2096,107 @@
.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: 6px;
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: 320px;
border: 1px solid #f0f0f0;
border-radius: 4px;
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>

View File

@@ -52,3 +52,134 @@ export function normalizeImportedNativeSchema(raw: unknown): NativeTemplateSchem
dataBinding,
};
}
/**
* 用于标题/日期对齐:优先取**最靠上**的表格块(常见混合版式上方 freeTable + 下方明细),
* 同 y 再取面积较大者;无 freeTable 时同理在 table/detailTable 中选。
*/
function pickPrimaryTableBlock(els: any[]): { y: number; x: number; w: number } | null {
const fts = els.filter((e) => e?.type === 'freeTable');
const grids = els.filter((e) => e?.type === 'table' || e?.type === 'detailTable');
const cand = (fts.length ? fts : grids) as any[];
if (!cand.length) {
return null;
}
return cand.reduce((best, cur) => {
const by = Number(best.y) || 0;
const cy = Number(cur.y) || 0;
if (cy < by) {
return cur;
}
if (cy > by) {
return best;
}
const ba = (Number(best.w) || 0) * (Number(best.h) || 0);
const ca = (Number(cur.w) || 0) * (Number(cur.h) || 0);
return ca >= ba ? cur : best;
});
}
/**
* 图片识别常见错位:表格外「标题 / 日期」被放到纸面中部或表格右侧。
* 在存在主表格块freeTable / table / detailTable将明显失真的 title/subtitle/date
* 压回「表顶上方」;**标题水平方向与主表块居中对齐**(解决「标题相对整页居中但与左对齐的表脱节」)。
* 仅用于上传图片分析等场景。
*/
export function applyStackedHeaderBandLayoutFix(schema: NativeTemplateSchema): NativeTemplateSchema {
const els = schema.elements;
if (!Array.isArray(els) || els.length === 0) {
return schema;
}
const block = pickPrimaryTableBlock(els);
if (!block) {
return schema;
}
const tableTop = Number(block.y) || 0;
const tableLeft = Number(block.x) || 0;
const tableW = Number(block.w) || 0;
const pageW = Number(schema.page?.width) || 210;
const margin = (schema.page?.margin as number[]) || [10, 10, 10, 10];
const mt = Number(margin[0]) || 10;
const mr = Number(margin[1]) || 10;
const nextElements = els.map((e: any) => {
if (e?.type === 'title' || e?.type === 'subtitle') {
const ex = Number(e.x) || 0;
const ey = Number(e.y) || 0;
const ew = Math.max(24, Number(e.w) || 90);
const eh = Math.max(8, Number(e.h) || 14);
// 标题应在表格外上方且水平居中:底边压到表顶以下,或整体偏到表格右侧,视为失真
const bottomPastTableTop = ey + eh > tableTop - 1;
const shiftedRight = ex > tableLeft + tableW * 0.28;
if (bottomPastTableTop || shiftedRight) {
// 与主表块水平居中对齐,避免「整页居中」与左对齐表格错位
const centeredOnTable = tableLeft + tableW / 2 - ew / 2;
const x = Math.max(mt, Math.min(pageW - mr - ew, centeredOnTable));
return {
...e,
x,
y: Math.max(2, tableTop - eh - 8),
style: { ...(e.style || {}), textAlign: 'center' },
};
}
}
if (e?.type === 'date') {
const ex = Number(e.x) || 0;
const ey = Number(e.y) || 0;
const ew = Math.max(28, Number(e.w) || 55);
const eh = Math.max(8, Number(e.h) || 10);
// 日期应在纸右上、表顶之上:纵向下移越过表顶,或整块落在表宽左侧,视为失真
const tooLow = ey + eh > tableTop + 2;
const notRightBand = ex + ew < tableLeft + tableW * 0.5;
if (tooLow || notRightBand) {
return {
...e,
x: Math.max(mt, pageW - mr - ew),
y: Math.max(2, tableTop - eh - 4),
style: { ...(e.style || {}), textAlign: 'right' },
};
}
}
return e;
});
return { ...schema, elements: nextElements as NativeElement[] };
}
/**
* 图片识别误开多级表头enableMultiHeader=true 但 headerConfig 大量空 title或行数与有字格子不匹配。
* 降级为单级表头,避免空白表头行与设计器错位。
*/
export function applyDetailTableMultiHeaderFalsePositiveFix(schema: NativeTemplateSchema): NativeTemplateSchema {
const nextElements = schema.elements.map((el: any) => {
if (el?.type !== 'table' && el?.type !== 'detailTable') {
return el;
}
if (el.enableMultiHeader !== true) {
return el;
}
const hc = el.headerConfig;
const cells = Array.isArray(hc?.cells) ? hc.cells : [];
const rowCount = Math.max(1, Number(hc?.rowCount || 1));
const nonEmpty = cells.filter((c: any) => String(c?.title ?? '').trim().length > 0);
// 格子全无字 → 假多级
if (nonEmpty.length === 0) {
const { headerConfig: _h, enableMultiHeader: _e, ...rest } = el;
return { ...rest, enableMultiHeader: false };
}
// 声明多行表头,但有文字的格子少于表头行数(典型:第二行全空)
if (rowCount >= 2 && nonEmpty.length < rowCount) {
const { headerConfig: _h, enableMultiHeader: _e, ...rest } = el;
return { ...rest, enableMultiHeader: false };
}
// 有格子但绝大部分无字
if (cells.length >= 4 && nonEmpty.length < Math.max(2, Math.ceil(cells.length * 0.25))) {
const { headerConfig: _h, enableMultiHeader: _e, ...rest } = el;
return { ...rest, enableMultiHeader: false };
}
return el;
});
return { ...schema, elements: nextElements as NativeElement[] };
}