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

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>