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

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

@@ -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[] };
}