增强条码元素和自由表格元素的渲染逻辑,支持更多条码格式和文本边框样式。新增条码渲染工具,优化打印预览窗口的打印机选择功能,提升用户体验和打印模板的灵活性。
This commit is contained in:
@@ -1059,6 +1059,13 @@
|
|||||||
}
|
}
|
||||||
if (type === 'qrcode' || type === 'barcode') {
|
if (type === 'qrcode' || type === 'barcode') {
|
||||||
base.value = payload.value || '';
|
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;
|
return base as NativeElement;
|
||||||
}
|
}
|
||||||
if (type === 'reportHeader' || type === 'reportFooter') {
|
if (type === 'reportHeader' || type === 'reportFooter') {
|
||||||
|
|||||||
@@ -86,6 +86,47 @@
|
|||||||
<template v-if="isText(selectedElement.type)">
|
<template v-if="isText(selectedElement.type)">
|
||||||
<a-divider />
|
<a-divider />
|
||||||
<a-input :value="(selectedElement as any).text" addon-before="内容" @update:value="updateField('text', $event)" />
|
<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>
|
||||||
<template v-if="isReportBand(selectedElement.type)">
|
<template v-if="isReportBand(selectedElement.type)">
|
||||||
<a-divider />
|
<a-divider />
|
||||||
@@ -165,6 +206,39 @@
|
|||||||
<template v-if="selectedElement.type === 'qrcode' || selectedElement.type === 'barcode'">
|
<template v-if="selectedElement.type === 'qrcode' || selectedElement.type === 'barcode'">
|
||||||
<a-divider />
|
<a-divider />
|
||||||
<a-input :value="(selectedElement as any).value" addon-before="编码值" @update:value="updateField('value', $event)" />
|
<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>
|
||||||
<template v-if="selectedElement.type === 'table' || selectedElement.type === 'detailTable'">
|
<template v-if="selectedElement.type === 'table' || selectedElement.type === 'detailTable'">
|
||||||
<a-divider />
|
<a-divider />
|
||||||
@@ -586,7 +660,7 @@
|
|||||||
:min="1"
|
:min="1"
|
||||||
:max="100"
|
:max="100"
|
||||||
size="small"
|
size="small"
|
||||||
disabled
|
@update:value="setFreeTableRowCount(Number($event || 1))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="free-table-dim-item">
|
<div class="free-table-dim-item">
|
||||||
@@ -596,16 +670,23 @@
|
|||||||
:min="1"
|
:min="1"
|
||||||
:max="50"
|
:max="50"
|
||||||
size="small"
|
size="small"
|
||||||
disabled
|
@update:value="setFreeTableColCount(Number($event || 1))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-space>
|
<a-space wrap>
|
||||||
<a-button size="small" @click="addFreeTableRow">新增行</a-button>
|
<a-button size="small" @click="addFreeTableRow">新增行</a-button>
|
||||||
<a-button size="small" @click="removeFreeTableRow">删除行</a-button>
|
<a-button size="small" @click="removeFreeTableRow">删除行</a-button>
|
||||||
<a-button size="small" @click="addFreeTableCol">新增列</a-button>
|
<a-button size="small" @click="addFreeTableCol">新增列</a-button>
|
||||||
<a-button size="small" @click="removeFreeTableCol">删除列</a-button>
|
<a-button size="small" @click="removeFreeTableCol">删除列</a-button>
|
||||||
</a-space>
|
</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>
|
<div class="free-table-track-head">单元格合并</div>
|
||||||
<a-button type="primary" size="small" block :disabled="!canMergeFreeTableSelection" @click="mergeFreeTableSelection">合并选中区域</a-button>
|
<a-button type="primary" size="small" block :disabled="!canMergeFreeTableSelection" @click="mergeFreeTableSelection">合并选中区域</a-button>
|
||||||
<a-button size="small" block :disabled="!canSplitFreeTableMerged" @click="splitFreeTableMerged">拆分当前合并</a-button>
|
<a-button size="small" block :disabled="!canSplitFreeTableMerged" @click="splitFreeTableMerged">拆分当前合并</a-button>
|
||||||
@@ -1321,14 +1402,67 @@
|
|||||||
{ label: 'JPEG', value: 'image/jpeg' },
|
{ label: 'JPEG', value: 'image/jpeg' },
|
||||||
{ label: 'WEBP', value: 'image/webp' },
|
{ label: 'WEBP', value: 'image/webp' },
|
||||||
];
|
];
|
||||||
|
// jsbarcode 全量支持的码制清单(按官方分组)。a-select 通过嵌套 options 自动渲染为
|
||||||
|
// OptGroup(antdv 4+)。桌面端 ZXing.Net 已对所有这些 value 做了等价/最接近的映射,
|
||||||
|
// 未覆盖的(EAN5/EAN2/MSI 变体/pharmacode)会回退到 CODE_128 渲染,避免渲染失败。
|
||||||
const BARCODE_FORMAT_OPTIONS = [
|
const BARCODE_FORMAT_OPTIONS = [
|
||||||
{ label: 'CODE128', value: 'CODE128' },
|
{
|
||||||
{ label: 'CODE39', value: 'CODE39' },
|
label: 'CODE 128 系列',
|
||||||
{ label: 'EAN13', value: 'EAN13' },
|
options: [
|
||||||
{ label: 'EAN8', value: 'EAN8' },
|
{ label: 'CODE128(自动)', value: 'CODE128' },
|
||||||
{ label: 'ITF14', value: 'ITF14' },
|
{ label: 'CODE128 A', value: 'CODE128A' },
|
||||||
{ label: 'MSI', value: 'MSI' },
|
{ label: 'CODE128 B', value: 'CODE128B' },
|
||||||
{ label: 'pharmacode', value: 'pharmacode' },
|
{ 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 = [
|
const REFRESH_PAGE_OPTIONS = [
|
||||||
{ label: '刷新页:不应用', value: 'none' },
|
{ 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) {
|
function updateField(key: string, value: any) {
|
||||||
if (!props.selectedElement) return;
|
if (!props.selectedElement) return;
|
||||||
emit('update-element', { id: props.selectedElement.id, patch: { [key]: value } as any });
|
emit('update-element', { id: props.selectedElement.id, patch: { [key]: value } as any });
|
||||||
@@ -1706,6 +1880,115 @@
|
|||||||
updateFreeTableElement({ rowCount, cells, rowHeights: nextRh });
|
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() {
|
function removeFreeTableRow() {
|
||||||
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
||||||
const el = props.selectedElement as any;
|
const el = props.selectedElement as any;
|
||||||
|
|||||||
@@ -1,50 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="barcode-element">
|
<div class="barcode-element">
|
||||||
<canvas ref="canvasRef"></canvas>
|
<svg ref="svgRef" class="barcode-svg" :aria-label="`barcode: ${displayValue}`"></svg>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import type { NativeCodeElement } from '../../core/types';
|
import type { NativeCodeElement } from '../../core/types';
|
||||||
|
import { renderNativeBarcodeIntoSvg } from '../../core/barcodeRenderer';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
element: NativeCodeElement;
|
element: NativeCodeElement;
|
||||||
previewData?: Record<string, any>;
|
previewData?: Record<string, any>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement>();
|
const svgRef = ref<SVGSVGElement>();
|
||||||
|
|
||||||
function resolveFieldValue(field?: string) {
|
function resolveFieldValue(field?: string) {
|
||||||
if (!field) return undefined;
|
if (!field) return undefined;
|
||||||
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
|
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() {
|
async function renderBarcode() {
|
||||||
if (!canvasRef.value) return;
|
if (!svgRef.value) return;
|
||||||
const module: any = await import('jsbarcode');
|
await renderNativeBarcodeIntoSvg(svgRef.value, displayValue.value, {
|
||||||
const JsBarcode = module.default || module;
|
format: (props.element as any).format,
|
||||||
const bindValue = resolveFieldValue(props.element.bindField);
|
displayValue: (props.element as any).displayValue !== false,
|
||||||
const value = bindValue !== undefined && bindValue !== null ? String(bindValue) : props.element.value || '0000000000';
|
fontSize: (props.element as any).fontSize,
|
||||||
JsBarcode(canvasRef.value, value, {
|
lineWidth: (props.element as any).lineWidth,
|
||||||
format: 'CODE128',
|
barHeight: (props.element as any).barHeight,
|
||||||
displayValue: true,
|
textAlign: (props.element as any).textAlign,
|
||||||
margin: 0,
|
|
||||||
width: 1.5,
|
|
||||||
height: 40,
|
|
||||||
fontSize: 12,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(renderBarcode);
|
||||||
renderBarcode();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.element.value, props.element.bindField, props.previewData],
|
() => [
|
||||||
() => {
|
displayValue.value,
|
||||||
renderBarcode();
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -57,10 +68,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
/* 关键:SVG 自带 preserveAspectRatio="xMidYMid meet",
|
||||||
width: 100%;
|
这里宽高 100% 后浏览器会自动按 viewBox 等比缩放并居中,
|
||||||
height: 100%;
|
不再像之前的 canvas 直接被 CSS 拉伸成扁平条码。 */
|
||||||
}
|
.barcode-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<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 -->
|
<!-- 选中时左上角四向箭头:与画布整体拖动一致,不在此阻止 pointerdown,事件冒泡到 ElementWrapper -->
|
||||||
<button
|
<button
|
||||||
v-if="isElementSelected"
|
v-if="isElementSelected"
|
||||||
@@ -30,13 +36,13 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="free-table-surface" @pointerdown.stop>
|
<div ref="surfaceRef" class="free-table-surface" @pointerdown.stop>
|
||||||
<table>
|
<table>
|
||||||
<colgroup>
|
<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>
|
</colgroup>
|
||||||
<tbody>
|
<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
|
<td
|
||||||
v-for="cell in anchorsForRow(r - 1)"
|
v-for="cell in anchorsForRow(r - 1)"
|
||||||
:key="`td_${cell.row}_${cell.col}`"
|
:key="`td_${cell.row}_${cell.col}`"
|
||||||
@@ -115,7 +121,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { getValueByPath } from '../../core/tableBuilder';
|
import { getValueByPath } from '../../core/tableBuilder';
|
||||||
import { normalizeFreeTableAnchors } from '../../core/freeTableGrid';
|
import { normalizeFreeTableAnchors } from '../../core/freeTableGrid';
|
||||||
@@ -128,6 +134,7 @@
|
|||||||
|
|
||||||
const qrCodeCache = ref<Record<string, string>>({});
|
const qrCodeCache = ref<Record<string, string>>({});
|
||||||
const barcodeCache = ref<Record<string, string>>({});
|
const barcodeCache = ref<Record<string, string>>({});
|
||||||
|
const surfaceRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -157,8 +164,33 @@
|
|||||||
|
|
||||||
const colWidthsMm = computed(() => resolveFreeTableColWidthsMm(props.element));
|
const colWidthsMm = computed(() => resolveFreeTableColWidthsMm(props.element));
|
||||||
const rowHeightsMm = computed(() => resolveFreeTableRowHeightsMm(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);
|
const w = Math.max(0.01, Number(props.element?.w) || 0.01);
|
||||||
let x = 0;
|
let x = 0;
|
||||||
const arr = colWidthsMm.value;
|
const arr = colWidthsMm.value;
|
||||||
@@ -170,7 +202,7 @@
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowGripPositionsPct = computed(() => {
|
const modelRowGripPositionsPct = computed(() => {
|
||||||
const h = Math.max(0.01, Number(props.element?.h) || 0.01);
|
const h = Math.max(0.01, Number(props.element?.h) || 0.01);
|
||||||
let y = 0;
|
let y = 0;
|
||||||
const arr = rowHeightsMm.value;
|
const arr = rowHeightsMm.value;
|
||||||
@@ -182,6 +214,62 @@
|
|||||||
return out;
|
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(() =>
|
const anchorsNormalized = computed(() =>
|
||||||
normalizeFreeTableAnchors(rowCount.value, colCount.value, props.element?.cells || []),
|
normalizeFreeTableAnchors(rowCount.value, colCount.value, props.element?.cells || []),
|
||||||
);
|
);
|
||||||
@@ -350,16 +438,33 @@
|
|||||||
rowCount.value,
|
rowCount.value,
|
||||||
colCount.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;
|
const nowrap = (cell as any).autoWrap === false;
|
||||||
return {
|
return {
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
minHeight: '0',
|
||||||
textAlign: cell?.align || 'left',
|
textAlign: cell?.align || 'left',
|
||||||
verticalAlign: cell?.verticalAlign || 'middle',
|
verticalAlign: cell?.verticalAlign || 'middle',
|
||||||
fontSize: `${Number(cell?.fontSize || 12)}px`,
|
fontSize: `${fitFontSize}px`,
|
||||||
color: cell?.color || '#111111',
|
color: cell?.color || '#111111',
|
||||||
backgroundColor: cell?.backgroundColor || '#ffffff',
|
backgroundColor: cell?.backgroundColor || '#ffffff',
|
||||||
|
padding: `${vPadPx}px ${hPadPx}px`,
|
||||||
whiteSpace: nowrap ? 'nowrap' : 'pre-wrap',
|
whiteSpace: nowrap ? 'nowrap' : 'pre-wrap',
|
||||||
wordBreak: nowrap ? 'normal' : 'break-all',
|
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',
|
borderTop: sides.top ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.top)} ${bc}` : 'none',
|
||||||
borderRight: sides.right ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.right)} ${bc}` : 'none',
|
borderRight: sides.right ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.right)} ${bc}` : 'none',
|
||||||
borderBottom: sides.bottom ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.bottom)} ${bc}` : 'none',
|
borderBottom: sides.bottom ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.bottom)} ${bc}` : 'none',
|
||||||
@@ -416,6 +521,8 @@
|
|||||||
const next = redistributeColEdge(base, edge, deltaMm, totalW);
|
const next = redistributeColEdge(base, edge, deltaMm, totalW);
|
||||||
if (next) {
|
if (next) {
|
||||||
emit('update-tracks', { colWidths: next });
|
emit('update-tracks', { colWidths: next });
|
||||||
|
// 拖拽中实时重算,保证蓝线始终贴着真实线
|
||||||
|
nextTick(syncTrackGripPositionsFromDom);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onUp = (ev: PointerEvent) => {
|
const onUp = (ev: PointerEvent) => {
|
||||||
@@ -453,6 +560,8 @@
|
|||||||
const next = redistributeRowEdge(base, edge, deltaMm, totalH);
|
const next = redistributeRowEdge(base, edge, deltaMm, totalH);
|
||||||
if (next) {
|
if (next) {
|
||||||
emit('update-tracks', { rowHeights: next });
|
emit('update-tracks', { rowHeights: next });
|
||||||
|
// 拖拽中实时重算,保证蓝线始终贴着真实线
|
||||||
|
nextTick(syncTrackGripPositionsFromDom);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onUp = (ev: PointerEvent) => {
|
const onUp = (ev: PointerEvent) => {
|
||||||
@@ -493,10 +602,31 @@
|
|||||||
window.addEventListener('pointermove', onMove);
|
window.addEventListener('pointermove', onMove);
|
||||||
window.addEventListener('pointerup', onUp);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.free-table-element {
|
.free-table-element {
|
||||||
|
--ft-edit-line-w: 1px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -571,25 +701,45 @@
|
|||||||
.track-grip--col {
|
.track-grip--col {
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 10px;
|
width: var(--ft-edit-line-w);
|
||||||
margin-left: -5px;
|
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
background: rgba(22, 119, 255, 0.14);
|
background: rgba(22, 119, 255, 0.95);
|
||||||
border-radius: 2px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-grip--row {
|
.track-grip--row {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 10px;
|
height: var(--ft-edit-line-w);
|
||||||
margin-top: -5px;
|
|
||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
background: rgba(22, 119, 255, 0.14);
|
background: rgba(22, 119, 255, 0.95);
|
||||||
border-radius: 2px;
|
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 {
|
.track-grip:hover {
|
||||||
background: rgba(22, 119, 255, 0.28);
|
background: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.free-table-surface table {
|
.free-table-surface table {
|
||||||
@@ -610,8 +760,8 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
min-height: 20px;
|
min-height: 0;
|
||||||
padding: 10px 4px 4px;
|
padding: 1px 2px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -626,6 +776,7 @@
|
|||||||
|
|
||||||
.cell-body {
|
.cell-body {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-media-free {
|
.table-media-free {
|
||||||
@@ -641,13 +792,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 22px;
|
min-width: 18px;
|
||||||
height: 12px;
|
height: 10px;
|
||||||
padding: 0 4px;
|
padding: 0 3px;
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
background: #1677ff;
|
background: #1677ff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -673,7 +824,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.free-table-cell.is-selected {
|
.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;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,20 +36,32 @@
|
|||||||
return props.element.text || '';
|
return props.element.text || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const styleObject = computed(() => ({
|
const styleObject = computed(() => {
|
||||||
fontSize: `${props.element.style?.fontSize || 12}px`,
|
const isBand = props.element.type === 'reportHeader' || props.element.type === 'reportFooter';
|
||||||
fontWeight: String(props.element.style?.fontWeight || 400),
|
const bw = Math.max(0, Number(props.element.style?.borderWidth || 0));
|
||||||
color: props.element.style?.color || '#111',
|
const bc = props.element.style?.borderColor || '#222';
|
||||||
textAlign: props.element.style?.textAlign || 'left',
|
const normalBorderTop = bw > 0 && props.element.style?.hideBorderTop !== true ? `${bw}px solid ${bc}` : 'none';
|
||||||
lineHeight: String(props.element.style?.lineHeight || 1.4),
|
const normalBorderRight = bw > 0 && props.element.style?.hideBorderRight !== true ? `${bw}px solid ${bc}` : 'none';
|
||||||
width: '100%',
|
const normalBorderBottom = bw > 0 && props.element.style?.hideBorderBottom !== true ? `${bw}px solid ${bc}` : 'none';
|
||||||
height: '100%',
|
const normalBorderLeft = bw > 0 && props.element.style?.hideBorderLeft !== true ? `${bw}px solid ${bc}` : 'none';
|
||||||
whiteSpace: 'pre-wrap',
|
return {
|
||||||
overflow: 'hidden',
|
fontSize: `${props.element.style?.fontSize || 12}px`,
|
||||||
backgroundColor: props.element.style?.backgroundColor || 'transparent',
|
fontWeight: String(props.element.style?.fontWeight || 400),
|
||||||
display: (props.element as any)?.visible === false ? 'none' : 'block',
|
color: props.element.style?.color || '#111',
|
||||||
borderTop: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
|
textAlign: props.element.style?.textAlign || 'left',
|
||||||
borderBottom: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
|
lineHeight: String(props.element.style?.lineHeight || 1.4),
|
||||||
background: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? 'rgba(22,119,255,0.06)' : props.element.style?.backgroundColor || 'transparent',
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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/height,SVG 会失去几何坐标系,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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { normalizeFreeTableAnchors } from './freeTableGrid';
|
|||||||
import { borderSidesToCssFragment, resolveFreeTableCellBorderSides } from './freeTableBorders';
|
import { borderSidesToCssFragment, resolveFreeTableCellBorderSides } from './freeTableBorders';
|
||||||
import { resolveFreeTableCellLineStyleKeys } from './freeTableLineStyles';
|
import { resolveFreeTableCellLineStyleKeys } from './freeTableLineStyles';
|
||||||
import { resolveFreeTableColWidthsMm, resolveFreeTableRowHeightsMm } from './freeTableTracks';
|
import { resolveFreeTableColWidthsMm, resolveFreeTableRowHeightsMm } from './freeTableTracks';
|
||||||
|
import { buildNativeBarcodeSvgString } from './barcodeRenderer';
|
||||||
|
|
||||||
function resolveBoundValue(element: NativeElement, data: Record<string, any>) {
|
function resolveBoundValue(element: NativeElement, data: Record<string, any>) {
|
||||||
const bindField = (element as any).bindField;
|
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 colCount = Math.max(1, Number((element as any)?.colCount || 1));
|
||||||
const wMm = Math.max(0.01, Number((element as any)?.w) || 0.01);
|
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 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 borderColor = String((element as any)?.borderColor || '#d9d9d9');
|
||||||
const borderWidth = Math.max(1, Number((element as any)?.borderWidth || 1));
|
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 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 anchors = normalizeFreeTableAnchors(rowCount, colCount, (element as any)?.cells || []);
|
||||||
const body = (
|
const body = (
|
||||||
@@ -79,12 +80,21 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
|
|||||||
const bodyInnerHtml = await resolvePrintCellInnerHtml(contentType, innerArg, cell as any);
|
const bodyInnerHtml = await resolvePrintCellInnerHtml(contentType, innerArg, cell as any);
|
||||||
const align = String((cell as any)?.align || 'left');
|
const align = String((cell as any)?.align || 'left');
|
||||||
const verticalAlign = String((cell as any)?.verticalAlign || 'middle');
|
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 color = String((cell as any)?.color || '#111111');
|
||||||
const backgroundColor = String((cell as any)?.backgroundColor || '#ffffff');
|
const backgroundColor = String((cell as any)?.backgroundColor || '#ffffff');
|
||||||
const rowspanAttr = rs > 1 ? ` rowspan="${rs}"` : '';
|
const rowspanAttr = rs > 1 ? ` rowspan="${rs}"` : '';
|
||||||
const colspanAttr = cs > 1 ? ` colspan="${cs}"` : '';
|
const colspanAttr = cs > 1 ? ` colspan="${cs}"` : '';
|
||||||
const spanW = colWidthsMm.slice(cell.col, cell.col + cs).reduce((a, b) => a + b, 0);
|
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 colWidthStyle = `width:${spanW}mm;`;
|
||||||
const sides = resolveFreeTableCellBorderSides(element, anchors, cell, cell.row, cell.col, rs, cs, rowCount, colCount);
|
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);
|
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 ws = nowrap ? 'nowrap' : 'normal';
|
||||||
const wb = nowrap ? 'normal' : 'break-all';
|
const wb = nowrap ? 'normal' : 'break-all';
|
||||||
const ow = nowrap ? 'normal' : 'anywhere';
|
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:${
|
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 ? `${rh}mm` : '1.3'
|
nowrap ? `${innerHmm.toFixed(3)}mm` : '1.15'
|
||||||
};">${bodyInnerHtml}</td>`;
|
};">${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>`;
|
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>) {
|
async function renderFixedRowsTablePages(element: NativeTableElement, data: Record<string, any>) {
|
||||||
const sourceRows = resolveTableRows(element, data);
|
const sourceRows = resolveTableRows(element, data);
|
||||||
const columns = normalizeTableWidths(element);
|
const columns = normalizeTableWidths(element);
|
||||||
@@ -302,9 +334,20 @@ async function resolvePrintCellInnerHtml(contentType: string, value: string, col
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contentType === 'barcode') {
|
if (contentType === 'barcode') {
|
||||||
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${fillCell ? '100%' : `${scale}%`};height:${
|
const svgStr = await buildNativeBarcodeSvgString(safeValue, {
|
||||||
fillCell ? '100%' : `${Math.max(20, scale * 0.6)}%`
|
format: column?.barcodeFormat,
|
||||||
};margin:0 auto;">BAR:${safeValue}</div>`;
|
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;
|
return safeValue;
|
||||||
}
|
}
|
||||||
@@ -397,6 +440,12 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
|||||||
const renderX = isReportHeader || isReportFooter ? 0 : item.x;
|
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 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 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 = [
|
const styleParts = [
|
||||||
`position:absolute`,
|
`position:absolute`,
|
||||||
`width:${renderW}mm`,
|
`width:${renderW}mm`,
|
||||||
@@ -407,7 +456,10 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
|||||||
`line-height:${item.style?.lineHeight || 1.4}`,
|
`line-height:${item.style?.lineHeight || 1.4}`,
|
||||||
`text-align:${item.style?.textAlign || 'left'}`,
|
`text-align:${item.style?.textAlign || 'left'}`,
|
||||||
`background:${item.style?.backgroundColor || 'transparent'}`,
|
`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',
|
'overflow:hidden',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -481,7 +533,9 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
|||||||
return ftParts.join('');
|
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 pages = shouldRepeat ? Array.from({ length: pageCount }, (_v, i) => i + 1) : [1];
|
||||||
const htmlByPage = await Promise.all(
|
const htmlByPage = await Promise.all(
|
||||||
pages.map(async (pageNo) => {
|
pages.map(async (pageNo) => {
|
||||||
@@ -497,6 +551,20 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
|||||||
}
|
}
|
||||||
if (item.type === 'barcode') {
|
if (item.type === 'barcode') {
|
||||||
const value = resolveBoundValue(item, data) ?? (item as any).value;
|
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% 充满定位 div;preserveAspectRatio 在 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>`;
|
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;border:1px dashed #999;">条形码:${value ?? ''}</div>`;
|
||||||
}
|
}
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export interface NativeElementBase {
|
|||||||
lineHeight?: number;
|
lineHeight?: number;
|
||||||
borderWidth?: number;
|
borderWidth?: number;
|
||||||
borderColor?: string;
|
borderColor?: string;
|
||||||
|
hideBorderTop?: boolean;
|
||||||
|
hideBorderRight?: boolean;
|
||||||
|
hideBorderBottom?: boolean;
|
||||||
|
hideBorderLeft?: boolean;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using System.Text.Json.Nodes;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using QRCoder;
|
using QRCoder;
|
||||||
|
using ZXing;
|
||||||
|
using ZXing.Rendering;
|
||||||
|
|
||||||
namespace YY.Admin.Services.Service.Print;
|
namespace YY.Admin.Services.Service.Print;
|
||||||
|
|
||||||
@@ -312,6 +314,11 @@ public static class NativePrintRenderService
|
|||||||
|
|
||||||
// ─── Barcode ────────────────────────────────────────────────────────────
|
// ─── Barcode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用 ZXing.Net 的 SVG 渲染器生成真实可扫码的 1D 条码,与 web 端 jsbarcode 输出的
|
||||||
|
/// SVG 行为一致(preserveAspectRatio="xMidYMid meet",外层 CSS 100% 保比例铺满)。
|
||||||
|
/// 失败时回退到原占位 SVG,避免渲染流程中断。
|
||||||
|
/// </summary>
|
||||||
private static string RenderBarcode(JsonNode el, JsonObject data, string posStyle, double w, double h,
|
private static string RenderBarcode(JsonNode el, JsonObject data, string posStyle, double w, double h,
|
||||||
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
|
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
|
||||||
{
|
{
|
||||||
@@ -321,12 +328,14 @@ public static class NativePrintRenderService
|
|||||||
value = ResolveField(data, bindField)?.ToString() ?? value;
|
value = ResolveField(data, bindField)?.ToString() ?? value;
|
||||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||||
|
|
||||||
var escapedVal = EscapeHtml(value);
|
// 从元素配置取格式/显示文字开关;元素未设时按 Code128 + 显示文字默认
|
||||||
var inner = $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"75%\">" +
|
var format = ParseBarcodeFormat(ReadAsString(el["format"]));
|
||||||
$"<rect width=\"100%\" height=\"100%\" fill=\"white\"/>" +
|
var displayValue = !string.Equals(ReadAsString(el["displayValue"], "true"), "false", StringComparison.OrdinalIgnoreCase);
|
||||||
$"<text x=\"50%\" y=\"50%\" font-size=\"8\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
|
var textAlign = ReadAsString(el["textAlign"], "center")!;
|
||||||
$"</svg><span style=\"font-size:9px;font-family:monospace;\">{escapedVal}</span>";
|
|
||||||
var wrapStyle = "display:flex;flex-direction:column;align-items:center;justify-content:center;";
|
var inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign);
|
||||||
|
|
||||||
|
var wrapStyle = "display:flex;align-items:center;justify-content:center;overflow:hidden;";
|
||||||
|
|
||||||
if (!bandRepeat || totalPages <= 1)
|
if (!bandRepeat || totalPages <= 1)
|
||||||
return $"<div class=\"el\" style=\"{posStyle}{wrapStyle}\">{inner}</div>\n";
|
return $"<div class=\"el\" style=\"{posStyle}{wrapStyle}\">{inner}</div>\n";
|
||||||
@@ -341,6 +350,230 @@ public static class NativePrintRenderService
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把模板元素上的 format 字符串映射到 ZXing 的 BarcodeFormat 枚举。
|
||||||
|
/// 覆盖 web 端设计器(jsbarcode)支持的全部码制;对 ZXing 不直接支持的子集
|
||||||
|
/// (CODE128 子集、MSI 变体、EAN-5/EAN-2、pharmacode)做最接近映射,避免渲染失败。
|
||||||
|
/// </summary>
|
||||||
|
private static BarcodeFormat ParseBarcodeFormat(string? fmt)
|
||||||
|
{
|
||||||
|
var key = (fmt ?? string.Empty).Trim().ToUpperInvariant().Replace("-", "_");
|
||||||
|
return key switch
|
||||||
|
{
|
||||||
|
// Code128 系列:ZXing 自动识别 A/B/C 编码集,统一映射到 CODE_128
|
||||||
|
"" => BarcodeFormat.CODE_128,
|
||||||
|
"CODE128" => BarcodeFormat.CODE_128,
|
||||||
|
"CODE_128" => BarcodeFormat.CODE_128,
|
||||||
|
"CODE128A" => BarcodeFormat.CODE_128,
|
||||||
|
"CODE128B" => BarcodeFormat.CODE_128,
|
||||||
|
"CODE128C" => BarcodeFormat.CODE_128,
|
||||||
|
|
||||||
|
// Code 39 / 93
|
||||||
|
"CODE39" => BarcodeFormat.CODE_39,
|
||||||
|
"CODE_39" => BarcodeFormat.CODE_39,
|
||||||
|
"CODE93" => BarcodeFormat.CODE_93,
|
||||||
|
"CODE_93" => BarcodeFormat.CODE_93,
|
||||||
|
|
||||||
|
// EAN / UPC
|
||||||
|
"EAN13" => BarcodeFormat.EAN_13,
|
||||||
|
"EAN_13" => BarcodeFormat.EAN_13,
|
||||||
|
"EAN8" => BarcodeFormat.EAN_8,
|
||||||
|
"EAN_8" => BarcodeFormat.EAN_8,
|
||||||
|
// EAN-5 / EAN-2 是 EAN-13 的附加码,ZXing 不直接支持;为避免渲染失败回退为 CODE_128
|
||||||
|
"EAN5" => BarcodeFormat.CODE_128,
|
||||||
|
"EAN_5" => BarcodeFormat.CODE_128,
|
||||||
|
"EAN2" => BarcodeFormat.CODE_128,
|
||||||
|
"EAN_2" => BarcodeFormat.CODE_128,
|
||||||
|
"UPC" => BarcodeFormat.UPC_A,
|
||||||
|
"UPCA" => BarcodeFormat.UPC_A,
|
||||||
|
"UPC_A" => BarcodeFormat.UPC_A,
|
||||||
|
"UPCE" => BarcodeFormat.UPC_E,
|
||||||
|
"UPC_E" => BarcodeFormat.UPC_E,
|
||||||
|
|
||||||
|
// ITF / ITF-14
|
||||||
|
"ITF" => BarcodeFormat.ITF,
|
||||||
|
"ITF14" => BarcodeFormat.ITF,
|
||||||
|
"ITF_14" => BarcodeFormat.ITF,
|
||||||
|
|
||||||
|
// MSI 全部变体:ZXing 不直接支持,回退到 CODE_128(保证至少能扫码识别值)
|
||||||
|
"MSI" => BarcodeFormat.CODE_128,
|
||||||
|
"MSI10" => BarcodeFormat.CODE_128,
|
||||||
|
"MSI11" => BarcodeFormat.CODE_128,
|
||||||
|
"MSI1010" => BarcodeFormat.CODE_128,
|
||||||
|
"MSI1110" => BarcodeFormat.CODE_128,
|
||||||
|
|
||||||
|
// Pharmacode:ZXing 不支持,回退 CODE_128
|
||||||
|
"PHARMACODE" => BarcodeFormat.CODE_128,
|
||||||
|
|
||||||
|
// Codabar
|
||||||
|
"CODABAR" => BarcodeFormat.CODABAR,
|
||||||
|
|
||||||
|
_ => BarcodeFormat.CODE_128,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 保持原始条宽比例"的形式:
|
||||||
|
/// 1) 若原 SVG 无 viewBox,用原 width/height 派生(让 preserveAspectRatio 生效);
|
||||||
|
/// 2) 删除根 svg 节点的 width/height 属性(移交给 CSS);
|
||||||
|
/// 3) 强制 preserveAspectRatio="xMidYMid meet"(与 web 端 jsbarcode 一致);
|
||||||
|
/// 4) 重新挂上 width="100%" height="100%",确保 div 100% 铺满。
|
||||||
|
/// 防护层:若 ZXing 输出的 SVG 已带 viewBox,直接复用;否则从 width/height 推导。
|
||||||
|
/// </summary>
|
||||||
|
private static string? NormalizeBarcodeSvg(string svg)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(svg)) return null;
|
||||||
|
return Regex.Replace(svg, @"<svg\b([^>]*)>", m =>
|
||||||
|
{
|
||||||
|
var attrs = m.Groups[1].Value;
|
||||||
|
|
||||||
|
// 取原 width/height 数值(若有),用于派生 viewBox
|
||||||
|
var widthMatch = Regex.Match(attrs, @"\swidth\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
||||||
|
var heightMatch = Regex.Match(attrs, @"\sheight\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
||||||
|
var hasViewBox = Regex.IsMatch(attrs, @"\sviewBox\s*=", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (!hasViewBox && widthMatch.Success && heightMatch.Success
|
||||||
|
&& double.TryParse(Regex.Replace(widthMatch.Groups[1].Value, @"[^\d.]", ""),
|
||||||
|
NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0
|
||||||
|
&& double.TryParse(Regex.Replace(heightMatch.Groups[1].Value, @"[^\d.]", ""),
|
||||||
|
NumberStyles.Any, CultureInfo.InvariantCulture, out var h) && h > 0)
|
||||||
|
{
|
||||||
|
attrs += $" viewBox=\"0 0 {w.ToString("0.###", CultureInfo.InvariantCulture)} {h.ToString("0.###", CultureInfo.InvariantCulture)}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs = Regex.Replace(attrs, @"\s(width|height)\s*=\s*""[^""]*""", string.Empty, RegexOptions.IgnoreCase);
|
||||||
|
if (!Regex.IsMatch(attrs, @"preserveAspectRatio", RegexOptions.IgnoreCase))
|
||||||
|
attrs += " preserveAspectRatio=\"xMidYMid meet\"";
|
||||||
|
return $"<svg width=\"100%\" height=\"100%\"{attrs}>";
|
||||||
|
}, RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>条码生成失败时的占位 SVG,至少把原文字显示出来便于排查。</summary>
|
||||||
|
private static string BuildFallbackBarcodeSvg(string value)
|
||||||
|
{
|
||||||
|
var escapedVal = EscapeHtml(value);
|
||||||
|
return $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"100%\" preserveAspectRatio=\"xMidYMid meet\" viewBox=\"0 0 200 60\">" +
|
||||||
|
$"<rect width=\"200\" height=\"60\" fill=\"white\"/>" +
|
||||||
|
$"<text x=\"100\" y=\"35\" font-size=\"12\" text-anchor=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
|
||||||
|
$"</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表格单元格用:包装统一的 BuildBarcodeSvgInner,保持与独立 barcode 元素一致的视觉比例。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildBarcodeCellSvg(string value, string? format = null, bool displayValue = true, string? textAlign = null)
|
||||||
|
=> BuildBarcodeSvgInner(value ?? string.Empty, ParseBarcodeFormat(format), displayValue, textAlign);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 调用 ZXing.Net 生成条码 SVG 的统一入口:用与 web 端 jsbarcode 默认参数等价的比例
|
||||||
|
/// (lineWidth ≈ 2px/module、barHeight ≈ 60px、底部文字区 ≈ 18px)来派生 ZXing 的
|
||||||
|
/// Width/Height,使生成的 SVG 内部 viewBox 比例稳定(与 web 视觉风格对齐),不会随
|
||||||
|
/// 元素框形状(90×60 / 50×30 mm)漂移成 1.5:1。外层 div 用 100%+preserveAspectRatio
|
||||||
|
/// ="xMidYMid meet" 自适应铺满。
|
||||||
|
/// textAlign 控制底部文字对齐:center / left / right / justify(两端),通过 SVG
|
||||||
|
/// 后处理修改 ZXing 输出的 <text> 节点 x/text-anchor 实现,与 web 端逻辑同源。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildBarcodeSvgInner(string value, BarcodeFormat format, bool displayValue, string? textAlign = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// moduleCount 仅用于派生稳定的 SVG viewBox 宽度,ZXing 实际按编码后的真实模块数
|
||||||
|
// 1:1 绘制;多给一点宽度不会让条变粗,只会让两侧留白略多。
|
||||||
|
var moduleCount = EstimateBarcodeModuleCount(value, format);
|
||||||
|
const int lineWidth = 2;
|
||||||
|
const int barHeight = 60;
|
||||||
|
var fontFooter = displayValue ? 18 : 0; // jsbarcode 默认 fontSize 14 + 上下 padding ≈ 18px
|
||||||
|
var widthPx = Math.Max(120, moduleCount * lineWidth);
|
||||||
|
var heightPx = barHeight + fontFooter;
|
||||||
|
|
||||||
|
var writer = new BarcodeWriterSvg
|
||||||
|
{
|
||||||
|
Format = format,
|
||||||
|
Options = new ZXing.Common.EncodingOptions
|
||||||
|
{
|
||||||
|
Width = widthPx,
|
||||||
|
Height = heightPx,
|
||||||
|
Margin = 0,
|
||||||
|
PureBarcode = !displayValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var svgStr = writer.Write(value)?.Content ?? string.Empty;
|
||||||
|
var normalized = NormalizeBarcodeSvg(svgStr) ?? BuildFallbackBarcodeSvg(value);
|
||||||
|
// 仅当显示文字时执行对齐后处理
|
||||||
|
if (displayValue) normalized = ApplyBarcodeTextAlign(normalized, textAlign);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return BuildFallbackBarcodeSvg(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 后处理 ZXing 输出 SVG 中的底部文字 <text> 节点,按 textAlign 修改 x/text-anchor,
|
||||||
|
/// justify(两端对齐)通过 textLength + lengthAdjust=spacing 让浏览器自动拉伸字符间距。
|
||||||
|
/// 宽度从 SVG 自身的 viewBox 第三个分量读取,不依赖 ZXing 的内部缩放策略,保证
|
||||||
|
/// "user units 坐标系内对齐 + 浏览器渲染" 一致。
|
||||||
|
/// </summary>
|
||||||
|
private static string ApplyBarcodeTextAlign(string svg, string? textAlign)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(svg)) return svg;
|
||||||
|
var align = (textAlign ?? "center").Trim().ToLowerInvariant();
|
||||||
|
if (align is not ("left" or "right" or "justify")) return svg; // center 即 ZXing 默认,不动
|
||||||
|
|
||||||
|
// 解析 viewBox 取 user units 宽度,作为对齐坐标参照
|
||||||
|
double vbWidth = 0;
|
||||||
|
var vbMatch = Regex.Match(svg, @"viewBox\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
||||||
|
if (vbMatch.Success)
|
||||||
|
{
|
||||||
|
var parts = Regex.Split(vbMatch.Groups[1].Value.Trim(), @"[\s,]+");
|
||||||
|
if (parts.Length >= 4
|
||||||
|
&& double.TryParse(parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0)
|
||||||
|
{
|
||||||
|
vbWidth = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vbWidth <= 0) return svg; // 拿不到 viewBox 宽度时不动,保持 ZXing 默认居中
|
||||||
|
|
||||||
|
return Regex.Replace(svg, @"<text\b([^>]*)>", m =>
|
||||||
|
{
|
||||||
|
var attrs = m.Groups[1].Value;
|
||||||
|
attrs = Regex.Replace(attrs, @"\s(x|text-anchor|textLength|lengthAdjust)\s*=\s*""[^""]*""", string.Empty, RegexOptions.IgnoreCase);
|
||||||
|
var widthStr = vbWidth.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return align switch
|
||||||
|
{
|
||||||
|
"left" => $"<text x=\"0\" text-anchor=\"start\"{attrs}>",
|
||||||
|
"right" => $"<text x=\"{widthStr}\" text-anchor=\"end\"{attrs}>",
|
||||||
|
"justify" => $"<text x=\"0\" text-anchor=\"start\" textLength=\"{widthStr}\" lengthAdjust=\"spacing\"{attrs}>",
|
||||||
|
_ => m.Value,
|
||||||
|
};
|
||||||
|
}, RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 估算 1D 条码所需的模块数(即最窄条单位个数),用于给 ZXing 提供合适的 Width,
|
||||||
|
/// 确保 SVG viewBox 比例稳定。各码制按字符 × 每字符模块数 + 起止/校验粗略估算。
|
||||||
|
/// </summary>
|
||||||
|
private static int EstimateBarcodeModuleCount(string value, BarcodeFormat format)
|
||||||
|
{
|
||||||
|
var n = Math.Max(1, value?.Length ?? 1);
|
||||||
|
return format switch
|
||||||
|
{
|
||||||
|
BarcodeFormat.CODE_128 => n * 11 + 35,
|
||||||
|
BarcodeFormat.CODE_39 => (n + 2) * 13,
|
||||||
|
BarcodeFormat.CODE_93 => (n + 4) * 9,
|
||||||
|
BarcodeFormat.EAN_13 => 95,
|
||||||
|
BarcodeFormat.UPC_A => 95,
|
||||||
|
BarcodeFormat.EAN_8 => 67,
|
||||||
|
BarcodeFormat.UPC_E => 51,
|
||||||
|
BarcodeFormat.ITF => (n / 2 + 1) * 9 + 7,
|
||||||
|
BarcodeFormat.CODABAR => (n + 2) * 10,
|
||||||
|
_ => n * 11 + 35,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Image ─────────────────────────────────────────────────────────────
|
// ─── Image ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
|
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
|
||||||
@@ -798,7 +1031,8 @@ public static class NativePrintRenderService
|
|||||||
{
|
{
|
||||||
var ws = fillCell ? "100%" : $"{scale.ToString("0", CultureInfo.InvariantCulture)}%";
|
var ws = fillCell ? "100%" : $"{scale.ToString("0", CultureInfo.InvariantCulture)}%";
|
||||||
var hs = fillCell ? "100%" : $"{Math.Max(20d, scale * 0.6d).ToString("0", CultureInfo.InvariantCulture)}%";
|
var hs = fillCell ? "100%" : $"{Math.Max(20d, scale * 0.6d).ToString("0", CultureInfo.InvariantCulture)}%";
|
||||||
return $"<div style=\"display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:{ws};height:{hs};margin:0 auto;font-family:monospace;\">{EscapeHtml(innerArg)}</div>";
|
var svg = BuildBarcodeCellSvg(innerArg);
|
||||||
|
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:{ws};height:{hs};margin:0 auto;overflow:hidden;\">{svg}</div>";
|
||||||
}
|
}
|
||||||
return EscapeHtml(displayValue);
|
return EscapeHtml(displayValue);
|
||||||
}
|
}
|
||||||
@@ -1350,7 +1584,10 @@ public static class NativePrintRenderService
|
|||||||
catch { return EscapeHtml(value); }
|
catch { return EscapeHtml(value); }
|
||||||
}
|
}
|
||||||
if (t == "barcode")
|
if (t == "barcode")
|
||||||
return $"<div style=\"font-family:monospace;text-align:center;\">{EscapeHtml(value)}</div>";
|
{
|
||||||
|
var svg = BuildBarcodeCellSvg(value);
|
||||||
|
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;\">{svg}</div>";
|
||||||
|
}
|
||||||
if (t == "image")
|
if (t == "image")
|
||||||
return $"<img src=\"{EscapeHtml(value)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
|
return $"<img src=\"{EscapeHtml(value)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
|
||||||
return EscapeHtml(value);
|
return EscapeHtml(value);
|
||||||
|
|||||||
@@ -90,10 +90,31 @@ public class PrintDotService : IPrintDotService
|
|||||||
var status = resDoc?["status"]?.GetValue<string>();
|
var status = resDoc?["status"]?.GetValue<string>();
|
||||||
if (status == null) continue;
|
if (status == null) continue;
|
||||||
if (status == "success") return;
|
if (status == "success") return;
|
||||||
throw new InvalidOperationException(resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败");
|
var rawMsg = resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败";
|
||||||
|
throw new InvalidOperationException(EnhanceErrorMessage(rawMsg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 PrintDot 返回的部分英文错误转换为带本地处理步骤的中文提示。
|
||||||
|
/// 与 web 端 printDotBridge.ts::enhancePrintDotErrorMessage 行为一致,方便桌面端用户自助排查。
|
||||||
|
/// </summary>
|
||||||
|
private static string EnhanceErrorMessage(string raw)
|
||||||
|
{
|
||||||
|
var m = (raw ?? string.Empty).Trim();
|
||||||
|
// 缺 SumatraPDF:是 PrintDot 客户端最常见的初始化错误
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(m, @"SumatraPDF\.exe not found", System.Text.RegularExpressions.RegexOptions.IgnoreCase)
|
||||||
|
|| System.Text.RegularExpressions.Regex.IsMatch(m, "SUMATRAPDF_PATH", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
return m + "。\n本地处理:PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 SumatraPDF 后任选其一:\n" +
|
||||||
|
"① 将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;\n" +
|
||||||
|
"② 或将 SumatraPDF 安装目录加入系统 PATH;\n" +
|
||||||
|
"③ 或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径;\n" +
|
||||||
|
"然后重启 PrintDot 桥接器即可。";
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> ReceiveTextAsync(ClientWebSocket ws, CancellationToken ct)
|
private static async Task<string> ReceiveTextAsync(ClientWebSocket ws, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var buffer = new ArraySegment<byte>(new byte[64 * 1024]);
|
var buffer = new ArraySegment<byte>(new byte[64 * 1024]);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||||
|
<!-- 1D 条码渲染(与 web 端 jsbarcode 路径对齐:输出 SVG,由前端 CSS preserveAspectRatio 保比例显示) -->
|
||||||
|
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace YY.Admin.ViewModels.Print;
|
|||||||
public class PrintTemplateListViewModel : BaseViewModel
|
public class PrintTemplateListViewModel : BaseViewModel
|
||||||
{
|
{
|
||||||
private readonly IPrintTemplateService _printTemplateService;
|
private readonly IPrintTemplateService _printTemplateService;
|
||||||
|
private readonly IPrintDotService _printDotService;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
private SubscriptionToken? _changeToken;
|
private SubscriptionToken? _changeToken;
|
||||||
|
|
||||||
@@ -21,6 +22,32 @@ public class PrintTemplateListViewModel : BaseViewModel
|
|||||||
|
|
||||||
public ObservableCollection<PrintTemplate> Templates { get; } = new();
|
public ObservableCollection<PrintTemplate> Templates { get; } = new();
|
||||||
|
|
||||||
|
// ── PrintDot 打印机选择 ────────────────────────────────────────────────
|
||||||
|
public ObservableCollection<PrintDotPrinter> Printers { get; } = new();
|
||||||
|
|
||||||
|
private bool _suppressPrinterSave;
|
||||||
|
private PrintDotPrinter? _selectedPrinter;
|
||||||
|
public PrintDotPrinter? SelectedPrinter
|
||||||
|
{
|
||||||
|
get => _selectedPrinter;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetProperty(ref _selectedPrinter, value)) return;
|
||||||
|
if (_suppressPrinterSave) return;
|
||||||
|
// 持久化用户选择,预览窗口和后续会话使用
|
||||||
|
var s = PrintDotSettings.Load();
|
||||||
|
s.SelectedPrinter = value?.Name ?? string.Empty;
|
||||||
|
s.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _printerStatus = string.Empty;
|
||||||
|
public string PrinterStatus
|
||||||
|
{
|
||||||
|
get => _printerStatus;
|
||||||
|
set => SetProperty(ref _printerStatus, value);
|
||||||
|
}
|
||||||
|
|
||||||
private string _statusMessage = string.Empty;
|
private string _statusMessage = string.Empty;
|
||||||
public string StatusMessage
|
public string StatusMessage
|
||||||
{
|
{
|
||||||
@@ -52,14 +79,17 @@ public class PrintTemplateListViewModel : BaseViewModel
|
|||||||
public DelegateCommand SearchCommand { get; }
|
public DelegateCommand SearchCommand { get; }
|
||||||
public DelegateCommand ResetCommand { get; }
|
public DelegateCommand ResetCommand { get; }
|
||||||
public DelegateCommand<PrintTemplate> PreviewCommand { get; }
|
public DelegateCommand<PrintTemplate> PreviewCommand { get; }
|
||||||
|
public DelegateCommand RefreshPrintersCommand { get; }
|
||||||
|
|
||||||
public PrintTemplateListViewModel(
|
public PrintTemplateListViewModel(
|
||||||
IPrintTemplateService printTemplateService,
|
IPrintTemplateService printTemplateService,
|
||||||
|
IPrintDotService printDotService,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
IContainerExtension container,
|
IContainerExtension container,
|
||||||
IRegionManager regionManager) : base(container, regionManager)
|
IRegionManager regionManager) : base(container, regionManager)
|
||||||
{
|
{
|
||||||
_printTemplateService = printTemplateService;
|
_printTemplateService = printTemplateService;
|
||||||
|
_printDotService = printDotService;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
|
|
||||||
SearchCommand = new DelegateCommand(ApplyFilter);
|
SearchCommand = new DelegateCommand(ApplyFilter);
|
||||||
@@ -71,6 +101,7 @@ public class PrintTemplateListViewModel : BaseViewModel
|
|||||||
FilterCategory = null;
|
FilterCategory = null;
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
});
|
});
|
||||||
|
RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true));
|
||||||
|
|
||||||
_changeToken = _eventAggregator
|
_changeToken = _eventAggregator
|
||||||
.GetEvent<PrintTemplateChangedEvent>()
|
.GetEvent<PrintTemplateChangedEvent>()
|
||||||
@@ -79,6 +110,51 @@ public class PrintTemplateListViewModel : BaseViewModel
|
|||||||
// 先用缓存立即填充,再后台静默刷新
|
// 先用缓存立即填充,再后台静默刷新
|
||||||
ShowCached();
|
ShowCached();
|
||||||
_ = RefreshSilentlyAsync();
|
_ = RefreshSilentlyAsync();
|
||||||
|
|
||||||
|
// 后台静默连接 PrintDot 桥接器,初次加载打印机
|
||||||
|
_ = RefreshPrintersAsync(verbose: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通过 PrintDot 桥接器拉取打印机列表,与后端 web 列表页的 fetchPrintDotPrinters 行为对齐。
|
||||||
|
/// </summary>
|
||||||
|
private async Task RefreshPrintersAsync(bool verbose)
|
||||||
|
{
|
||||||
|
if (verbose) PrinterStatus = "刷新打印机中...";
|
||||||
|
var savedName = PrintDotSettings.Load().SelectedPrinter;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _printDotService.GetPrintersAsync();
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
Printers.Clear();
|
||||||
|
foreach (var p in list) Printers.Add(p);
|
||||||
|
|
||||||
|
// 选中规则:上次保存 > 系统默认 > 首台
|
||||||
|
var match = list.FirstOrDefault(p => p.Name == savedName)
|
||||||
|
?? list.FirstOrDefault(p => p.IsDefault)
|
||||||
|
?? (list.Count > 0 ? list[0] : null);
|
||||||
|
|
||||||
|
_suppressPrinterSave = true;
|
||||||
|
SelectedPrinter = match;
|
||||||
|
_suppressPrinterSave = false;
|
||||||
|
|
||||||
|
PrinterStatus = list.Count > 0 ? $"共 {list.Count} 台打印机" : "未检测到打印机";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
Printers.Clear();
|
||||||
|
_suppressPrinterSave = true;
|
||||||
|
SelectedPrinter = null;
|
||||||
|
_suppressPrinterSave = false;
|
||||||
|
PrinterStatus = verbose
|
||||||
|
? $"PrintDot 未连接:{ex.Message}"
|
||||||
|
: "PrintDot 未连接";
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowCached()
|
private void ShowCached()
|
||||||
@@ -192,7 +268,7 @@ public class PrintTemplateListViewModel : BaseViewModel
|
|||||||
catch { /* 保持 json 为 null,预览窗口显示"尚未设计" */ }
|
catch { /* 保持 json 为 null,预览窗口显示"尚未设计" */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
var win = new PrintPreviewWindow(template, json)
|
var win = new PrintPreviewWindow(template, json, _printDotService, SelectedPrinter?.Name)
|
||||||
{
|
{
|
||||||
Owner = Application.Current.MainWindow
|
Owner = Application.Current.MainWindow
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,9 +44,29 @@
|
|||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBlock x:Name="TbStatus"
|
<TextBlock x:Name="TbStatus"
|
||||||
FontSize="12" Foreground="#888888"
|
FontSize="12" Foreground="#888888"
|
||||||
VerticalAlignment="Center" Margin="0,0,16,0"/>
|
VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
|
MaxWidth="320"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTipService.ShowDuration="30000"/>
|
||||||
|
<TextBlock Text="打印机:" VerticalAlignment="Center"
|
||||||
|
FontSize="12" Foreground="#333333"/>
|
||||||
|
<ComboBox x:Name="PrinterCombo"
|
||||||
|
MinWidth="200" Height="30"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
DisplayMemberPath="Name"/>
|
||||||
|
<Button x:Name="BtnRefreshPrinters"
|
||||||
|
Content="刷新打印机"
|
||||||
|
Click="RefreshPrinters_Click"
|
||||||
|
Margin="6,0,0,0" Height="30" Padding="10,0" FontSize="12"
|
||||||
|
Style="{StaticResource ButtonDefault}"/>
|
||||||
|
<Button x:Name="BtnPrint"
|
||||||
|
Content="打印"
|
||||||
|
Click="Print_Click"
|
||||||
|
Margin="12,0,0,0" Width="84" Height="30" FontSize="13"
|
||||||
|
Style="{StaticResource ButtonPrimary}"/>
|
||||||
<Button Content="关闭" Click="CloseButton_Click"
|
<Button Content="关闭" Click="CloseButton_Click"
|
||||||
Width="72" Height="30" FontSize="13"
|
Margin="8,0,0,0" Width="72" Height="30" FontSize="13"
|
||||||
Style="{StaticResource ButtonDefault}"/>
|
Style="{StaticResource ButtonDefault}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using YY.Admin.Core.Entity;
|
using YY.Admin.Core.Entity;
|
||||||
|
using YY.Admin.Core.Services;
|
||||||
using YY.Admin.Services.Service.Print;
|
using YY.Admin.Services.Service.Print;
|
||||||
|
|
||||||
namespace YY.Admin.Views.Print;
|
namespace YY.Admin.Views.Print;
|
||||||
@@ -10,11 +13,23 @@ namespace YY.Admin.Views.Print;
|
|||||||
public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
||||||
{
|
{
|
||||||
private readonly string _templateJson;
|
private readonly string _templateJson;
|
||||||
|
private readonly PrintTemplate _template;
|
||||||
|
private readonly IPrintDotService? _printDotService;
|
||||||
|
private readonly string? _initialPrinterName;
|
||||||
|
|
||||||
public PrintPreviewWindow(PrintTemplate template, string? templateJson)
|
public PrintPreviewWindow(PrintTemplate template, string? templateJson)
|
||||||
|
: this(template, templateJson, null, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PrintPreviewWindow(PrintTemplate template, string? templateJson,
|
||||||
|
IPrintDotService? printDotService, string? selectedPrinterName)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
_template = template;
|
||||||
_templateJson = templateJson ?? string.Empty;
|
_templateJson = templateJson ?? string.Empty;
|
||||||
|
_printDotService = printDotService;
|
||||||
|
_initialPrinterName = selectedPrinterName;
|
||||||
|
|
||||||
TbTemplateName.Text = template.TemplateName ?? "(未命名)";
|
TbTemplateName.Text = template.TemplateName ?? "(未命名)";
|
||||||
TbTemplateCode.Text = $"编码:{template.TemplateCode} " +
|
TbTemplateCode.Text = $"编码:{template.TemplateCode} " +
|
||||||
@@ -23,20 +38,32 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
|
|
||||||
TbParamJson.Text = BuildMockParamJson(_templateJson);
|
TbParamJson.Text = BuildMockParamJson(_templateJson);
|
||||||
|
|
||||||
Loaded += async (_, _) => await LoadPreviewAsync();
|
// 没有 PrintDot 服务时禁用打印相关按钮
|
||||||
|
if (_printDotService == null)
|
||||||
|
{
|
||||||
|
BtnPrint.IsEnabled = false;
|
||||||
|
BtnRefreshPrinters.IsEnabled = false;
|
||||||
|
PrinterCombo.IsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Loaded += async (_, _) =>
|
||||||
|
{
|
||||||
|
await LoadPreviewAsync();
|
||||||
|
await LoadPrintersAsync(verbose: false);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadPreviewAsync()
|
private async Task LoadPreviewAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
TbStatus.Text = "加载中…";
|
SetStatus("加载中…");
|
||||||
await WebView.EnsureCoreWebView2Async();
|
await WebView.EnsureCoreWebView2Async();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}")
|
if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}")
|
||||||
{
|
{
|
||||||
WebView.NavigateToString(BuildEmptyHtml());
|
WebView.NavigateToString(BuildEmptyHtml());
|
||||||
TbStatus.Text = "尚未设计模板内容";
|
SetStatus("尚未设计模板内容");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +71,7 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
TbStatus.Text = $"预览失败:{ex.Message}";
|
SetStatus($"预览失败:{ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +102,7 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
TbStatus.Text = "渲染中…";
|
SetStatus("渲染中…");
|
||||||
|
|
||||||
JsonObject dataObj;
|
JsonObject dataObj;
|
||||||
var text = TbParamJson.Text?.Trim();
|
var text = TbParamJson.Text?.Trim();
|
||||||
@@ -89,7 +116,7 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
if (node is not JsonObject obj)
|
if (node is not JsonObject obj)
|
||||||
{
|
{
|
||||||
WebView.NavigateToString(BuildErrorHtml("参数JSON必须是对象(JSON Object)"));
|
WebView.NavigateToString(BuildErrorHtml("参数JSON必须是对象(JSON Object)"));
|
||||||
TbStatus.Text = "参数JSON格式错误";
|
SetStatus("参数JSON格式错误");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dataObj = obj;
|
dataObj = obj;
|
||||||
@@ -97,12 +124,12 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
|
|
||||||
var html = NativePrintRenderService.RenderToHtml(_templateJson, dataObj);
|
var html = NativePrintRenderService.RenderToHtml(_templateJson, dataObj);
|
||||||
WebView.NavigateToString(html);
|
WebView.NavigateToString(html);
|
||||||
TbStatus.Text = string.Empty;
|
SetStatus(string.Empty);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
WebView.NavigateToString(BuildErrorHtml(ex.Message));
|
WebView.NavigateToString(BuildErrorHtml(ex.Message));
|
||||||
TbStatus.Text = $"渲染失败:{ex.Message}";
|
SetStatus($"渲染失败:{ex.Message}");
|
||||||
}
|
}
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -192,8 +219,8 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
{
|
{
|
||||||
"number" => (i + 1) * 123.45,
|
"number" => (i + 1) * 123.45,
|
||||||
"amount" => (i + 1) * 24567.89,
|
"amount" => (i + 1) * 24567.89,
|
||||||
"qrcode" => $"QR_{field}_{i + 1}",
|
"qrcode" => BuildQrcodeMockValue(field, rng),
|
||||||
"barcode" => $"BAR_{field}_{i + 1}",
|
"barcode" => BuildBarcodeMockValue(field, rng),
|
||||||
"image" => $"https://picsum.photos/seed/{Uri.EscapeDataString(field + "_" + (i + 1))}/260/120",
|
"image" => $"https://picsum.photos/seed/{Uri.EscapeDataString(field + "_" + (i + 1))}/260/120",
|
||||||
_ => $"{field}_示例值_{i + 1}"
|
_ => $"{field}_示例值_{i + 1}"
|
||||||
};
|
};
|
||||||
@@ -209,6 +236,30 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
if (!string.IsNullOrWhiteSpace(bind))
|
if (!string.IsNullOrWhiteSpace(bind))
|
||||||
fields.Add(bind);
|
fields.Add(bind);
|
||||||
|
|
||||||
|
// 针对单一元素按 type 提前预填合法 mock 值,避免下面兜底成"字段_示例值"
|
||||||
|
// (barcode 含中文会导致 Code128 编码失败)。与 web 端 nativeMockData.ts 行为对齐。
|
||||||
|
if (!string.IsNullOrWhiteSpace(bind) && !obj.ContainsKey(bind))
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "barcode":
|
||||||
|
obj[bind] = BuildBarcodeMockValue(bind, rng);
|
||||||
|
break;
|
||||||
|
case "qrcode":
|
||||||
|
obj[bind] = BuildQrcodeMockValue(bind, rng);
|
||||||
|
break;
|
||||||
|
case "image":
|
||||||
|
obj[bind] = $"https://picsum.photos/seed/{Uri.EscapeDataString(bind)}/260/120";
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
obj[bind] = "2026-01-01";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeTable / 其它含 cells 的元素:根据 cell.contentType 预填合法 mock 值
|
||||||
|
CollectCellsMock(el["cells"], obj, fields, rng);
|
||||||
|
|
||||||
// 提取 text 中的 {{field}} 占位符(支持内嵌)
|
// 提取 text 中的 {{field}} 占位符(支持内嵌)
|
||||||
var text = el["text"]?.ToString() ?? string.Empty;
|
var text = el["text"]?.ToString() ?? string.Empty;
|
||||||
foreach (Match m in Regex.Matches(text, @"\{\{\s*([\w\.]+)\s*\}\}"))
|
foreach (Match m in Regex.Matches(text, @"\{\{\s*([\w\.]+)\s*\}\}"))
|
||||||
@@ -217,8 +268,6 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
if (!string.IsNullOrWhiteSpace(key))
|
if (!string.IsNullOrWhiteSpace(key))
|
||||||
fields.Add(key);
|
fields.Add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectBindFields(el["cells"], fields);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var f in fields.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
foreach (var f in fields.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
@@ -237,6 +286,71 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成一个 ASCII 安全、Code128 可编码的条码 mock 值:BAR + 12 位数字 + 字段名前 6 位转大写字母。
|
||||||
|
/// 与 web 端 nativeMockData.ts::buildBarcodeValue 同款规则。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildBarcodeMockValue(string field, Random rng)
|
||||||
|
{
|
||||||
|
var digits = rng.NextInt64(100000000000L, 999999999999L).ToString(CultureInfo.InvariantCulture);
|
||||||
|
var suffix = new string((field ?? string.Empty)
|
||||||
|
.Where(c => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))
|
||||||
|
.Take(6).ToArray()).ToUpperInvariant();
|
||||||
|
if (string.IsNullOrEmpty(suffix)) suffix = "BARCOD";
|
||||||
|
return $"BAR{digits}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>生成 QR mock 值:纯 ASCII,便于 QRCoder 编码与人眼区分字段。</summary>
|
||||||
|
private static string BuildQrcodeMockValue(string field, Random rng)
|
||||||
|
{
|
||||||
|
var safe = new string((field ?? string.Empty)
|
||||||
|
.Where(c => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_')
|
||||||
|
.ToArray());
|
||||||
|
if (string.IsNullOrEmpty(safe)) safe = "QR";
|
||||||
|
return $"QR_{safe}_{rng.Next(100000, 999999)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归扫描 cells 节点(freeTable、嵌套结构),按每个 cell 的 contentType 提前预填合法 mock 值。
|
||||||
|
/// 不识别的 cell 继续走原来的 fields 兜底流程。
|
||||||
|
/// </summary>
|
||||||
|
private static void CollectCellsMock(JsonNode? node, JsonObject obj, ISet<string> fields, Random rng)
|
||||||
|
{
|
||||||
|
if (node == null) return;
|
||||||
|
if (node is JsonObject o)
|
||||||
|
{
|
||||||
|
var bind = (o["bindField"]?.ToString() ?? string.Empty).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(bind))
|
||||||
|
{
|
||||||
|
fields.Add(bind);
|
||||||
|
if (!obj.ContainsKey(bind))
|
||||||
|
{
|
||||||
|
var ct = (o["contentType"]?.ToString() ?? "text").Trim().ToLowerInvariant();
|
||||||
|
switch (ct)
|
||||||
|
{
|
||||||
|
case "barcode":
|
||||||
|
obj[bind] = BuildBarcodeMockValue(bind, rng);
|
||||||
|
break;
|
||||||
|
case "qrcode":
|
||||||
|
obj[bind] = BuildQrcodeMockValue(bind, rng);
|
||||||
|
break;
|
||||||
|
case "image":
|
||||||
|
obj[bind] = $"https://picsum.photos/seed/{Uri.EscapeDataString(bind)}/260/120";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var kv in o)
|
||||||
|
CollectCellsMock(kv.Value, obj, fields, rng);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node is JsonArray arr)
|
||||||
|
{
|
||||||
|
foreach (var it in arr)
|
||||||
|
CollectCellsMock(it, obj, fields, rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void CollectBindFields(JsonNode? node, ISet<string> fields)
|
private static void CollectBindFields(JsonNode? node, ISet<string> fields)
|
||||||
{
|
{
|
||||||
if (node == null) return;
|
if (node == null) return;
|
||||||
@@ -285,4 +399,134 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
|
private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置顶部状态栏文字:单行显示首行概要,完整多行内容通过 ToolTip 兜底,避免把工具栏撑高。
|
||||||
|
/// </summary>
|
||||||
|
private void SetStatus(string? message)
|
||||||
|
{
|
||||||
|
var full = (message ?? string.Empty).Trim();
|
||||||
|
var firstLine = full.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
|
||||||
|
TbStatus.Text = firstLine;
|
||||||
|
TbStatus.ToolTip = full.Contains('\n') || full.Length > firstLine.Length ? full : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 打印机列表加载(PrintDot 桥接器) ────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通过 PrintDot 桥接器拉取打印机列表,填充顶部下拉框,并按上次选择/系统默认/首台优先选中。
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadPrintersAsync(bool verbose)
|
||||||
|
{
|
||||||
|
if (_printDotService == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (verbose) SetStatus("刷新打印机中...");
|
||||||
|
var list = await _printDotService.GetPrintersAsync();
|
||||||
|
PrinterCombo.ItemsSource = list;
|
||||||
|
|
||||||
|
var preferName = _initialPrinterName
|
||||||
|
?? PrintDotSettings.Load().SelectedPrinter;
|
||||||
|
var match = list.FirstOrDefault(p => p.Name == preferName)
|
||||||
|
?? list.FirstOrDefault(p => p.IsDefault)
|
||||||
|
?? list.FirstOrDefault();
|
||||||
|
PrinterCombo.SelectedItem = match;
|
||||||
|
|
||||||
|
SetStatus(list.Count > 0
|
||||||
|
? $"已发现 {list.Count} 台打印机"
|
||||||
|
: "未检测到打印机");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PrinterCombo.ItemsSource = null;
|
||||||
|
SetStatus($"PrintDot 未连接:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void RefreshPrinters_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
await LoadPrintersAsync(verbose: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打印流程:
|
||||||
|
/// 1) 用 WebView2.PrintToPdfAsync 按模板纸张尺寸生成 PDF(与 @page 一致,零边距);
|
||||||
|
/// 2) 读取 PDF → Base64;
|
||||||
|
/// 3) 通过 PrintDot 桥接器发送至本地物理打印机。
|
||||||
|
/// 与后端 web 端 printNativeSchemaViaPrintDot 的行为基本一致,只是 HTML→PDF 改用 WebView2 内置能力,
|
||||||
|
/// 比 html2canvas + jsPDF 更接近浏览器原生打印效果。
|
||||||
|
/// </summary>
|
||||||
|
private async void Print_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_printDotService == null)
|
||||||
|
{
|
||||||
|
HandyControl.Controls.Growl.Warning("PrintDot 服务不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}")
|
||||||
|
{
|
||||||
|
HandyControl.Controls.Growl.Warning("当前模板没有可打印内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = PrinterCombo.SelectedItem as PrintDotPrinter;
|
||||||
|
if (selected == null || string.IsNullOrWhiteSpace(selected.Name))
|
||||||
|
{
|
||||||
|
HandyControl.Controls.Growl.Warning("请先选择打印机");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BtnPrint.IsEnabled = false;
|
||||||
|
var pdfPath = Path.Combine(Path.GetTempPath(), $"qhmes_print_{Guid.NewGuid():N}.pdf");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1) 生成 PDF
|
||||||
|
SetStatus("正在生成 PDF...");
|
||||||
|
await WebView.EnsureCoreWebView2Async();
|
||||||
|
|
||||||
|
var pageW = (_template.PaperWidthMm ?? 210);
|
||||||
|
var pageH = (_template.PaperHeightMm ?? 297);
|
||||||
|
var settings = WebView.CoreWebView2.Environment.CreatePrintSettings();
|
||||||
|
settings.PageWidth = pageW / 25.4d; // 毫米转英寸
|
||||||
|
settings.PageHeight = pageH / 25.4d;
|
||||||
|
settings.MarginTop = 0;
|
||||||
|
settings.MarginBottom = 0;
|
||||||
|
settings.MarginLeft = 0;
|
||||||
|
settings.MarginRight = 0;
|
||||||
|
settings.ShouldPrintBackgrounds = true;
|
||||||
|
settings.ShouldPrintHeaderAndFooter = false;
|
||||||
|
settings.Orientation = string.Equals(_template.PaperOrientation, "横向", StringComparison.Ordinal)
|
||||||
|
? Microsoft.Web.WebView2.Core.CoreWebView2PrintOrientation.Landscape
|
||||||
|
: Microsoft.Web.WebView2.Core.CoreWebView2PrintOrientation.Portrait;
|
||||||
|
|
||||||
|
var ok = await WebView.CoreWebView2.PrintToPdfAsync(pdfPath, settings);
|
||||||
|
if (!ok || !File.Exists(pdfPath))
|
||||||
|
throw new InvalidOperationException("生成 PDF 失败,请确认预览已加载完成");
|
||||||
|
|
||||||
|
// 2) 读取为 Base64
|
||||||
|
var pdfBytes = await File.ReadAllBytesAsync(pdfPath);
|
||||||
|
var pdfBase64 = Convert.ToBase64String(pdfBytes);
|
||||||
|
|
||||||
|
// 3) 通过 PrintDot 桥接器发送
|
||||||
|
SetStatus($"正在通过 PrintDot 发送到「{selected.Name}」...");
|
||||||
|
var jobName = string.IsNullOrWhiteSpace(_template.TemplateName) ? "QH-MES" : _template.TemplateName!;
|
||||||
|
await _printDotService.PrintAsync(selected.Name, pdfBase64, jobName, copies: 1);
|
||||||
|
|
||||||
|
SetStatus("打印任务已发送");
|
||||||
|
HandyControl.Controls.Growl.Success("打印任务已发送至 PrintDot");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 顶部状态栏单行显示首行,完整多行处理步骤在 Growl 弹窗中展示
|
||||||
|
SetStatus($"打印失败:{ex.Message}");
|
||||||
|
HandyControl.Controls.Growl.Error($"打印失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (File.Exists(pdfPath)) File.Delete(pdfPath); } catch { /* 忽略清理失败 */ }
|
||||||
|
BtnPrint.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,20 +52,54 @@
|
|||||||
|
|
||||||
<!-- 操作工具栏 -->
|
<!-- 操作工具栏 -->
|
||||||
<Border Grid.Row="1" Margin="0,10">
|
<Border Grid.Row="1" Margin="0,10">
|
||||||
<hc:UniformSpacingPanel Spacing="10">
|
<Grid>
|
||||||
<Button Style="{StaticResource ButtonPrimary}" Command="{Binding SearchCommand}">
|
<Grid.ColumnDefinitions>
|
||||||
<StackPanel Orientation="Horizontal">
|
<ColumnDefinition Width="Auto"/>
|
||||||
<md:PackIcon Kind="Search"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<TextBlock Text="搜索" Style="{StaticResource IconButtonStyle}"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</StackPanel>
|
</Grid.ColumnDefinitions>
|
||||||
</Button>
|
|
||||||
<Button Style="{StaticResource ButtonDefault}" Command="{Binding ResetCommand}">
|
<hc:UniformSpacingPanel Grid.Column="0" Spacing="10" VerticalAlignment="Center">
|
||||||
<StackPanel Orientation="Horizontal">
|
<Button Style="{StaticResource ButtonPrimary}" Command="{Binding SearchCommand}">
|
||||||
<md:PackIcon Kind="Refresh"/>
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Text="重置" Style="{StaticResource IconButtonStyle}"/>
|
<md:PackIcon Kind="Search"/>
|
||||||
</StackPanel>
|
<TextBlock Text="搜索" Style="{StaticResource IconButtonStyle}"/>
|
||||||
</Button>
|
</StackPanel>
|
||||||
</hc:UniformSpacingPanel>
|
</Button>
|
||||||
|
<Button Style="{StaticResource ButtonDefault}" Command="{Binding ResetCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<md:PackIcon Kind="Refresh"/>
|
||||||
|
<TextBlock Text="重置" Style="{StaticResource IconButtonStyle}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</hc:UniformSpacingPanel>
|
||||||
|
|
||||||
|
<!-- PrintDot 打印机选择(与后端列表页对齐) -->
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="打印机:" VerticalAlignment="Center"
|
||||||
|
FontSize="13" Foreground="#333333"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Printers}"
|
||||||
|
SelectedItem="{Binding SelectedPrinter}"
|
||||||
|
DisplayMemberPath="Name"
|
||||||
|
MinWidth="220" Height="30"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
hc:InfoElement.Placeholder="请选择打印机"/>
|
||||||
|
<Button Command="{Binding RefreshPrintersCommand}"
|
||||||
|
Height="30" Padding="10,0" Margin="8,0,0,0"
|
||||||
|
FontSize="12"
|
||||||
|
Style="{StaticResource ButtonDefault}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<md:PackIcon Kind="Refresh" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="刷新打印机" Margin="4,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Text="{Binding PrinterStatus}"
|
||||||
|
Margin="12,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource SecondaryTextBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 数据表格 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user