增强条码元素和自由表格元素的渲染逻辑,支持更多条码格式和文本边框样式。新增条码渲染工具,优化打印预览窗口的打印机选择功能,提升用户体验和打印模板的灵活性。

This commit is contained in:
geht
2026-05-13 12:35:02 +08:00
parent d2f49add82
commit 2c8620522b
15 changed files with 1446 additions and 125 deletions

View File

@@ -1059,6 +1059,13 @@
}
if (type === 'qrcode' || type === 'barcode') {
base.value = payload.value || '';
if (type === 'barcode') {
// 默认 Code128自动与 jsbarcode 默认一致displayValue 默认显示底部文字
base.format = payload.format || 'CODE128';
base.displayValue = payload.displayValue !== false;
// 条码下文字对齐center / left / right / justify两端对齐默认居中
base.textAlign = payload.textAlign || 'center';
}
return base as NativeElement;
}
if (type === 'reportHeader' || type === 'reportFooter') {

View File

@@ -86,6 +86,47 @@
<template v-if="isText(selectedElement.type)">
<a-divider />
<a-input :value="(selectedElement as any).text" addon-before="内容" @update:value="updateField('text', $event)" />
<template v-if="supportsTextBorderDesign(selectedElement.type)">
<a-space direction="vertical" style="width: 100%; margin-top: 8px">
<a-switch
:checked="Number(selectedElement.style?.borderWidth || 0) > 0"
checked-children="显示边框"
un-checked-children="隐藏边框"
@update:checked="updateTextBorderEnabled($event)"
/>
<template v-if="Number(selectedElement.style?.borderWidth || 0) > 0">
<a-input-number
:value="Math.max(1, Number(selectedElement.style?.borderWidth || 1))"
addon-before="边框宽(px)"
:min="1"
:max="8"
style="width: 100%"
@update:value="updateStyle('borderWidth', Math.max(1, Number($event || 1)))"
/>
<div class="color-input-row">
<a-input
:value="selectedElement.style?.borderColor || '#222222'"
addon-before="边框色"
placeholder="#222222 / rgb(...)"
@update:value="updateStyle('borderColor', $event)"
/>
<input
type="color"
class="native-color-picker-trigger"
title="选择边框颜色"
:value="toColorHex(String(selectedElement.style?.borderColor || '#222222'))"
@input="handleStyleColorInput('borderColor', $event, '#222222')"
/>
</div>
<a-space wrap size="small">
<a-checkbox :checked="selectedElement.style?.hideBorderTop !== true" @update:checked="updateTextBorderSide('top', $event)">上边</a-checkbox>
<a-checkbox :checked="selectedElement.style?.hideBorderRight !== true" @update:checked="updateTextBorderSide('right', $event)">右边</a-checkbox>
<a-checkbox :checked="selectedElement.style?.hideBorderBottom !== true" @update:checked="updateTextBorderSide('bottom', $event)">下边</a-checkbox>
<a-checkbox :checked="selectedElement.style?.hideBorderLeft !== true" @update:checked="updateTextBorderSide('left', $event)">左边</a-checkbox>
</a-space>
</template>
</a-space>
</template>
</template>
<template v-if="isReportBand(selectedElement.type)">
<a-divider />
@@ -165,6 +206,39 @@
<template v-if="selectedElement.type === 'qrcode' || selectedElement.type === 'barcode'">
<a-divider />
<a-input :value="(selectedElement as any).value" addon-before="编码值" @update:value="updateField('value', $event)" />
<template v-if="selectedElement.type === 'barcode'">
<div class="bind-param-compact" style="margin-top: 8px">
<span class="bind-param-compact__addon">条码类型</span>
<a-select
:value="(selectedElement as any).format || 'CODE128'"
:options="BARCODE_FORMAT_OPTIONS"
show-search
option-filter-prop="label"
class="bind-param-compact__select"
@update:value="updateField('format', $event)"
/>
</div>
<a-switch
style="margin-top: 8px"
:checked="(selectedElement as any).displayValue !== false"
checked-children="显示条码下文字"
un-checked-children="隐藏条码下文字"
@update:checked="updateField('displayValue', $event)"
/>
<div
v-if="(selectedElement as any).displayValue !== false"
class="bind-param-compact"
style="margin-top: 8px"
>
<span class="bind-param-compact__addon">文字对齐</span>
<a-select
:value="(selectedElement as any).textAlign || 'center'"
:options="BARCODE_TEXT_ALIGN_OPTIONS"
class="bind-param-compact__select"
@update:value="updateField('textAlign', $event)"
/>
</div>
</template>
</template>
<template v-if="selectedElement.type === 'table' || selectedElement.type === 'detailTable'">
<a-divider />
@@ -586,7 +660,7 @@
:min="1"
:max="100"
size="small"
disabled
@update:value="setFreeTableRowCount(Number($event || 1))"
/>
</div>
<div class="free-table-dim-item">
@@ -596,16 +670,23 @@
:min="1"
:max="50"
size="small"
disabled
@update:value="setFreeTableColCount(Number($event || 1))"
/>
</div>
</div>
<a-space>
<a-space wrap>
<a-button size="small" @click="addFreeTableRow">新增行</a-button>
<a-button size="small" @click="removeFreeTableRow">删除行</a-button>
<a-button size="small" @click="addFreeTableCol">新增列</a-button>
<a-button size="small" @click="removeFreeTableCol">删除列</a-button>
</a-space>
<a-space wrap>
<a-button size="small" @click="resetFreeTableEvenGrid">重置为均分网格</a-button>
<a-button size="small" type="primary" ghost @click="applyFreeTableKeyValueTemplate">套用「键值对」模板</a-button>
</a-space>
<div class="free-table-merge-tip">
「键值对」模板:把当前表改成 N 行 × 2 列,左列预填"标题1标题2"占位字符,方便快速制作图二那种标签卡片;右列由你填字段或绑定字段值。
</div>
<div class="free-table-track-head">单元格合并</div>
<a-button type="primary" size="small" block :disabled="!canMergeFreeTableSelection" @click="mergeFreeTableSelection">合并选中区域</a-button>
<a-button size="small" block :disabled="!canSplitFreeTableMerged" @click="splitFreeTableMerged">拆分当前合并</a-button>
@@ -1321,14 +1402,67 @@
{ label: 'JPEG', value: 'image/jpeg' },
{ label: 'WEBP', value: 'image/webp' },
];
// jsbarcode 全量支持的码制清单按官方分组。a-select 通过嵌套 options 自动渲染为
// OptGroupantdv 4+)。桌面端 ZXing.Net 已对所有这些 value 做了等价/最接近的映射,
// 未覆盖的EAN5/EAN2/MSI 变体/pharmacode会回退到 CODE_128 渲染,避免渲染失败。
const BARCODE_FORMAT_OPTIONS = [
{ label: 'CODE128', value: 'CODE128' },
{ label: 'CODE39', value: 'CODE39' },
{ label: 'EAN13', value: 'EAN13' },
{ label: 'EAN8', value: 'EAN8' },
{ label: 'ITF14', value: 'ITF14' },
{ label: 'MSI', value: 'MSI' },
{ label: 'pharmacode', value: 'pharmacode' },
{
label: 'CODE 128 系列',
options: [
{ label: 'CODE128自动', value: 'CODE128' },
{ label: 'CODE128 A', value: 'CODE128A' },
{ label: 'CODE128 B', value: 'CODE128B' },
{ label: 'CODE128 C', value: 'CODE128C' },
],
},
{
label: 'EAN / UPC',
options: [
{ label: 'EAN-13', value: 'EAN13' },
{ label: 'EAN-8', value: 'EAN8' },
{ label: 'EAN-5', value: 'EAN5' },
{ label: 'EAN-2', value: 'EAN2' },
{ label: 'UPC-A', value: 'UPC' },
{ label: 'UPC-E', value: 'UPCE' },
],
},
{
label: 'CODE 39',
options: [{ label: 'CODE39', value: 'CODE39' }],
},
{
label: 'ITF (Interleaved 2 of 5)',
options: [
{ label: 'ITF', value: 'ITF' },
{ label: 'ITF-14', value: 'ITF14' },
],
},
{
label: 'MSI',
options: [
{ label: 'MSI无校验', value: 'MSI' },
{ label: 'MSI10 (Mod10)', value: 'MSI10' },
{ label: 'MSI11 (Mod11)', value: 'MSI11' },
{ label: 'MSI1010 (Mod10+Mod10)', value: 'MSI1010' },
{ label: 'MSI1110 (Mod11+Mod10)', value: 'MSI1110' },
],
},
{
label: '其它',
options: [
{ label: 'Pharmacode', value: 'pharmacode' },
{ label: 'Codabar', value: 'codabar' },
],
},
];
// 条码下文字对齐方式jsbarcode 原生支持 left/center/right"两端对齐" 由我们在
// SVG 后处理时给 <text> 加上 textLength=条码宽 + lengthAdjust=spacing 实现,浏览器
// 会自动拉伸字符间距让文字横向占满条码宽度,桌面端在 ZXing 输出 SVG 上同款处理。
const BARCODE_TEXT_ALIGN_OPTIONS = [
{ label: '居中', value: 'center' },
{ label: '靠左', value: 'left' },
{ label: '靠右', value: 'right' },
{ label: '两端对齐', value: 'justify' },
];
const REFRESH_PAGE_OPTIONS = [
{ label: '刷新页:不应用', value: 'none' },
@@ -1364,6 +1498,46 @@
});
}
/** 仅标题/副标题/正文支持自由边框控制(日期/页码不展示该项,避免误操作)。 */
function supportsTextBorderDesign(type: string) {
return type === 'title' || type === 'subtitle' || type === 'text';
}
/** 文本边框总开关:开=默认四边显示,关=边框宽设为 0。 */
function updateTextBorderEnabled(checked: boolean) {
if (!props.selectedElement) return;
if (!supportsTextBorderDesign(props.selectedElement.type)) return;
if (checked) {
emit('update-element', {
id: props.selectedElement.id,
patch: {
style: {
...(props.selectedElement.style || {}),
borderWidth: Math.max(1, Number(props.selectedElement.style?.borderWidth || 1)),
borderColor: props.selectedElement.style?.borderColor || '#222222',
hideBorderTop: false,
hideBorderRight: false,
hideBorderBottom: false,
hideBorderLeft: false,
},
} as any,
});
return;
}
updateStyle('borderWidth', 0);
}
/** 单边显示控制:勾选=显示,取消=隐藏。内部存储为 hideBorderX boolean。 */
function updateTextBorderSide(side: 'top' | 'right' | 'bottom' | 'left', visible: boolean) {
const map = {
top: 'hideBorderTop',
right: 'hideBorderRight',
bottom: 'hideBorderBottom',
left: 'hideBorderLeft',
} as const;
updateStyle(map[side], visible !== true);
}
function updateField(key: string, value: any) {
if (!props.selectedElement) return;
emit('update-element', { id: props.selectedElement.id, patch: { [key]: value } as any });
@@ -1706,6 +1880,115 @@
updateFreeTableElement({ rowCount, cells, rowHeights: nextRh });
}
/**
* 直接设置 freeTable 的行数:保留已有 cells按新 rowCount 均分行高(总高度不变)。
* 与"逐一点击新增/删除行"等价,但允许用户一次性把 2 改成 9 等批量调整。
*/
function setFreeTableRowCount(nextRowCount: number) {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const target = Math.max(1, Math.min(100, Math.round(Number(nextRowCount) || 1)));
if (target === Number(el.rowCount || 1)) return;
const colCount = Math.max(1, Number(el.colCount || 1));
const cells = rebuildFreeTableCells(target, colCount, el.cells || []);
const totalH = Math.max(0.01, Number(el.h) || 0.01);
updateFreeTableElement({ rowCount: target, cells, rowHeights: buildEvenRowHeights(target, totalH) });
}
/**
* 直接设置 freeTable 的列数:保留已有 cells按新 colCount 均分列宽(总宽度不变)。
*/
function setFreeTableColCount(nextColCount: number) {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const target = Math.max(1, Math.min(50, Math.round(Number(nextColCount) || 1)));
if (target === Number(el.colCount || 1)) return;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const cells = rebuildFreeTableCells(rowCount, target, el.cells || []);
const totalW = Math.max(0.01, Number(el.w) || 0.01);
updateFreeTableElement({ colCount: target, cells, colWidths: buildEvenColWidths(target, totalW) });
}
/**
* 重置为均分网格:清空所有 cells包括用户填写的文字、绑定、合并按当前
* rowCount × colCount 重新均分行高/列宽。用于一键洗牌网格、从零开始设计。
*/
function resetFreeTableEvenGrid() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1));
const totalW = Math.max(0.01, Number(el.w) || 0.01);
const totalH = Math.max(0.01, Number(el.h) || 0.01);
const cells = normalizeFreeTableAnchors(rowCount, colCount, []);
updateFreeTableElement({
cells,
colWidths: buildEvenColWidths(colCount, totalW),
rowHeights: buildEvenRowHeights(rowCount, totalH),
});
createMessage.success('已重置为均分网格');
}
/**
* 套用"键值对"模板:把当前 freeTable 整为 N 行 × 2 列,左列预填"标题i"占位、
* 右列留空用户后续填值或绑定字段。N 取当前 rowCount列数强制为 2
* 列宽按 1:2 比例(标题列窄、值列宽,常见标签布局)。
*/
function applyFreeTableKeyValueTemplate() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = 2;
const totalW = Math.max(0.01, Number(el.w) || 0.01);
const totalH = Math.max(0.01, Number(el.h) || 0.01);
// 列宽 1:2标题列居中加粗、值列靠左
const labelColW = Math.max(MIN_FREE_TABLE_TRACK_MM, Math.round((totalW / 3) * 100) / 100);
const valueColW = Math.max(MIN_FREE_TABLE_TRACK_MM, Math.round((totalW - labelColW) * 100) / 100);
const cells: any[] = [];
for (let r = 0; r < rowCount; r += 1) {
cells.push({
row: r,
col: 0,
rowspan: 1,
colspan: 1,
text: `标题${r + 1}`,
bindField: '',
align: 'center',
verticalAlign: 'middle',
fontSize: 12,
color: '#111111',
backgroundColor: '#f7f7f7',
contentType: 'text',
autoWrap: true,
});
cells.push({
row: r,
col: 1,
rowspan: 1,
colspan: 1,
text: '',
bindField: '',
align: 'left',
verticalAlign: 'middle',
fontSize: 12,
color: '#111111',
backgroundColor: '#ffffff',
contentType: 'text',
autoWrap: true,
});
}
updateFreeTableElement({
colCount,
rowCount,
cells: normalizeFreeTableAnchors(rowCount, colCount, cells),
colWidths: [labelColW, valueColW],
rowHeights: buildEvenRowHeights(rowCount, totalH),
});
createMessage.success('已套用「键值对」模板');
}
function removeFreeTableRow() {
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
const el = props.selectedElement as any;

View File

@@ -1,50 +1,61 @@
<template>
<div class="barcode-element">
<canvas ref="canvasRef"></canvas>
<svg ref="svgRef" class="barcode-svg" :aria-label="`barcode: ${displayValue}`"></svg>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import type { NativeCodeElement } from '../../core/types';
import { renderNativeBarcodeIntoSvg } from '../../core/barcodeRenderer';
const props = defineProps<{
element: NativeCodeElement;
previewData?: Record<string, any>;
}>();
const canvasRef = ref<HTMLCanvasElement>();
const svgRef = ref<SVGSVGElement>();
function resolveFieldValue(field?: string) {
if (!field) return undefined;
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
}
/** 实际用于渲染的字符串:优先取 previewData 里绑定字段值,否则 element.value再否则示例 */
const displayValue = computed(() => {
const bind = resolveFieldValue(props.element.bindField);
if (bind !== undefined && bind !== null && String(bind).trim() !== '') {
return String(bind);
}
return String(props.element.value || '0000000000');
});
async function renderBarcode() {
if (!canvasRef.value) return;
const module: any = await import('jsbarcode');
const JsBarcode = module.default || module;
const bindValue = resolveFieldValue(props.element.bindField);
const value = bindValue !== undefined && bindValue !== null ? String(bindValue) : props.element.value || '0000000000';
JsBarcode(canvasRef.value, value, {
format: 'CODE128',
displayValue: true,
margin: 0,
width: 1.5,
height: 40,
fontSize: 12,
if (!svgRef.value) return;
await renderNativeBarcodeIntoSvg(svgRef.value, displayValue.value, {
format: (props.element as any).format,
displayValue: (props.element as any).displayValue !== false,
fontSize: (props.element as any).fontSize,
lineWidth: (props.element as any).lineWidth,
barHeight: (props.element as any).barHeight,
textAlign: (props.element as any).textAlign,
});
}
onMounted(() => {
renderBarcode();
});
onMounted(renderBarcode);
watch(
() => [props.element.value, props.element.bindField, props.previewData],
() => {
renderBarcode();
},
() => [
displayValue.value,
props.element.bindField,
(props.element as any).format,
(props.element as any).displayValue,
(props.element as any).fontSize,
(props.element as any).lineWidth,
(props.element as any).barHeight,
(props.element as any).textAlign,
],
renderBarcode,
);
</script>
@@ -57,10 +68,15 @@
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
}
canvas {
width: 100%;
height: 100%;
}
/* 关键SVG 自带 preserveAspectRatio="xMidYMid meet"
这里宽高 100% 后浏览器会自动按 viewBox 等比缩放并居中,
不再像之前的 canvas 直接被 CSS 拉伸成扁平条码。 */
.barcode-svg {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,5 +1,11 @@
<template>
<div class="free-table-element" :data-free-table-id="element.id">
<div
class="free-table-element"
:data-free-table-id="element.id"
:style="{
'--ft-edit-line-w': `${Math.max(1, Number(element?.borderWidth || 1))}px`,
}"
>
<!-- 选中时左上角四向箭头与画布整体拖动一致不在此阻止 pointerdown事件冒泡到 ElementWrapper -->
<button
v-if="isElementSelected"
@@ -30,13 +36,13 @@
/>
</svg>
</button>
<div class="free-table-surface" @pointerdown.stop>
<div ref="surfaceRef" class="free-table-surface" @pointerdown.stop>
<table>
<colgroup>
<col v-for="(cw, ci) in colWidthsMm" :key="`col_${ci}`" :style="{ width: `${cw}mm` }" />
<col v-for="(cw, ci) in renderColWidthsMm" :key="`col_${ci}`" :style="{ width: `${cw}mm` }" />
</colgroup>
<tbody>
<tr v-for="r in rowCount" :key="`tr_${r - 1}`" :style="{ height: `${rowHeightsMm[r - 1] ?? 6}mm` }">
<tr v-for="r in rowCount" :key="`tr_${r - 1}`" :style="{ height: `${renderRowHeightsMm[r - 1] ?? 6}mm` }">
<td
v-for="cell in anchorsForRow(r - 1)"
:key="`td_${cell.row}_${cell.col}`"
@@ -115,7 +121,7 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import QRCode from 'qrcode';
import { getValueByPath } from '../../core/tableBuilder';
import { normalizeFreeTableAnchors } from '../../core/freeTableGrid';
@@ -128,6 +134,7 @@
const qrCodeCache = ref<Record<string, string>>({});
const barcodeCache = ref<Record<string, string>>({});
const surfaceRef = ref<HTMLElement | null>(null);
const props = withDefaults(
defineProps<{
@@ -157,8 +164,33 @@
const colWidthsMm = computed(() => resolveFreeTableColWidthsMm(props.element));
const rowHeightsMm = computed(() => resolveFreeTableRowHeightsMm(props.element));
const renderColWidthsMm = computed(() => compensateFreeTableTracksByBorder(colWidthsMm.value, Number(props.element?.w || 0.01), colCount.value, Number(props.element?.borderWidth || 1)));
const renderRowHeightsMm = computed(() => compensateFreeTableTracksByBorder(rowHeightsMm.value, Number(props.element?.h || 0.01), rowCount.value, Number(props.element?.borderWidth || 1)));
const colGripPositionsPct = computed(() => {
/**
* 在显示层把“边框占用预算”从轨道尺寸里扣掉,避免 row/col 总和 + 网格线宽度 > 元素外框,
* 导致新增行/列在底部(或右侧)被裁切。
*/
function compensateFreeTableTracksByBorder(tracks: number[], totalMm: number, count: number, borderWidthPx: number): number[] {
const n = Math.max(1, Number(count || tracks.length || 1));
const safeTracks = (tracks || []).slice(0, n).map((v) => Math.max(0.01, Number(v) || 0.01));
while (safeTracks.length < n) safeTracks.push(0.01);
const total = Math.max(0.01, Number(totalMm) || 0.01);
const bwMm = Math.max(0, Number(borderWidthPx) || 1) / PX_PER_MM;
// n 条轨道对应 n+1 条网格线(含外框上下/左右)
const lineBudget = (n + 1) * bwMm;
const targetInner = Math.max(0.01, total - lineBudget);
const sourceInner = safeTracks.reduce((s, v) => s + v, 0);
const scale = sourceInner > 0 ? targetInner / sourceInner : 1;
const out = safeTracks.map((v) => Math.max(0.01, Math.round(v * scale * 1000) / 1000));
// 尾项兜底补差,保证总和严格贴合 targetInner
const sum = out.reduce((s, v) => s + v, 0);
out[out.length - 1] = Math.max(0.01, Math.round((out[out.length - 1] + (targetInner - sum)) * 1000) / 1000);
return out;
}
const modelColGripPositionsPct = computed(() => {
const w = Math.max(0.01, Number(props.element?.w) || 0.01);
let x = 0;
const arr = colWidthsMm.value;
@@ -170,7 +202,7 @@
return out;
});
const rowGripPositionsPct = computed(() => {
const modelRowGripPositionsPct = computed(() => {
const h = Math.max(0.01, Number(props.element?.h) || 0.01);
let y = 0;
const arr = rowHeightsMm.value;
@@ -182,6 +214,62 @@
return out;
});
const domColGripPositionsPct = ref<number[] | null>(null);
const domRowGripPositionsPct = ref<number[] | null>(null);
/**
* 基于真实 DOM 渲染结果计算行/列分隔线位置,彻底消除“理论 mm 百分比”与浏览器
* 表格布局border-collapse、像素取整、缩放之间的偏差。
*/
function syncTrackGripPositionsFromDom() {
const surface = surfaceRef.value;
const table = surface?.querySelector?.('table') as HTMLTableElement | null;
if (!surface || !table) return;
const sRect = surface.getBoundingClientRect();
if (sRect.width <= 0 || sRect.height <= 0) return;
// 列边界:优先读取 colgroup 的真实宽度(支持 table-layout:fixed
const colEls = Array.from(table.querySelectorAll('colgroup col')) as HTMLTableColElement[];
if (colEls.length > 1) {
let x = 0;
const out: number[] = [];
for (let i = 0; i < colEls.length - 1; i += 1) {
x += colEls[i].getBoundingClientRect().width;
out.push((x / sRect.width) * 100);
}
domColGripPositionsPct.value = out;
} else {
domColGripPositionsPct.value = null;
}
// 行边界:读取每个 tr 的真实渲染高度
const rows = Array.from(table.querySelectorAll('tbody > tr')) as HTMLTableRowElement[];
if (rows.length > 1) {
let y = 0;
const out: number[] = [];
for (let i = 0; i < rows.length - 1; i += 1) {
y += rows[i].getBoundingClientRect().height;
out.push((y / sRect.height) * 100);
}
domRowGripPositionsPct.value = out;
} else {
domRowGripPositionsPct.value = null;
}
}
const colGripPositionsPct = computed(() => {
const dom = domColGripPositionsPct.value;
if (dom && dom.length === Math.max(0, colCount.value - 1)) return dom;
return modelColGripPositionsPct.value;
});
const rowGripPositionsPct = computed(() => {
const dom = domRowGripPositionsPct.value;
if (dom && dom.length === Math.max(0, rowCount.value - 1)) return dom;
return modelRowGripPositionsPct.value;
});
const anchorsNormalized = computed(() =>
normalizeFreeTableAnchors(rowCount.value, colCount.value, props.element?.cells || []),
);
@@ -350,16 +438,33 @@
rowCount.value,
colCount.value,
);
// 行高较小时(如 4~5mm固定大内边距会把行撑爆导致下方行被裁切
// 这里按当前(含合并)单元格高度动态给一个较小 padding保证能展示全部行线。
const spanHmm = renderRowHeightsMm.value
.slice(cell.row, Math.min(rowCount.value, cell.row + rs))
.reduce((sum, v) => sum + (Number(v) || 0), 0);
const spanHpx = Math.max(8, spanHmm * PX_PER_MM);
const vPadPx = Math.max(1, Math.min(4, Math.round(spanHpx * 0.08)));
const hPadPx = Math.max(2, Math.min(6, Math.round(vPadPx * 1.5)));
const baseFontSize = Math.max(1, Number(cell?.fontSize || 12));
// 高密行场景(例如 26mm 内 9~10 行)优先保证行线完整展示:
// 按可用高度自动收缩画布字体,最小降到 1px确保“行越多字体越小直到全部行可见”。
const innerHpx = Math.max(1, spanHpx - vPadPx * 2);
const fitFontSize = Math.max(1, Math.min(baseFontSize, Math.floor(innerHpx * 0.82)));
const nowrap = (cell as any).autoWrap === false;
return {
boxSizing: 'border-box',
minHeight: '0',
textAlign: cell?.align || 'left',
verticalAlign: cell?.verticalAlign || 'middle',
fontSize: `${Number(cell?.fontSize || 12)}px`,
fontSize: `${fitFontSize}px`,
color: cell?.color || '#111111',
backgroundColor: cell?.backgroundColor || '#ffffff',
padding: `${vPadPx}px ${hPadPx}px`,
whiteSpace: nowrap ? 'nowrap' : 'pre-wrap',
wordBreak: nowrap ? 'normal' : 'break-all',
lineHeight: nowrap ? `${Math.max(1, innerHpx)}px` : '1.15',
overflow: 'hidden',
borderTop: sides.top ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.top)} ${bc}` : 'none',
borderRight: sides.right ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.right)} ${bc}` : 'none',
borderBottom: sides.bottom ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.bottom)} ${bc}` : 'none',
@@ -416,6 +521,8 @@
const next = redistributeColEdge(base, edge, deltaMm, totalW);
if (next) {
emit('update-tracks', { colWidths: next });
// 拖拽中实时重算,保证蓝线始终贴着真实线
nextTick(syncTrackGripPositionsFromDom);
}
};
const onUp = (ev: PointerEvent) => {
@@ -453,6 +560,8 @@
const next = redistributeRowEdge(base, edge, deltaMm, totalH);
if (next) {
emit('update-tracks', { rowHeights: next });
// 拖拽中实时重算,保证蓝线始终贴着真实线
nextTick(syncTrackGripPositionsFromDom);
}
};
const onUp = (ev: PointerEvent) => {
@@ -493,10 +602,31 @@
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
onMounted(() => {
nextTick(syncTrackGripPositionsFromDom);
});
watch(
() => [
rowCount.value,
colCount.value,
colWidthsMm.value.join(','),
rowHeightsMm.value.join(','),
Number(props.element?.borderWidth || 1),
Number(props.element?.w || 0),
Number(props.element?.h || 0),
Number(props.scale || 1),
],
() => {
nextTick(syncTrackGripPositionsFromDom);
},
);
</script>
<style scoped lang="less">
.free-table-element {
--ft-edit-line-w: 1px;
position: relative;
display: block;
width: 100%;
@@ -571,25 +701,45 @@
.track-grip--col {
top: 0;
bottom: 0;
width: 10px;
margin-left: -5px;
width: var(--ft-edit-line-w);
cursor: col-resize;
background: rgba(22, 119, 255, 0.14);
border-radius: 2px;
background: rgba(22, 119, 255, 0.95);
border-radius: 0;
}
.track-grip--row {
left: 0;
right: 0;
height: 10px;
margin-top: -5px;
height: var(--ft-edit-line-w);
cursor: row-resize;
background: rgba(22, 119, 255, 0.14);
border-radius: 2px;
background: rgba(22, 119, 255, 0.95);
border-radius: 0;
}
/* 线保持细,拖拽热区单独加大,不影响视觉粗细 */
.track-grip--col::before,
.track-grip--row::before {
content: '';
position: absolute;
pointer-events: auto;
}
.track-grip--col::before {
top: 0;
bottom: 0;
left: -4px;
right: -4px;
}
.track-grip--row::before {
left: 0;
right: 0;
top: -4px;
bottom: -4px;
}
.track-grip:hover {
background: rgba(22, 119, 255, 0.28);
background: #1677ff;
}
.free-table-surface table {
@@ -610,8 +760,8 @@
position: relative;
box-sizing: border-box;
min-width: 20px;
min-height: 20px;
padding: 10px 4px 4px;
min-height: 0;
padding: 1px 2px;
white-space: pre-wrap;
word-break: break-all;
user-select: none;
@@ -626,6 +776,7 @@
.cell-body {
display: block;
overflow: hidden;
}
.table-media-free {
@@ -641,13 +792,13 @@
display: flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 12px;
padding: 0 4px;
border-radius: 3px;
min-width: 18px;
height: 10px;
padding: 0 3px;
border-radius: 2px;
background: #1677ff;
color: #fff;
font-size: 10px;
font-size: 9px;
line-height: 1;
opacity: 0;
pointer-events: none;
@@ -673,7 +824,7 @@
}
.free-table-cell.is-selected {
box-shadow: inset 0 0 0 2px #1677ff;
box-shadow: inset 0 0 0 var(--ft-edit-line-w) #1677ff;
z-index: 1;
}
</style>

View File

@@ -36,20 +36,32 @@
return props.element.text || '';
});
const styleObject = computed(() => ({
fontSize: `${props.element.style?.fontSize || 12}px`,
fontWeight: String(props.element.style?.fontWeight || 400),
color: props.element.style?.color || '#111',
textAlign: props.element.style?.textAlign || 'left',
lineHeight: String(props.element.style?.lineHeight || 1.4),
width: '100%',
height: '100%',
whiteSpace: 'pre-wrap',
overflow: 'hidden',
backgroundColor: props.element.style?.backgroundColor || 'transparent',
display: (props.element as any)?.visible === false ? 'none' : 'block',
borderTop: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
borderBottom: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
background: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? 'rgba(22,119,255,0.06)' : props.element.style?.backgroundColor || 'transparent',
}));
const styleObject = computed(() => {
const isBand = props.element.type === 'reportHeader' || props.element.type === 'reportFooter';
const bw = Math.max(0, Number(props.element.style?.borderWidth || 0));
const bc = props.element.style?.borderColor || '#222';
const normalBorderTop = bw > 0 && props.element.style?.hideBorderTop !== true ? `${bw}px solid ${bc}` : 'none';
const normalBorderRight = bw > 0 && props.element.style?.hideBorderRight !== true ? `${bw}px solid ${bc}` : 'none';
const normalBorderBottom = bw > 0 && props.element.style?.hideBorderBottom !== true ? `${bw}px solid ${bc}` : 'none';
const normalBorderLeft = bw > 0 && props.element.style?.hideBorderLeft !== true ? `${bw}px solid ${bc}` : 'none';
return {
fontSize: `${props.element.style?.fontSize || 12}px`,
fontWeight: String(props.element.style?.fontWeight || 400),
color: props.element.style?.color || '#111',
textAlign: props.element.style?.textAlign || 'left',
lineHeight: String(props.element.style?.lineHeight || 1.4),
width: '100%',
height: '100%',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap',
overflow: 'hidden',
display: (props.element as any)?.visible === false ? 'none' : 'block',
// 报表头/尾保留原蓝色虚线辅助;标题/副标题/正文走用户配置四边边框
borderTop: isBand ? '1px dashed rgba(22,119,255,0.5)' : normalBorderTop,
borderRight: isBand ? 'none' : normalBorderRight,
borderBottom: isBand ? '1px dashed rgba(22,119,255,0.5)' : normalBorderBottom,
borderLeft: isBand ? 'none' : normalBorderLeft,
background: isBand ? 'rgba(22,119,255,0.06)' : props.element.style?.backgroundColor || 'transparent',
};
});
</script>

View File

@@ -0,0 +1,146 @@
/**
* 共享的 1D 条码渲染工具:用 jsbarcode 生成 SVG 字符串/元素。
*
* 关键设计:
* - 保比例:返回的 SVG 设置 preserveAspectRatio="xMidYMid meet"CSS 100% 拉伸时
* 会保持原始条宽/条高比,避免被容器横向拉长后变形。
* - 容器铺满:内部 viewBox 由 jsbarcode 按 width/height 参数自动写入,外层只需
* width:100%;height:100% 即可让 SVG 在容器内最大化保比例显示。
* - 兼容设计器画布与打印路径printRenderer.ts两种使用场景画布走 renderInto
* 打印走 buildString 拼到 HTML 字符串中。
*/
export interface NativeBarcodeOptions {
/** Code128 / EAN13 / EAN8 / UPC / CODE39 等,默认 CODE128 */
format?: string;
/** 是否在底部显示文本,默认 true */
displayValue?: boolean;
/** 单根细线宽度(像素),默认 2 */
lineWidth?: number;
/** 条码主体高度(像素,不含文字),默认 60 */
barHeight?: number;
/** 底部文本字号(像素),默认 14 */
fontSize?: number;
/** 上下左右安静区(像素),默认 0 */
margin?: number;
/** 前景色(条),默认黑 */
lineColor?: string;
/** 背景色,默认白 */
background?: string;
/**
* 条码下文字对齐center / left / right 直接交由 jsbarcode 原生 textAlign
* justify两端对齐在 jsbarcode center 输出基础上后处理,给 <text> 加 textLength
* + lengthAdjust=spacing由浏览器拉伸字符间距让文字横向铺满条码宽度。
*/
textAlign?: 'left' | 'center' | 'right' | 'justify';
}
const SVG_NS = 'http://www.w3.org/2000/svg';
function resolveOptions(value: string, options: NativeBarcodeOptions | undefined) {
const text = String(value ?? '').trim() || '0000000000';
const rawAlign = (options?.textAlign || 'center').toLowerCase();
const isJustify = rawAlign === 'justify';
// jsbarcode 不直接支持 justify先按 center 出 SVG再后处理拉伸字符间距
const jsAlign = (['left', 'center', 'right'] as const).includes(rawAlign as any) ? (rawAlign as 'left' | 'center' | 'right') : 'center';
return {
text,
format: options?.format || 'CODE128',
displayValue: options?.displayValue !== false,
width: Math.max(0.5, Number(options?.lineWidth ?? 2)),
height: Math.max(10, Number(options?.barHeight ?? 60)),
fontSize: Math.max(8, Number(options?.fontSize ?? 14)),
margin: Math.max(0, Number(options?.margin ?? 0)),
lineColor: options?.lineColor || '#000000',
background: options?.background || '#ffffff',
textAlign: jsAlign,
justifyText: isJustify,
};
}
/**
* 把条码渲染到一个已存在的 SVG 元素中(用于 Vue 组件内的 svg ref
* 渲染完成后会清除 svg 自身的 width/height 属性,并设置 preserveAspectRatio
* 以便外层 CSS 100% 高宽时仍保持原比例。
*/
export async function renderNativeBarcodeIntoSvg(
svg: SVGSVGElement,
value: string,
options?: NativeBarcodeOptions,
): Promise<boolean> {
const module: any = await import('jsbarcode');
const JsBarcode = module.default || module;
const opts = resolveOptions(value, options);
try {
JsBarcode(svg, opts.text, {
format: opts.format,
displayValue: opts.displayValue,
margin: opts.margin,
width: opts.width,
height: opts.height,
fontSize: opts.fontSize,
lineColor: opts.lineColor,
background: opts.background,
textAlign: opts.textAlign,
});
// 关键修复jsbarcode 默认只设 width/height 属性,没有 viewBox。我们若直接移除
// width/heightSVG 会失去几何坐标系preserveAspectRatio 不生效——
// 在设计画布(外层 flex+overflow:hidden里浏览器会按 user units 裁切显示成"满铺"
// 在打印输出 div 里却按缩水的 user units 居中,造成两边视觉差异。
// 修复:用 jsbarcode 设置的 width/height 派生 viewBox再交给 CSS 控制最终尺寸,
// preserveAspectRatio="xMidYMid meet" 才能保比例统一缩放。
const rawWidth = parseFloat(svg.getAttribute('width') || '0');
const rawHeight = parseFloat(svg.getAttribute('height') || '0');
if (rawWidth > 0 && rawHeight > 0 && !svg.getAttribute('viewBox')) {
svg.setAttribute('viewBox', `0 0 ${rawWidth} ${rawHeight}`);
}
svg.removeAttribute('width');
svg.removeAttribute('height');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// 两端对齐:找到底部文字 <text>,让浏览器把字符间距自动拉伸到条码宽度
if (opts.displayValue && opts.justifyText) {
applyJustifyTextAlign(svg);
}
return true;
} catch {
return false;
}
}
/**
* 把 jsbarcode 输出的底部文字(<text>)改成两端对齐:保留原 y/字号,重置 x=0、
* text-anchor=start并加 textLength=条码宽 + lengthAdjust=spacing
* 浏览器会自动拉伸字符间距让文字横向铺满条码宽度。
*/
function applyJustifyTextAlign(svg: SVGSVGElement) {
const texts = svg.querySelectorAll('text');
if (!texts.length) return;
// jsbarcode 输出的 SVG 第一个有 viewBox/width取其宽度作为目标 textLength
const viewBox = (svg.getAttribute('viewBox') || '').split(/\s+/).map((n) => Number(n) || 0);
const targetWidth = viewBox.length === 4 && viewBox[2] > 0 ? viewBox[2] : Number(svg.getAttribute('width')) || 0;
if (!targetWidth) return;
texts.forEach((t) => {
t.setAttribute('x', '0');
t.setAttribute('text-anchor', 'start');
t.setAttribute('textLength', String(targetWidth));
t.setAttribute('lengthAdjust', 'spacing');
});
}
/**
* 生成可直接拼入 HTML 字符串的 `<svg>...</svg>` 文本(供打印 HTML 渲染使用)。
* 失败时回退到带条码内容的占位 div 字符串,便于使用方区分。
*/
export async function buildNativeBarcodeSvgString(
value: string,
options?: NativeBarcodeOptions,
): Promise<string> {
if (typeof document === 'undefined') {
return '';
}
const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement;
const ok = await renderNativeBarcodeIntoSvg(svg, value, options);
if (!ok) return '';
// 让外层布局可以通过 CSS 控制宽高width/height attribute 已在 renderInto 中清除)
return new XMLSerializer().serializeToString(svg);
}

View File

@@ -7,6 +7,7 @@ import { normalizeFreeTableAnchors } from './freeTableGrid';
import { borderSidesToCssFragment, resolveFreeTableCellBorderSides } from './freeTableBorders';
import { resolveFreeTableCellLineStyleKeys } from './freeTableLineStyles';
import { resolveFreeTableColWidthsMm, resolveFreeTableRowHeightsMm } from './freeTableTracks';
import { buildNativeBarcodeSvgString } from './barcodeRenderer';
function resolveBoundValue(element: NativeElement, data: Record<string, any>) {
const bindField = (element as any).bindField;
@@ -48,10 +49,10 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
const colCount = Math.max(1, Number((element as any)?.colCount || 1));
const wMm = Math.max(0.01, Number((element as any)?.w) || 0.01);
const hMm = Math.max(0.01, Number((element as any)?.h) || 0.01);
const colWidthsMm = resolveFreeTableColWidthsMm(element as any);
const rowHeightsMm = resolveFreeTableRowHeightsMm(element as any);
const borderColor = String((element as any)?.borderColor || '#d9d9d9');
const borderWidth = Math.max(1, Number((element as any)?.borderWidth || 1));
const colWidthsMm = compensateFreeTableTracksByBorder(resolveFreeTableColWidthsMm(element as any), wMm, colCount, borderWidth);
const rowHeightsMm = compensateFreeTableTracksByBorder(resolveFreeTableRowHeightsMm(element as any), hMm, rowCount, borderWidth);
const colgroup = `<colgroup>${colWidthsMm.map((cw) => `<col style="width:${cw}mm;box-sizing:border-box" />`).join('')}</colgroup>`;
const anchors = normalizeFreeTableAnchors(rowCount, colCount, (element as any)?.cells || []);
const body = (
@@ -79,12 +80,21 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
const bodyInnerHtml = await resolvePrintCellInnerHtml(contentType, innerArg, cell as any);
const align = String((cell as any)?.align || 'left');
const verticalAlign = String((cell as any)?.verticalAlign || 'middle');
const fontSize = Math.max(8, Number((cell as any)?.fontSize || element.style?.fontSize || 12));
const baseFontSize = Math.max(1, Number((cell as any)?.fontSize || element.style?.fontSize || 12));
const color = String((cell as any)?.color || '#111111');
const backgroundColor = String((cell as any)?.backgroundColor || '#ffffff');
const rowspanAttr = rs > 1 ? ` rowspan="${rs}"` : '';
const colspanAttr = cs > 1 ? ` colspan="${cs}"` : '';
const spanW = colWidthsMm.slice(cell.col, cell.col + cs).reduce((a, b) => a + b, 0);
const spanH = rowHeightsMm.slice(cell.row, cell.row + rs).reduce((a, b) => a + b, 0);
// 行高很小4~5mm时固定 2mm padding 会把行撑爆,导致后续行被容器裁切。
// 这里改为按单元格高度自适应,保证高密度标签场景仍能完整显示所有行线。
const vPadMm = Math.max(0.15, Math.min(0.8, spanH * 0.08));
const hPadMm = Math.max(0.3, Math.min(1.2, vPadMm * 1.6));
// 随行密度自动收缩字体:优先保证全部行可见(与画布规则保持一致)
const innerHmm = Math.max(0.1, spanH - vPadMm * 2);
const innerHpx = innerHmm * (96 / 25.4);
const fitFontSize = Math.max(1, Math.min(baseFontSize, Math.floor(innerHpx * 0.82)));
const colWidthStyle = `width:${spanW}mm;`;
const sides = resolveFreeTableCellBorderSides(element, anchors, cell, cell.row, cell.col, rs, cs, rowCount, colCount);
const lineKeys = resolveFreeTableCellLineStyleKeys(element, cell.row, cell.col, rs, cs, rowCount, colCount);
@@ -93,8 +103,8 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
const ws = nowrap ? 'nowrap' : 'normal';
const wb = nowrap ? 'normal' : 'break-all';
const ow = nowrap ? 'normal' : 'anywhere';
return `<td${rowspanAttr}${colspanAttr} style="box-sizing:border-box;${borderCss}${colWidthStyle}padding:2mm;text-align:${align};vertical-align:${verticalAlign};font-size:${fontSize}px;color:${color};background:${backgroundColor};white-space:${ws};word-break:${wb};overflow-wrap:${ow};line-height:${
nowrap ? `${rh}mm` : '1.3'
return `<td${rowspanAttr}${colspanAttr} style="box-sizing:border-box;${borderCss}${colWidthStyle}padding:${vPadMm.toFixed(3)}mm ${hPadMm.toFixed(3)}mm;text-align:${align};vertical-align:${verticalAlign};font-size:${fitFontSize}px;color:${color};background:${backgroundColor};white-space:${ws};word-break:${wb};overflow-wrap:${ow};overflow:hidden;line-height:${
nowrap ? `${innerHmm.toFixed(3)}mm` : '1.15'
};">${bodyInnerHtml}</td>`;
}),
)
@@ -107,6 +117,28 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
return `<table style="width:${wMm}mm;border-collapse:collapse;border-spacing:0;table-layout:fixed;box-sizing:border-box;">${colgroup}<tbody>${body}</tbody></table>`;
}
/**
* 在打印渲染层按边框宽度扣减轨道预算,避免“轨道总高 + 网格线宽度”超过元素高度,
* 从而导致底部行被裁切(特别是高行数小高度,如 9 行 / 26mm
*/
function compensateFreeTableTracksByBorder(tracks: number[], totalMm: number, count: number, borderWidthPx: number): number[] {
const n = Math.max(1, Number(count || tracks.length || 1));
const safeTracks = (tracks || []).slice(0, n).map((v) => Math.max(0.01, Number(v) || 0.01));
while (safeTracks.length < n) safeTracks.push(0.01);
const total = Math.max(0.01, Number(totalMm) || 0.01);
const mmPerPx = 25.4 / 96;
const bwMm = Math.max(0, Number(borderWidthPx) || 1) * mmPerPx;
const lineBudget = (n + 1) * bwMm;
const targetInner = Math.max(0.01, total - lineBudget);
const sourceInner = safeTracks.reduce((s, v) => s + v, 0);
const scale = sourceInner > 0 ? targetInner / sourceInner : 1;
const out = safeTracks.map((v) => Math.max(0.01, Math.round(v * scale * 1000) / 1000));
const sum = out.reduce((s, v) => s + v, 0);
out[out.length - 1] = Math.max(0.01, Math.round((out[out.length - 1] + (targetInner - sum)) * 1000) / 1000);
return out;
}
async function renderFixedRowsTablePages(element: NativeTableElement, data: Record<string, any>) {
const sourceRows = resolveTableRows(element, data);
const columns = normalizeTableWidths(element);
@@ -302,9 +334,20 @@ async function resolvePrintCellInnerHtml(contentType: string, value: string, col
}
}
if (contentType === 'barcode') {
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${fillCell ? '100%' : `${scale}%`};height:${
fillCell ? '100%' : `${Math.max(20, scale * 0.6)}%`
};margin:0 auto;">BAR:${safeValue}</div>`;
const svgStr = await buildNativeBarcodeSvgString(safeValue, {
format: column?.barcodeFormat,
displayValue: column?.displayValue !== false,
fontSize: column?.barcodeFontSize,
});
const w = fillCell ? '100%' : `${scale}%`;
const h = fillCell ? '100%' : `${Math.max(20, scale * 0.6)}%`;
if (svgStr) {
// SVG 已设 preserveAspectRatio="xMidYMid meet",下面套层 div 限定宽高、居中显示,
// 浏览器会保持原始条宽比例自动缩放,不再被单元格拉宽变形。
const sized = svgStr.replace(/<svg /i, '<svg width="100%" height="100%" ');
return `<div style="display:flex;align-items:center;justify-content:center;width:${w};height:${h};margin:0 auto;overflow:hidden;">${sized}</div>`;
}
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${w};height:${h};margin:0 auto;">${safeValue}</div>`;
}
return safeValue;
}
@@ -397,6 +440,12 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
const renderX = isReportHeader || isReportFooter ? 0 : item.x;
const renderY = isReportHeader ? 0 : isReportFooter && (item as any).printAtPageBottom === true ? Math.max(0, schema.page.height - item.h) : item.y;
const renderW = isReportHeader || isReportFooter ? schema.page.width : item.w;
const borderWidth = Math.max(0, Number(item.style?.borderWidth || 0));
const borderColor = item.style?.borderColor || '#222';
const borderTopCss = borderWidth > 0 && item.style?.hideBorderTop !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
const borderRightCss = borderWidth > 0 && item.style?.hideBorderRight !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
const borderBottomCss = borderWidth > 0 && item.style?.hideBorderBottom !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
const borderLeftCss = borderWidth > 0 && item.style?.hideBorderLeft !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
const styleParts = [
`position:absolute`,
`width:${renderW}mm`,
@@ -407,7 +456,10 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
`line-height:${item.style?.lineHeight || 1.4}`,
`text-align:${item.style?.textAlign || 'left'}`,
`background:${item.style?.backgroundColor || 'transparent'}`,
item.style?.borderWidth ? `border:${item.style.borderWidth}px solid ${item.style.borderColor || '#222'}` : '',
`border-top:${borderTopCss}`,
`border-right:${borderRightCss}`,
`border-bottom:${borderBottomCss}`,
`border-left:${borderLeftCss}`,
'overflow:hidden',
]
.filter(Boolean)
@@ -481,7 +533,9 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
return ftParts.join('');
}
const shouldRepeat = (repeatReportHeader || repeatHeaderElement) && pageCount > 1;
// pageNo 元素默认按页重复:与桌面端 NativePrintRenderService 行为对齐,
// 用户放置页码就一定每页都画一次,无需再单独勾选 printRepeated。
const shouldRepeat = (repeatReportHeader || repeatHeaderElement || item.type === 'pageNo') && pageCount > 1;
const pages = shouldRepeat ? Array.from({ length: pageCount }, (_v, i) => i + 1) : [1];
const htmlByPage = await Promise.all(
pages.map(async (pageNo) => {
@@ -497,6 +551,20 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
}
if (item.type === 'barcode') {
const value = resolveBoundValue(item, data) ?? (item as any).value;
const svgStr = await buildNativeBarcodeSvgString(String(value ?? ''), {
format: (item as any).format,
displayValue: (item as any).displayValue !== false,
fontSize: (item as any).fontSize,
lineWidth: (item as any).lineWidth,
barHeight: (item as any).barHeight,
textAlign: (item as any).textAlign,
});
if (svgStr) {
// 让 SVG 100% 充满定位 divpreserveAspectRatio 在 SVG 内部已设为 xMidYMid meet
// 因此条码在元素框内保持原比例居中,不会被拉宽/压扁。
const sized = svgStr.replace(/<svg /i, '<svg width="100%" height="100%" ');
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;overflow:hidden;">${sized}</div>`;
}
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;border:1px dashed #999;">条形码:${value ?? ''}</div>`;
}
if (item.type === 'image') {

View File

@@ -35,6 +35,10 @@ export interface NativeElementBase {
lineHeight?: number;
borderWidth?: number;
borderColor?: string;
hideBorderTop?: boolean;
hideBorderRight?: boolean;
hideBorderBottom?: boolean;
hideBorderLeft?: boolean;
backgroundColor?: string;
};
}