优化图片分析弹窗,新增可拖动和调整大小功能,改进预览区布局和缩放控制,确保用户体验流畅。同时,修复标题和日期对齐问题,提升模板生成的准确性。
This commit is contained in:
@@ -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 或表格列 bindField;params/detailTables 要覆盖所有用到的绑定键。
|
||||
5) page 宽高与图片长宽比一致,可假定常见标签纸或 A4;单位 mm,数值合理。
|
||||
6) 版面上单独出现「二维码」文字标签、或黑白方块二维码图案:必须用 type「qrcode」表达(value 可用示例 URL,bindField 建议 qrCodeValue),不要用 text 写「二维码」,也不要用空 src 的 image 占位方块码。
|
||||
7) 「日期」「打印日期」「制单日期」等标签或具体年月日:用 type「date」,必须设 format(YYYY-MM-DD / YYYY年MM月DD日 等与版式一致);需要数据绑定则设 bindField 如 printDate。
|
||||
3) 从图片读到的**静态展示文字**用 text/title/subtitle;需要数据绑定的用 bindField;params/detailTables 必须覆盖所有用到的绑定键。
|
||||
4) page 宽高与图片长宽比一致(常见标签纸或 A4);单位 mm,数值合理。
|
||||
5) **坐标与版心(防错位,极其重要)**:
|
||||
- 原点为纸张**左上角**,**x 向右、y 向下**增大;禁止负坐标或颠倒轴向。
|
||||
- 排版前先在脑中确定**主表格块**(freeTable / table / detailTable 中外接矩形最大者)的 **(x,y,w,h)**,再布置表外元素;**禁止**在未对齐主块的情况下随意写 x,y。
|
||||
- **title / subtitle**:若在表格**上方**,应满足 **y + h ≤ 表格.y − 2~10mm**(留出空隙)。水平位置须与图中一致:若标题相对**整张纸**居中则用 **x ≈ (page.width − w) / 2**;若标题明显相对**下方表格块**居中(表宽明显小于页宽、标题盖住表的中线),则用 **x ≈ table.x + (table.w − w) / 2**,使标题与表格**左右对齐、视觉同一列宽区域**。**禁止**把标题写到表格**右缘以右**的空白区充当「居中」。
|
||||
- **date(表格外右上角)**:**x ≈ page.width − margin[1] − w**,**y** 与标题**同一水平带**(同排或略低 1~3mm),且 **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**(允许 1~2mm);**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 可用示例 URL,bindField 建议 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)) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 左侧工具栏宽度(px),0 表示隐藏 */
|
||||
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>
|
||||
|
||||
@@ -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[] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user