Files
qhmes/jeecgboot-vue3/src/views/print/template/native/components/elements/FreeTableElement.vue

680 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="free-table-element" :data-free-table-id="element.id">
<!-- 选中时左上角四向箭头与画布整体拖动一致不在此阻止 pointerdown事件冒泡到 ElementWrapper -->
<button
v-if="isElementSelected"
type="button"
class="free-table-move-handle"
title="拖动移动整个表格"
aria-label="拖动移动整个表格"
@click.stop
>
<svg
class="free-table-move-icon"
viewBox="0 0 16 16"
width="11"
height="11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3"
stroke="currentColor"
stroke-width="1.35"
stroke-linecap="round"
/>
<path
fill="currentColor"
d="M8 2.35 6.15 4.55h3.7L8 2.35zm0 11.3 1.85-2.2H6.15L8 13.65zM2.35 8l2.2 1.85V6.15L2.35 8zm11.3 0-2.2-1.85v3.7L13.65 8z"
/>
</svg>
</button>
<div class="free-table-surface" @pointerdown.stop>
<table>
<colgroup>
<col v-for="(cw, ci) in colWidthsMm" :key="`col_${ci}`" :style="{ width: `${cw}mm` }" />
</colgroup>
<tbody>
<tr v-for="r in rowCount" :key="`tr_${r - 1}`" :style="{ height: `${rowHeightsMm[r - 1] ?? 6}mm` }">
<td
v-for="cell in anchorsForRow(r - 1)"
:key="`td_${cell.row}_${cell.col}`"
class="free-table-cell"
:class="{
'is-selected': isAnchorSelected(cell),
'is-merge-range': isAnchorInMergeRange(cell),
}"
:rowspan="Math.max(1, Number(cell.rowspan || 1))"
:colspan="Math.max(1, Number(cell.colspan || 1))"
:data-ft-row="cell.row"
:data-ft-col="cell.col"
:style="cellStyle(cell)"
@pointerdown.stop="handleSelectCell(cell, $event)"
@dblclick.stop="handleCellDblClick(cell)"
>
<span
class="cell-move-handle"
title="拖动交换单元格内容"
@pointerdown.stop.prevent="startCellSwapDrag($event, cell.row, cell.col)"
>
<span class="cell-move-handle-icon" aria-hidden="true"></span>
</span>
<span v-if="resolveCellContentType(cell) === 'text'" class="cell-body cell-body--text">{{ resolveCellTextForAnchor(cell) }}</span>
<span
v-else-if="resolveCellContentType(cell) === 'number' || resolveCellContentType(cell) === 'amount'"
class="cell-body cell-body--numeric"
>
{{ formatFreeCellNumeric(cell) }}
</span>
<img
v-else-if="resolveCellContentType(cell) === 'image'"
class="table-media-free"
alt=""
:src="resolveFreeCellImageSrc(cell)"
:style="freeCellMediaStyle(cell, 'image')"
/>
<img
v-else-if="resolveCellContentType(cell) === 'qrcode'"
class="table-media-free"
alt=""
:src="resolveFreeCellQrcodeSrc(cell)"
:style="freeCellMediaStyle(cell, 'qrcode')"
/>
<img
v-else-if="resolveCellContentType(cell) === 'barcode'"
class="table-media-free"
alt=""
:src="resolveFreeCellBarcodeSrc(cell)"
:style="freeCellMediaStyle(cell, 'barcode')"
/>
</td>
</tr>
</tbody>
</table>
<div v-if="isElementSelected" class="free-table-track-layer" aria-hidden="true">
<div
v-for="(pct, i) in colGripPositionsPct"
:key="`cg_${i}`"
class="track-grip track-grip--col"
:style="{ left: `${pct}%` }"
title="拖动调整列宽"
@pointerdown.stop.prevent="onColGripPointerDown(i, $event)"
/>
<div
v-for="(pct, i) in rowGripPositionsPct"
:key="`rg_${i}`"
class="track-grip track-grip--row"
:style="{ top: `${pct}%` }"
title="拖动调整行高"
@pointerdown.stop.prevent="onRowGripPointerDown(i, $event)"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import QRCode from 'qrcode';
import { getValueByPath } from '../../core/tableBuilder';
import { normalizeFreeTableAnchors } from '../../core/freeTableGrid';
import { resolveFreeTableCellBorderSides } from '../../core/freeTableBorders';
import { lineStyleKeyToCssBorderStyle, resolveFreeTableCellLineStyleKeys } from '../../core/freeTableLineStyles';
import { redistributeColEdge, redistributeRowEdge, resolveFreeTableColWidthsMm, resolveFreeTableRowHeightsMm } from '../../core/freeTableTracks';
import type { NativeFreeTableCell, NativeFreeTableElement } from '../../core/types';
const PX_PER_MM = 3.7795275591;
const qrCodeCache = ref<Record<string, string>>({});
const barcodeCache = ref<Record<string, string>>({});
const props = withDefaults(
defineProps<{
element: NativeFreeTableElement;
previewData: Record<string, any>;
selectedCell?: { row: number; col: number } | null;
/** 与 selectedCell 配合Shift 选取的第二角(网格坐标,左上角锚点) */
mergeRangeCorner?: { row: number; col: number } | null;
isElementSelected?: boolean;
/** 画布缩放,用于把指针位移换算为 mm */
scale?: number;
}>(),
{ scale: 1 },
);
const emit = defineEmits<{
(e: 'select-cell', payload: { row: number; col: number; shiftKey?: boolean }): void;
(e: 'swap-cells', payload: { fromRow: number; fromCol: number; toRow: number; toCol: number }): void;
/** 双击单元格:打开仅针对本表的单元格编辑弹窗 */
(e: 'edit-cell', payload: { row: number; col: number }): void;
/** 列宽/行高变更mm */
(e: 'update-tracks', payload: { colWidths?: number[]; rowHeights?: number[] }): void;
}>();
const rowCount = computed(() => Math.max(1, Number(props.element?.rowCount || 1)));
const colCount = computed(() => Math.max(1, Number(props.element?.colCount || 1)));
const colWidthsMm = computed(() => resolveFreeTableColWidthsMm(props.element));
const rowHeightsMm = computed(() => resolveFreeTableRowHeightsMm(props.element));
const colGripPositionsPct = computed(() => {
const w = Math.max(0.01, Number(props.element?.w) || 0.01);
let x = 0;
const arr = colWidthsMm.value;
const out: number[] = [];
for (let i = 0; i < arr.length - 1; i += 1) {
x += arr[i];
out.push((x / w) * 100);
}
return out;
});
const rowGripPositionsPct = computed(() => {
const h = Math.max(0.01, Number(props.element?.h) || 0.01);
let y = 0;
const arr = rowHeightsMm.value;
const out: number[] = [];
for (let i = 0; i < arr.length - 1; i += 1) {
y += arr[i];
out.push((y / h) * 100);
}
return out;
});
const anchorsNormalized = computed(() =>
normalizeFreeTableAnchors(rowCount.value, colCount.value, props.element?.cells || []),
);
const mergeRect = computed(() => {
if (!props.selectedCell || !props.mergeRangeCorner) return null;
const a = props.selectedCell;
const b = props.mergeRangeCorner;
return {
r0: Math.min(a.row, b.row),
r1: Math.max(a.row, b.row),
c0: Math.min(a.col, b.col),
c1: Math.max(a.col, b.col),
};
});
function anchorsForRow(r: number) {
return anchorsNormalized.value.filter((c) => c.row === r).sort((a, b) => a.col - b.col);
}
function resolveCellContentType(cell: NativeFreeTableCell) {
return String((cell as any)?.contentType || 'text');
}
function resolveCellRawString(cell: NativeFreeTableCell) {
const bindField = String(cell?.bindField || '').trim();
if (bindField) {
const bound = getValueByPath(props.previewData || {}, bindField);
if (bound !== undefined && bound !== null && String(bound).trim()) {
return String(bound);
}
}
return String(cell?.text || '').trim();
}
function resolveCellTextForAnchor(cell: NativeFreeTableCell) {
const t = resolveCellContentType(cell);
if (t === 'number' || t === 'amount') {
return formatFreeCellNumeric(cell);
}
const raw = resolveCellRawString(cell);
return raw || ' ';
}
function formatFreeCellNumeric(cell: NativeFreeTableCell) {
const raw = resolveCellRawString(cell);
const numeric = Number(raw);
if (!Number.isFinite(numeric)) {
return raw || ' ';
}
const decimals = Math.max(0, Math.min(6, Number((cell as any)?.decimalPlaces ?? 2)));
const finalValue =
(cell as any)?.roundHalfUp === false
? Math.trunc(numeric * 10 ** decimals) / 10 ** decimals
: Number(numeric.toFixed(decimals));
const formatted = finalValue.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
if (resolveCellContentType(cell) === 'amount') {
const symbol = (cell as any)?.amountType === 'USD' ? '$' : (cell as any)?.amountType === 'EUR' ? 'EUR ' : '¥';
return `${symbol}${formatted}`;
}
return formatted;
}
function resolveFreeCellImageSrc(cell: NativeFreeTableCell) {
const v = resolveCellRawString(cell);
if (v) {
return v;
}
return `https://via.placeholder.com/180x80.png?text=${encodeURIComponent('Image')}`;
}
function freeCellMediaStyle(cell: NativeFreeTableCell, type: 'image' | 'qrcode' | 'barcode') {
const fillCell = (cell as any)?.fillCell !== false;
const scale = Math.max(10, Math.min(100, Number((cell as any)?.contentScale || 100)));
const percent = `${scale}%`;
const base = {
display: 'block',
margin: '0 auto',
maxWidth: '100%',
maxHeight: '100%',
objectFit: (type === 'image' ? (cell as any)?.imageFit || 'contain' : 'contain') as string,
} as Record<string, any>;
if (fillCell) {
base.width = '100%';
base.height = '100%';
} else {
base.width = percent;
base.height = type === 'barcode' ? `${Math.max(20, scale * 0.6)}%` : percent;
}
return base;
}
function resolveFreeCellQrcodeSrc(cell: NativeFreeTableCell) {
const value = String(resolveCellRawString(cell) || 'qrcode_empty');
const key = `${(cell as any)?.qrLevel || 'M'}|${(cell as any)?.qrRenderType || 'image/png'}|${value}`;
if (qrCodeCache.value[key]) {
return qrCodeCache.value[key];
}
void QRCode.toDataURL(value, {
errorCorrectionLevel: (cell as any)?.qrLevel || 'M',
margin: 0,
type: (cell as any)?.qrRenderType || 'image/png',
width: 200,
})
.then((url) => {
qrCodeCache.value = { ...qrCodeCache.value, [key]: url };
})
.catch(() => {});
return '';
}
async function buildBarcodeDataUrl(value: string, format: string) {
const mod: any = await import('jsbarcode');
const JsBarcode = mod.default || mod;
const canvas = document.createElement('canvas');
JsBarcode(canvas, value || '00000000', {
format: format || 'CODE128',
displayValue: false,
margin: 0,
width: 2,
height: 70,
});
return canvas.toDataURL('image/png');
}
function resolveFreeCellBarcodeSrc(cell: NativeFreeTableCell) {
const value = String(resolveCellRawString(cell) || '00000000');
const format = String((cell as any)?.barcodeFormat || 'CODE128');
const key = `${format}|${value}`;
if (barcodeCache.value[key]) {
return barcodeCache.value[key];
}
void buildBarcodeDataUrl(value, format)
.then((url) => {
barcodeCache.value = { ...barcodeCache.value, [key]: url };
})
.catch(() => {});
return '';
}
function cellStyle(cell: NativeFreeTableCell) {
const rs = Math.max(1, Number(cell?.rowspan || 1));
const cs = Math.max(1, Number(cell?.colspan || 1));
const bw = Math.max(1, Number(props.element?.borderWidth || 1));
const bc = props.element?.borderColor || '#d9d9d9';
const sides = resolveFreeTableCellBorderSides(
props.element,
anchorsNormalized.value,
cell,
cell.row,
cell.col,
rs,
cs,
rowCount.value,
colCount.value,
);
const lineKeys = resolveFreeTableCellLineStyleKeys(
props.element,
cell.row,
cell.col,
rs,
cs,
rowCount.value,
colCount.value,
);
const nowrap = (cell as any).autoWrap === false;
return {
boxSizing: 'border-box',
textAlign: cell?.align || 'left',
verticalAlign: cell?.verticalAlign || 'middle',
fontSize: `${Number(cell?.fontSize || 12)}px`,
color: cell?.color || '#111111',
backgroundColor: cell?.backgroundColor || '#ffffff',
whiteSpace: nowrap ? 'nowrap' : 'pre-wrap',
wordBreak: nowrap ? 'normal' : 'break-all',
borderTop: sides.top ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.top)} ${bc}` : 'none',
borderRight: sides.right ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.right)} ${bc}` : 'none',
borderBottom: sides.bottom ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.bottom)} ${bc}` : 'none',
borderLeft: sides.left ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.left)} ${bc}` : 'none',
};
}
function handleSelectCell(cell: NativeFreeTableCell, e: PointerEvent) {
emit('select-cell', {
row: cell.row,
col: cell.col,
shiftKey: e.shiftKey === true,
});
}
function handleCellDblClick(cell: NativeFreeTableCell) {
emit('edit-cell', { row: cell.row, col: cell.col });
}
function isAnchorSelected(cell: NativeFreeTableCell) {
if (!props.isElementSelected || !props.selectedCell) return false;
return props.selectedCell.row === cell.row && props.selectedCell.col === cell.col;
}
function isAnchorInMergeRange(cell: NativeFreeTableCell) {
const rect = mergeRect.value;
if (!rect) return false;
const rs = Math.max(1, Number(cell.rowspan || 1));
const cs = Math.max(1, Number(cell.colspan || 1));
const a0 = cell.row;
const a1 = cell.row + rs - 1;
const b0 = cell.col;
const b1 = cell.col + cs - 1;
return !(a1 < rect.r0 || a0 > rect.r1 || b1 < rect.c0 || b0 > rect.c1);
}
function onColGripPointerDown(edge: number, e: PointerEvent) {
if (e.button !== 0) {
return;
}
const el = props.element;
const base = [...resolveFreeTableColWidthsMm(el)];
const totalW = Math.max(0.01, Number(el.w) || 0.01);
const startX = e.clientX;
const sc = Math.max(0.2, Number(props.scale) || 1);
const target = e.currentTarget as HTMLElement | null;
try {
target?.setPointerCapture?.(e.pointerId);
} catch (_err) {
/* 忽略 */
}
const onMove = (ev: PointerEvent) => {
const deltaMm = (ev.clientX - startX) / sc / PX_PER_MM;
const next = redistributeColEdge(base, edge, deltaMm, totalW);
if (next) {
emit('update-tracks', { colWidths: next });
}
};
const onUp = (ev: PointerEvent) => {
try {
target?.releasePointerCapture?.(ev.pointerId);
} catch (_err) {
/* 忽略 */
}
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
}
function onRowGripPointerDown(edge: number, e: PointerEvent) {
if (e.button !== 0) {
return;
}
const el = props.element;
const base = [...resolveFreeTableRowHeightsMm(el)];
const totalH = Math.max(0.01, Number(el.h) || 0.01);
const startY = e.clientY;
const sc = Math.max(0.2, Number(props.scale) || 1);
const target = e.currentTarget as HTMLElement | null;
try {
target?.setPointerCapture?.(e.pointerId);
} catch (_err) {
/* 忽略 */
}
const onMove = (ev: PointerEvent) => {
const deltaMm = (ev.clientY - startY) / sc / PX_PER_MM;
const next = redistributeRowEdge(base, edge, deltaMm, totalH);
if (next) {
emit('update-tracks', { rowHeights: next });
}
};
const onUp = (ev: PointerEvent) => {
try {
target?.releasePointerCapture?.(ev.pointerId);
} catch (_err) {
/* 忽略 */
}
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
}
function startCellSwapDrag(event: PointerEvent, fromRow: number, fromCol: number) {
if (event.button !== 0) return;
const startId = props.element.id;
const onMove = (_e: PointerEvent) => {};
const onUp = (up: PointerEvent) => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const top = document.elementFromPoint(up.clientX, up.clientY) as HTMLElement | null;
const host = top?.closest?.('[data-free-table-id]') as HTMLElement | null;
if (!host || host.getAttribute('data-free-table-id') !== startId) {
return;
}
const td = top?.closest?.('td[data-ft-row]') as HTMLElement | null;
if (!td) return;
const toRow = Number(td.getAttribute('data-ft-row'));
const toCol = Number(td.getAttribute('data-ft-col'));
if (!Number.isFinite(toRow) || !Number.isFinite(toCol)) return;
if (fromRow === toRow && fromCol === toCol) return;
emit('swap-cells', { fromRow, fromCol, toRow, toCol });
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
</script>
<style scoped lang="less">
.free-table-element {
position: relative;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
border: 1px dashed #999;
background: #fff;
box-sizing: border-box;
}
.free-table-move-handle {
position: absolute;
left: -1px;
top: -1px;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin: 0;
padding: 0;
box-sizing: border-box;
border: 1px solid #bfbfbf;
border-radius: 2px;
background: linear-gradient(180deg, #ffffff 0%, #e8eef8 55%, #dce6f5 100%);
color: #1f1f1f;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
cursor: grab;
user-select: none;
line-height: 0;
&:active {
cursor: grabbing;
}
&:focus-visible {
outline: 2px solid #1677ff;
outline-offset: 1px;
}
}
.free-table-move-icon {
display: block;
flex-shrink: 0;
opacity: 0.92;
}
.free-table-surface {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
/* 设计器内不显示滚动条,与打印区域一致;略溢出时裁切即可 */
overflow: hidden;
}
.free-table-track-layer {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.track-grip {
position: absolute;
pointer-events: auto;
z-index: 6;
box-sizing: border-box;
}
.track-grip--col {
top: 0;
bottom: 0;
width: 10px;
margin-left: -5px;
cursor: col-resize;
background: rgba(22, 119, 255, 0.14);
border-radius: 2px;
}
.track-grip--row {
left: 0;
right: 0;
height: 10px;
margin-top: -5px;
cursor: row-resize;
background: rgba(22, 119, 255, 0.14);
border-radius: 2px;
}
.track-grip:hover {
background: rgba(22, 119, 255, 0.28);
}
.free-table-surface table {
width: 100%;
height: 100%;
border-collapse: collapse;
border-spacing: 0;
table-layout: fixed;
overflow: hidden;
box-sizing: border-box;
}
.free-table-surface tbody tr {
box-sizing: border-box;
}
.free-table-cell {
position: relative;
box-sizing: border-box;
min-width: 20px;
min-height: 20px;
padding: 10px 4px 4px;
white-space: pre-wrap;
word-break: break-all;
user-select: none;
cursor: pointer;
vertical-align: top;
}
.free-table-cell.is-merge-range:not(.is-selected) {
box-shadow: inset 0 0 0 1px rgba(250, 140, 22, 0.65);
background-color: rgba(255, 247, 230, 0.55);
}
.cell-body {
display: block;
}
.table-media-free {
box-sizing: border-box;
}
.cell-move-handle {
position: absolute;
left: 50%;
top: 1px;
transform: translateX(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 12px;
padding: 0 4px;
border-radius: 3px;
background: #1677ff;
color: #fff;
font-size: 10px;
line-height: 1;
opacity: 0;
pointer-events: none;
cursor: grab;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
transition: opacity 0.12s ease;
&:active {
cursor: grabbing;
}
}
.cell-move-handle-icon {
display: block;
transform: scaleY(0.85);
font-weight: 700;
}
.free-table-cell:hover .cell-move-handle,
.free-table-cell.is-selected .cell-move-handle {
opacity: 1;
pointer-events: auto;
}
.free-table-cell.is-selected {
box-shadow: inset 0 0 0 2px #1677ff;
z-index: 1;
}
</style>