新增打印模块功能,支持图片分析生成原生模板JSON,查询可用打印机,服务端直打功能,优化打印设计器界面,添加打印机选择和快速打印选项,同时更新依赖项以支持PDF处理。

This commit is contained in:
geht
2026-04-14 17:18:50 +08:00
parent 0024c071ff
commit e04169a694
55 changed files with 30188 additions and 1595 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,724 @@
<template>
<a-modal
v-model:open="innerOpen"
title="模板预览"
width="1240px"
:footer="null"
:body-style="{ padding: '16px 20px 22px' }"
destroy-on-close
wrap-class-name="native-template-list-preview-modal"
@cancel="onClose"
>
<a-spin :spinning="loading">
<div v-if="errorText" class="native-preview-error">{{ errorText }}</div>
<a-row v-else-if="schema" :gutter="20" class="native-preview-row">
<a-col :xs="24" :lg="11" class="native-preview-left">
<a-tabs v-model:activeKey="jsonTabKey" size="small" class="json-tabs">
<a-tab-pane key="template" tab="模板JSON">
<div class="json-template-pane">
<div class="json-box-title">画布实际 JSON模板样式</div>
<a-textarea v-model:value="canvasJsonText" :rows="16" class="json-textarea" placeholder="模板 JSON" />
</div>
</a-tab-pane>
<a-tab-pane key="params" tab="参数JSON">
<div class="params-json-pane">
<div class="params-json-head">
<div class="json-sub-tabs json-sub-tabs--segmented" role="tablist" aria-label="参数数据来源">
<button
type="button"
role="tab"
:class="['capsule-tab', { 'is-active': dataSourceType === 'manual' }]"
@click="dataSourceType = 'manual'"
>
手动JSON
</button>
<button
type="button"
role="tab"
:class="['capsule-tab', { 'is-active': dataSourceType === 'mock' }]"
@click="dataSourceType = 'mock'"
>
模拟JSON
</button>
</div>
<a-button
v-show="dataSourceType === 'mock'"
size="small"
type="primary"
ghost
class="json-capsule-btn"
@click="onGenerateMock"
>
根据画布生成
</a-button>
</div>
<a-textarea
v-show="dataSourceType === 'manual'"
v-model:value="previewDataText"
:rows="14"
class="json-textarea json-textarea--main"
placeholder="手动输入预览数据 JSON"
/>
<template v-if="dataSourceType === 'mock'">
<a-textarea
v-model:value="mockDataText"
:rows="12"
class="json-textarea json-textarea--main"
placeholder="点击「根据画布生成」自动填充模拟数据"
/>
</template>
</div>
</a-tab-pane>
</a-tabs>
</a-col>
<a-col :xs="24" :lg="13" class="native-preview-right">
<div class="preview-header-row">
<div class="preview-title">预览样式</div>
<a-space v-if="previewHtml" size="small" align="center" wrap class="preview-header-actions" @click.stop>
<a-tooltip title="缩小">
<a-button type="default" size="small" class="preview-zoom-btn" @click="zoomPreviewOut">
<Icon icon="ant-design:zoom-out-outlined" />
</a-button>
</a-tooltip>
<span class="preview-zoom-label">{{ zoomPercentLabel }}</span>
<a-tooltip title="放大">
<a-button type="default" size="small" class="preview-zoom-btn" @click="zoomPreviewIn">
<Icon icon="ant-design:zoom-in-outlined" />
</a-button>
</a-tooltip>
<a-tooltip title="恢复按窗口适应">
<a-button type="default" size="small" class="preview-zoom-fit-btn" @click="resetPreviewZoom">适应</a-button>
</a-tooltip>
<a-tooltip title="使用浏览器打印当前预览内容">
<a-button type="primary" size="small" class="preview-print-btn" @click="handleBrowserPrint">
<Icon icon="ant-design:printer-outlined" />
打印
</a-button>
</a-tooltip>
</a-space>
</div>
<div v-if="layoutPaperPx" ref="previewHostRef" class="preview-frame-wrap">
<template v-if="previewHtml">
<!-- 内层用 margin:auto放大后父级可四向滚动避免 flex 居中导致裁切 -->
<div class="preview-scroll-flex">
<div class="preview-zoom-slot">
<div
class="preview-scale-shim"
:style="{
width: `${layoutPaperPx.wPx * previewDisplayScale}px`,
height: `${layoutPaperPx.hPx * previewDisplayScale}px`,
}"
>
<div
class="preview-scale-inner"
:style="{
width: `${layoutPaperPx.wPx}px`,
height: `${layoutPaperPx.hPx}px`,
transform: `scale(${previewDisplayScale})`,
}"
>
<iframe
ref="previewIframeRef"
class="preview-iframe"
title="原生模板预览"
:srcdoc="previewHtml"
:style="{
width: `${layoutPaperPx.wPx}px`,
height: `${layoutPaperPx.hPx}px`,
}"
@load="onPreviewIframeLoad"
/>
</div>
</div>
</div>
</div>
</template>
<a-empty v-else description="正在生成预览…" />
</div>
</a-col>
</a-row>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { useDebounceFn, useResizeObserver } from '@vueuse/core';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { queryById } from '../printTemplate.api';
import { renderNativePrintHtml, resolvePrintPageCount } from '../native/core/printRenderer';
import { stringifyNativeTemplateStyle } from '../native/core/nativeTemplateStyleSerialize';
import { generateNativeMockDataObject } from '../native/core/nativeMockData';
import { normalizeImportedNativeSchema } from '../native/core/nativeSchemaNormalize';
import type { NativeTemplateSchema } from '../native/core/types';
const props = defineProps<{
open: boolean;
/** 打印模板主键 id */
templateId: string | null;
}>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
}>();
const { createMessage } = useMessage();
const innerOpen = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
const loading = ref(false);
const errorText = ref('');
const schema = ref<NativeTemplateSchema | null>(null);
const canvasJsonText = ref('{}');
const jsonTabKey = ref<'template' | 'params'>('template');
const dataSourceType = ref<'manual' | 'mock'>('mock');
const previewDataText = ref('{}');
const mockDataText = ref('{}');
const previewHtml = ref('');
/** 预览区容器,用于根据可用空间计算缩放比 */
const previewHostRef = ref<HTMLElement | null>(null);
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
/** 自动适应窗口时的缩放系数(相对纸张像素,通常 ≤1 */
const autoFitScale = ref(1);
/** 用户在「适应」基础上的缩放倍数1 表示与自动适应一致 */
const zoomMultiplier = ref(1);
const ZOOM_STEP = 1.15;
const ZOOM_MULT_MIN = 0.25;
const ZOOM_MULT_MAX = 4;
/** 最终用于 transform 的缩放 = 自动适应 × 手动倍数,并做安全上下限 */
const previewDisplayScale = computed(() => {
const raw = autoFitScale.value * zoomMultiplier.value;
if (!Number.isFinite(raw) || raw <= 0) {
return 1;
}
return Math.min(4, Math.max(0.08, raw));
});
/** 相对「适应窗口」的百分比,便于用户理解当前放大程度 */
const zoomPercentLabel = computed(() => `${Math.round(zoomMultiplier.value * 100)}%`);
/** CSS mm 转 px与浏览器常规 96dpi 一致) */
const MM_TO_CSS_PX = 96 / 25.4;
const activeDataText = computed(() => (dataSourceType.value === 'mock' ? mockDataText.value : previewDataText.value));
const previewData = computed(() => {
try {
return JSON.parse(activeDataText.value || '{}');
} catch (_e) {
return {};
}
});
/** 按纸张 mm × 页数换算为像素理论下限auto 表格等实际可能更高,见 contentMeasurePx */
const paperPixelSize = computed(() => {
if (!schema.value) {
return null;
}
const pageCount = Math.max(1, resolvePrintPageCount(schema.value, previewData.value));
const wMm = Number(schema.value.page?.width || 210);
const hMm = Number(schema.value.page?.height || 297);
const wPx = wMm * MM_TO_CSS_PX;
const hPx = hMm * pageCount * MM_TO_CSS_PX;
return { wPx, hPx, pageCount };
});
/** iframe 内实际排版测量(解决 autoPage 表格超出单页高度后仍被裁切) */
const contentMeasurePx = ref({ w: 0, h: 0 });
/** 预览/打印用的版面像素:取理论纸张与实测内容的较大值 */
const layoutPaperPx = computed(() => {
const ps = paperPixelSize.value;
if (!ps) {
return null;
}
const m = contentMeasurePx.value;
return {
...ps,
wPx: Math.max(ps.wPx, m.w || 0),
hPx: Math.max(ps.hPx, m.h || 0),
};
});
/** 遍历 iframe 文档与 scrollHeight估算真实内容宽高 */
function measureIframeContentBox(doc: Document): { w: number; h: number } {
const body = doc.body;
let minTop = Infinity;
let maxBottom = 0;
let minLeft = Infinity;
let maxRight = 0;
const visit = (el: Element) => {
if (el instanceof HTMLElement) {
const r = el.getBoundingClientRect();
if (r.height > 0.5 && r.width > 0.5) {
minTop = Math.min(minTop, r.top);
maxBottom = Math.max(maxBottom, r.bottom);
minLeft = Math.min(minLeft, r.left);
maxRight = Math.max(maxRight, r.right);
}
}
for (let i = 0; i < el.children.length; i++) {
visit(el.children[i]);
}
};
visit(body);
const byRect =
Number.isFinite(minTop) && maxBottom > 0
? { w: Math.ceil(maxRight - minLeft + 6), h: Math.ceil(maxBottom - minTop + 12) }
: { w: 0, h: 0 };
const sh = Math.max(doc.documentElement.scrollHeight, body.scrollHeight, byRect.h);
const sw = Math.max(doc.documentElement.scrollWidth, body.scrollWidth, byRect.w);
return { w: Math.ceil(sw), h: Math.ceil(sh) };
}
function updateContentMeasure() {
void nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const doc = previewIframeRef.value?.contentDocument;
if (!doc?.body) {
contentMeasurePx.value = { w: 0, h: 0 };
scheduleComputeScale();
return;
}
contentMeasurePx.value = measureIframeContentBox(doc);
scheduleComputeScale();
});
});
});
}
async function refreshPreview() {
if (!schema.value) {
previewHtml.value = '';
return;
}
try {
previewHtml.value = await renderNativePrintHtml(schema.value, previewData.value);
} catch (e: any) {
previewHtml.value = '';
createMessage.error(`预览渲染失败:${e?.message || '未知错误'}`);
}
}
const debouncedRefreshPreview = useDebounceFn(() => void refreshPreview(), 320);
function computePreviewScale() {
const host = previewHostRef.value;
const ps = layoutPaperPx.value;
if (!host || !ps) {
autoFitScale.value = 1;
return;
}
const pad = 20;
const availW = Math.max(0, host.clientWidth - pad);
const availH = Math.max(0, host.clientHeight - pad);
if (availW <= 0 || availH <= 0 || ps.wPx <= 0 || ps.hPx <= 0) {
autoFitScale.value = 1;
return;
}
const raw = Math.min(availW / ps.wPx, availH / ps.hPx, 1) * 0.96;
autoFitScale.value = Number.isFinite(raw) && raw > 0 ? raw : 1;
}
function zoomPreviewIn() {
zoomMultiplier.value = Math.min(ZOOM_MULT_MAX, zoomMultiplier.value * ZOOM_STEP);
scheduleScrollPreviewCenter();
}
function zoomPreviewOut() {
zoomMultiplier.value = Math.max(ZOOM_MULT_MIN, zoomMultiplier.value / ZOOM_STEP);
scheduleScrollPreviewCenter();
}
/** 缩放后把可视区域滚到预览块大致居中,便于看到全貌 */
function scheduleScrollPreviewCenter() {
void nextTick(() => {
const host = previewHostRef.value;
if (!host) {
return;
}
const slot = host.querySelector('.preview-zoom-slot') as HTMLElement | null;
if (!slot) {
return;
}
const sr = slot.getBoundingClientRect();
const hr = host.getBoundingClientRect();
const nextLeft = host.scrollLeft + (sr.left - hr.left) + sr.width / 2 - host.clientWidth / 2;
const nextTop = host.scrollTop + (sr.top - hr.top) + sr.height / 2 - host.clientHeight / 2;
host.scrollTo({
left: Math.max(0, nextLeft),
top: Math.max(0, nextTop),
behavior: 'smooth',
});
});
}
/** 恢复为仅按窗口自动适应(清除手动缩放) */
function resetPreviewZoom() {
zoomMultiplier.value = 1;
scheduleComputeScale();
scheduleScrollPreviewCenter();
}
function onPreviewIframeLoad() {
updateContentMeasure();
// 二维码等异步绘制后再测一次,避免首次高度偏小
window.setTimeout(() => updateContentMeasure(), 400);
}
/** 调用浏览器打印预览 iframe 内文档(与当前预览 HTML 一致) */
function handleBrowserPrint() {
const win = previewIframeRef.value?.contentWindow;
if (!win) {
createMessage.warning('预览未就绪,请稍后再试');
return;
}
try {
win.focus();
win.print();
} catch (_e) {
createMessage.error('无法唤起打印,请检查浏览器是否拦截弹窗');
}
}
function scheduleComputeScale() {
void nextTick(() => computePreviewScale());
}
useResizeObserver(previewHostRef, () => {
scheduleComputeScale();
});
watch([paperPixelSize, previewHtml, () => props.open], () => {
contentMeasurePx.value = { w: 0, h: 0 };
scheduleComputeScale();
});
watch([layoutPaperPx, () => props.open], () => {
if (props.open) {
scheduleComputeScale();
}
});
function onClose() {
errorText.value = '';
schema.value = null;
previewHtml.value = '';
zoomMultiplier.value = 1;
autoFitScale.value = 1;
contentMeasurePx.value = { w: 0, h: 0 };
}
function onGenerateMock() {
if (!schema.value) {
return;
}
try {
const mockObj = generateNativeMockDataObject(schema.value.elements, canvasJsonText.value);
mockDataText.value = JSON.stringify(mockObj, null, 2);
createMessage.success('已根据画布组件生成模拟数据 JSON');
} catch (e: any) {
createMessage.error(`生成失败:${e?.message || '未知错误'}`);
}
}
watch(
() => [props.open, props.templateId] as const,
async ([isOpen, id]) => {
if (!isOpen || !id) {
return;
}
loading.value = true;
errorText.value = '';
schema.value = null;
previewHtml.value = '';
zoomMultiplier.value = 1;
autoFitScale.value = 1;
contentMeasurePx.value = { w: 0, h: 0 };
try {
const record = (await queryById(id)) as Record<string, any>;
const rawText = String(record?.templateJson || '').trim();
if (!rawText) {
errorText.value = '该记录没有模板 JSON';
return;
}
const parsed = JSON.parse(rawText);
if (parsed?.engine !== 'native') {
errorText.value = '仅原生模板engine=native支持此预览';
return;
}
const normalized = normalizeImportedNativeSchema(parsed);
const pw = Number(record?.paperWidthMm);
const ph = Number(record?.paperHeightMm);
if (pw > 0 && ph > 0) {
normalized.page.width = pw;
normalized.page.height = ph;
}
schema.value = normalized;
canvasJsonText.value = stringifyNativeTemplateStyle(normalized);
const mockObj = generateNativeMockDataObject(normalized.elements, canvasJsonText.value);
mockDataText.value = JSON.stringify(mockObj, null, 2);
previewDataText.value = mockDataText.value;
dataSourceType.value = 'mock';
jsonTabKey.value = 'params';
await refreshPreview();
scheduleComputeScale();
} catch (e: any) {
errorText.value = e?.message || '加载模板失败';
} finally {
loading.value = false;
}
},
{ immediate: false },
);
watch([activeDataText, schema, () => props.open], () => {
if (!props.open || !schema.value) {
return;
}
void debouncedRefreshPreview();
});
watch(
() => props.open,
(v) => {
if (!v) {
onClose();
}
},
);
</script>
<style scoped lang="less">
.native-preview-error {
padding: 12px 4px;
color: #cf1322;
}
.native-preview-row {
min-height: 440px;
align-items: stretch;
}
.native-preview-left {
display: flex;
flex-direction: column;
min-height: 0;
}
.native-preview-right {
display: flex;
flex-direction: column;
min-height: 440px;
max-height: 72vh;
}
.json-tabs {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #f0f0f0;
border-radius: 10px;
background: #fff;
padding: 10px 12px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.json-tabs :deep(.ant-tabs-content-holder) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.json-tabs :deep(.ant-tabs-content),
.json-tabs :deep(.ant-tabs-tabpane) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.json-template-pane,
.params-json-pane {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-height: 0;
}
.json-box-title {
margin-bottom: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
}
.json-textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 12px;
line-height: 1.45;
}
.json-textarea--main {
flex: 1;
min-height: 200px;
resize: none;
}
.params-json-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
/* 手动 / 模拟:胶囊分段(与设计器视觉一致) */
.json-sub-tabs.json-sub-tabs--segmented {
display: inline-flex;
align-items: center;
padding: 3px 5px;
background: #f0f2f5;
border-radius: 999px;
border: 1px solid #e8e8e8;
gap: 3px;
flex-shrink: 0;
}
.capsule-tab {
margin: 0;
padding: 4px 14px;
border: none;
border-radius: 999px;
background: transparent;
font-size: 12px;
line-height: 1.45;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s;
}
.capsule-tab:hover:not(.is-active) {
color: rgba(0, 0, 0, 0.88);
}
.capsule-tab.is-active {
background: #fff;
color: #1677ff;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.params-json-head :deep(.json-capsule-btn.ant-btn) {
height: 28px;
padding: 0 16px;
line-height: 26px;
border-radius: 999px;
}
.preview-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
flex-shrink: 0;
}
.preview-title {
margin-bottom: 0;
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.preview-header-actions {
flex-shrink: 0;
justify-content: flex-end;
}
.preview-print-btn {
display: inline-flex;
align-items: center;
gap: 4px;
}
.preview-zoom-label {
min-width: 40px;
text-align: center;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
user-select: none;
}
.preview-zoom-btn,
.preview-zoom-fit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.preview-zoom-fit-btn {
padding: 0 10px;
font-size: 12px;
}
.preview-frame-wrap {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 10px;
background: #f5f5f5;
padding: 12px;
box-sizing: border-box;
}
/* 至少占满可视区;内容超出时由外层 preview-frame-wrap 滚动,避免 flex 居中裁切放大内容 */
.preview-scroll-flex {
min-height: 100%;
display: flex;
box-sizing: border-box;
}
.preview-zoom-slot {
margin: auto;
flex-shrink: 0;
}
.preview-scale-shim {
overflow: visible;
flex-shrink: 0;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.preview-scale-inner {
transform-origin: top left;
will-change: transform;
}
.preview-iframe {
display: block;
border: none;
background: #fff;
}
</style>
<!-- 弹窗在窄屏下不超出视口 -->
<style lang="less">
.native-template-list-preview-modal .ant-modal {
max-width: calc(100vw - 24px) !important;
}
</style>

View File

@@ -8,15 +8,30 @@
import { computed, ref, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form';
import { formSchema } from '../printTemplate.data';
import { formSchema, PAPER_PRESET_MAP } from '../printTemplate.data';
import { add, edit } from '../printTemplate.api';
const emit = defineEmits(['success', 'register']);
const isUpdate = ref(false);
const isNativeMode = ref(false);
const title = computed(() => (!unref(isUpdate) ? '新增打印模板' : '编辑打印模板'));
function inferPresetBySize(record: Recordable) {
const width = Number(record?.paperWidthMm || 0);
const height = Number(record?.paperHeightMm || 0);
const orientation = String(record?.paperOrientation || 'portrait') as 'portrait' | 'landscape';
if (!width || !height) {
return undefined;
}
const key = Object.keys(PAPER_PRESET_MAP).find((presetKey) => {
const item = PAPER_PRESET_MAP[presetKey];
return item.width === width && item.height === height && item.orientation === orientation;
});
return key || 'CUSTOM';
}
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 110,
schemas: formSchema,
@@ -28,30 +43,94 @@
resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
isNativeMode.value = data?.isNative === true;
if (unref(isUpdate) && data?.record) {
const paperPreset = inferPresetBySize(data.record);
setFieldsValue({
...data.record,
paperPreset: paperPreset || 'CUSTOM',
});
return;
}
setFieldsValue({
paperPreset: 'A4',
});
});
/** 编辑时在列表里改了纸张,需同步写回 templateJson.page否则原生设计器仍读旧尺寸 */
function mergeNativePaperIntoTemplateJson(templateJson: string, paperWidthMm: number, paperHeightMm: number): string {
const width = Math.max(10, Number(paperWidthMm) || 210);
const height = Math.max(10, Number(paperHeightMm) || 297);
try {
const parsed = JSON.parse(templateJson || '{}');
if (parsed?.engine === 'native') {
if (!parsed.page || typeof parsed.page !== 'object') {
parsed.page = { unit: 'mm', margin: [10, 10, 10, 10], gridSize: 2 };
}
parsed.page.width = width;
parsed.page.height = height;
return JSON.stringify(parsed);
}
} catch {
/* 非 JSON 或损坏则原样返回 */
}
return templateJson;
}
function buildNativeTemplateJson(values: Recordable) {
const width = Math.max(10, Number(values?.paperWidthMm || 210));
const height = Math.max(10, Number(values?.paperHeightMm || 297));
return JSON.stringify({
engine: 'native',
version: '1.0.0',
page: {
width,
height,
unit: 'mm',
margin: [10, 10, 10, 10],
gridSize: 2,
},
elements: [],
dataBinding: {
fieldMap: {},
tableSources: ['mainTable', 'detailList'],
},
});
}
async function handleSubmit() {
try {
const values = await validate();
delete values.paperPreset;
if (unref(isUpdate) && values.templateJson) {
values.templateJson = mergeNativePaperIntoTemplateJson(
String(values.templateJson),
Number(values.paperWidthMm),
Number(values.paperHeightMm),
);
}
if (!unref(isUpdate)) {
delete values.id;
if (!values.templateJson) {
if (isNativeMode.value) {
values.templateJson = buildNativeTemplateJson(values);
} else if (!values.templateJson) {
values.templateJson = '{}';
}
}
setModalProps({ confirmLoading: true });
let savedResult: any = null;
if (unref(isUpdate)) {
await edit(values);
savedResult = await edit(values);
} else {
await add(values);
savedResult = await add(values);
}
closeModal();
emit('success');
emit('success', {
isNative: isNativeMode.value,
isUpdate: unref(isUpdate),
values,
savedResult,
});
} finally {
setModalProps({ confirmLoading: false });
}

View File

@@ -1,215 +1,252 @@
import { hiprint } from 'vue-plugin-hiprint';
/**
* QH-MES 自定义 provider参考 vue-plugin-hiprint 动态 provider 机制)
* - 新增一组“报表/套打”常用组件
* - 提供一个默认的“普通明细表”(单行表头)
*
* 注意:此 provider 不替换 defaultElementTypeProvider只做补充。
*/
export function createQhmesProvider() {
const key = 'qhmesModule';
const addElementTypes = function (context: any) {
// 避免重复注册
context.removePrintElementTypes(key);
const commonText = (tid: string, title: string, extraOptions: Record<string, any> = {}) => {
return {
tid,
title,
type: 'text',
options: {
title,
field: '',
testData: title,
...extraOptions,
},
};
};
const elements: any[] = [
commonText(`${key}.reportTitle`, '报表标题', { fontSize: 18, fontWeight: 'bold', textAlign: 'center' }),
commonText(`${key}.subTitle`, '副标题', { fontSize: 12, textAlign: 'center' }),
commonText(`${key}.labelValue`, '标签:值', { fontSize: 10 }),
commonText(`${key}.pageNo`, '页码', { field: 'pageNumber', testData: '1/1' }),
// 二维码/条码(用 text + textType
{
tid: `${key}.qrcode`,
title: '二维码',
type: 'text',
options: {
title: '二维码',
field: 'qrcode',
testData: 'QRCODE_DEMO',
textType: 'qrcode',
width: 35,
height: 35,
},
},
{
tid: `${key}.barcode`,
title: '条形码',
type: 'text',
options: {
title: '条形码',
field: 'barcode',
testData: '1234567890',
textType: 'barcode',
width: 80,
height: 25,
},
},
// 普通明细表(单行表头,支持多级分组合并)
{
tid: `${key}.tableSimple`,
title: '普通明细表',
type: 'html',
options: {
title: '普通明细表',
field: 'table',
testData: '',
width: 180,
height: 60,
__qhmesManaged: true,
columns: [
{ title: '物料', order: 0, field: 'name', width: 90, align: 'left' },
{ title: '数量', order: 1, field: 'qty', width: 45, align: 'right' },
{ title: '金额', order: 2, field: 'amount', width: 45, align: 'right' },
],
groupFields: [],
formatter: `
function(t,e,printData){
var opts = (t && t.options) ? t.options : {};
var list = printData && Array.isArray(printData[opts.field || 'table']) ? printData[opts.field || 'table'] : [];
var globalCols = printData && Array.isArray(printData.__qhmesTableColumns) ? printData.__qhmesTableColumns : [];
var columns = Array.isArray(opts.columns) && opts.columns.length ? opts.columns : (globalCols.length ? globalCols : [
{ title: '物料', order: 0, field: 'name', width: 90, align: 'left' },
{ title: '数量', order: 1, field: 'qty', width: 45, align: 'right' },
{ title: '金额', order: 2, field: 'amount', width: 45, align: 'right' }
]);
columns = columns.slice().sort(function(a,b){
var ao = Number(a && a.order);
var bo = Number(b && b.order);
var av = isFinite(ao) ? ao : 9999;
var bv = isFinite(bo) ? bo : 9999;
return av - bv;
});
var globalGroups = printData && Array.isArray(printData.__qhmesGroupFields) ? printData.__qhmesGroupFields : [];
var groupFields = Array.isArray(opts.groupFields) && opts.groupFields.length ? opts.groupFields : globalGroups;
var style = opts.__qhmesStyle || {};
var fontSize = style.fontSize || 10;
var borderColor = style.borderColor || '#000';
var borderWidth = style.borderWidth || 1;
var cellPadding = style.cellPadding || '2pt 4pt';
var headerBg = style.headerBg || '';
var tableWidth = style.tableWidth || '100%';
function esc(v){
if (v === null || v === undefined) return '';
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function isGroupCol(field){
return groupFields.indexOf(field) > -1;
}
// 计算每个分组列在每行的 rowspan多级上层一致前提下再判断下层
var rowspanMap = {};
for (var c=0;c<columns.length;c++){
var f = columns[c].field;
rowspanMap[f] = new Array(list.length).fill(1);
}
for (var g=0; g<groupFields.length; g++){
var gf = groupFields[g];
// 分组字段可能不在 columns 中,需先兜底初始化,避免 rowspanMap[gf] 未定义报错
if (!rowspanMap[gf]) rowspanMap[gf] = new Array(list.length).fill(1);
var i = 0;
while(i < list.length){
var j = i + 1;
while(j < list.length){
var upperOk = true;
for (var up=0; up<g; up++){
var uf = groupFields[up];
if ((list[j][uf] ?? '') !== (list[i][uf] ?? '')) { upperOk = false; break; }
}
if (!upperOk) break;
if ((list[j][gf] ?? '') === (list[i][gf] ?? '')) j++;
else break;
}
var span = j - i;
if (!rowspanMap[gf]) rowspanMap[gf] = new Array(list.length).fill(1);
rowspanMap[gf][i] = span;
for (var k=i+1;k<j;k++) rowspanMap[gf][k] = 0;
i = j;
}
}
var html = '<table style="width:'+tableWidth+';border-collapse:collapse;table-layout:fixed;font-size:'+fontSize+'pt;">';
html += '<thead><tr>';
for (var h=0;h<columns.length;h++){
var hc = columns[h];
var hw = hc.width ? ('width:'+hc.width+'pt;') : '';
var colBg = hc.headerBg || '';
var hbg = (colBg || headerBg) ? ('background:'+(colBg || headerBg)+';') : '';
html += '<th style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:center;'+hbg+hw+'">'+esc(hc.title || hc.field || '')+'</th>';
}
html += '</tr></thead><tbody>';
for (var r=0;r<list.length;r++){
html += '<tr>';
for (var cc=0;cc<columns.length;cc++){
var col = columns[cc];
var field = col.field;
var align = col.align || 'left';
if (isGroupCol(field)){
var rs = rowspanMap[field][r];
if (rs > 0){
html += '<td rowspan="'+rs+'" style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:center;vertical-align:middle;">'+esc(list[r][field])+'</td>';
}
}else{
html += '<td style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:'+align+';">'+esc(list[r][field])+'</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
`,
},
},
{
tid: `${key}.tableSingleHeader`,
title: '单行表头表格',
type: 'table',
options: {
title: '单行表头表格',
field: 'table',
testData: '',
width: 180,
height: 60,
},
columns: [
[
{ title: '单号', field: 'fbillno', width: 55, align: 'left', colspan: 1, rowspan: 1 },
{ title: '物料', field: 'name', width: 75, align: 'left', colspan: 1, rowspan: 1 },
{ title: '数量', field: 'qty', width: 35, align: 'right', colspan: 1, rowspan: 1 },
{ title: '金额', field: 'amount', width: 35, align: 'right', colspan: 1, rowspan: 1 },
],
],
},
];
// 分组(左侧面板展示更友好)
const groups = [
new (hiprint as any).PrintElementTypeGroup('HttpPrinter风格组件', elements),
];
context.addPrintElementTypes(key, groups);
};
return { addElementTypes };
export interface DesignerSampleData {
[key: string]: any;
}
export interface MultiHeaderColumnConfig {
id: string;
title: string;
field: string;
width?: number;
align?: 'left' | 'center' | 'right';
}
export interface MultiHeaderGroupConfig {
id: string;
title: string;
columns: MultiHeaderColumnConfig[];
}
export const defaultMultiHeaderConfig: MultiHeaderGroupConfig[] = [
{
id: 'group_base',
title: '基础信息',
columns: [{ id: 'col_day', title: '日期', field: 'day', width: 80, align: 'center' }],
},
{
id: 'group_qty',
title: '产量信息',
columns: [
{ id: 'col_planQty', title: '计划数', field: 'planQty', width: 90, align: 'right' },
{ id: 'col_actualQty', title: '实际数', field: 'actualQty', width: 90, align: 'right' },
{ id: 'col_passRate', title: '达成率', field: 'passRate', width: 90, align: 'center' },
],
},
];
export const defaultTemplateJson = {
panels: [
{
index: 0,
paperType: 'A4',
height: 297,
width: 210,
paperHeader: 8,
paperFooter: 8,
printElements: [
{
options: {
left: 12,
top: 10,
height: 16,
width: 186,
title: 'QH-MES生产工单',
textType: 'text',
fontSize: 16,
fontWeight: '700',
textAlign: 'center',
},
printElementType: {
title: '文本',
type: 'text',
},
},
],
},
],
};
export const defaultPrintData: DesignerSampleData = {
docNo: 'MO-20260409001',
orderNo: 'SO-20260408-003',
customerName: '华东电子科技',
printTime: '2026-04-09 14:30:21',
operator: '张三',
mainTable: [
{
materialCode: 'MAT-001',
materialName: '主控板',
spec: 'A版-24V',
qty: 1200,
unit: 'PCS',
remark: '优先排产',
tp: 'TP-MAT-001-20260409',
},
{
materialCode: 'MAT-002',
materialName: '传感器',
spec: 'TH-08',
qty: 3000,
unit: 'PCS',
remark: '',
tp: 'TP-MAT-002-20260409',
},
],
detailList: [
{
processName: '贴片',
machineNo: 'SMT-03',
worker: '李四',
startTime: '2026-04-09 08:10',
endTime: '2026-04-09 10:45',
okQty: 1180,
ngQty: 20,
tp: 'TP-SMT03-202604090810',
},
{
processName: '贴片',
machineNo: 'SMT-03',
worker: '李四',
startTime: '2026-04-09 10:50',
endTime: '2026-04-09 11:40',
okQty: 1210,
ngQty: 15,
tp: 'TP-SMT03-202604091050',
},
{
processName: '贴片',
machineNo: 'SMT-03',
worker: '李四',
startTime: '2026-04-09 13:00',
endTime: '2026-04-09 14:25',
okQty: 1195,
ngQty: 18,
tp: 'TP-SMT03-202604091300',
},
{
processName: '回流焊',
machineNo: 'RF-01',
worker: '王五',
startTime: '2026-04-09 11:00',
endTime: '2026-04-09 12:30',
okQty: 1170,
ngQty: 10,
tp: 'TP-RF01-202604091100',
},
{
processName: '回流焊',
machineNo: 'RF-01',
worker: '王五',
startTime: '2026-04-09 14:30',
endTime: '2026-04-09 15:40',
okQty: 1220,
ngQty: 12,
tp: 'TP-RF01-202604091430',
},
{
processName: '回流焊',
machineNo: 'RF-01',
worker: '王五',
startTime: '2026-04-09 15:45',
endTime: '2026-04-09 17:10',
okQty: 1205,
ngQty: 16,
tp: 'TP-RF01-202604091545',
},
{
processName: '插件',
machineNo: 'AI-02',
worker: '赵六',
startTime: '2026-04-10 08:05',
endTime: '2026-04-10 09:25',
okQty: 980,
ngQty: 8,
tp: 'TP-AI02-202604100805',
},
{
processName: '插件',
machineNo: 'AI-02',
worker: '赵六',
startTime: '2026-04-10 09:30',
endTime: '2026-04-10 11:00',
okQty: 1015,
ngQty: 11,
tp: 'TP-AI02-202604100930',
},
{
processName: '插件',
machineNo: 'AI-03',
worker: '赵六',
startTime: '2026-04-10 13:20',
endTime: '2026-04-10 14:50',
okQty: 990,
ngQty: 9,
tp: 'TP-AI03-202604101320',
},
{
processName: '测试',
machineNo: 'TEST-01',
worker: '孙七',
startTime: '2026-04-10 15:00',
endTime: '2026-04-10 16:10',
okQty: 950,
ngQty: 14,
tp: 'TP-TEST01-202604101500',
},
{
processName: '测试',
machineNo: 'TEST-01',
worker: '孙七',
startTime: '2026-04-10 16:15',
endTime: '2026-04-10 17:25',
okQty: 965,
ngQty: 10,
tp: 'TP-TEST01-202604101615',
},
{
processName: '包装',
machineNo: 'PK-01',
worker: '周八',
startTime: '2026-04-11 08:30',
endTime: '2026-04-11 10:00',
okQty: 1880,
ngQty: 6,
tp: 'TP-PK01-202604110830',
},
{
processName: '包装',
machineNo: 'PK-01',
worker: '周八',
startTime: '2026-04-11 10:05',
endTime: '2026-04-11 11:40',
okQty: 1920,
ngQty: 5,
tp: 'TP-PK01-202604111005',
},
],
multiHeaderTable: [
{ day: '周一', planQty: 600, actualQty: 580, passRate: '96.67%' },
{ day: '周二', planQty: 620, actualQty: 600, passRate: '96.77%' },
{ day: '周三', planQty: 640, actualQty: 635, passRate: '99.22%' },
],
};
export const dragElementList = [
{ label: '文本', tid: 'defaultModule.text', tip: '单行文本' },
{ label: '长文本', tid: 'defaultModule.longText', tip: '自动换行文本' },
{ label: '图片', tid: 'defaultModule.image', tip: '支持动态图片链接' },
{ label: '条形码', tid: 'defaultModule.barcode', tip: '常用于单据编码' },
{ label: '二维码', tid: 'defaultModule.qrcode', tip: '常用于追溯码' },
{ label: '表格', tid: 'defaultModule.table', tip: '主表或明细表' },
{ label: '横线', tid: 'defaultModule.hline', tip: '分割线' },
{ label: '竖线', tid: 'defaultModule.vline', tip: '分割线' },
{ label: '矩形', tid: 'defaultModule.rect', tip: '区域框选' },
];
export function resolveProviders(hiprintModule: Record<string, any>) {
const providers: any[] = [];
const defaultProviderCtor = hiprintModule?.defaultElementTypeProvider;
if (typeof defaultProviderCtor === 'function') {
providers.push(new defaultProviderCtor());
}
return providers;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
/**
* 动态加载 C-Lodop 本地服务提供的 CLodopfuncs.js。
* 仅安装并启动 C-Lodop 客户端时若页面未引入该脚本window 上不会出现 getLodop导致误判「未检测到」。
*/
function hasLodopGlobal(): boolean {
const w = window as any;
return typeof w.getLodop === 'function' || !!w.LODOP || !!w.CLODOP;
}
let scriptIdSeq = 0;
const urlToScriptId = new Map<string, string>();
function scriptDomId(url: string): string {
let id = urlToScriptId.get(url);
if (!id) {
id = `qhmes-clodop-script-${++scriptIdSeq}`;
urlToScriptId.set(url, id);
}
return id;
}
function loadScriptOnce(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const id = scriptDomId(src);
const existed = document.getElementById(id) as HTMLScriptElement | null;
if (existed) {
if (existed.getAttribute('data-loaded') === '1') {
resolve();
return;
}
existed.addEventListener('load', () => resolve(), { once: true });
existed.addEventListener(
'error',
() => reject(new Error(`加载失败: ${src}`)),
{ once: true },
);
return;
}
const s = document.createElement('script');
s.id = id;
s.src = src;
s.async = true;
s.setAttribute('data-qhmes-clodop-src', src);
s.onload = () => {
s.setAttribute('data-loaded', '1');
resolve();
};
s.onerror = () => {
s.remove();
urlToScriptId.delete(src);
reject(new Error(`加载失败: ${src}`));
};
document.head.appendChild(s);
});
}
function removeOurScript(url: string) {
const id = urlToScriptId.get(url);
if (id) {
document.getElementById(id)?.remove();
urlToScriptId.delete(url);
}
}
/** 按当前页面协议列出常见 C-Lodop 脚本地址(与官方文档端口一致) */
function candidateClodopScriptUrls(): string[] {
const isHttps = window.location.protocol === 'https:';
if (isHttps) {
return [
'https://localhost.lodop.net:8443/CLodopfuncs.js',
'https://127.0.0.1:8443/CLodopfuncs.js',
// 混合内容HTTPS 页面可能拦截下列地址,仅作兜底尝试
'http://localhost:8000/CLodopfuncs.js',
'http://127.0.0.1:8000/CLodopfuncs.js',
];
}
return [
'http://localhost:8000/CLodopfuncs.js',
'http://127.0.0.1:8000/CLodopfuncs.js',
'http://localhost:18000/CLodopfuncs.js',
'http://127.0.0.1:18000/CLodopfuncs.js',
];
}
let loadPromise: Promise<void> | null = null;
/**
* 确保已加载 CLodopfuncs.js多次调用共享同一 Promise失败后可重试
*/
export function ensureClodopScriptLoaded(): Promise<void> {
if (hasLodopGlobal()) {
return Promise.resolve();
}
if (!loadPromise) {
loadPromise = (async () => {
let lastError: Error | null = null;
for (const url of candidateClodopScriptUrls()) {
try {
await loadScriptOnce(url);
if (hasLodopGlobal()) {
return;
}
lastError = new Error(`已加载脚本但未发现 getLodop${url}`);
} catch (e: any) {
lastError = e instanceof Error ? e : new Error(String(e));
}
removeOurScript(url);
}
throw (
lastError ||
new Error(
'无法从本机加载 CLodopfuncs.js。若站点为 HTTPS请安装 C-Lodop 扩展版并在浏览器中信任 https://localhost.lodop.net:8443 证书后重试。',
)
);
})().catch((e) => {
loadPromise = null;
throw e;
});
}
return loadPromise;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,622 @@
<template>
<div class="binding-detail-fields-editor">
<div class="binding-actions-row">
<a-button type="primary" size="small" @click="openBatchTablesModal">批量新增明细表</a-button>
<a-button type="primary" size="small" :disabled="!detailTables.length" @click="openBatchFieldsModal">批量新增字段</a-button>
<a-button
size="small"
:danger="selectedTableRowKeys.length > 0"
:disabled="!selectedTableRowKeys.length"
@click="batchDeleteTables"
>
批量删除
</a-button>
</div>
<a-table
v-model:expandedRowKeys="expandedRowKeys"
size="small"
:columns="masterColumns"
:data-source="detailTables"
:row-key="(r) => r.tableKey"
:pagination="false"
:scroll="{ y: detailMainTableScrollY }"
:row-selection="{
selectedRowKeys: selectedTableRowKeys,
onChange: onTableSelectChange,
}"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'tableKey'">
<a-input
:value="record.tableKey"
size="small"
placeholder="数据源键 List1"
@update:value="(v) => onInlineTableKeyChange(record, String(v ?? ''), index)"
/>
</template>
<template v-else-if="column.dataIndex === 'label'">
<a-input
:value="record.label ?? ''"
size="small"
placeholder="显示名可选"
@update:value="(v) => onInlineTableLabelChange(record.tableKey, String(v ?? ''))"
/>
</template>
<template v-else-if="column.dataIndex === 'fieldCount'">
<span class="field-count">{{ (record.fields || []).length }} 个</span>
</template>
</template>
<template #expandedRowRender="{ record }">
<div class="nested-fields">
<div class="nested-fields-head">
<span>字段列表({{ record.tableKey }}</span>
<a-button type="link" size="small" @click="addFieldRow(record.tableKey)">添加字段</a-button>
</div>
<a-table
size="small"
:columns="fieldColumns"
:data-source="record.fields || []"
:row-key="(f) => `${record.tableKey}__${f.key}`"
:pagination="false"
:show-header="true"
:scroll="{ y: nestedFieldsTableScrollY }"
>
<template #bodyCell="{ column: fcol, record: frec }">
<template v-if="fcol.dataIndex === 'key'">
<a-input
:value="frec.key"
size="small"
placeholder="字段键 Field1"
@update:value="(v) => onInlineFieldKeyChange(record.tableKey, frec.key, String(v ?? ''))"
/>
</template>
<template v-else-if="fcol.dataIndex === 'label'">
<a-input
:value="frec.label ?? ''"
size="small"
placeholder="显示名"
@update:value="(v) => onInlineFieldLabelChange(record.tableKey, frec.key, String(v ?? ''))"
/>
</template>
<template v-else-if="fcol.dataIndex === 'actions'">
<a-button type="link" danger size="small" @click="removeField(record.tableKey, frec.key)">删除</a-button>
</template>
</template>
</a-table>
</div>
</template>
</a-table>
<!-- 批量新增明细表:与「参数」批量弹窗同一结构(快速生成 + 双输入手动行) -->
<a-modal
v-model:open="batchTablesModalOpen"
title="批量新增明细表"
ok-text="确定添加"
cancel-text="取消"
width="560px"
@ok="confirmBatchTables"
@cancel="resetBatchTablesModal"
>
<div class="modal-section">
<div class="section-title">快速生成</div>
<div class="section-desc">按数量自动生成,数据源键为 List + 数字,显示名为 列表 + 数字与参数侧「Parameter + 数字 / 参数 + 数字」对应)。生成结果在下方列表中,可再修改。</div>
<a-space align="center" wrap class="quick-row">
<span class="quick-label">数量</span>
<a-input-number v-model:value="quickTableCount" :min="1" :max="200" :precision="0" style="width: 120px" />
<a-button type="primary" @click="quickGenerateTables">一键新增</a-button>
</a-space>
</div>
<a-divider style="margin: 14px 0" />
<div class="modal-section">
<div class="section-title">手动添加</div>
<div class="section-desc">每行两个输入框:左侧数据源键,右侧显示名。可点「添加一行」增加空行。</div>
<div v-for="(row, idx) in batchTableRows" :key="idx" class="manual-row">
<a-input v-model:value="row.tableKey" size="small" placeholder="数据源键" allow-clear />
<a-input v-model:value="row.label" size="small" placeholder="显示名可选" allow-clear />
<a-button type="text" danger size="small" :disabled="batchTableRows.length <= 1" @click="removeBatchTableRow(idx)">删除</a-button>
</div>
<a-button type="dashed" block size="small" class="add-row-btn" @click="addBatchTableRow">添加一行</a-button>
</div>
</a-modal>
<!-- 批量新增字段:同一套区块样式;快速生成写入下方草稿行 -->
<a-modal
v-model:open="batchFieldsModalOpen"
title="批量新增字段"
ok-text="确定添加"
cancel-text="取消"
width="560px"
@ok="confirmBatchFields"
@cancel="resetBatchFieldsModal"
>
<div class="modal-section">
<div class="section-title">快速生成</div>
<div class="section-desc">
先选择父级明细表,再填数量:字段键为 Field + 数字,显示名为 字段 + 数字。生成结果追加到下方「手动添加」列表,可再修改后点「确定添加」。
</div>
<a-space direction="vertical" style="width: 100%" size="small">
<a-select
v-model:value="quickFieldParentKey"
:options="parentTableOptions"
show-search
option-filter-prop="label"
placeholder="选择父级明细数据源"
style="width: 100%"
/>
<a-space align="center" wrap class="quick-row">
<span class="quick-label">数量</span>
<a-input-number v-model:value="quickFieldCount" :min="1" :max="200" :precision="0" style="width: 120px" />
<a-button type="primary" :disabled="!quickFieldParentKey" @click="quickGenerateFieldsToRows">一键新增</a-button>
</a-space>
</a-space>
</div>
<a-divider style="margin: 14px 0" />
<div class="modal-section">
<div class="section-title">手动添加</div>
<div class="section-desc">每行:父级数据源键、字段键、显示名(可选)。父级须为已登记的明细表。</div>
<div v-for="(row, idx) in batchFieldRows" :key="idx" class="manual-row manual-row--triple">
<a-input v-model:value="row.parentKey" size="small" placeholder="父级数据源键" allow-clear />
<a-input v-model:value="row.fieldKey" size="small" placeholder="字段键" allow-clear />
<a-input v-model:value="row.label" size="small" placeholder="显示名可选" allow-clear />
<a-button type="text" danger size="small" :disabled="batchFieldRows.length <= 1" @click="removeBatchFieldRow(idx)">删除</a-button>
</div>
<a-button type="dashed" block size="small" class="add-row-btn" @click="addBatchFieldRow">添加一行</a-button>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import type { NativeDataBindingDetailField, NativeDataBindingDetailTable } from '../core/types';
const props = defineProps<{
detailTables: NativeDataBindingDetailTable[];
}>();
const emit = defineEmits<{
(e: 'update:detailTables', value: NativeDataBindingDetailTable[]): void;
}>();
const { createMessage } = useMessage();
const viewportH = ref(typeof window !== 'undefined' ? window.innerHeight : 800);
function onResize() {
viewportH.value = window.innerHeight;
}
onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));
/** 主表明细数据源列表可视高度 */
const detailMainTableScrollY = computed(() => {
const h = viewportH.value;
return Math.round(Math.min(500, Math.max(220, h - 380)));
});
/** 展开行内字段子表可视高度 */
const nestedFieldsTableScrollY = computed(() => {
const h = viewportH.value;
return Math.round(Math.min(300, Math.max(140, h - 520)));
});
const expandedRowKeys = ref<string[]>([]);
const selectedTableRowKeys = ref<string[]>([]);
const batchTablesModalOpen = ref(false);
const quickTableCount = ref(1);
const batchTableRows = ref<Array<{ tableKey: string; label: string }>>([{ tableKey: '', label: '' }]);
const batchFieldsModalOpen = ref(false);
const quickFieldParentKey = ref<string | undefined>(undefined);
const quickFieldCount = ref(1);
const batchFieldRows = ref<Array<{ parentKey: string; fieldKey: string; label: string }>>([
{ parentKey: '', fieldKey: '', label: '' },
]);
const parentTableOptions = computed(() =>
(props.detailTables || []).map((t) => ({
value: t.tableKey,
label: t.label ? `${t.tableKey}${t.label}` : t.tableKey,
})),
);
const masterColumns = [
{ title: '数据源', dataIndex: 'tableKey', ellipsis: true, width: 96 },
{ title: '显示名', dataIndex: 'label', ellipsis: true },
{ title: '字段数', dataIndex: 'fieldCount', width: 72, align: 'right' as const },
];
const fieldColumns = [
{ title: '字段键', dataIndex: 'key', ellipsis: true },
{ title: '显示名', dataIndex: 'label', ellipsis: true },
{ title: '操作', dataIndex: 'actions', width: 56, align: 'center' as const },
];
watch(
() => props.detailTables.map((t) => t.tableKey).join(','),
() => {
const keys = new Set(props.detailTables.map((t) => t.tableKey));
selectedTableRowKeys.value = selectedTableRowKeys.value.filter((k) => keys.has(k));
expandedRowKeys.value = expandedRowKeys.value.filter((k) => keys.has(k));
},
);
function onTableSelectChange(keys: string[]) {
selectedTableRowKeys.value = keys;
}
/** 已占用的数据源键:主列表 + 弹窗草稿 */
function collectUsedTableKeys(): Set<string> {
const s = new Set(props.detailTables.map((t) => t.tableKey.trim()).filter(Boolean));
batchTableRows.value.forEach((r) => {
const k = r.tableKey.trim();
if (k) s.add(k);
});
return s;
}
function maxListSuffix(used: Set<string>): number {
let max = 0;
for (const k of used) {
const m = String(k).match(/^List(\d+)$/i);
if (m) max = Math.max(max, Number(m[1]));
}
return max;
}
function maxFieldSuffixForParent(parentKey: string): number {
const used = new Set<string>();
const table = props.detailTables.find((t) => t.tableKey === parentKey);
(table?.fields || []).forEach((f) => used.add(f.key));
batchFieldRows.value.forEach((r) => {
if (String(r.parentKey ?? '').trim() === parentKey && r.fieldKey.trim()) used.add(r.fieldKey.trim());
});
let max = 0;
for (const k of used) {
const m = String(k).match(/^Field(\d+)$/i);
if (m) max = Math.max(max, Number(m[1]));
}
return max;
}
function resolveTableIndex(record: NativeDataBindingDetailTable, index?: number) {
if (typeof index === 'number' && index >= 0) return index;
return props.detailTables.findIndex((t) => t.tableKey === record.tableKey);
}
function onInlineTableKeyChange(record: NativeDataBindingDetailTable, newKey: string, index: number) {
const idx = resolveTableIndex(record, index);
const oldKey = props.detailTables[idx]?.tableKey ?? record.tableKey;
const nk = String(newKey || '').trim();
if (!nk) {
createMessage.warning('数据源键不能为空');
return;
}
if (nk !== oldKey && props.detailTables.some((t) => t.tableKey === nk)) {
createMessage.warning('该数据源键已存在');
return;
}
const list = [...props.detailTables];
if (idx < 0 || idx >= list.length) return;
list[idx] = { ...list[idx], tableKey: nk };
emit('update:detailTables', list);
selectedTableRowKeys.value = selectedTableRowKeys.value.map((k) => (k === oldKey ? nk : k));
expandedRowKeys.value = expandedRowKeys.value.map((k) => (k === oldKey ? nk : k));
}
function onInlineTableLabelChange(tableKey: string, label: string) {
const next = props.detailTables.map((t) =>
t.tableKey === tableKey ? { ...t, label: label.trim() || undefined } : t,
);
emit('update:detailTables', next);
}
function onInlineFieldKeyChange(tableKey: string, oldFieldKey: string, newKey: string) {
const fk = String(newKey || '').trim();
if (!fk) {
createMessage.warning('字段键不能为空');
return;
}
const table = props.detailTables.find((t) => t.tableKey === tableKey);
if (!table) return;
const fields = [...(table.fields || [])];
if (fk !== oldFieldKey && fields.some((f) => f.key === fk)) {
createMessage.warning('该字段键已存在');
return;
}
const nextFields = fields.map((f) => (f.key === oldFieldKey ? { ...f, key: fk } : f));
const next = props.detailTables.map((t) => (t.tableKey === tableKey ? { ...t, fields: nextFields } : t));
emit('update:detailTables', next);
}
function onInlineFieldLabelChange(tableKey: string, fieldKey: string, label: string) {
const next = props.detailTables.map((t) => {
if (t.tableKey !== tableKey) return t;
const fields = (t.fields || []).map((f) =>
f.key === fieldKey ? { ...f, label: label.trim() || undefined } : f,
);
return { ...t, fields };
});
emit('update:detailTables', next);
}
function addFieldRow(tableKey: string) {
const table = props.detailTables.find((t) => t.tableKey === tableKey);
if (!table) return;
const used = new Set((table.fields || []).map((f) => f.key));
let num = maxFieldSuffixForTable(tableKey) + 1;
while (used.has(`Field${num}`)) num += 1;
const key = `Field${num}`;
const next = props.detailTables.map((t) =>
t.tableKey === tableKey ? { ...t, fields: [...(t.fields || []), { key, label: `字段${num}` }] } : t,
);
emit('update:detailTables', next);
}
/** 仅统计当前表下 Field 数字后缀 */
function maxFieldSuffixForTable(tableKey: string): number {
const table = props.detailTables.find((t) => t.tableKey === tableKey);
let max = 0;
for (const f of table?.fields || []) {
const m = String(f.key).match(/^Field(\d+)$/i);
if (m) max = Math.max(max, Number(m[1]));
}
return max;
}
function removeField(tableKey: string, fieldKey: string) {
const next = props.detailTables.map((t) =>
t.tableKey === tableKey ? { ...t, fields: (t.fields || []).filter((f) => f.key !== fieldKey) } : t,
);
emit('update:detailTables', next);
}
function batchDeleteTables() {
if (!selectedTableRowKeys.value.length) return;
const drop = new Set(selectedTableRowKeys.value);
emit('update:detailTables', props.detailTables.filter((t) => !drop.has(t.tableKey)));
selectedTableRowKeys.value = [];
expandedRowKeys.value = [];
}
/* ---------- 批量:明细表 ---------- */
function resetBatchTablesModal() {
quickTableCount.value = 1;
batchTableRows.value = [{ tableKey: '', label: '' }];
}
function openBatchTablesModal() {
resetBatchTablesModal();
batchTablesModalOpen.value = true;
}
function addBatchTableRow() {
batchTableRows.value.push({ tableKey: '', label: '' });
}
function removeBatchTableRow(idx: number) {
if (batchTableRows.value.length <= 1) return;
batchTableRows.value.splice(idx, 1);
}
function quickGenerateTables() {
const count = Math.max(1, Math.min(200, Math.floor(Number(quickTableCount.value) || 1)));
const used = collectUsedTableKeys();
let num = Math.max(1, maxListSuffix(used) + 1);
for (let i = 0; i < count; i++) {
while (used.has(`List${num}`)) num += 1;
const key = `List${num}`;
used.add(key);
batchTableRows.value.push({ tableKey: key, label: `列表${num}` });
num += 1;
}
createMessage.success(`已新增 ${count} 行到下方列表,可修改后点「确定添加」。`);
}
function confirmBatchTables() {
const existing = new Set(props.detailTables.map((t) => t.tableKey.trim()).filter(Boolean));
const toAdd: NativeDataBindingDetailTable[] = [];
const seen = new Set<string>(existing);
for (const row of batchTableRows.value) {
const tk = String(row.tableKey || '').trim();
if (!tk) continue;
if (seen.has(tk)) continue;
seen.add(tk);
const lab = String(row.label || '').trim();
toAdd.push({ tableKey: tk, label: lab || undefined, fields: [] });
}
if (toAdd.length) emit('update:detailTables', [...props.detailTables, ...toAdd]);
batchTablesModalOpen.value = false;
resetBatchTablesModal();
}
/* ---------- 批量:字段 ---------- */
function resetBatchFieldsModal() {
quickFieldParentKey.value = undefined;
quickFieldCount.value = 1;
batchFieldRows.value = [{ parentKey: '', fieldKey: '', label: '' }];
}
function openBatchFieldsModal() {
resetBatchFieldsModal();
if (props.detailTables.length) {
quickFieldParentKey.value = props.detailTables[0].tableKey;
}
batchFieldsModalOpen.value = true;
}
function addBatchFieldRow() {
batchFieldRows.value.push({ parentKey: '', fieldKey: '', label: '' });
}
function removeBatchFieldRow(idx: number) {
if (batchFieldRows.value.length <= 1) return;
batchFieldRows.value.splice(idx, 1);
}
function quickGenerateFieldsToRows() {
const parent = String(quickFieldParentKey.value || '').trim();
if (!parent) {
createMessage.warning('请先选择父级明细数据源');
return;
}
if (!props.detailTables.some((t) => t.tableKey === parent)) {
createMessage.warning('父级不存在,请先在主表或「批量新增明细表」中登记');
return;
}
const count = Math.max(1, Math.min(200, Math.floor(Number(quickFieldCount.value) || 1)));
const used = new Set<string>();
const table = props.detailTables.find((t) => t.tableKey === parent);
(table?.fields || []).forEach((f) => used.add(f.key));
batchFieldRows.value.forEach((r) => {
if (String(r.parentKey ?? '').trim() === parent && r.fieldKey.trim()) used.add(r.fieldKey.trim());
});
let num = Math.max(1, maxFieldSuffixForParent(parent) + 1);
for (let i = 0; i < count; i++) {
while (used.has(`Field${num}`)) num += 1;
const key = `Field${num}`;
used.add(key);
batchFieldRows.value.push({ parentKey: parent, fieldKey: key, label: `字段${num}` });
num += 1;
}
createMessage.success(`已新增 ${count} 行到下方列表,可修改后点「确定添加」。`);
}
function confirmBatchFields() {
const byParent = new Map<string, NativeDataBindingDetailField[]>();
for (const row of batchFieldRows.value) {
const pk = String(row.parentKey || '').trim();
const fk = String(row.fieldKey || '').trim();
if (!pk || !fk) continue;
if (!props.detailTables.some((t) => t.tableKey === pk)) continue;
const lab = String(row.label || '').trim();
const list = byParent.get(pk) || [];
if (list.some((x) => x.key === fk)) continue;
list.push({ key: fk, label: lab || undefined });
byParent.set(pk, list);
}
if (!byParent.size) {
batchFieldsModalOpen.value = false;
resetBatchFieldsModal();
return;
}
const next = props.detailTables.map((t) => {
const add = byParent.get(t.tableKey);
if (!add?.length) return t;
const existing = new Set((t.fields || []).map((f) => f.key));
const merged = [...(t.fields || [])];
for (const f of add) {
if (!existing.has(f.key)) {
existing.add(f.key);
merged.push(f);
}
}
return { ...t, fields: merged };
});
emit('update:detailTables', next);
batchFieldsModalOpen.value = false;
resetBatchFieldsModal();
}
</script>
<style scoped lang="less">
.binding-detail-fields-editor {
padding: 6px 4px 4px;
}
.binding-actions-row {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
margin-bottom: 10px;
min-width: 0;
overflow-x: auto;
:deep(.ant-btn) {
flex-shrink: 0;
padding-inline: 7px;
}
}
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
.field-count {
color: #888;
font-size: 12px;
}
.nested-fields {
padding: 10px 10px 8px 20px;
margin-top: 4px;
background: #fafafa;
border-radius: 6px;
}
.nested-fields-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #555;
}
.modal-section {
.section-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 6px;
}
.section-desc {
font-size: 12px;
color: #666;
line-height: 1.5;
margin-bottom: 10px;
}
}
.quick-row {
margin-top: 4px;
}
.quick-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
}
.manual-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
:deep(.ant-input) {
flex: 1;
min-width: 0;
}
}
/* 与双列 manual-row 视觉对齐:三列等宽 + 删除按钮固定列 */
.manual-row.manual-row--triple {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
align-items: center;
gap: 8px;
margin-bottom: 8px;
:deep(.ant-input) {
min-width: 0;
}
}
.add-row-btn {
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="binding-params-editor">
<div class="binding-actions-row">
<a-button type="primary" size="small" @click="openBatchAdd">批量新增</a-button>
<!-- 未勾选时不用 danger避免部分主题下 danger+disabled 出现异常图标 -->
<a-button
size="small"
:danger="selectedRowKeys.length > 0"
:disabled="!selectedRowKeys.length"
@click="batchDelete"
>
批量删除
</a-button>
</div>
<a-table
size="small"
:columns="columns"
:data-source="params"
:row-key="(r) => r.key"
:pagination="false"
:scroll="{ y: paramsTableScrollY }"
:row-selection="{
selectedRowKeys,
onChange: onSelectChange,
}"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'key'">
<a-input
:value="record.key"
size="small"
placeholder="参数键"
@update:value="(v) => patchParamAt(resolveRowIndex(record, index), 'key', String(v ?? ''))"
/>
</template>
<template v-else-if="column.dataIndex === 'label'">
<a-input
:value="record.label ?? ''"
size="small"
placeholder="显示名"
@update:value="(v) => patchParamAt(resolveRowIndex(record, index), 'label', String(v ?? ''))"
/>
</template>
</template>
</a-table>
<a-modal v-model:open="batchModalOpen" title="批量新增参数" ok-text="确定添加" cancel-text="取消" width="560px" @ok="confirmBatchAdd" @cancel="resetBatchModal">
<div class="modal-section">
<div class="section-title">快速生成</div>
<div class="section-desc">按数量自动生成,参数键为 Parameter + 数字,显示名为 参数 + 数字(与键后缀数字一致)。生成结果在下方列表中,可再修改。</div>
<a-space align="center" wrap class="quick-row">
<span class="quick-label">数量</span>
<a-input-number v-model:value="quickCount" :min="1" :max="200" :precision="0" style="width: 120px" />
<a-button type="primary" @click="quickGenerateToRows">一键新增</a-button>
</a-space>
</div>
<a-divider style="margin: 14px 0" />
<div class="modal-section">
<div class="section-title">手动添加</div>
<div class="section-desc">每行两个输入框:左侧参数键,右侧显示名。可点「添加一行」增加空行。</div>
<div v-for="(row, idx) in batchRows" :key="idx" class="manual-row">
<a-input v-model:value="row.key" size="small" placeholder="参数键" allow-clear />
<a-input v-model:value="row.label" size="small" placeholder="显示名可选" allow-clear />
<a-button type="text" danger size="small" :disabled="batchRows.length <= 1" @click="removeBatchRow(idx)">删除</a-button>
</div>
<a-button type="dashed" block size="small" class="add-row-btn" @click="addBatchRow">添加一行</a-button>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import type { NativeDataBindingParam } from '../core/types';
const props = defineProps<{
params: NativeDataBindingParam[];
}>();
const emit = defineEmits<{
(e: 'update:params', value: NativeDataBindingParam[]): void;
}>();
const { createMessage } = useMessage();
/** 表格体可视高度:随窗口变化,与侧栏 tab 拉长区域匹配 */
const viewportH = ref(typeof window !== 'undefined' ? window.innerHeight : 800);
function onResize() {
viewportH.value = window.innerHeight;
}
onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));
const paramsTableScrollY = computed(() => {
const h = viewportH.value;
return Math.round(Math.min(520, Math.max(260, h - 360)));
});
const columns = [
{ title: '参数键', dataIndex: 'key', ellipsis: true },
{ title: '显示名', dataIndex: 'label', ellipsis: true },
];
const selectedRowKeys = ref<string[]>([]);
const batchModalOpen = ref(false);
const quickCount = ref<number>(1);
const batchRows = ref<Array<{ key: string; label: string }>>([{ key: '', label: '' }]);
watch(
() => props.params,
() => {
const keys = new Set(props.params.map((p) => p.key));
selectedRowKeys.value = selectedRowKeys.value.filter((k) => keys.has(k));
},
);
function onSelectChange(keys: string[]) {
selectedRowKeys.value = keys;
}
function resetBatchModal() {
quickCount.value = 1;
batchRows.value = [{ key: '', label: '' }];
}
function openBatchAdd() {
resetBatchModal();
batchModalOpen.value = true;
}
/** 已占用的参数键:已有参数 + 弹窗内已填行 */
function collectUsedKeys(): Set<string> {
const s = new Set(props.params.map((p) => p.key.trim()).filter(Boolean));
batchRows.value.forEach((r) => {
const k = r.key.trim();
if (k) s.add(k);
});
return s;
}
/** 当前最大 Parameter 后缀数字,用于续号 */
function maxParameterSuffix(used: Set<string>): number {
let max = 0;
for (const k of used) {
const m = String(k).match(/^Parameter(\d+)$/i);
if (m) max = Math.max(max, Number(m[1]));
}
return max;
}
function quickGenerateToRows() {
const count = Math.max(1, Math.min(200, Math.floor(Number(quickCount.value) || 1)));
const used = collectUsedKeys();
let num = Math.max(1, maxParameterSuffix(used) + 1);
for (let i = 0; i < count; i++) {
while (used.has(`Parameter${num}`)) {
num += 1;
}
const key = `Parameter${num}`;
used.add(key);
batchRows.value.push({ key, label: `参数${num}` });
num += 1;
}
createMessage.success(`已新增 ${count} 行到下方列表,可修改后点「确定添加」。`);
}
function addBatchRow() {
batchRows.value.push({ key: '', label: '' });
}
function removeBatchRow(idx: number) {
if (batchRows.value.length <= 1) return;
batchRows.value.splice(idx, 1);
}
function confirmBatchAdd() {
const existing = new Set(props.params.map((p) => p.key.trim()).filter(Boolean));
const toAdd: NativeDataBindingParam[] = [];
const seen = new Set<string>(existing);
for (const row of batchRows.value) {
const k = row.key.trim();
if (!k) continue;
if (seen.has(k)) continue;
seen.add(k);
const lab = row.label.trim();
toAdd.push({ key: k, label: lab || undefined });
}
if (toAdd.length) {
emit('update:params', [...props.params, ...toAdd]);
}
batchModalOpen.value = false;
resetBatchModal();
}
function batchDelete() {
if (!selectedRowKeys.value.length) return;
const drop = new Set(selectedRowKeys.value);
emit('update:params', props.params.filter((p) => !drop.has(p.key)));
selectedRowKeys.value = [];
}
function resolveRowIndex(record: NativeDataBindingParam, index?: number) {
if (typeof index === 'number' && index >= 0) {
return index;
}
return props.params.findIndex((p) => p.key === record.key);
}
function patchParamAt(index: number, field: 'key' | 'label', raw: string) {
const list = [...props.params];
if (index < 0 || index >= list.length) return;
const cur = list[index];
if (field === 'label') {
const v = raw.trim();
list[index] = { ...cur, label: v || undefined };
emit('update:params', list);
return;
}
const newKey = raw.trim();
if (!newKey) {
createMessage.warning('参数键不能为空');
return;
}
if (newKey !== cur.key && list.some((p, i) => i !== index && p.key === newKey)) {
createMessage.warning('参数键已存在');
return;
}
const oldKey = cur.key;
list[index] = { ...cur, key: newKey };
emit('update:params', list);
selectedRowKeys.value = selectedRowKeys.value.map((k) => (k === oldKey ? newKey : k));
}
</script>
<style scoped lang="less">
.binding-params-editor {
padding: 6px 4px 4px;
}
.binding-actions-row {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
margin-bottom: 10px;
min-width: 0;
overflow-x: auto;
:deep(.ant-btn) {
flex-shrink: 0;
padding-inline: 7px;
}
}
:deep(.ant-table-thead > tr > th) {
white-space: nowrap;
}
.modal-section {
.section-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 6px;
}
.section-desc {
font-size: 12px;
color: #666;
line-height: 1.5;
margin-bottom: 10px;
}
}
.quick-row {
margin-top: 4px;
}
.quick-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
}
.manual-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
:deep(.ant-input) {
flex: 1;
min-width: 0;
}
}
.add-row-btn {
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,527 @@
<template>
<div class="designer-canvas-wrap">
<div class="canvas-stage" :style="stageStyle">
<div class="ruler ruler-top">
<div
v-for="tick in topTicks"
:key="`top_${tick.mm}`"
class="tick"
:class="{ major: tick.major }"
:style="{ left: `${tick.posPx}px` }"
>
<span v-if="tick.major" class="tick-label">{{ tick.mm }}</span>
</div>
</div>
<div class="ruler ruler-left">
<div
v-for="tick in leftTicks"
:key="`left_${tick.mm}`"
class="tick"
:class="{ major: tick.major }"
:style="{ top: `${tick.posPx}px` }"
>
<span v-if="tick.major" class="tick-label">{{ tick.mm }}</span>
</div>
</div>
<div class="ruler-corner"></div>
<div class="designer-canvas" :style="canvasStyle" @click="emit('select', '')">
<div v-if="bandLayout.headerHeight > 0" class="band-area band-header" :style="{ height: `${bandLayout.headerHeight}mm` }">报表头区域</div>
<div
v-if="bandLayout.footerHeight > 0"
class="band-area band-footer"
:style="{ height: `${bandLayout.footerHeight}mm`, top: `${schema.page.height - bandLayout.footerHeight}mm` }"
>
报表尾区域
</div>
<div
class="band-divider"
:style="{ top: `${bandLayout.headerHeight}mm` }"
v-if="bandLayout.headerHeight > 0"
></div>
<div
class="band-divider"
:style="{ top: `${bandLayout.bodyBottom}mm` }"
v-if="bandLayout.footerHeight > 0"
></div>
<div v-if="guideState.showVertical" class="center-guide vertical" :style="{ left: `${pageCenterXPx}mm` }"></div>
<div v-if="guideState.showHorizontal" class="center-guide horizontal" :style="{ top: `${pageCenterYPx}mm` }"></div>
<ElementWrapper
v-for="element in sortedElements"
:key="element.id"
:element="element"
:active="selectedId === element.id"
:scale="scale"
:grid-size="schema.page.gridSize"
:page-width="schema.page.width"
:page-height="schema.page.height"
:movable="!isBandElement(element)"
:resizable="!isBandElement(element)"
:drag-bounds="resolveDragBounds(element)"
@select="emit('select', $event)"
@update="emit('update', $event)"
@dragging="handleElementDragging"
>
<TextElement v-if="isTextElement(element.type)" :element="element as any" :preview-data="previewData" />
<ImageElement v-else-if="element.type === 'image'" :element="element as any" :preview-data="previewData" />
<TableElement
v-else-if="element.type === 'table' || element.type === 'detailTable'"
:element="element as any"
:preview-data="previewData"
:is-element-selected="selectedId === element.id"
:selected-column-key="resolveSelectedColumnKey(element.id)"
@select-column="emit('select-table-column', { elementId: element.id, columnKey: $event.columnKey })"
@update-columns="handleTableColumnsUpdate(element.id, $event.columns)"
@update-header-config="handleTableHeaderConfigUpdate(element.id, $event.headerConfig)"
/>
<FreeTableElement
v-else-if="element.type === 'freeTable'"
:element="element as any"
:preview-data="previewData"
:scale="scale"
:is-element-selected="selectedId === element.id"
:selected-cell="resolveSelectedFreeTableCell(element.id)"
:merge-range-corner="resolveFreeTableMergeCorner(element.id)"
@select-cell="
emit('select-free-table-cell', {
elementId: element.id,
row: $event.row,
col: $event.col,
shiftKey: $event.shiftKey,
})
"
@swap-cells="handleFreeTableCellSwap(element.id, $event)"
@update-tracks="handleFreeTableTracksUpdate(element.id, $event)"
@edit-cell="
emit('edit-free-table-cell', {
elementId: element.id,
row: $event.row,
col: $event.col,
})
"
/>
<QrcodeElement v-else-if="element.type === 'qrcode'" :element="element as any" :preview-data="previewData" />
<BarcodeElement v-else-if="element.type === 'barcode'" :element="element as any" :preview-data="previewData" />
</ElementWrapper>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
import ElementWrapper from './ElementWrapper.vue';
import TextElement from './elements/TextElement.vue';
import ImageElement from './elements/ImageElement.vue';
import TableElement from './elements/TableElement.vue';
import FreeTableElement from './elements/FreeTableElement.vue';
import QrcodeElement from './elements/QrcodeElement.vue';
import BarcodeElement from './elements/BarcodeElement.vue';
import type { NativeElement, NativeTemplateSchema } from '../core/types';
import { normalizeFreeTableAnchors, swapFreeTableOwnerPayloads } from '../core/freeTableGrid';
const PX_PER_MM = 3.7795275591;
const RULER_SIZE = 20;
const props = defineProps<{
schema: NativeTemplateSchema;
selectedId: string;
scale: number;
previewData: Record<string, any>;
selectedTableColumn?: { elementId: string; columnKey: string } | null;
selectedFreeTableCell?: { elementId: string; row: number; col: number } | null;
/** Shift 选取第二角(与 selectedFreeTableCell 同属一个自由表格时用于合并区域预览) */
selectedFreeTableMergeCorner?: { elementId: string; row: number; col: number } | null;
}>();
const emit = defineEmits<{
(e: 'select', id: string): void;
(e: 'update', payload: { id: string; patch: Partial<NativeElement> }): void;
(e: 'select-table-column', payload: { elementId: string; columnKey: string }): void;
(e: 'select-free-table-cell', payload: { elementId: string; row: number; col: number; shiftKey?: boolean }): void;
(e: 'edit-free-table-cell', payload: { elementId: string; row: number; col: number }): void;
}>();
const sortedElements = computed(() => [...props.schema.elements].sort((a, b) => a.zIndex - b.zIndex));
const bandLayout = computed(() => {
const pageHeight = props.schema.page.height;
const header = props.schema.elements.find((item) => item.type === 'reportHeader') as any;
const footer = props.schema.elements.find((item) => item.type === 'reportFooter') as any;
const headerHeight = Number(header?.h || 0);
const footerHeight = Number(footer?.h || 0);
const bodyBottom = Math.max(headerHeight, pageHeight - footerHeight);
return { headerHeight, footerHeight, bodyBottom };
});
const guideState = reactive({
showVertical: false,
showHorizontal: false,
});
const stageStyle = computed(() => ({
width: `${props.schema.page.width * PX_PER_MM * props.scale + RULER_SIZE}px`,
height: `${props.schema.page.height * PX_PER_MM * props.scale + RULER_SIZE}px`,
}));
const canvasStyle = computed(() => ({
left: `${RULER_SIZE}px`,
top: `${RULER_SIZE}px`,
width: `${props.schema.page.width}mm`,
height: `${props.schema.page.height}mm`,
transform: `scale(${props.scale})`,
transformOrigin: 'top left',
backgroundSize: `${props.schema.page.gridSize}mm ${props.schema.page.gridSize}mm`,
backgroundImage:
'linear-gradient(to right, rgba(22,119,255,0.08) 1px, transparent 1px),linear-gradient(to bottom, rgba(22,119,255,0.08) 1px, transparent 1px)',
}));
const pageCenterXPx = computed(() => props.schema.page.width / 2);
const pageCenterYPx = computed(() => props.schema.page.height / 2);
const topTicks = computed(() => buildRulerTicks(props.schema.page.width));
const leftTicks = computed(() => buildRulerTicks(props.schema.page.height));
function isTextElement(type: string) {
return ['title', 'subtitle', 'text', 'date', 'pageNo', 'reportHeader', 'reportFooter'].includes(type);
}
function isBandElement(element: NativeElement) {
return element.type === 'reportHeader' || element.type === 'reportFooter';
}
function resolveSelectedColumnKey(elementId: string) {
if (props.selectedTableColumn?.elementId === elementId) {
return props.selectedTableColumn.columnKey;
}
return '';
}
function resolveSelectedFreeTableCell(elementId: string) {
if (props.selectedFreeTableCell?.elementId === elementId) {
return {
row: Number(props.selectedFreeTableCell.row || 0),
col: Number(props.selectedFreeTableCell.col || 0),
};
}
return null;
}
function resolveFreeTableMergeCorner(elementId: string) {
if (props.selectedFreeTableMergeCorner?.elementId !== elementId) {
return null;
}
return {
row: Number(props.selectedFreeTableMergeCorner.row || 0),
col: Number(props.selectedFreeTableMergeCorner.col || 0),
};
}
function handleFreeTableTracksUpdate(
elementId: string,
payload: { colWidths?: number[]; rowHeights?: number[] },
) {
emit('update', {
id: elementId,
patch: { ...payload } as any,
});
}
function handleFreeTableCellSwap(
elementId: string,
payload: { fromRow: number; fromCol: number; toRow: number; toCol: number },
) {
const { fromRow, fromCol, toRow, toCol } = payload;
if (fromRow === toRow && fromCol === toCol) return;
const target = props.schema.elements.find((item) => item.id === elementId) as any;
if (!target || target.type !== 'freeTable') return;
const rowCount = Math.max(1, Number(target.rowCount || 1));
const colCount = Math.max(1, Number(target.colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, target.cells || []);
const next = swapFreeTableOwnerPayloads(anchors, rowCount, colCount, fromRow, fromCol, toRow, toCol);
emit('update', {
id: elementId,
patch: { cells: next } as any,
});
}
function handleTableColumnsUpdate(elementId: string, columns: any[]) {
emit('update', {
id: elementId,
patch: {
columns,
} as any,
});
}
function handleTableHeaderConfigUpdate(elementId: string, headerConfig: any) {
const target = props.schema.elements.find((item) => item.id === elementId) as any;
const currentColumns = Array.isArray(target?.columns) ? target.columns : [];
const nextColumns = syncColumnTitlesByHeaderConfig(currentColumns, headerConfig);
emit('update', {
id: elementId,
patch: {
headerConfig,
columns: nextColumns,
} as any,
});
}
function syncColumnTitlesByHeaderConfig(columns: any[], headerConfig: any) {
const colCount = columns.length;
const rowCount = Math.max(1, Number(headerConfig?.rowCount || 1));
const owner: any[][] = Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => null));
const cells = Array.isArray(headerConfig?.cells) ? headerConfig.cells : [];
cells.forEach((item: any) => {
const row = Math.max(0, Number(item?.row || 0));
const col = Math.max(0, Number(item?.col || 0));
const rowspan = Math.max(1, Number(item?.rowspan || 1));
const colspan = Math.max(1, Number(item?.colspan || 1));
if (row >= rowCount || col >= colCount || owner[row][col]) return;
const maxRow = Math.min(rowCount, row + rowspan);
const maxCol = Math.min(colCount, col + colspan);
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
if (owner[r][c]) return;
}
}
const next = { ...item, row, col, rowspan: maxRow - row, colspan: maxCol - col };
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
owner[r][c] = next;
}
}
});
return columns.map((col, index) => {
const bottomCell = owner[rowCount - 1]?.[index];
const nextTitle =
bottomCell && Number(bottomCell?.row ?? rowCount - 1) < rowCount - 1
? String(col?.title || `${index + 1}`)
: String(bottomCell?.title || col?.title || `${index + 1}`);
return { ...col, title: nextTitle };
});
}
function buildRulerTicks(lengthMm: number) {
const length = Math.max(0, Math.floor(lengthMm));
const list: Array<{ mm: number; major: boolean; posPx: number }> = [];
for (let mm = 0; mm <= length; mm += 5) {
list.push({
mm,
major: mm % 10 === 0,
posPx: mm * PX_PER_MM * props.scale,
});
}
return list;
}
function handleElementDragging(payload: { id: string; rect: { x: number; y: number; w: number; h: number }; active: boolean }) {
if (!payload?.active) {
guideState.showVertical = false;
guideState.showHorizontal = false;
return;
}
const centerX = payload.rect.x + payload.rect.w / 2;
const centerY = payload.rect.y + payload.rect.h / 2;
const pageCenterX = props.schema.page.width / 2;
const pageCenterY = props.schema.page.height / 2;
const thresholdMm = Math.max(0.5, 1 / Math.max(0.2, props.scale));
guideState.showVertical = Math.abs(centerX - pageCenterX) <= thresholdMm;
guideState.showHorizontal = Math.abs(centerY - pageCenterY) <= thresholdMm;
}
function resolveElementRegion(element: NativeElement) {
const region = (element as any).region;
if (region === 'header' || region === 'footer' || region === 'body') {
return region;
}
const centerY = element.y + element.h / 2;
if (centerY <= bandLayout.value.headerHeight) return 'header';
if (centerY >= bandLayout.value.bodyBottom) return 'footer';
return 'body';
}
function resolveDragBounds(element: NativeElement) {
const pageWidth = props.schema.page.width;
const pageHeight = props.schema.page.height;
if (isBandElement(element)) {
const isHeader = element.type === 'reportHeader';
const fixedY = isHeader ? 0 : Math.max(0, props.schema.page.height - element.h);
return {
minX: 0,
maxX: 0,
minY: fixedY,
maxY: fixedY,
};
}
const region = resolveElementRegion(element);
const minX = 0;
const maxX = Math.max(0, pageWidth - element.w);
if (region === 'header') {
return {
minX,
maxX,
minY: 0,
maxY: Math.max(0, bandLayout.value.headerHeight - element.h),
};
}
if (region === 'footer') {
return {
minX,
maxX,
minY: Math.max(0, bandLayout.value.bodyBottom),
maxY: Math.max(0, pageHeight - element.h),
};
}
return {
minX,
maxX,
minY: bandLayout.value.headerHeight,
maxY: Math.max(bandLayout.value.headerHeight, bandLayout.value.bodyBottom - element.h),
};
}
</script>
<style scoped lang="less">
.designer-canvas-wrap {
padding: 16px;
overflow: auto;
height: calc(100vh - 150px);
background: #f0f2f5;
}
.canvas-stage {
position: relative;
margin: 0 auto;
}
.ruler {
position: absolute;
background: #f7f8fa;
z-index: 5;
}
.ruler-top {
left: 20px;
top: 0;
right: 0;
height: 20px;
border-bottom: 1px solid #d9d9d9;
}
.ruler-left {
left: 0;
top: 20px;
bottom: 0;
width: 20px;
border-right: 1px solid #d9d9d9;
}
.ruler-corner {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
background: #eef0f3;
border-right: 1px solid #d9d9d9;
border-bottom: 1px solid #d9d9d9;
z-index: 6;
}
.ruler-top .tick {
position: absolute;
top: 0;
width: 1px;
height: 8px;
background: #8c8c8c;
}
.ruler-top .tick.major {
height: 12px;
background: #595959;
}
.ruler-left .tick {
position: absolute;
left: 0;
width: 8px;
height: 1px;
background: #8c8c8c;
}
.ruler-left .tick.major {
width: 12px;
background: #595959;
}
.ruler-top .tick-label {
position: absolute;
top: 10px;
left: 2px;
font-size: 10px;
color: #666;
}
.ruler-left .tick-label {
position: absolute;
left: 10px;
top: -4px;
font-size: 10px;
color: #666;
}
.designer-canvas {
position: absolute;
margin: 0;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.band-area {
position: absolute;
left: 0;
width: 100%;
border-top: 1px dashed rgba(22, 119, 255, 0.4);
border-bottom: 1px dashed rgba(22, 119, 255, 0.4);
background: rgba(22, 119, 255, 0.06);
color: #1677ff;
font-size: 12px;
text-align: center;
line-height: 22px;
pointer-events: none;
z-index: 1;
}
.band-header {
top: 0;
}
.band-footer {
left: 0;
}
.band-divider {
position: absolute;
left: 0;
width: 100%;
border-top: 1px dashed rgba(82, 196, 26, 0.75);
pointer-events: none;
z-index: 2;
}
.center-guide {
position: absolute;
background: rgba(255, 77, 79, 0.85);
pointer-events: none;
z-index: 4;
}
.center-guide.vertical {
top: 0;
width: 1px;
height: 100%;
transform: translateX(-0.5px);
}
.center-guide.horizontal {
left: 0;
height: 1px;
width: 100%;
transform: translateY(-0.5px);
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="element-wrapper" :class="{ active }" :style="wrapperStyle" @pointerdown.stop="startDrag" @click.stop="emit('select', element.id)">
<slot />
<template v-if="active && resizable">
<span v-for="handle in handles" :key="handle" class="resize-handle" :class="`handle-${handle}`" @pointerdown.stop="startResize($event, handle)" />
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { calcDragRect, calcResizeRect } from '../core/dragResize';
import type { NativeElement } from '../core/types';
const PX_PER_MM = 3.7795275591;
const props = defineProps<{
element: NativeElement;
active: boolean;
scale: number;
gridSize: number;
pageWidth: number;
pageHeight: number;
movable?: boolean;
resizable?: boolean;
dragBounds?: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
}>();
const emit = defineEmits<{
(e: 'update', payload: { id: string; patch: Partial<NativeElement> }): void;
(e: 'select', id: string): void;
(e: 'dragging', payload: { id: string; rect: { x: number; y: number; w: number; h: number }; active: boolean }): void;
}>();
const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
const wrapperStyle = computed(() => ({
position: 'absolute',
left: `${props.element.x}mm`,
top: `${props.element.y}mm`,
width: `${props.element.w}mm`,
height: `${props.element.h}mm`,
zIndex: props.element.zIndex,
outline: props.active ? '1px solid #1677ff' : '1px dashed transparent',
userSelect: 'none',
}));
const movable = computed(() => props.movable !== false);
const resizable = computed(() => props.resizable !== false);
function clampByBounds(rect: { x: number; y: number; w: number; h: number }) {
const bounds = props.dragBounds;
if (!bounds) return rect;
return {
...rect,
x: Math.max(bounds.minX, Math.min(bounds.maxX, rect.x)),
y: Math.max(bounds.minY, Math.min(bounds.maxY, rect.y)),
};
}
function startDrag(event: PointerEvent) {
emit('select', props.element.id);
if ((event.target as HTMLElement)?.classList.contains('resize-handle')) return;
if (!movable.value) return;
const start = { x: props.element.x, y: props.element.y, w: props.element.w, h: props.element.h };
const startX = event.clientX;
const startY = event.clientY;
const onMove = (moveEvent: PointerEvent) => {
// 鼠标位移是 px画布坐标是 mm这里必须做单位换算才会跟手
const deltaX = (moveEvent.clientX - startX) / props.scale / PX_PER_MM;
const deltaY = (moveEvent.clientY - startY) / props.scale / PX_PER_MM;
const next = calcDragRect(start, { width: props.pageWidth, height: props.pageHeight }, deltaX, deltaY, props.gridSize);
const bounded = clampByBounds(next);
emit('update', { id: props.element.id, patch: bounded });
emit('dragging', { id: props.element.id, rect: bounded, active: true });
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
emit('dragging', { id: props.element.id, rect: { x: props.element.x, y: props.element.y, w: props.element.w, h: props.element.h }, active: false });
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function startResize(event: PointerEvent, direction: any) {
emit('select', props.element.id);
if (!resizable.value) return;
const start = { x: props.element.x, y: props.element.y, w: props.element.w, h: props.element.h };
const startX = event.clientX;
const startY = event.clientY;
const onMove = (moveEvent: PointerEvent) => {
// 鼠标位移是 px画布尺寸是 mm缩放时同样要按单位换算
const deltaX = (moveEvent.clientX - startX) / props.scale / PX_PER_MM;
const deltaY = (moveEvent.clientY - startY) / props.scale / PX_PER_MM;
const next = calcResizeRect(direction, start, { width: props.pageWidth, height: props.pageHeight }, deltaX, deltaY, props.gridSize);
emit('update', { id: props.element.id, patch: next });
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
</script>
<style scoped lang="less">
.element-wrapper {
box-sizing: border-box;
.resize-handle {
position: absolute;
width: 6px;
height: 6px;
background: #1677ff;
border-radius: 50%;
margin: -3px;
}
.handle-nw {
left: 0;
top: 0;
cursor: nwse-resize;
}
.handle-n {
left: 50%;
top: 0;
cursor: ns-resize;
}
.handle-ne {
left: 100%;
top: 0;
cursor: nesw-resize;
}
.handle-e {
left: 100%;
top: 50%;
cursor: ew-resize;
}
.handle-se {
left: 100%;
top: 100%;
cursor: nwse-resize;
}
.handle-s {
left: 50%;
top: 100%;
cursor: ns-resize;
}
.handle-sw {
left: 0;
top: 100%;
cursor: nesw-resize;
}
.handle-w {
left: 0;
top: 50%;
cursor: ew-resize;
}
}
</style>

View File

@@ -0,0 +1,307 @@
<template>
<a-modal
:open="open"
:title="modalTitle"
width="600px"
wrap-class-name="free-table-cell-edit-modal"
:body-style="{ padding: '16px 32px 8px' }"
destroy-on-close
ok-text="确定"
cancel-text="取消"
@ok="handleOk"
@cancel="onCancel"
@update:open="onUpdateOpen"
>
<a-form v-if="open && elementId" layout="vertical" class="free-table-cell-edit-form">
<a-row :gutter="[20, 4]">
<a-col :span="24">
<a-form-item label="单元格文本" class="form-item-tight">
<a-textarea
v-model:value="form.text"
:rows="2"
:maxlength="2000"
show-count
placeholder="静态文本;若选择绑定参数且预览有值,则优先显示参数值"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="绑定参数" class="form-item-tight">
<a-select
:value="resolveParamBindSelectValue(form.bindField)"
:options="bindingParamOptions"
allow-clear
show-search
option-filter-prop="label"
placeholder="请先在左侧「参数」页维护"
class="control-full"
@update:value="onModalBindParamChange"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="字号(px)" class="form-item-tight">
<a-input-number v-model:value="form.fontSize" :min="8" :max="72" class="control-full" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="文字对齐" class="form-item-tight">
<a-select v-model:value="form.align" :options="alignOptions" class="control-full" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="文字颜色" class="form-item-tight">
<a-space-compact block class="color-row">
<a-input v-model:value="form.color" placeholder="#111111" class="color-input" />
<input v-model="form.color" type="color" class="color-native" title="取色" aria-label="文字色取色" />
</a-space-compact>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="背景色" class="form-item-tight">
<a-space-compact block class="color-row">
<a-input v-model:value="form.backgroundColor" placeholder="#ffffff" class="color-input" />
<input
v-model="form.backgroundColor"
type="color"
class="color-native"
title="取色"
aria-label="背景色取色"
/>
</a-space-compact>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue';
import { getFreeTableOwnerAt, normalizeFreeTableAnchors } from '../core/freeTableGrid';
import type { NativeDataBindingParam, NativeFreeTableElement, NativeTemplateSchema } from '../core/types';
const ALIGN_OPTIONS = [
{ label: '左对齐', value: 'left' },
{ label: '居中', value: 'center' },
{ label: '右对齐', value: 'right' },
];
const props = defineProps<{
open: boolean;
/** 当前编辑的自由表格元素 id */
elementId: string;
row: number;
col: number;
schema: NativeTemplateSchema;
}>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
(
e: 'save',
payload: {
elementId: string;
row: number;
col: number;
patch: {
text?: string;
bindField?: string;
fontSize?: number;
color?: string;
backgroundColor?: string;
align?: 'left' | 'center' | 'right';
};
},
): void;
}>();
const alignOptions = ALIGN_OPTIONS;
const form = reactive({
text: '',
bindField: '',
fontSize: 12 as number,
color: '#111111',
backgroundColor: '#ffffff',
align: 'left' as 'left' | 'center' | 'right',
});
const targetElement = computed(() => {
const list = props.schema?.elements || [];
const el = list.find((e) => e.id === props.elementId);
if (el && (el as any).type === 'freeTable') {
return el as NativeFreeTableElement;
}
return null;
});
const bindingParamOptions = computed(() =>
(props.schema.dataBinding?.params ?? []).map((p: NativeDataBindingParam) => ({
value: p.key,
label: p.label ? `${p.key}${p.label}` : p.key,
})),
);
function resolveParamBindSelectValue(raw: string | undefined | null) {
const k = String(raw || '').trim();
if (!k) return undefined;
const keys = new Set((props.schema.dataBinding?.params ?? []).map((p: NativeDataBindingParam) => p.key));
return keys.has(k) ? k : undefined;
}
function resolveParamDisplayName(paramKey: string) {
const k = String(paramKey || '').trim();
if (!k) return '';
const p = (props.schema.dataBinding?.params ?? []).find((x: NativeDataBindingParam) => x.key === k);
if (p?.label && String(p.label).trim()) return String(p.label).trim();
return k;
}
function onModalBindParamChange(v: string | undefined) {
form.bindField = v ?? '';
const k = String(v || '').trim();
if (k) {
form.text = resolveParamDisplayName(k);
}
}
const modalTitle = computed(() => {
const r = Number(props.row || 0) + 1;
const c = Number(props.col || 0) + 1;
return `编辑单元格(第 ${r} 行 · 第 ${c} 列)`;
});
function syncFormFromSchema() {
const el = targetElement.value;
if (!el) {
form.text = '';
form.bindField = '';
form.fontSize = 12;
form.color = '#111111';
form.backgroundColor = '#ffffff';
form.align = 'left';
return;
}
const rowCount = Math.max(1, Number(el.rowCount || 1));
const colCount = Math.max(1, Number(el.colCount || 1));
const anchors = normalizeFreeTableAnchors(rowCount, colCount, el.cells || []);
const owner = getFreeTableOwnerAt(anchors, props.row, props.col);
form.text = String(owner.text ?? '');
const rawBf = String(owner.bindField ?? '').trim();
const paramKeys = new Set((props.schema.dataBinding?.params ?? []).map((p: NativeDataBindingParam) => p.key));
form.bindField = rawBf && paramKeys.has(rawBf) ? rawBf : '';
form.fontSize = Number(owner.fontSize || 12);
form.color = String(owner.color || '#111111');
form.backgroundColor = String(owner.backgroundColor || '#ffffff');
const a = owner.align;
form.align = a === 'center' || a === 'right' ? a : 'left';
}
watch(
() => props.open,
(v) => {
if (v) {
syncFormFromSchema();
}
},
);
function onUpdateOpen(v: boolean) {
emit('update:open', v);
}
function onCancel() {
emit('update:open', false);
}
function handleOk() {
const fs = Number(form.fontSize);
const paramKeys = new Set((props.schema.dataBinding?.params ?? []).map((p: NativeDataBindingParam) => p.key));
const bfRaw = String(form.bindField || '').trim();
const bindFieldSafe = bfRaw && paramKeys.has(bfRaw) ? bfRaw : '';
emit('save', {
elementId: props.elementId,
row: props.row,
col: props.col,
patch: {
text: form.text,
bindField: bindFieldSafe,
fontSize: Number.isFinite(fs) ? Math.min(72, Math.max(8, fs)) : 12,
color: String(form.color || '#111111').trim() || '#111111',
backgroundColor: String(form.backgroundColor || '#ffffff').trim() || '#ffffff',
align: form.align,
},
});
}
</script>
<style scoped lang="less">
.free-table-cell-edit-form {
max-width: 100%;
}
.form-item-tight {
margin-bottom: 12px;
}
.control-full {
width: 100%;
}
:deep(.control-full.ant-input-number),
:deep(.control-full.ant-select .ant-select-selector) {
width: 100% !important;
}
:deep(.control-full.ant-select) {
width: 100%;
}
.color-row {
width: 100%;
}
.color-input {
flex: 1;
min-width: 0;
}
.color-native {
flex: 0 0 36px;
width: 36px;
height: 32px;
padding: 2px;
box-sizing: border-box;
border: 1px solid #d9d9d9;
border-radius: 0 6px 6px 0;
border-left: none;
background: #fff;
cursor: pointer;
}
</style>
<style lang="less">
/* 弹窗标题与底部按钮区略作收紧,与加宽后的表单区协调 */
.free-table-cell-edit-modal {
.ant-modal-header {
padding: 14px 28px;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-title {
font-weight: 600;
font-size: 15px;
}
.ant-modal-footer {
padding: 12px 28px 16px;
border-top: 1px solid #f0f0f0;
}
.ant-form-item-label > label {
color: rgba(0, 0, 0, 0.75);
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<a-modal
v-model:open="modalOpen"
title="页面配置"
width="520px"
:footer="null"
destroy-on-close
wrap-class-name="native-page-config-modal"
:body-style="{ padding: '20px 24px 24px' }"
>
<div v-if="schema?.page" class="page-config-modal-body">
<div class="page-config-section-title">纸张与网格</div>
<a-space direction="vertical" :size="12" class="page-config-field-stack">
<a-input-number :value="schema.page.width" addon-before="(mm)" :min="10" :max="2000" style="width: 100%" @update:value="emitPage('width', $event)" />
<a-input-number :value="schema.page.height" addon-before="(mm)" :min="10" :max="2000" style="width: 100%" @update:value="emitPage('height', $event)" />
<a-input-number :value="schema.page.gridSize" addon-before="网格(mm)" :min="1" :max="20" style="width: 100%" @update:value="emitPage('gridSize', $event)" />
</a-space>
<a-divider class="page-config-divider" />
<div class="page-config-section-title">页面边距(mm)</div>
<a-row :gutter="[12, 12]" class="page-config-margin-grid">
<a-col :span="12">
<a-input-number
:value="Number(schema.page.margin?.[0] || 0)"
addon-before=""
:min="0"
:max="200"
style="width: 100%"
@update:value="emitPageMargin(0, $event)"
/>
</a-col>
<a-col :span="12">
<a-input-number
:value="Number(schema.page.margin?.[1] || 0)"
addon-before=""
:min="0"
:max="200"
style="width: 100%"
@update:value="emitPageMargin(1, $event)"
/>
</a-col>
<a-col :span="12">
<a-input-number
:value="Number(schema.page.margin?.[2] || 0)"
addon-before=""
:min="0"
:max="200"
style="width: 100%"
@update:value="emitPageMargin(2, $event)"
/>
</a-col>
<a-col :span="12">
<a-input-number
:value="Number(schema.page.margin?.[3] || 0)"
addon-before=""
:min="0"
:max="200"
style="width: 100%"
@update:value="emitPageMargin(3, $event)"
/>
</a-col>
</a-row>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { NativeTemplateSchema } from '../core/types';
const props = defineProps<{
open: boolean;
schema: NativeTemplateSchema;
}>();
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'update-page', patch: Partial<NativeTemplateSchema['page']>): void;
}>();
const modalOpen = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
function emitPage(key: string, value: any) {
emit('update-page', { [key]: Number(value || 0) } as any);
}
function emitPageMargin(index: 0 | 1 | 2 | 3, value: any) {
const current = Array.isArray(props.schema?.page?.margin) ? [...props.schema.page.margin] : [10, 10, 10, 10];
current[index] = Math.max(0, Number(value || 0));
emit('update-page', { margin: current as [number, number, number, number] });
}
</script>
<style scoped lang="less">
.page-config-modal-body {
min-width: 0;
}
.page-config-section-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
margin-bottom: 10px;
line-height: 1.4;
}
.page-config-field-stack {
width: 100%;
}
.page-config-field-stack :deep(.ant-input-number) {
width: 100%;
}
.page-config-divider {
margin: 18px 0 16px;
border-color: #f0f0f0;
}
.page-config-margin-grid {
margin-top: 2px;
}
.page-config-margin-grid :deep(.ant-input-number) {
width: 100%;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
<template>
<div class="header-config-editor">
<div class="toolbar">
<a-space>
<a-input-number :value="rowCount" :min="1" :max="6" addon-before="表头行数" @update:value="changeRowCount(Number($event || 1))" />
<a-select :value="activeCellAlign" style="width: 120px" @update:value="updateActiveCellAlign($event)">
<a-select-option value="left">左对齐</a-select-option>
<a-select-option value="center">居中</a-select-option>
<a-select-option value="right">右对齐</a-select-option>
</a-select>
<a-button size="small" @click="mergeSelection">合并选中</a-button>
<a-button size="small" @click="splitCurrent">拆分当前</a-button>
</a-space>
</div>
<div class="grid-wrap" @click="hideContextMenu">
<table class="grid-table">
<tbody>
<tr v-for="r in rowCount" :key="`row_${r - 1}`">
<template v-for="c in colCount" :key="`slot_${r - 1}_${c - 1}`">
<td
v-if="isCellStart(r - 1, c - 1)"
:rowspan="getCellByStart(r - 1, c - 1)?.rowspan || 1"
:colspan="getCellByStart(r - 1, c - 1)?.colspan || 1"
:class="cellClass(r - 1, c - 1)"
@mousedown.prevent="startSelect(r - 1, c - 1)"
@mousemove.prevent="moveSelect(r - 1, c - 1)"
@mouseup.prevent="endSelect"
@contextmenu.prevent="openContextMenu($event, r - 1, c - 1)"
>
<a-input
size="small"
:value="getCellByStart(r - 1, c - 1)?.title || ''"
@update:value="updateCellTitle(r - 1, c - 1, $event)"
/>
</td>
</template>
</tr>
</tbody>
</table>
</div>
<div v-if="contextMenu.visible" class="context-menu" :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }">
<div class="menu-item" @click="handleContextMerge">合并</div>
<div class="menu-item" @click="handleContextSplit">拆分</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue';
import type { NativeTableHeaderCell, NativeTableHeaderConfig } from '../core/types';
const props = defineProps<{
rowCount: number;
colCount: number;
columnTitles?: string[];
value?: NativeTableHeaderConfig;
}>();
const emit = defineEmits<{
(e: 'update:value', value: NativeTableHeaderConfig): void;
}>();
const state = reactive({
cells: [] as NativeTableHeaderCell[],
selecting: false,
selection: { r1: 0, c1: 0, r2: 0, c2: 0 },
contextMenu: { visible: false, x: 0, y: 0 },
});
const colCount = computed(() => Math.max(1, Number(props.colCount || 1)));
const rowCount = computed(() => Math.max(1, Number(props.rowCount || 1)));
function resolveColumnTitle(colIndex: number) {
const title = props.columnTitles?.[colIndex];
return String(title || `${colIndex + 1}`);
}
function createDefaultCells(rows: number, cols: number) {
const list: NativeTableHeaderCell[] = [];
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
list.push({
id: `h_${r}_${c}_${Math.random().toString(36).slice(2, 8)}`,
row: r,
col: c,
rowspan: 1,
colspan: 1,
title: r === rows - 1 ? resolveColumnTitle(c) : `表头${r + 1}-${c + 1}`,
align: 'center',
});
}
}
return list;
}
function normalizeCells(rows: number, cols: number, source?: NativeTableHeaderConfig) {
if (!source || !Array.isArray(source.cells) || source.colCount !== cols || source.rowCount !== rows) {
return createDefaultCells(rows, cols);
}
return source.cells.map((item) => ({ ...item }));
}
watch(
() => [props.rowCount, props.colCount, props.value],
() => {
state.cells = normalizeCells(rowCount.value, colCount.value, props.value);
},
{ immediate: true, deep: true },
);
function emitValue() {
emit('update:value', {
rowCount: rowCount.value,
colCount: colCount.value,
cells: state.cells.map((item) => ({ ...item })),
});
}
function getCellByStart(row: number, col: number) {
return state.cells.find((item) => item.row === row && item.col === col);
}
function findOwnerCell(row: number, col: number) {
return state.cells.find((item) => row >= item.row && row < item.row + item.rowspan && col >= item.col && col < item.col + item.colspan);
}
function isCellStart(row: number, col: number) {
return !!getCellByStart(row, col);
}
function inSelection(row: number, col: number) {
const minR = Math.min(state.selection.r1, state.selection.r2);
const maxR = Math.max(state.selection.r1, state.selection.r2);
const minC = Math.min(state.selection.c1, state.selection.c2);
const maxC = Math.max(state.selection.c1, state.selection.c2);
return row >= minR && row <= maxR && col >= minC && col <= maxC;
}
function cellClass(row: number, col: number) {
const cell = getCellByStart(row, col);
return {
selected: inSelection(row, col),
focused: cell?.id === findOwnerCell(state.selection.r2, state.selection.c2)?.id,
};
}
function startSelect(row: number, col: number) {
state.selecting = true;
state.selection = { r1: row, c1: col, r2: row, c2: col };
hideContextMenu();
}
function moveSelect(row: number, col: number) {
if (!state.selecting) return;
state.selection.r2 = row;
state.selection.c2 = col;
}
function endSelect() {
state.selecting = false;
}
function updateCellTitle(row: number, col: number, value: string) {
const cell = getCellByStart(row, col);
if (!cell) return;
cell.title = String(value || '');
emitValue();
}
function selectedRect() {
return {
minR: Math.min(state.selection.r1, state.selection.r2),
maxR: Math.max(state.selection.r1, state.selection.r2),
minC: Math.min(state.selection.c1, state.selection.c2),
maxC: Math.max(state.selection.c1, state.selection.c2),
};
}
function canMerge() {
const { minR, maxR, minC, maxC } = selectedRect();
const selectedCells = state.cells.filter((cell) => cell.row >= minR && cell.row <= maxR && cell.col >= minC && cell.col <= maxC);
const totalSlots = (maxR - minR + 1) * (maxC - minC + 1);
return selectedCells.length === totalSlots && selectedCells.every((cell) => cell.rowspan === 1 && cell.colspan === 1);
}
function mergeSelection() {
if (!canMerge()) return;
const { minR, maxR, minC, maxC } = selectedRect();
const previousBottomTitles = Array.from({ length: maxC - minC + 1 }).map((_, idx) => {
const col = minC + idx;
const owner = findOwnerCell(rowCount.value - 1, col);
return String(owner?.title || resolveColumnTitle(col));
});
const master = getCellByStart(minR, minC);
if (!master) return;
master.rowspan = maxR - minR + 1;
master.colspan = maxC - minC + 1;
// 纵向合并且覆盖到底层时,默认标题沿用底层(绑定字段对应)列标题
if (maxR === rowCount.value - 1) {
master.title = minC === maxC ? previousBottomTitles[0] : previousBottomTitles.join(' / ');
}
state.cells = state.cells.filter((cell) => cell === master || cell.row < minR || cell.row > maxR || cell.col < minC || cell.col > maxC);
emitValue();
}
function splitCurrent() {
const owner = findOwnerCell(state.selection.r2, state.selection.c2);
if (!owner || (owner.rowspan === 1 && owner.colspan === 1)) return;
const { row, col, rowspan, colspan, title } = owner;
state.cells = state.cells.filter((item) => item.id !== owner.id);
for (let r = row; r < row + rowspan; r += 1) {
for (let c = col; c < col + colspan; c += 1) {
state.cells.push({
id: `h_${r}_${c}_${Math.random().toString(36).slice(2, 8)}`,
row: r,
col: c,
rowspan: 1,
colspan: 1,
title: r === row && c === col ? title : '',
align: owner.align || 'center',
});
}
}
emitValue();
}
function changeRowCount(nextRows: number) {
const rows = Math.max(1, Math.min(6, Number(nextRows || 1)));
state.cells = createDefaultCells(rows, colCount.value);
emit('update:value', { rowCount: rows, colCount: colCount.value, cells: state.cells.map((item) => ({ ...item })) });
}
function openContextMenu(event: MouseEvent, row: number, col: number) {
if (!inSelection(row, col)) {
state.selection = { r1: row, c1: col, r2: row, c2: col };
}
state.contextMenu = { visible: true, x: event.clientX, y: event.clientY };
}
function hideContextMenu() {
state.contextMenu.visible = false;
}
function handleContextMerge() {
mergeSelection();
hideContextMenu();
}
function handleContextSplit() {
splitCurrent();
hideContextMenu();
}
const contextMenu = computed(() => state.contextMenu);
const activeCell = computed(() => findOwnerCell(state.selection.r2, state.selection.c2));
const activeCellAlign = computed(() => String(activeCell.value?.align || 'center'));
function updateActiveCellAlign(value: string) {
const cell = activeCell.value;
if (!cell) return;
cell.align = String(value || 'center') as any;
emitValue();
}
</script>
<style scoped lang="less">
.header-config-editor {
position: relative;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px;
background: #fafafa;
.toolbar {
margin-bottom: 8px;
}
.grid-wrap {
overflow: auto;
max-height: 220px;
background: #fff;
border: 1px solid #f0f0f0;
}
.grid-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
td {
border: 1px solid #d9d9d9;
padding: 2px;
vertical-align: middle;
}
td.selected {
background: #e6f4ff;
}
}
.context-menu {
position: fixed;
z-index: 9999;
min-width: 120px;
background: #fff;
border: 1px solid #d9d9d9;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
.menu-item {
padding: 6px 10px;
cursor: pointer;
}
.menu-item:hover {
background: #f5f5f5;
}
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="toolbar-palette">
<div class="palette-title">组件库</div>
<a-radio-group
class="palette-insert-region"
:value="insertRegion"
button-style="solid"
size="small"
@update:value="emit('update:insertRegion', $event)"
>
<a-radio-button value="header">新增到报表头</a-radio-button>
<a-radio-button value="body">新增到报表主体</a-radio-button>
</a-radio-group>
<a-tabs v-model:activeKey="activeTab" size="small" class="palette-tabs">
<a-tab-pane key="bands" tab="报表节">
<div class="tab-scroll">
<a-button v-for="item in bandItems" :key="item.type" block size="small" class="palette-btn" @click="emit('add', item.type)">
{{ item.label }}
</a-button>
</div>
</a-tab-pane>
<a-tab-pane key="components" tab="组件框">
<div class="tab-scroll">
<a-button v-for="item in componentItems" :key="item.type" block size="small" class="palette-btn" @click="emit('add', item.type)">
{{ item.label }}
</a-button>
</div>
</a-tab-pane>
<a-tab-pane key="params">
<template #tab>
<span class="palette-tab-help-label" :title="paramsTabHelp">参数</span>
</template>
<div class="tab-scroll">
<BindingParamsEditor :params="paramsList" @update:params="onUpdateParams" />
</div>
</a-tab-pane>
<a-tab-pane key="fields">
<template #tab>
<span class="palette-tab-help-label" :title="fieldsTabHelp">字段</span>
</template>
<div class="tab-scroll">
<BindingDetailFieldsEditor :detail-tables="detailTablesList" @update:detail-tables="onUpdateDetailTables" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import BindingDetailFieldsEditor from './BindingDetailFieldsEditor.vue';
import BindingParamsEditor from './BindingParamsEditor.vue';
import type {
NativeDataBindingDetailTable,
NativeDataBindingParam,
NativeElementType,
NativeTemplateSchema,
} from '../core/types';
const props = defineProps<{
dataBinding?: NativeTemplateSchema['dataBinding'];
/** 新增元素落入报表头或主体(与画布逻辑一致) */
insertRegion: 'header' | 'body';
}>();
const emit = defineEmits<{
(e: 'add', type: NativeElementType): void;
(e: 'update-data-binding', value: Partial<NonNullable<NativeTemplateSchema['dataBinding']>>): void;
(e: 'update:insertRegion', value: 'header' | 'body'): void;
}>();
const activeTab = ref<'bands' | 'components' | 'params' | 'fields'>('bands');
/** 鼠标悬停在「参数」标签上时展示 */
const paramsTabHelp =
'用于非表格类组件(文本、标题、自由表格单元格等)的「绑定参数」下拉选项维护;普通/明细表格列请在「字段」页维护明细数据源与字段。';
/** 鼠标悬停在「字段」标签上时展示 */
const fieldsTabHelp =
'用于明细表格类组件的「数据源」与下属绑定字段维护(数据源键与画布明细表格的 source 一致)。';
const paramsList = computed(() => props.dataBinding?.params ?? []);
const detailTablesList = computed(() => props.dataBinding?.detailTables ?? []);
function onUpdateParams(params: NativeDataBindingParam[]) {
emit('update-data-binding', { params });
}
function onUpdateDetailTables(detailTables: NativeDataBindingDetailTable[]) {
emit('update-data-binding', { detailTables });
}
const bandItems: Array<{ type: NativeElementType; label: string }> = [
{ type: 'reportHeader', label: '报表头' },
{ type: 'reportFooter', label: '报表尾' },
];
const componentItems: Array<{ type: NativeElementType; label: string }> = [
{ type: 'title', label: '标题' },
{ type: 'subtitle', label: '副标题' },
{ type: 'text', label: '文本' },
{ type: 'date', label: '日期' },
{ type: 'pageNo', label: '页码' },
{ type: 'image', label: '图片' },
{ type: 'table', label: '普通表格' },
{ type: 'detailTable', label: '明细表格' },
{ type: 'freeTable', label: '自由表格' },
{ type: 'qrcode', label: '二维码' },
{ type: 'barcode', label: '条形码' },
];
</script>
<style scoped lang="less">
.toolbar-palette {
padding-inline: 2px;
.palette-title {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.palette-insert-region {
display: flex;
width: 100%;
margin-bottom: 8px;
:deep(.ant-radio-button-wrapper) {
flex: 1;
padding-inline: 6px;
text-align: center;
}
}
.palette-tabs {
:deep(.ant-tabs-nav) {
margin-bottom: 8px;
padding-inline: 2px;
}
:deep(.ant-tabs-content) {
padding: 6px 4px 4px;
}
}
/* 随视窗增高,参数/字段等列表可展示更多行 */
.tab-scroll {
max-height: clamp(300px, calc(100vh - 210px), 680px);
overflow: auto;
padding: 6px 10px 12px 8px;
}
.palette-btn {
margin-bottom: 6px;
text-align: left;
}
.palette-tab-help-label {
cursor: help;
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="barcode-element">
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import type { NativeCodeElement } from '../../core/types';
const props = defineProps<{
element: NativeCodeElement;
previewData?: Record<string, any>;
}>();
const canvasRef = ref<HTMLCanvasElement>();
function resolveFieldValue(field?: string) {
if (!field) return undefined;
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
}
async function renderBarcode() {
if (!canvasRef.value) return;
const module: any = await import('jsbarcode');
const JsBarcode = module.default || module;
const bindValue = resolveFieldValue(props.element.bindField);
const value = bindValue !== undefined && bindValue !== null ? String(bindValue) : props.element.value || '0000000000';
JsBarcode(canvasRef.value, value, {
format: 'CODE128',
displayValue: true,
margin: 0,
width: 1.5,
height: 40,
fontSize: 12,
});
}
onMounted(() => {
renderBarcode();
});
watch(
() => [props.element.value, props.element.bindField, props.previewData],
() => {
renderBarcode();
},
);
</script>
<style scoped lang="less">
.barcode-element {
width: 100%;
height: 100%;
border: 1px dashed #999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
canvas {
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,679 @@
<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>

View File

@@ -0,0 +1,51 @@
<template>
<div class="image-element">
<img v-if="renderSrc" :src="renderSrc" :style="{ objectFit: element.fit || 'contain' }" />
<div v-else class="image-placeholder">图片</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { NativeImageElement } from '../../core/types';
const props = defineProps<{
element: NativeImageElement;
previewData?: Record<string, any>;
}>();
function resolveFieldValue(field?: string) {
if (!field) return undefined;
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
}
const renderSrc = computed(() => {
const value = resolveFieldValue(props.element.bindField);
if (value) {
return String(value);
}
return props.element.src;
});
</script>
<style scoped lang="less">
.image-element {
width: 100%;
height: 100%;
border: 1px dashed #999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
.image-placeholder {
font-size: 12px;
color: #666;
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="qrcode-element">
<img v-if="url" :src="url" alt="qrcode" />
<div v-else class="qrcode-placeholder">二维码</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import QRCode from 'qrcode';
import type { NativeCodeElement } from '../../core/types';
const props = defineProps<{
element: NativeCodeElement;
previewData?: Record<string, any>;
}>();
const url = ref('');
function resolveFieldValue(field?: string) {
if (!field) return undefined;
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
}
async function renderCode() {
const bindValue = resolveFieldValue(props.element.bindField);
const value = bindValue !== undefined && bindValue !== null ? String(bindValue) : props.element.value || 'empty';
url.value = await QRCode.toDataURL(value);
}
watch(
() => [props.element.value, props.element.bindField, props.previewData],
() => {
renderCode();
},
{ immediate: true },
);
</script>
<style scoped lang="less">
.qrcode-element {
width: 100%;
height: 100%;
border: 1px dashed #999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
.qrcode-placeholder {
color: #666;
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,747 @@
<template>
<div ref="tableWrapRef" class="table-element">
<table>
<colgroup>
<col v-for="col in columns" :key="`col_width_${col.key}`" :style="{ width: `${col.widthPercent || 0}%` }" />
</colgroup>
<thead v-if="element.showHeader">
<tr v-for="(rowCells, rowIndex) in headerRenderRows" :key="`header_row_${rowIndex}`" :style="headerRowStyle">
<th
v-for="cell in rowCells"
:key="cell.id"
:rowspan="cell.rowspan"
:colspan="cell.colspan"
:class="{ 'is-selected-col': isCellSelected(cell) }"
:style="headerCellStyle(cell)"
@click="handleHeaderClick(cell)"
@dblclick.stop="startHeaderEdit(cell)"
>
<template v-if="editingHeaderCellId === cell.id">
<input
ref="editingInputRef"
class="header-inline-input"
:value="editingHeaderText"
@input="editingHeaderText = ($event.target as HTMLInputElement).value"
@blur="commitHeaderEdit()"
@keydown.enter.prevent="commitHeaderEdit()"
@keydown.esc.prevent="cancelHeaderEdit()"
/>
</template>
<template v-else>
{{ cell.title }}
</template>
<span v-if="canResizeFromCell(cell)" class="col-resize-handle" @pointerdown.stop.prevent="startResizeColumn($event, cell.col)" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in renderRowsWithMarkers" :key="item.key" :style="item.kind === 'data' ? bodyRowStyle : undefined" :class="{ 'page-break-marker-row': item.kind === 'marker' }">
<td v-if="item.kind === 'marker'" :colspan="Math.max(1, columns.length)" class="page-break-marker-cell">
{{ item.pageNo }} 页起始
</td>
<template v-else-if="item.kind === 'footer'">
<td v-for="(col, colIndex) in columns" :key="`${item.key}_${col.key}`" :style="footerCellStyle(col, colIndex)">
<template v-if="isFooterColumn(col)">
{{ formatNumericValue(resolveFooterTotalByRange(col, item.start, item.end), col) }}
</template>
<template v-else-if="isFooterLabelColumn(col, colIndex)">{{ footerLabelText }}</template>
</td>
</template>
<template v-else>
<template v-for="col in columns" :key="`${item.rowIndex}_${col.key}`">
<td v-if="showCell(item.rowIndex, col.bindField || col.field)" :rowspan="rowSpan(item.rowIndex, col.bindField || col.field)" :style="bodyCellStyle(col, item.row)">
<template v-if="resolveColumnContentType(col) === 'text'">
{{ resolveCellValue(item.row, col.bindField || col.field) }}
</template>
<template v-else-if="resolveColumnContentType(col) === 'number' || resolveColumnContentType(col) === 'amount'">
{{ formatNumericValue(resolveCellValue(item.row, col.bindField || col.field), col) }}
</template>
<template v-else-if="resolveColumnContentType(col) === 'image'">
<img class="table-media" :src="resolveImageSrc(item.row, col)" :style="mediaStyle(col, 'image')" />
</template>
<template v-else-if="resolveColumnContentType(col) === 'qrcode'">
<img class="table-media" :src="resolveQrcodeSrc(item.row, col)" :style="mediaStyle(col, 'qrcode')" />
</template>
<template v-else-if="resolveColumnContentType(col) === 'barcode'">
<img class="table-media" :src="resolveBarcodeSrc(item.row, col)" :style="mediaStyle(col, 'barcode')" />
</template>
</td>
</template>
</template>
</tr>
</tbody>
<tfoot v-if="showFooterTotal && !showPagedFooterRows">
<tr class="table-footer-row">
<td v-for="(col, colIndex) in columns" :key="`footer_${col.key}`" :style="footerCellStyle(col, colIndex)">
<template v-if="isFooterColumn(col)">
{{ formatNumericValue(resolveFooterTotal(col), col) }}
</template>
<template v-else-if="isFooterLabelColumn(col, colIndex)">{{ footerLabelText }}</template>
</td>
</tr>
</tfoot>
</table>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watchEffect } from 'vue';
import QRCode from 'qrcode';
import { buildRowSpanMap } from '../../core/tableMerge';
import { getValueByPath, normalizeTableWidths, resolveTableRows } from '../../core/tableBuilder';
import type { NativeTableElement } from '../../core/types';
const props = defineProps<{
element: NativeTableElement;
previewData: Record<string, any>;
selectedColumnKey?: string;
isElementSelected?: boolean;
}>();
const emit = defineEmits<{
(e: 'select-column', payload: { columnKey: string }): void;
(e: 'update-columns', payload: { columns: any[] }): void;
(e: 'update-header-config', payload: { headerConfig: any }): void;
}>();
const tableWrapRef = ref<HTMLElement | null>(null);
const editingInputRef = ref<HTMLInputElement | null>(null);
const editingHeaderCellId = ref('');
const editingHeaderColumnKey = ref('');
const editingHeaderText = ref('');
const qrCodeCache = ref<Record<string, string>>({});
const barcodeCache = ref<Record<string, string>>({});
const rows = computed(() => {
const resolved = resolveTableRows(props.element, props.previewData || {});
return resolved.length ? resolved : [{ field1: '示例A', field2: '示例B', field3: '示例C' }];
});
const renderedRows = computed(() => {
return rows.value;
});
const renderRowsWithMarkers = computed(() => {
const list: Array<
| { kind: 'marker'; key: string; pageNo: number }
| { kind: 'footer'; key: string; start: number; end: number }
| { kind: 'data'; key: string; row: Record<string, any>; rowIndex: number }
> = [];
const pageSize = Math.max(1, Number(props.element.fixedRows || 5));
const pushFooterRow = (start: number, end: number) => {
if (!showPagedFooterRows.value) return;
if (start >= end) return;
list.push({
kind: 'footer',
key: `footer_${start}_${end}`,
start,
end,
});
};
renderedRows.value.forEach((row, rowIndex) => {
if (isPageBreakRow(rowIndex)) {
const prevStart = Math.max(0, rowIndex - pageSize);
pushFooterRow(prevStart, rowIndex);
list.push({
kind: 'marker',
key: `marker_${rowIndex}`,
pageNo: resolvePageNo(rowIndex),
});
}
list.push({
kind: 'data',
key: `data_${rowIndex}`,
row,
rowIndex,
});
});
if (renderedRows.value.length) {
const total = renderedRows.value.length;
const pageStart = Math.floor((total - 1) / pageSize) * pageSize;
pushFooterRow(pageStart, total);
}
return list;
});
const columns = computed(() => normalizeTableWidths(props.element));
const headerRenderRows = computed(() => buildHeaderRenderRows());
const headerRowCount = computed(() => Math.max(1, headerRenderRows.value.length));
const spanMap = computed(() => {
if (!isFixedRowsPagination()) {
return buildRowSpanMap(
renderedRows.value,
props.element.columns,
(props.element as any).mergeColumnKeys || [],
(props.element as any).strictGrouping !== false,
);
}
const pageSize = Math.max(1, Number(props.element.fixedRows || 5));
const mergedMap: Record<string, number> = {};
for (let start = 0; start < renderedRows.value.length; start += pageSize) {
const chunk = renderedRows.value.slice(start, start + pageSize);
const chunkMap = buildRowSpanMap(
chunk,
props.element.columns,
(props.element as any).mergeColumnKeys || [],
(props.element as any).strictGrouping !== false,
);
Object.entries(chunkMap).forEach(([key, value]) => {
const [rowIndex, field] = key.split('_');
const absoluteRow = Number(rowIndex) + start;
mergedMap[`${absoluteRow}_${field}`] = value;
});
}
return mergedMap;
});
const showFooterTotal = computed(() => {
return (props.element as any).footerShowTotal !== false;
});
const showPagedFooterRows = computed(() => {
return showFooterTotal.value && isFixedRowsPagination() && String((props.element as any).footerTotalMode || 'overall') === 'page';
});
const footerLabelText = computed(() => String(props.element.footerLabelText || '合计'));
const footerLabelColumnKey = computed(() => String(props.element.footerLabelColumnKey || columns.value?.[0]?.key || ''));
const footerLabelCenter = computed(() => props.element.footerLabelCenter !== false);
const headerRowStyle = computed(() => ({
height: `${(props.element.headerHeight || 10) / headerRowCount.value}mm`,
}));
const bodyRowStyle = computed(() => ({
height: `${props.element.rowHeight || 8}mm`,
}));
function rowSpan(rowIndex: number, field: string) {
return spanMap.value[`${rowIndex}_${field}`] || 1;
}
function showCell(rowIndex: number, field: string) {
return spanMap.value[`${rowIndex}_${field}`] !== 0;
}
function resolveCellValue(row: Record<string, any>, field: string) {
const value = getValueByPath(row || {}, field);
return value ?? '';
}
function resolveColumnContentType(col: any) {
return String(col?.contentType || 'text');
}
function isNumericColumn(col: any) {
const type = resolveColumnContentType(col);
return type === 'number' || type === 'amount';
}
function isFooterColumn(col: any) {
return isNumericColumn(col) && !!col?.enableFooterTotal;
}
function resolveFooterTotal(col: any) {
const field = col?.bindField || col?.field;
return resolveFooterRows().reduce((sum, row) => {
const value = Number(resolveCellValue(row, field));
return sum + (Number.isFinite(value) ? value : 0);
}, 0);
}
function resolveFooterTotalByRange(col: any, start: number, end: number) {
const field = col?.bindField || col?.field;
return renderedRows.value.slice(start, end).reduce((sum, row) => {
const value = Number(resolveCellValue(row, field));
return sum + (Number.isFinite(value) ? value : 0);
}, 0);
}
function resolveFooterRows() {
const mode = String((props.element as any).footerTotalMode || 'overall');
if (mode !== 'page' || !isFixedRowsPagination()) {
return renderedRows.value;
}
const pageSize = Math.max(1, Number(props.element.fixedRows || 5));
const total = renderedRows.value.length;
if (!total) return [];
const lastPageStart = Math.floor((total - 1) / pageSize) * pageSize;
return renderedRows.value.slice(lastPageStart, lastPageStart + pageSize);
}
function formatNumericValue(value: any, col: any) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value ?? '');
}
const decimals = Math.max(0, Math.min(6, Number(col?.decimalPlaces ?? 2)));
const finalValue = col?.roundHalfUp === false ? Math.trunc(numeric * 10 ** decimals) / 10 ** decimals : Number(numeric.toFixed(decimals));
const formatted = finalValue.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
if (resolveColumnContentType(col) === 'amount') {
const symbol = col?.amountType === 'USD' ? '$' : col?.amountType === 'EUR' ? 'EUR ' : '¥';
return `${symbol}${formatted}`;
}
return formatted;
}
function isFooterLabelColumn(col: any, colIndex: number) {
const key = String(col?.key || '');
if (footerLabelColumnKey.value) {
return key === footerLabelColumnKey.value;
}
return colIndex === 0;
}
function footerCellStyle(col: any, colIndex: number) {
const base = bodyCellStyle(col, {}) as Record<string, any>;
if (isFooterLabelColumn(col, colIndex) && !isFooterColumn(col)) {
base.textAlign = footerLabelCenter.value ? 'center' : 'left';
base.fontWeight = 600;
}
if (isFooterColumn(col)) {
base.fontWeight = 600;
base.background = '#fafafa';
}
return base;
}
function resolveImageSrc(row: Record<string, any>, col: any) {
const raw = resolveCellValue(row, col.bindField || col.field);
const value = String(raw || '').trim();
if (value) return value;
return `https://via.placeholder.com/180x80.png?text=${encodeURIComponent(col?.title || 'Image')}`;
}
function mediaStyle(col: any, type: 'image' | 'qrcode' | 'barcode') {
const fillCell = col?.fillCell !== false;
const scale = Math.max(10, Math.min(100, Number(col?.contentScale || 100)));
const percent = `${scale}%`;
const base = {
display: 'block',
margin: '0 auto',
maxWidth: '100%',
maxHeight: '100%',
objectFit: type === 'image' ? col?.imageFit || 'contain' : 'contain',
} 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 resolveQrcodeSrc(row: Record<string, any>, col: any) {
const value = String(resolveCellValue(row, col.bindField || col.field) || `${col?.title || 'qrcode'}_empty`);
const key = `${col?.qrLevel || 'M'}|${col?.qrRenderType || 'image/png'}|${value}`;
if (qrCodeCache.value[key]) {
return qrCodeCache.value[key];
}
QRCode.toDataURL(value, {
errorCorrectionLevel: col?.qrLevel || 'M',
margin: 0,
type: col?.qrRenderType || 'image/png',
width: 200,
})
.then((url) => {
qrCodeCache.value = { ...qrCodeCache.value, [key]: url };
})
.catch(() => {
// ignore
});
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 resolveBarcodeSrc(row: Record<string, any>, col: any) {
const value = String(resolveCellValue(row, col.bindField || col.field) || '00000000');
const format = String(col?.barcodeFormat || 'CODE128');
const key = `${format}|${value}`;
if (barcodeCache.value[key]) {
return barcodeCache.value[key];
}
buildBarcodeDataUrl(value, format)
.then((url) => {
barcodeCache.value = { ...barcodeCache.value, [key]: url };
})
.catch(() => {
// ignore
});
return '';
}
function handleSelectColumn(col: any) {
if (!isLeafColumnCell(col)) {
return;
}
const key = String(col?.columnKey || col?.key || '');
if (!key) return;
emit('select-column', { columnKey: key });
}
function handleHeaderClick(col: any) {
// 交互约定:第一次单击先选中组件;组件已选中后再次单击表头才选中列
if (!props.isElementSelected) {
return;
}
handleSelectColumn(col);
}
function startHeaderEdit(col: any) {
handleSelectColumn(col);
editingHeaderCellId.value = String(col?.id || '');
editingHeaderColumnKey.value = String(col?.columnKey || col?.key || '');
editingHeaderText.value = String(col?.title || '');
nextTick(() => {
editingInputRef.value?.focus();
editingInputRef.value?.select();
});
}
function commitHeaderEdit() {
const nextTitle = String(editingHeaderText.value || '').trim() || '未命名列';
const cellId = editingHeaderCellId.value;
const headerConfig = (props.element as any)?.headerConfig;
if (cellId && headerConfig?.cells?.length) {
const nextHeader = {
...headerConfig,
cells: headerConfig.cells.map((item: any) => (item?.id === cellId ? { ...item, title: nextTitle } : { ...item })),
};
emit('update-header-config', { headerConfig: nextHeader });
} else {
const key = editingHeaderColumnKey.value;
const nextColumns = (props.element.columns || []).map((item: any) => ({ ...item }));
const target = nextColumns.find((item: any) => item?.key === key);
if (target) {
target.title = nextTitle;
emit('update-columns', { columns: nextColumns });
}
}
editingHeaderCellId.value = '';
editingHeaderColumnKey.value = '';
}
function cancelHeaderEdit() {
editingHeaderCellId.value = '';
editingHeaderColumnKey.value = '';
}
function startResizeColumn(event: PointerEvent, colIndex: number) {
const baseColumns = Array.isArray(props.element.columns) ? props.element.columns : [];
if (!baseColumns.length || colIndex < 0 || colIndex >= baseColumns.length - 1) {
return;
}
const tableWidthPx = Math.max(1, tableWrapRef.value?.clientWidth || 1);
const totalWidth = baseColumns.reduce((sum, item) => sum + Number(item?.width || 0), 0) || 1;
const leftStart = Number(baseColumns[colIndex]?.width || 0);
const rightStart = Number(baseColumns[colIndex + 1]?.width || 0);
const startX = event.clientX;
const minWidth = 10;
const onMove = (moveEvent: PointerEvent) => {
const deltaPx = moveEvent.clientX - startX;
const deltaWidth = (deltaPx / tableWidthPx) * totalWidth;
const minDelta = -(leftStart - minWidth);
const maxDelta = rightStart - minWidth;
const clampedDelta = Math.max(minDelta, Math.min(maxDelta, deltaWidth));
const nextColumns = baseColumns.map((item) => ({ ...item }));
nextColumns[colIndex].width = Number((leftStart + clampedDelta).toFixed(2));
nextColumns[colIndex + 1].width = Number((rightStart - clampedDelta).toFixed(2));
emit('update-columns', { columns: nextColumns });
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function headerCellStyle(cell: any) {
const col = columns.value?.[cell.col] || {};
const fontSize = resolveHeaderFontSize(col);
const isSelected = isCellSelected(cell);
const heightMm = ((props.element.headerHeight || 10) / headerRowCount.value) * Number(cell.rowspan || 1);
return {
textAlign: cell.align || col.align || 'center',
height: `${heightMm}mm`,
lineHeight: col?.autoWrap === false ? `${heightMm}mm` : '1.3',
backgroundColor: isSelected ? '#e6f4ff' : props.element.headerBgColor || '#f5f5f5',
color: isSelected ? '#1677ff' : col?.fontColor || props.element.headerTextColor || '#111111',
fontFamily: col?.fontFamily || 'inherit',
fontSize: `${fontSize}px`,
whiteSpace: col?.autoWrap === false ? 'nowrap' : 'normal',
wordBreak: col?.autoWrap === false ? 'normal' : 'break-all',
overflowWrap: col?.autoWrap === false ? 'normal' : 'anywhere',
boxSizing: 'border-box',
boxShadow: isSelected ? 'inset 0 0 0 2px #1677ff' : 'none',
position: 'relative',
zIndex: isSelected ? 1 : 0,
};
}
function buildHeaderRenderRows() {
const cols = columns.value || [];
const colCount = cols.length;
if (!colCount) return [];
if (props.element.enableMultiHeader !== true) {
return [
cols.map((col: any, index: number) => ({
id: `single_${index}`,
row: 0,
col: index,
rowspan: 1,
colspan: 1,
title: String(col?.title || ''),
align: col?.align || 'center',
widthPercent: Number(col?.widthPercent || 0),
columnKey: col?.key,
})),
];
}
const headerConfig = (props.element as any)?.headerConfig;
const rowCount = Math.max(1, Number(headerConfig?.rowCount || 1));
const owner: any[][] = Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => null));
const cells: any[] = [];
const configCells = Array.isArray(headerConfig?.cells) && Number(headerConfig?.colCount || 0) === colCount ? headerConfig.cells : [];
configCells.forEach((item: any) => {
const row = Math.max(0, Number(item?.row || 0));
const col = Math.max(0, Number(item?.col || 0));
const rowspan = Math.max(1, Number(item?.rowspan || 1));
const colspan = Math.max(1, Number(item?.colspan || 1));
if (row >= rowCount || col >= colCount) return;
const maxRow = Math.min(rowCount, row + rowspan);
const maxCol = Math.min(colCount, col + colspan);
if (owner[row][col]) return;
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
if (owner[r][c]) return;
}
}
const next = {
id: String(item?.id || `h_${row}_${col}`),
row,
col,
rowspan: maxRow - row,
colspan: maxCol - col,
title: String(item?.title || ''),
align: String(item?.align || 'center'),
};
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
owner[r][c] = next;
}
}
cells.push(next);
});
for (let r = 0; r < rowCount; r += 1) {
for (let c = 0; c < colCount; c += 1) {
if (owner[r][c]) continue;
const fallback = {
id: `auto_${r}_${c}`,
row: r,
col: c,
rowspan: 1,
colspan: 1,
title: r === rowCount - 1 ? String(cols[c]?.title || '') : '',
align: cols[c]?.align || 'center',
};
owner[r][c] = fallback;
cells.push(fallback);
}
}
const rows = Array.from({ length: rowCount }, () => [] as any[]);
cells.forEach((cell) => {
if (owner[cell.row][cell.col] !== cell) return;
let widthPercent = 0;
for (let i = 0; i < cell.colspan; i += 1) {
widthPercent += Number(cols[cell.col + i]?.widthPercent || 0);
}
rows[cell.row].push({
...cell,
widthPercent,
columnKey: cols[cell.col]?.key,
});
});
rows.forEach((list) => list.sort((a, b) => a.col - b.col));
return rows;
}
function isCellSelected(cell: any) {
const key = String(props.selectedColumnKey || '');
if (!key) return false;
const selectedIndex = columns.value.findIndex((item: any) => item?.key === key);
if (selectedIndex < 0) return false;
return selectedIndex >= cell.col && selectedIndex < cell.col + cell.colspan;
}
function canResizeFromCell(cell: any) {
return cell.row + cell.rowspan === headerRowCount.value && cell.colspan === 1 && cell.col < columns.value.length - 1;
}
function isLeafColumnCell(cell: any) {
return cell.row + cell.rowspan === headerRowCount.value && cell.colspan === 1;
}
function bodyCellStyle(col: any, row: Record<string, any>) {
const raw = resolveCellValue(row, col.bindField || col.field);
const text = isNumericColumn(col) ? String(formatNumericValue(raw, col)) : String(raw ?? '');
const fontSize = resolveBodyFontSize(col, text);
return {
textAlign: col.align || 'left',
height: `${props.element.rowHeight || 8}mm`,
lineHeight: col?.autoWrap === false ? `${props.element.rowHeight || 8}mm` : '1.3',
fontFamily: col?.fontFamily || 'inherit',
fontSize: `${fontSize}px`,
color: col?.fontColor || '#111111',
whiteSpace: col?.autoWrap === false ? 'nowrap' : 'normal',
wordBreak: col?.autoWrap === false ? 'normal' : 'break-all',
overflowWrap: col?.autoWrap === false ? 'normal' : 'anywhere',
boxSizing: 'border-box',
};
}
function resolveAutoFontSize(col: any, text: string, rowHeightMm: number, baseSize: number) {
const base = Number(baseSize || 12);
if (!col?.autoFitFont) {
return base;
}
const tableWidthPx = Math.max(1, tableWrapRef.value?.clientWidth || 600);
const colWidthPx = Math.max(1, (tableWidthPx * Number(col?.widthPercent || 0)) / 100);
const heightPx = Math.max(1, rowHeightMm * 3.7795275591);
const textLen = Math.max(1, text.length);
const byWidth = colWidthPx / Math.max(1, textLen * 0.62);
const byHeight = col?.autoWrap === false ? heightPx * 0.55 : heightPx * 0.36;
const next = Math.min(base, byWidth, byHeight);
return Math.max(8, Math.round(next));
}
function resolveHeaderFontSize(_col: any) {
return Number(props.element.headerFontSize || 12);
}
function resolveBodyFontSize(col: any, text: string) {
const base = col?.useCustomFontSize ? Number(col?.fontSize || 12) : Number(props.element.bodyFontSize || 12);
return resolveAutoFontSize(col, text, props.element.rowHeight || 8, base);
}
function isPageBreakRow(rowIndex: number) {
if (!isFixedRowsPagination()) {
return false;
}
const pageSize = Math.max(1, Number(props.element.fixedRows || 5));
return rowIndex > 0 && rowIndex % pageSize === 0;
}
function resolvePageNo(rowIndex: number) {
const pageSize = Math.max(1, Number(props.element.fixedRows || 5));
return Math.floor(rowIndex / pageSize) + 1;
}
function isFixedRowsPagination() {
return String(props.element.tableHeightMode || 'autoPage') === 'fixedRows';
}
watchEffect(() => {
const rowsValue = renderedRows.value || [];
const cols = columns.value || [];
rowsValue.forEach((row) => {
cols.forEach((col: any) => {
const type = resolveColumnContentType(col);
if (type === 'qrcode') {
resolveQrcodeSrc(row, col);
} else if (type === 'barcode') {
resolveBarcodeSrc(row, col);
}
});
});
});
</script>
<style scoped lang="less">
.table-element {
width: 100%;
height: 100%;
overflow: auto;
border: 1px dashed #999;
background: #fff;
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
th,
td {
border: 1px solid #d9d9d9;
padding: 2px 4px;
}
th {
cursor: pointer;
user-select: none;
position: relative;
}
.is-selected-col {
background: #e6f4ff;
color: #1677ff;
}
.col-resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
.header-inline-input {
width: calc(100% - 6px);
border: 1px solid #1677ff;
border-radius: 4px;
outline: none;
font-size: inherit;
line-height: 1.2;
padding: 1px 3px;
background: #fff;
color: inherit;
box-sizing: border-box;
}
.table-media {
background: transparent;
}
.table-footer-row {
td {
font-weight: 600;
background: #fafafa;
}
}
.page-break-marker-row td {
border-top: 2px dashed #fa8c16;
border-bottom: 1px dashed #fa8c16;
background: #fff7e6;
color: #d46b08;
text-align: center;
font-size: 11px;
line-height: 1.4;
padding: 2px 6px;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="text-element" :style="styleObject">
{{ displayText }}
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import dayjs from 'dayjs';
import type { NativeReportBandElement, NativeTextElement } from '../../core/types';
const props = defineProps<{
element: NativeTextElement | NativeReportBandElement;
previewData?: Record<string, any>;
}>();
function resolveFieldValue(field?: string) {
if (!field) return undefined;
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
}
const displayText = computed(() => {
const bindValue = resolveFieldValue(props.element.bindField);
if (bindValue !== undefined && bindValue !== null && props.element.type !== 'pageNo') {
if (props.element.type === 'date') {
return dayjs(bindValue).format(props.element.format || 'YYYY-MM-DD');
}
return String(bindValue);
}
if (props.element.type === 'date') {
return dayjs().format(props.element.format || 'YYYY-MM-DD');
}
if (props.element.type === 'pageNo') {
return props.element.text || '第 1 / 1 页';
}
return props.element.text || '';
});
const styleObject = computed(() => ({
fontSize: `${props.element.style?.fontSize || 12}px`,
fontWeight: String(props.element.style?.fontWeight || 400),
color: props.element.style?.color || '#111',
textAlign: props.element.style?.textAlign || 'left',
lineHeight: String(props.element.style?.lineHeight || 1.4),
width: '100%',
height: '100%',
whiteSpace: 'pre-wrap',
overflow: 'hidden',
backgroundColor: props.element.style?.backgroundColor || 'transparent',
display: (props.element as any)?.visible === false ? 'none' : 'block',
borderTop: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
borderBottom: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
background: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? 'rgba(22,119,255,0.06)' : props.element.style?.backgroundColor || 'transparent',
}));
</script>

View File

@@ -0,0 +1,59 @@
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
type Direction = 'n' | 's' | 'w' | 'e' | 'nw' | 'ne' | 'sw' | 'se';
const MIN_SIZE = 6;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function roundToGrid(value: number, gridSize: number) {
if (!gridSize || gridSize <= 1) return value;
return Math.round(value / gridSize) * gridSize;
}
export function calcDragRect(
startRect: Rect,
pageSize: { width: number; height: number },
deltaX: number,
deltaY: number,
gridSize: number,
) {
const x = clamp(roundToGrid(startRect.x + deltaX, gridSize), 0, pageSize.width - startRect.w);
const y = clamp(roundToGrid(startRect.y + deltaY, gridSize), 0, pageSize.height - startRect.h);
return { ...startRect, x, y };
}
export function calcResizeRect(
direction: Direction,
startRect: Rect,
pageSize: { width: number; height: number },
deltaX: number,
deltaY: number,
gridSize: number,
) {
const next = { ...startRect };
if (direction.includes('e')) {
next.w = clamp(roundToGrid(startRect.w + deltaX, gridSize), MIN_SIZE, pageSize.width - startRect.x);
}
if (direction.includes('s')) {
next.h = clamp(roundToGrid(startRect.h + deltaY, gridSize), MIN_SIZE, pageSize.height - startRect.y);
}
if (direction.includes('w')) {
const newX = clamp(roundToGrid(startRect.x + deltaX, gridSize), 0, startRect.x + startRect.w - MIN_SIZE);
next.w = clamp(roundToGrid(startRect.w + (startRect.x - newX), gridSize), MIN_SIZE, pageSize.width - newX);
next.x = newX;
}
if (direction.includes('n')) {
const newY = clamp(roundToGrid(startRect.y + deltaY, gridSize), 0, startRect.y + startRect.h - MIN_SIZE);
next.h = clamp(roundToGrid(startRect.h + (startRect.y - newY), gridSize), MIN_SIZE, pageSize.height - newY);
next.y = newY;
}
return next;
}

View File

@@ -0,0 +1,19 @@
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/700.css';
import '@fontsource/noto-serif-sc/400.css';
import '@fontsource/noto-serif-sc/700.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/700.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/700.css';
export const TABLE_FONT_OPTIONS = [
{ label: '默认字体', value: '' },
{ label: 'Noto Sans SC思源黑体', value: '"Noto Sans SC", sans-serif' },
{ label: 'Noto Serif SC思源宋体', value: '"Noto Serif SC", serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Open Sans', value: '"Open Sans", sans-serif' },
{ label: 'Microsoft YaHei微软雅黑', value: '"Microsoft YaHei", sans-serif' },
{ label: 'SimSun宋体', value: 'SimSun, serif' },
{ label: 'SimHei黑体', value: 'SimHei, sans-serif' },
];

View File

@@ -0,0 +1,143 @@
/**
* 自由表格:外框/内线与单元格单边隐藏 → 四边是否绘制边框
*/
import { lineStyleKeyToCssBorderStyle } from './freeTableLineStyles';
import { getFreeTableOwnerAt, type FreeTableAnchorCell } from './freeTableGrid';
export interface FreeTableBorderSides {
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
}
/** 表格外轮廓四边,缺省均为显示 */
export function resolveOuterBorderFlags(el: { outerBorder?: Record<string, boolean | undefined> } | null | undefined): FreeTableBorderSides {
const o = el?.outerBorder;
return {
top: o?.top !== false,
right: o?.right !== false,
bottom: o?.bottom !== false,
left: o?.left !== false,
};
}
/** 内部网格线:横向(行间)、纵向(列间),缺省均为显示 */
export function resolveInnerBorderFlags(el: { innerBorder?: { horizontal?: boolean; vertical?: boolean } } | null | undefined): {
horizontal: boolean;
vertical: boolean;
} {
const i = el?.innerBorder;
return {
horizontal: i?.horizontal !== false,
vertical: i?.vertical !== false,
};
}
/**
* 计算锚点单元格(含合并)四边是否画线。
* 共享边:若本格或相邻格任一侧声明隐藏(如上格的 bottom 与本格的 top则两边都不画避免预览/打印仍留线。
*/
export function resolveFreeTableCellBorderSides(
el: any,
anchors: FreeTableAnchorCell[],
cell: any,
anchorRow: number,
anchorCol: number,
rs: number,
cs: number,
rowCount: number,
colCount: number,
): FreeTableBorderSides {
const outer = resolveOuterBorderFlags(el);
const inner = resolveInnerBorderFlags(el);
const rEnd = anchorRow + rs - 1;
const cEnd = anchorCol + cs - 1;
let top = anchorRow === 0 ? outer.top : inner.horizontal;
let right = cEnd === colCount - 1 ? outer.right : inner.vertical;
let bottom = rEnd === rowCount - 1 ? outer.bottom : inner.horizontal;
let left = anchorCol === 0 ? outer.left : inner.vertical;
if (cell?.hideBorderTop === true) {
top = false;
}
if (cell?.hideBorderRight === true) {
right = false;
}
if (cell?.hideBorderBottom === true) {
bottom = false;
}
if (cell?.hideBorderLeft === true) {
left = false;
}
// 与上方格共享横线:对方 hideBorderBottom 则本格也不画上边
if (top && anchorRow > 0) {
for (let cc = anchorCol; cc <= anchorCol + cs - 1; cc += 1) {
const up = getFreeTableOwnerAt(anchors, anchorRow - 1, cc);
if (up?.hideBorderBottom === true) {
top = false;
break;
}
}
}
// 与下方格共享横线
if (bottom && rEnd < rowCount - 1) {
const belowRow = anchorRow + rs;
for (let cc = anchorCol; cc <= anchorCol + cs - 1; cc += 1) {
const dn = getFreeTableOwnerAt(anchors, belowRow, cc);
if (dn?.hideBorderTop === true) {
bottom = false;
break;
}
}
}
// 与左侧格共享竖线
if (left && anchorCol > 0) {
for (let rr = anchorRow; rr <= anchorRow + rs - 1; rr += 1) {
const lf = getFreeTableOwnerAt(anchors, rr, anchorCol - 1);
if (lf?.hideBorderRight === true) {
left = false;
break;
}
}
}
// 与右侧格共享竖线
if (right && cEnd < colCount - 1) {
const rightCol = anchorCol + cs;
for (let rr = anchorRow; rr <= anchorRow + rs - 1; rr += 1) {
const rt = getFreeTableOwnerAt(anchors, rr, rightCol);
if (rt?.hideBorderLeft === true) {
right = false;
break;
}
}
}
return { top, right, bottom, left };
}
/** 各边线型(外框线 / 横线 / 竖线),缺省均为 solid */
export interface FreeTableSideLineStyleKeys {
top: string;
right: string;
bottom: string;
left: string;
}
export function borderSidesToCssFragment(
sides: FreeTableBorderSides,
bw: number,
color: string,
lineStyles?: FreeTableSideLineStyleKeys | null,
): string {
const css = (side: keyof FreeTableBorderSides) =>
lineStyles ? lineStyleKeyToCssBorderStyle(lineStyles[side]) : 'solid';
const t = sides.top ? `${bw}px ${css('top')} ${color}` : 'none';
const r = sides.right ? `${bw}px ${css('right')} ${color}` : 'none';
const b = sides.bottom ? `${bw}px ${css('bottom')} ${color}` : 'none';
const l = sides.left ? `${bw}px ${css('left')} ${color}` : 'none';
return `border-top:${t};border-right:${r};border-bottom:${b};border-left:${l};`;
}

View File

@@ -0,0 +1,312 @@
/**
* 自由表格:锚点单元格 + rowspan/colspan与 HTML table 一致
*/
export interface FreeTableAnchorCell {
row: number;
col: number;
rowspan: number;
colspan: number;
text?: string;
bindField?: string;
contentType?: 'text' | 'image' | 'qrcode' | 'barcode' | 'number' | 'amount';
fillCell?: boolean;
contentScale?: number;
imageFit?: 'fill' | 'contain' | 'cover';
qrLevel?: 'L' | 'M' | 'Q' | 'H';
qrRenderType?: 'image/png' | 'image/jpeg' | 'image/webp';
barcodeFormat?: string;
decimalPlaces?: number;
roundHalfUp?: boolean;
amountType?: 'CNY' | 'USD' | 'EUR';
autoWrap?: boolean;
autoFitFont?: boolean;
align?: string;
verticalAlign?: string;
fontSize?: number;
color?: string;
backgroundColor?: string;
hideBorderTop?: boolean;
hideBorderRight?: boolean;
hideBorderBottom?: boolean;
hideBorderLeft?: boolean;
}
const FREE_CELL_CONTENT_TYPES = new Set(['text', 'image', 'qrcode', 'barcode', 'number', 'amount']);
function parseCell(c: any): FreeTableAnchorCell {
const row = {
row: Math.max(0, Number(c?.row || 0)),
col: Math.max(0, Number(c?.col || 0)),
rowspan: Math.max(1, Number(c?.rowspan || 1)),
colspan: Math.max(1, Number(c?.colspan || 1)),
text: String(c?.text ?? ''),
bindField: String(c?.bindField ?? ''),
align: String(c?.align || 'left'),
verticalAlign: String(c?.verticalAlign || 'middle'),
fontSize: Math.max(8, Number(c?.fontSize || 12)),
color: String(c?.color || '#111111'),
backgroundColor: String(c?.backgroundColor || '#ffffff'),
} as FreeTableAnchorCell;
const ct = String(c?.contentType || '').trim();
if (ct && FREE_CELL_CONTENT_TYPES.has(ct)) {
row.contentType = ct as FreeTableAnchorCell['contentType'];
}
if (typeof c?.fillCell === 'boolean') {
row.fillCell = c.fillCell;
}
if (c?.contentScale != null && Number.isFinite(Number(c.contentScale))) {
row.contentScale = Number(c.contentScale);
}
if (c?.imageFit === 'fill' || c?.imageFit === 'contain' || c?.imageFit === 'cover') {
row.imageFit = c.imageFit;
}
if (c?.qrLevel === 'L' || c?.qrLevel === 'M' || c?.qrLevel === 'Q' || c?.qrLevel === 'H') {
row.qrLevel = c.qrLevel;
}
if (c?.qrRenderType === 'image/png' || c?.qrRenderType === 'image/jpeg' || c?.qrRenderType === 'image/webp') {
row.qrRenderType = c.qrRenderType;
}
if (c?.barcodeFormat != null && String(c.barcodeFormat).trim()) {
row.barcodeFormat = String(c.barcodeFormat).trim();
}
if (c?.decimalPlaces != null && Number.isFinite(Number(c.decimalPlaces))) {
row.decimalPlaces = Number(c.decimalPlaces);
}
if (typeof c?.roundHalfUp === 'boolean') {
row.roundHalfUp = c.roundHalfUp;
}
if (c?.amountType === 'CNY' || c?.amountType === 'USD' || c?.amountType === 'EUR') {
row.amountType = c.amountType;
}
if (typeof c?.autoWrap === 'boolean') {
row.autoWrap = c.autoWrap;
}
if (typeof c?.autoFitFont === 'boolean') {
row.autoFitFont = c.autoFitFont;
}
if (c?.hideBorderTop === true) row.hideBorderTop = true;
if (c?.hideBorderRight === true) row.hideBorderRight = true;
if (c?.hideBorderBottom === true) row.hideBorderBottom = true;
if (c?.hideBorderLeft === true) row.hideBorderLeft = true;
return row;
}
export function defaultFreeTableCell(row: number, col: number): FreeTableAnchorCell {
return {
row,
col,
rowspan: 1,
colspan: 1,
text: '',
bindField: '',
align: 'left',
verticalAlign: 'middle',
fontSize: 12,
color: '#111111',
backgroundColor: '#ffffff',
};
}
/** 将 cells 规范为互不重叠的锚点列表,并填满网格中未覆盖的 1x1 默认格 */
export function normalizeFreeTableAnchors(rowCount: number, colCount: number, cellsInput: any[]): FreeTableAnchorCell[] {
const occ: boolean[][] = Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => false));
const anchors: FreeTableAnchorCell[] = [];
const parsed = (cellsInput || []).map(parseCell).sort((a, b) => a.row - b.row || a.col - b.col);
for (const c of parsed) {
const rs = Math.min(c.rowspan, rowCount - c.row);
const cs = Math.min(c.colspan, colCount - c.col);
if (rs < 1 || cs < 1 || c.row >= rowCount || c.col >= colCount) continue;
let overlap = false;
for (let dr = 0; dr < rs && !overlap; dr += 1) {
for (let dc = 0; dc < cs && !overlap; dc += 1) {
const r = c.row + dr;
const cc = c.col + dc;
if (r >= rowCount || cc >= colCount || occ[r][cc]) {
overlap = true;
}
}
}
if (overlap) continue;
for (let dr = 0; dr < rs; dr += 1) {
for (let dc = 0; dc < cs; dc += 1) {
occ[c.row + dr][c.col + dc] = true;
}
}
anchors.push({ ...c, rowspan: rs, colspan: cs });
}
for (let r = 0; r < rowCount; r += 1) {
for (let c = 0; c < colCount; c += 1) {
if (!occ[r][c]) {
occ[r][c] = true;
anchors.push(defaultFreeTableCell(r, c));
}
}
}
anchors.sort((a, b) => a.row - b.row || a.col - b.col);
return anchors;
}
export function getFreeTableOwnerAt(anchors: FreeTableAnchorCell[], r: number, c: number): FreeTableAnchorCell {
for (const cell of anchors) {
const rs = Math.max(1, Number(cell.rowspan || 1));
const cs = Math.max(1, Number(cell.colspan || 1));
if (r >= cell.row && r < cell.row + rs && c >= cell.col && c < cell.col + cs) {
return cell;
}
}
return defaultFreeTableCell(r, c);
}
/** 矩形区域内是否均为 1x1 独立锚点(可合并) */
export function canMergeFreeTableRegion(
anchors: FreeTableAnchorCell[],
rowCount: number,
colCount: number,
r0: number,
c0: number,
r1: number,
c1: number,
): boolean {
const rr0 = Math.min(r0, r1);
const rr1 = Math.max(r0, r1);
const cc0 = Math.min(c0, c1);
const cc1 = Math.max(c0, c1);
if (rr0 < 0 || cc0 < 0 || rr1 >= rowCount || cc1 >= colCount) return false;
for (let r = rr0; r <= rr1; r += 1) {
for (let c = cc0; c <= cc1; c += 1) {
const o = getFreeTableOwnerAt(anchors, r, c);
const rs = Math.max(1, Number(o.rowspan || 1));
const cs = Math.max(1, Number(o.colspan || 1));
if (o.row !== r || o.col !== c) return false;
if (rs !== 1 || cs !== 1) return false;
}
}
return (rr1 - rr0 + 1) * (cc1 - cc0 + 1) > 1;
}
export function mergeFreeTableRegion(
anchors: FreeTableAnchorCell[],
rowCount: number,
colCount: number,
r0: number,
c0: number,
r1: number,
c1: number,
): FreeTableAnchorCell[] {
const rr0 = Math.min(r0, r1);
const rr1 = Math.max(r0, r1);
const cc0 = Math.min(c0, c1);
const cc1 = Math.max(c0, c1);
if (!canMergeFreeTableRegion(anchors, rowCount, colCount, rr0, cc0, rr1, cc1)) {
return anchors;
}
const survivor = { ...getFreeTableOwnerAt(anchors, rr0, cc0) };
const next = anchors.filter((cell) => {
const r = cell.row;
const c = cell.col;
return r < rr0 || r > rr1 || c < cc0 || c > cc1;
});
survivor.row = rr0;
survivor.col = cc0;
survivor.rowspan = rr1 - rr0 + 1;
survivor.colspan = cc1 - cc0 + 1;
next.push(survivor);
next.sort((a, b) => a.row - b.row || a.col - b.col);
return next;
}
export function splitFreeTableAt(anchors: FreeTableAnchorCell[], rowCount: number, colCount: number, r: number, c: number): FreeTableAnchorCell[] {
const owner = getFreeTableOwnerAt(anchors, r, c);
const rs = Math.max(1, Number(owner.rowspan || 1));
const cs = Math.max(1, Number(owner.colspan || 1));
if (rs === 1 && cs === 1) {
return anchors;
}
const next = anchors.filter((cell) => !(cell.row === owner.row && cell.col === owner.col));
for (let dr = 0; dr < rs; dr += 1) {
for (let dc = 0; dc < cs; dc += 1) {
const nr = owner.row + dr;
const nc = owner.col + dc;
if (nr >= rowCount || nc >= colCount) continue;
if (dr === 0 && dc === 0) {
next.push({
...defaultFreeTableCell(nr, nc),
...(pickSwapPayload(owner) as any),
} as FreeTableAnchorCell);
} else {
next.push(defaultFreeTableCell(nr, nc));
}
}
}
next.sort((a, b) => a.row - b.row || a.col - b.col);
return next;
}
function pickSwapPayload(cell: FreeTableAnchorCell) {
const raw: Record<string, unknown> = {
text: String(cell?.text ?? ''),
bindField: String(cell?.bindField ?? ''),
contentType: cell.contentType || 'text',
fillCell: cell.fillCell,
contentScale: cell.contentScale,
imageFit: cell.imageFit,
qrLevel: cell.qrLevel,
qrRenderType: cell.qrRenderType,
barcodeFormat: cell.barcodeFormat,
decimalPlaces: cell.decimalPlaces,
roundHalfUp: cell.roundHalfUp,
amountType: cell.amountType,
autoWrap: cell.autoWrap,
autoFitFont: cell.autoFitFont,
align: String(cell?.align || 'left'),
verticalAlign: String(cell?.verticalAlign || 'middle'),
fontSize: Math.max(8, Number(cell?.fontSize || 12)),
color: String(cell?.color || '#111111'),
backgroundColor: String(cell?.backgroundColor || '#ffffff'),
hideBorderTop: cell.hideBorderTop === true ? true : undefined,
hideBorderRight: cell.hideBorderRight === true ? true : undefined,
hideBorderBottom: cell.hideBorderBottom === true ? true : undefined,
hideBorderLeft: cell.hideBorderLeft === true ? true : undefined,
};
return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined)) as Record<string, unknown>;
}
/** 交换两个锚点格的可交换字段(不改变 rowspan/colspan */
export function swapFreeTableOwnerPayloads(
anchors: FreeTableAnchorCell[],
rowCount: number,
colCount: number,
fromRow: number,
fromCol: number,
toRow: number,
toCol: number,
): FreeTableAnchorCell[] {
const a = getFreeTableOwnerAt(anchors, fromRow, fromCol);
const b = getFreeTableOwnerAt(anchors, toRow, toCol);
if (a.row === b.row && a.col === b.col) return anchors;
const pa = pickSwapPayload(a);
const pb = pickSwapPayload(b);
return anchors.map((cell) => {
if (cell.row === a.row && cell.col === a.col) {
return { ...cell, ...pb };
}
if (cell.row === b.row && cell.col === b.col) {
return { ...cell, ...pa };
}
return { ...cell };
});
}
/** 删除/改维度前:去掉越界的锚点 */
export function clipAnchorsToGrid(rowCount: number, colCount: number, cellsInput: any[]): FreeTableAnchorCell[] {
const list = (cellsInput || []).map(parseCell);
return list.filter((c) => {
const rs = Math.max(1, Number(c.rowspan || 1));
const cs = Math.max(1, Number(c.colspan || 1));
return c.row >= 0 && c.col >= 0 && c.row + rs <= rowCount && c.col + cs <= colCount;
});
}

View File

@@ -0,0 +1,57 @@
/**
* 自由表格:外框线 / 行间横线 / 列间竖线 的线型(与属性面板、画布、打印 HTML 共用)
*/
export type FreeTableLineStyleKey = 'solid' | 'dashed' | 'dotted' | 'dash_dot' | 'double_dash_dot';
export const FREE_TABLE_LINE_STYLE_OPTIONS: { value: FreeTableLineStyleKey; label: string }[] = [
{ value: 'solid', label: '实线' },
{ value: 'dashed', label: '段线' },
{ value: 'dotted', label: '虚线' },
{ value: 'dash_dot', label: '点划线' },
{ value: 'double_dash_dot', label: '双点划线' },
];
const LINE_STYLE_SET = new Set(FREE_TABLE_LINE_STYLE_OPTIONS.map((o) => o.value));
export function normalizeFreeTableLineStyleKey(raw: unknown): FreeTableLineStyleKey {
const s = String(raw || 'solid');
return LINE_STYLE_SET.has(s as FreeTableLineStyleKey) ? (s as FreeTableLineStyleKey) : 'solid';
}
/**
* 将线型映射为 CSS border-style点划/双点划在标准边框中以最接近的样式近似)
*/
export function lineStyleKeyToCssBorderStyle(key: FreeTableLineStyleKey | string | undefined): string {
const k = normalizeFreeTableLineStyleKey(key);
if (k === 'dashed') return 'dashed';
if (k === 'dotted') return 'dotted';
if (k === 'dash_dot') return 'dashed';
if (k === 'double_dash_dot') return 'double';
return 'solid';
}
/**
* 根据单元格位置判断每条可见边属于外框还是内线,返回各边应使用的线型键(与 resolveFreeTableCellBorderSides 几何一致)
*/
export function resolveFreeTableCellLineStyleKeys(
el: any,
anchorRow: number,
anchorCol: number,
rs: number,
cs: number,
rowCount: number,
colCount: number,
): { top: FreeTableLineStyleKey; right: FreeTableLineStyleKey; bottom: FreeTableLineStyleKey; left: FreeTableLineStyleKey } {
const rEnd = anchorRow + rs - 1;
const cEnd = anchorCol + cs - 1;
const outerStyle = normalizeFreeTableLineStyleKey(el?.outerBorderLineStyle);
const hStyle = normalizeFreeTableLineStyleKey(el?.innerBorderHorizontalLineStyle);
const vStyle = normalizeFreeTableLineStyleKey(el?.innerBorderVerticalLineStyle);
return {
top: anchorRow === 0 ? outerStyle : hStyle,
right: cEnd === colCount - 1 ? outerStyle : vStyle,
bottom: rEnd === rowCount - 1 ? outerStyle : hStyle,
left: anchorCol === 0 ? outerStyle : vStyle,
};
}

View File

@@ -0,0 +1,223 @@
/**
* 自由表格:列宽、行高(单位 mm与元素 w/h 总和一致
*/
export const MIN_FREE_TABLE_TRACK_MM = 4;
export function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/** 均分总尺寸(内部用于初始化) */
export function evenSplitTracks(totalMm: number, count: number): number[] {
const n = Math.max(1, count);
const t = Math.max(0.01, Number(totalMm) || 0.01);
const base = round2(t / n);
const arr = Array.from({ length: n }, () => base);
const sum = arr.reduce((a, b) => a + b, 0);
arr[n - 1] = round2(arr[n - 1] + (t - sum));
return arr;
}
/** 将各轨道缩放到总和 = totalMm且每项不小于 minMm */
export function clampTrackSumToTotal(tracks: number[], totalMm: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
const n = tracks.length;
if (n === 0) return [];
const t = Math.max(0.01, Number(totalMm) || 0.01);
let next = tracks.map((x) => round2(Math.max(minMm, Number(x) || minMm)));
let sum = next.reduce((a, b) => a + b, 0);
if (Math.abs(sum - t) < 0.02) {
return next;
}
const scale = t / sum;
next = next.map((x) => round2(x * scale));
sum = next.reduce((a, b) => a + b, 0);
next[n - 1] = round2(next[n - 1] + (t - sum));
return next;
}
export function resolveFreeTableColWidthsMm(el: { colCount?: number; w: number; colWidths?: number[] | null }): number[] {
const colCount = Math.max(1, Number(el?.colCount || 1));
const w = Math.max(0.01, Number(el?.w) || 0.01);
const raw = Array.isArray(el?.colWidths) ? el.colWidths : [];
if (raw.length !== colCount) {
return evenSplitTracks(w, colCount);
}
return clampTrackSumToTotal(raw.map((x) => Number(x) || MIN_FREE_TABLE_TRACK_MM), w);
}
export function resolveFreeTableRowHeightsMm(el: { rowCount?: number; h: number; rowHeights?: number[] | null }): number[] {
const rowCount = Math.max(1, Number(el?.rowCount || 1));
const h = Math.max(0.01, Number(el?.h) || 0.01);
const raw = Array.isArray(el?.rowHeights) ? el.rowHeights : [];
if (raw.length !== rowCount) {
return evenSplitTracks(h, rowCount);
}
return clampTrackSumToTotal(raw.map((x) => Number(x) || MIN_FREE_TABLE_TRACK_MM), h);
}
/** 拖拽列边界:在 edge 与 edge+1 之间移动edge ∈ [0, n-2] */
export function redistributeColEdge(
base: number[],
edge: number,
deltaMm: number,
totalW: number,
minMm = MIN_FREE_TABLE_TRACK_MM,
): number[] | null {
if (base.length < 2 || edge < 0 || edge >= base.length - 1) {
return null;
}
const next = [...base];
next[edge] = round2(next[edge] + deltaMm);
next[edge + 1] = round2(next[edge + 1] - deltaMm);
if (next[edge] < minMm || next[edge + 1] < minMm) {
return null;
}
return clampTrackSumToTotal(next, totalW, minMm);
}
/** 拖拽行边界:在 edge 与 edge+1 之间移动 */
export function redistributeRowEdge(
base: number[],
edge: number,
deltaMm: number,
totalH: number,
minMm = MIN_FREE_TABLE_TRACK_MM,
): number[] | null {
if (base.length < 2 || edge < 0 || edge >= base.length - 1) {
return null;
}
const next = [...base];
next[edge] = round2(next[edge] + deltaMm);
next[edge + 1] = round2(next[edge + 1] - deltaMm);
if (next[edge] < minMm || next[edge + 1] < minMm) {
return null;
}
return clampTrackSumToTotal(next, totalH, minMm);
}
/** 新增列后列宽(总宽不变,均分空间给新列) */
export function colWidthsAfterAddCol(widths: number[], totalW: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
const n = widths.length;
if (n < 1) {
return evenSplitTracks(totalW, 1);
}
const factor = n / (n + 1);
const scaled = widths.map((c) => round2(c * factor));
const sum = scaled.reduce((a, b) => a + b, 0);
scaled.push(round2(Math.max(minMm, totalW - sum)));
return clampTrackSumToTotal(scaled, totalW, minMm);
}
/** 删除列后列宽(被删列宽度并入相邻列) */
export function colWidthsAfterRemoveCol(widths: number[], removeIdx: number, totalW: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
if (widths.length <= 1) {
return [totalW];
}
const removed = widths[removeIdx] ?? 0;
const next = widths.filter((_, i) => i !== removeIdx);
const adj = Math.min(Math.max(0, removeIdx), next.length - 1);
next[adj] = round2((next[adj] ?? 0) + removed);
return clampTrackSumToTotal(next, totalW, minMm);
}
export function rowHeightsAfterAddRow(heights: number[], totalH: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
const n = heights.length;
if (n < 1) {
return evenSplitTracks(totalH, 1);
}
const factor = n / (n + 1);
const scaled = heights.map((h) => round2(h * factor));
const sum = scaled.reduce((a, b) => a + b, 0);
scaled.push(round2(Math.max(minMm, totalH - sum)));
return clampTrackSumToTotal(scaled, totalH, minMm);
}
export function rowHeightsAfterRemoveRow(heights: number[], removeIdx: number, totalH: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
if (heights.length <= 1) {
return [totalH];
}
const removed = heights[removeIdx] ?? 0;
const next = heights.filter((_, i) => i !== removeIdx);
const adj = Math.min(Math.max(0, removeIdx), next.length - 1);
next[adj] = round2((next[adj] ?? 0) + removed);
return clampTrackSumToTotal(next, totalH, minMm);
}
/** 整表缩放外框时,按比例缩放行列尺寸使总和仍贴合新 w/h */
export function scaleFreeTableTracks(
colWidths: number[],
rowHeights: number[],
prevW: number,
prevH: number,
newW: number,
newH: number,
minMm = MIN_FREE_TABLE_TRACK_MM,
): { colWidths: number[]; rowHeights: number[] } {
const pw = Math.max(0.01, Number(prevW) || 0.01);
const ph = Math.max(0.01, Number(prevH) || 0.01);
const nw = Math.max(0.01, Number(newW) || 0.01);
const nh = Math.max(0.01, Number(newH) || 0.01);
const sx = nw / pw;
const sy = nh / ph;
const nextCw = colWidths.length ? colWidths.map((c) => round2(c * sx)) : [];
const nextRh = rowHeights.length ? rowHeights.map((r) => round2(r * sy)) : [];
return {
colWidths: nextCw.length ? clampTrackSumToTotal(nextCw, nw, minMm) : nextCw,
rowHeights: nextRh.length ? clampTrackSumToTotal(nextRh, nh, minMm) : nextRh,
};
}
/** 修改某一列宽为指定值,差额由「另一列」消化(用于属性面板) */
export function setColWidthAt(colWidths: number[], index: number, valueMm: number, totalW: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] | null {
const n = colWidths.length;
if (n < 1 || index < 0 || index >= n) {
return null;
}
const next = [...colWidths];
const v = round2(Math.max(minMm, Number(valueMm) || minMm));
const diff = v - next[index];
const partner = index === n - 1 ? n - 2 : n - 1;
if (partner < 0) {
next[0] = totalW;
return clampTrackSumToTotal(next, totalW, minMm);
}
next[index] = v;
next[partner] = round2(next[partner] - diff);
if (next[partner] < minMm) {
return null;
}
return clampTrackSumToTotal(next, totalW, minMm);
}
/** 修改某一行高 */
export function setRowHeightAt(rowHeights: number[], index: number, valueMm: number, totalH: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] | null {
const n = rowHeights.length;
if (n < 1 || index < 0 || index >= n) {
return null;
}
const next = [...rowHeights];
const v = round2(Math.max(minMm, Number(valueMm) || minMm));
const diff = v - next[index];
const partner = index === n - 1 ? n - 2 : n - 1;
if (partner < 0) {
next[0] = totalH;
return clampTrackSumToTotal(next, totalH, minMm);
}
next[index] = v;
next[partner] = round2(next[partner] - diff);
if (next[partner] < minMm) {
return null;
}
return clampTrackSumToTotal(next, totalH, minMm);
}
/** 均分列宽(写入 colWidths */
export function buildEvenColWidths(colCount: number, totalW: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
return clampTrackSumToTotal(evenSplitTracks(totalW, Math.max(1, colCount)), totalW, minMm);
}
/** 均分行高(写入 rowHeights */
export function buildEvenRowHeights(rowCount: number, totalH: number, minMm = MIN_FREE_TABLE_TRACK_MM): number[] {
return clampTrackSumToTotal(evenSplitTracks(totalH, Math.max(1, rowCount)), totalH, minMm);
}

View File

@@ -0,0 +1,239 @@
import type { NativeElement } from './types';
/**
* 根据画布元素与「画布实际 JSON」文本生成模拟数据对象。
* 逻辑与 NativePrintDesigner.generateMockData 中根据画布生成部分一致。
*/
export function generateNativeMockDataObject(elements: NativeElement[], canvasJsonText: string): Record<string, any> {
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
const randomPick = (list: any[]) => list[randomInt(0, Math.max(0, list.length - 1))];
const shortWords = ['标准件', '不锈钢', '铝板', '铜件', '塑胶件', '辅料', '组件'];
const longWords = [
'用于产线组装的关键部件,需按工艺要求进行批次追溯与检验记录。',
'该物料用于连续生产流程,建议结合库存周转与批次有效期进行动态补料。',
'本条数据为模拟长文本,主要用于验证列宽变化后自动换行与字号自适应效果。',
];
const buildShortText = (field: string, rowIndex: number) => `${field}_${randomPick(shortWords)}_${rowIndex + 1}`;
const buildLongText = (field: string, rowIndex: number) => `${field}_${rowIndex + 1}_${randomPick(longWords)}`;
const buildRandomText = (field: string, rowIndex: number, shouldWrap: boolean) => {
if (shouldWrap && Math.random() < 0.45) {
return buildLongText(field, rowIndex);
}
return buildShortText(field, rowIndex);
};
const toAlphaNumKey = (input: string) => {
const normalized = String(input || '')
.replace(/[^a-zA-Z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
return normalized || 'CODE';
};
const buildQrValue = (field: string) => `QR_${toAlphaNumKey(field)}_${randomInt(100000, 999999)}`;
const buildBarcodeValue = (field: string) => `BAR${randomInt(100000000000, 999999999999)}${toAlphaNumKey(field).slice(0, 6).toUpperCase()}`;
const buildMergeValue = (field: string, groupIndex: number, shouldWrap: boolean) =>
shouldWrap ? `${field}_合并组${groupIndex + 1}_${randomPick(longWords)}` : `${field}_合并组${groupIndex + 1}_${randomPick(shortWords)}`;
const resolveTemplateColumns = (element: any) => {
let parsed: any = {};
try {
parsed = JSON.parse(canvasJsonText || '{}');
} catch (_error) {
parsed = {};
}
if (!Array.isArray(parsed?.elements)) {
return Array.isArray(element?.columns) ? element.columns : [];
}
const matched = parsed.elements.find((item: any) => {
const component = String(item?.component || '');
return item?.id === element?.id && (component === 'table' || component === 'detailTable');
});
if (Array.isArray(matched?.payload?.columns)) {
return matched.payload.columns;
}
return Array.isArray(element?.columns) ? element.columns : [];
};
const resolveTemplateFreeTableCells = (element: any) => {
const fromElement = Array.isArray(element?.cells) ? element.cells : [];
let parsed: any = {};
try {
parsed = JSON.parse(canvasJsonText || '{}');
} catch (_error) {
parsed = {};
}
if (!Array.isArray(parsed?.elements)) {
return fromElement;
}
const matched = parsed.elements.find(
(item: any) => item?.id === element?.id && String(item?.component || '') === 'freeTable',
);
const fromJson = matched?.payload?.cells;
if (!Array.isArray(fromJson) || !fromJson.length) {
return fromElement;
}
const map = new Map<string, any>();
fromElement.forEach((c: any) => {
const k = `${Number(c?.row ?? 0)}_${Number(c?.col ?? 0)}`;
map.set(k, { ...c });
});
fromJson.forEach((jc: any) => {
const k = `${Number(jc?.row ?? 0)}_${Number(jc?.col ?? 0)}`;
const prev = map.get(k) || {};
map.set(k, { ...prev, ...jc });
});
return Array.from(map.values());
};
const mock: Record<string, any> = {};
const placeholderReg = /{{\s*([\w.]+)\s*}}/g;
const setByPath = (target: Record<string, any>, path: string, value: any) => {
const segments = String(path || '')
.split('.')
.filter(Boolean);
if (!segments.length) {
return;
}
let cursor: Record<string, any> = target;
for (let i = 0; i < segments.length - 1; i += 1) {
const key = segments[i];
if (!cursor[key] || typeof cursor[key] !== 'object') {
cursor[key] = {};
}
cursor = cursor[key];
}
cursor[segments[segments.length - 1]] = value;
};
const getByPath = (target: Record<string, any> | null, path: string) => {
if (!target) return undefined;
return String(path || '')
.split('.')
.filter(Boolean)
.reduce((acc: any, key: string) => acc?.[key], target);
};
let qrcodeIndex = 1;
let barcodeIndex = 1;
elements.forEach((element) => {
if (element.type === 'table' || element.type === 'detailTable') {
const source = (element as any).source || 'mainTable';
const columns = resolveTemplateColumns(element);
const mergeKeys = Array.isArray((element as any).mergeColumnKeys) ? ((element as any).mergeColumnKeys as string[]) : [];
const strictGrouping = (element as any).strictGrouping !== false;
const mergeFieldOrder = mergeKeys
.map((key) => columns.find((col: any) => String(col?.key || '') === String(key || '')))
.filter(Boolean)
.map((col: any) => String(col?.bindField || col?.field || ''))
.filter(Boolean);
const rowCount = randomInt(6, 10);
const rowsDataCache: Record<string, any>[] = [];
const rows = Array.from({ length: rowCount }).map((_, rowIndex) => {
const row: Record<string, any> = {};
const prevRow = rowIndex > 0 ? (rowsDataCache[rowIndex - 1] || {}) : null;
columns.forEach((col: any, colIndex: number) => {
const field = String(col?.bindField || col?.field || `field${colIndex + 1}`);
const shouldWrap = col?.autoWrap !== false;
const contentType = String(col?.contentType || 'text');
const randomText = buildRandomText(field, rowIndex, shouldWrap);
const mergeIndex = mergeFieldOrder.findIndex((item) => item === field);
const enableMerge = mergeIndex >= 0;
const canFollowPrev =
!strictGrouping ||
mergeIndex <= 0 ||
mergeFieldOrder.slice(0, mergeIndex).every((parentField) => getByPath(prevRow, parentField) === getByPath(row, parentField));
if (enableMerge && rowIndex > 0 && prevRow && canFollowPrev && Math.random() < 0.5) {
const previousValue = getByPath(prevRow, field);
setByPath(row, field, previousValue ?? buildMergeValue(field, randomInt(0, 3), shouldWrap));
} else if (enableMerge) {
setByPath(row, field, buildMergeValue(field, randomInt(0, 3), shouldWrap));
} else {
if (contentType === 'image') {
setByPath(row, field, `https://picsum.photos/seed/${encodeURIComponent(`${field}_${rowIndex + 1}`)}/260/120`);
} else if (contentType === 'qrcode') {
setByPath(row, field, buildQrValue(field));
} else if (contentType === 'barcode') {
setByPath(row, field, buildBarcodeValue(field));
} else if (contentType === 'number' || contentType === 'amount') {
const decimals = Math.max(0, Math.min(6, Number(col?.decimalPlaces ?? 2)));
const base = randomInt(100, 50000) + Math.random();
const num = col?.roundHalfUp === false ? Math.trunc(base * 10 ** decimals) / 10 ** decimals : Number(base.toFixed(decimals));
setByPath(row, field, num);
} else {
setByPath(row, field, randomText);
}
}
});
rowsDataCache.push(row);
return row;
});
mock[source] = rows;
return;
}
if (element.type === 'freeTable') {
const cells = resolveTemplateFreeTableCells(element);
cells.forEach((cell: any, idx: number) => {
const bindField = String(cell?.bindField || '').trim();
if (!bindField) return;
if (bindField in mock) return;
const cellText = String(cell?.text || '').trim();
const contentType = String(cell?.contentType || 'text');
const shouldWrap = cell?.autoWrap !== false;
if (contentType === 'image') {
mock[bindField] = `https://picsum.photos/seed/${encodeURIComponent(`${bindField}_ft`)}/260/120`;
} else if (contentType === 'qrcode') {
mock[bindField] = buildQrValue(bindField || `ft${idx}`);
} else if (contentType === 'barcode') {
mock[bindField] = buildBarcodeValue(bindField || `ft${idx}`);
} else if (contentType === 'number' || contentType === 'amount') {
const decimals = Math.max(0, Math.min(6, Number(cell?.decimalPlaces ?? 2)));
const base = randomInt(100, 50000) + Math.random();
const num =
cell?.roundHalfUp === false ? Math.trunc(base * 10 ** decimals) / 10 ** decimals : Number(base.toFixed(decimals));
mock[bindField] = num;
} else {
mock[bindField] = cellText || buildRandomText(bindField, 0, shouldWrap);
}
});
return;
}
if (element.type === 'qrcode') {
const bindField = String((element as any).bindField || '').trim();
const key = bindField || `qrcodeValue${qrcodeIndex}`;
mock[key] = buildQrValue(bindField || `qrcode${qrcodeIndex}`);
qrcodeIndex += 1;
return;
}
if (element.type === 'barcode') {
const bindField = String((element as any).bindField || '').trim();
const key = bindField || `barcodeValue${barcodeIndex}`;
mock[key] = buildBarcodeValue(bindField || `barcode${barcodeIndex}`);
barcodeIndex += 1;
return;
}
const bindField = String((element as any).bindField || '').trim();
if (bindField && !(bindField in mock)) {
if (element.type === 'image') {
mock[bindField] = 'https://via.placeholder.com/180x80.png?text=Image';
} else if (element.type === 'date') {
mock[bindField] = '2026-01-01';
} else {
mock[bindField] = `${bindField}_示例值`;
}
}
const text = String((element as any).text || '');
const matches = Array.from(text.matchAll(placeholderReg));
matches.forEach((item) => {
const field = String(item?.[1] || '').split('.')[0];
if (!field) return;
if (!(field in mock)) {
mock[field] = `${field}_示例值`;
}
});
});
if (!Object.keys(mock).length) {
mock.docNo = 'DOC-001';
mock.orderNo = 'ORDER-001';
mock.mainTable = [{ field1: '值1', field2: '值2', field3: '值3' }];
}
return mock;
}

View File

@@ -0,0 +1,54 @@
import { createDefaultSchema } from './useDesignerStore';
import type { NativeElement, NativeTemplateSchema } from './types';
/** 将接口或导入的 JSON 规范为可用的 NativeTemplateSchema与 NativePrintDesigner 内原逻辑一致) */
export function normalizeImportedNativeSchema(raw: unknown): NativeTemplateSchema {
const base = createDefaultSchema();
const obj = raw as Record<string, any>;
if (!obj || obj.engine !== 'native') {
throw new Error('返回内容不是原生模板engine 需为 native');
}
const page = { ...base.page, ...(obj.page || {}) } as NativeTemplateSchema['page'];
page.unit = 'mm';
if (!Array.isArray(page.margin) || page.margin.length < 4) {
page.margin = [...base.page.margin];
}
const elements = Array.isArray(obj.elements) ? obj.elements : [];
let z = 1;
for (const el of elements as any[]) {
if (!el.id) {
el.id = `${String(el.type || 'el')}_${Math.random().toString(36).slice(2, 10)}`;
}
if (el.zIndex == null || el.zIndex === undefined) {
el.zIndex = z;
}
z += 1;
}
const dataBinding: NonNullable<NativeTemplateSchema['dataBinding']> = {
fieldMap: { ...(base.dataBinding?.fieldMap || {}), ...(obj.dataBinding?.fieldMap || {}) },
tableSources:
Array.isArray(obj.dataBinding?.tableSources) && obj.dataBinding.tableSources.length
? [...obj.dataBinding.tableSources]
: [...(base.dataBinding?.tableSources || ['mainTable', 'detailList'])],
params: Array.isArray(obj.dataBinding?.params) ? [...obj.dataBinding.params] : [...(base.dataBinding?.params || [])],
detailTables: Array.isArray(obj.dataBinding?.detailTables)
? obj.dataBinding.detailTables.map((t: any) => ({
tableKey: String(t.tableKey || ''),
label: t.label ? String(t.label) : undefined,
fields: Array.isArray(t.fields)
? t.fields.map((f: any) => ({
key: String(f.key || ''),
label: f.label ? String(f.label) : undefined,
}))
: [],
}))
: [...(base.dataBinding?.detailTables || [])],
};
return {
engine: 'native',
version: String(obj.version || '1.0.0'),
page,
elements: elements as NativeElement[],
dataBinding,
};
}

View File

@@ -0,0 +1,126 @@
import type { NativeElement, NativeTemplateSchema } from './types';
function defaultDataBinding(): NonNullable<NativeTemplateSchema['dataBinding']> {
return {
fieldMap: {},
tableSources: ['mainTable', 'detailList'],
params: [],
detailTables: [],
};
}
/** 与 NativePrintDesigner.mapElementToTemplateStyle 一致:画布元素 →「画布实际 JSON」中的元素节点 */
export function mapElementToTemplateStyle(element: NativeElement) {
return {
id: element.id,
component: element.type,
bindField: (element as any).bindField || '',
region: (element as any).region || '',
bandId: (element as any).bandId || '',
rect: { x: element.x, y: element.y, w: element.w, h: element.h, zIndex: element.zIndex },
style: { ...(element.style || {}) },
payload:
element.type === 'image'
? { src: (element as any).src, fit: (element as any).fit }
: element.type === 'table' || element.type === 'detailTable'
? {
source: (element as any).source,
mergeColumnKeys: (element as any).mergeColumnKeys || [],
strictGrouping: (element as any).strictGrouping !== false,
enableMultiHeader: (element as any).enableMultiHeader === true,
tableHeightMode: (element as any).tableHeightMode,
fixedRows: (element as any).fixedRows,
showHeader: (element as any).showHeader,
rowHeight: (element as any).rowHeight,
headerHeight: (element as any).headerHeight,
headerFontSize: (element as any).headerFontSize,
bodyFontSize: (element as any).bodyFontSize,
headerBgColor: (element as any).headerBgColor,
headerTextColor: (element as any).headerTextColor,
footerLabelColumnKey: (element as any).footerLabelColumnKey,
footerLabelText: (element as any).footerLabelText,
footerLabelCenter: (element as any).footerLabelCenter,
footerShowTotal: (element as any).footerShowTotal !== false,
footerTotalMode: (element as any).footerTotalMode || 'overall',
headerConfig: (element as any).headerConfig,
columns: (element as any).columns,
}
: element.type === 'freeTable'
? {
rowCount: Number((element as any).rowCount || 1),
colCount: Number((element as any).colCount || 1),
borderColor: (element as any).borderColor || '#d9d9d9',
borderWidth: Number((element as any).borderWidth || 1),
outerBorderLineStyle: (element as any).outerBorderLineStyle || 'solid',
innerBorderHorizontalLineStyle: (element as any).innerBorderHorizontalLineStyle || 'solid',
innerBorderVerticalLineStyle: (element as any).innerBorderVerticalLineStyle || 'solid',
colWidths: Array.isArray((element as any).colWidths) ? [...(element as any).colWidths] : undefined,
rowHeights: Array.isArray((element as any).rowHeights) ? [...(element as any).rowHeights] : undefined,
outerBorder: (element as any).outerBorder,
innerBorder: (element as any).innerBorder,
cells: Array.isArray((element as any).cells)
? (element as any).cells.map((cell: any) => ({
row: cell.row,
col: cell.col,
rowspan: Math.max(1, Number(cell?.rowspan || 1)),
colspan: Math.max(1, Number(cell?.colspan || 1)),
text: cell.text,
bindField: cell.bindField,
contentType: cell.contentType || 'text',
fillCell: cell.fillCell,
contentScale: cell.contentScale,
imageFit: cell.imageFit,
qrLevel: cell.qrLevel,
qrRenderType: cell.qrRenderType,
barcodeFormat: cell.barcodeFormat,
decimalPlaces: cell.decimalPlaces,
roundHalfUp: cell.roundHalfUp,
amountType: cell.amountType,
autoWrap: cell.autoWrap,
autoFitFont: cell.autoFitFont,
align: cell.align,
verticalAlign: cell.verticalAlign,
fontSize: cell.fontSize,
color: cell.color,
backgroundColor: cell.backgroundColor,
hideBorderTop: cell.hideBorderTop,
hideBorderRight: cell.hideBorderRight,
hideBorderBottom: cell.hideBorderBottom,
hideBorderLeft: cell.hideBorderLeft,
}))
: [],
}
: element.type === 'reportHeader' || element.type === 'reportFooter'
? {
text: (element as any).text,
bookmarkText: (element as any).bookmarkText,
keepTogether: (element as any).keepTogether,
centerWithDetail: (element as any).centerWithDetail,
refreshPage: (element as any).refreshPage,
visible: (element as any).visible,
stretch: (element as any).stretch,
shrink: (element as any).shrink,
printRepeated: (element as any).printRepeated,
printAtPageBottom: (element as any).printAtPageBottom,
removeBlankWhenNoData: (element as any).removeBlankWhenNoData,
}
: element.type === 'qrcode' || element.type === 'barcode'
? { value: (element as any).value }
: { text: (element as any).text, format: (element as any).format },
};
}
/** 与设计器「从画布生成」一致的画布实际 JSON 对象 */
export function buildNativeTemplateStylePayload(schema: NativeTemplateSchema) {
return {
engine: 'native-template-style',
version: '1.0.0',
page: { ...schema.page },
elements: schema.elements.map((item) => mapElementToTemplateStyle(item)),
dataBinding: schema.dataBinding || defaultDataBinding(),
};
}
export function stringifyNativeTemplateStyle(schema: NativeTemplateSchema, space = 2) {
return JSON.stringify(buildNativeTemplateStylePayload(schema), null, space);
}

View File

@@ -0,0 +1,547 @@
import dayjs from 'dayjs';
import QRCode from 'qrcode';
import { buildRowSpanMap } from './tableMerge';
import { getValueByPath, normalizeTableWidths, resolveTableRows } from './tableBuilder';
import type { NativeElement, NativeFreeTableElement, NativeTableElement, NativeTemplateSchema } from './types';
import { normalizeFreeTableAnchors } from './freeTableGrid';
import { borderSidesToCssFragment, resolveFreeTableCellBorderSides } from './freeTableBorders';
import { resolveFreeTableCellLineStyleKeys } from './freeTableLineStyles';
import { resolveFreeTableColWidthsMm, resolveFreeTableRowHeightsMm } from './freeTableTracks';
function resolveBoundValue(element: NativeElement, data: Record<string, any>) {
const bindField = (element as any).bindField;
if (!bindField) return undefined;
return String(bindField)
.split('.')
.reduce((acc: any, key: string) => acc?.[key], data || {});
}
function resolveText(element: NativeElement, data: Record<string, any>, pageNo = 1, totalPages = 1) {
const bindValue = resolveBoundValue(element, data);
if (bindValue !== undefined && bindValue !== null && element.type !== 'pageNo') {
if (element.type === 'date') {
return dayjs(bindValue).format((element as any).format || 'YYYY-MM-DD');
}
return String(bindValue);
}
if (element.type === 'date') {
return dayjs().format((element as any).format || 'YYYY-MM-DD');
}
if (element.type === 'pageNo') {
return (element as any).text.replace('{{pageNo}}', String(pageNo)).replace('{{totalPages}}', String(totalPages));
}
if ((element as any).text?.startsWith('{{') && (element as any).text?.endsWith('}}')) {
const key = (element as any).text.replaceAll('{', '').replaceAll('}', '').trim();
return String(getValueByPath(data || {}, key) ?? '');
}
return String((element as any).text ?? '');
}
async function renderTable(element: NativeTableElement, data: Record<string, any>) {
const sourceRows = resolveTableRows(element, data);
const columns = normalizeTableWidths(element);
return renderTablePage(element, columns as any[], sourceRows, true, sourceRows);
}
async function renderFreeTable(element: NativeFreeTableElement, data: Record<string, any>) {
const rowCount = Math.max(1, Number((element as any)?.rowCount || 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 hMm = Math.max(0.01, Number((element as any)?.h) || 0.01);
const colWidthsMm = resolveFreeTableColWidthsMm(element as any);
const rowHeightsMm = resolveFreeTableRowHeightsMm(element as any);
const borderColor = String((element as any)?.borderColor || '#d9d9d9');
const borderWidth = Math.max(1, Number((element as any)?.borderWidth || 1));
const colgroup = `<colgroup>${colWidthsMm.map((cw) => `<col style="width:${cw}mm;box-sizing:border-box" />`).join('')}</colgroup>`;
const anchors = normalizeFreeTableAnchors(rowCount, colCount, (element as any)?.cells || []);
const body = (
await Promise.all(
Array.from({ length: rowCount }, async (_, row) => {
const rh = rowHeightsMm[row] ?? hMm / rowCount;
const rowCells = (
await Promise.all(
anchors
.filter((cell) => cell.row === row)
.sort((a, b) => a.col - b.col)
.map(async (cell) => {
const rs = Math.max(1, Number((cell as any).rowspan || 1));
const cs = Math.max(1, Number((cell as any).colspan || 1));
const bindField = String((cell as any)?.bindField || '').trim();
const cellValue = bindField ? getValueByPath(data || {}, bindField) ?? '' : (cell as any)?.text ?? '';
const raw = String(cellValue ?? '');
const contentType = String((cell as any)?.contentType || 'text');
const displayValue =
contentType === 'number' || contentType === 'amount'
? formatNumericValue(cellValue, cell as any)
: raw;
const innerArg =
contentType === 'image' || contentType === 'qrcode' || contentType === 'barcode' ? raw : displayValue;
const bodyInnerHtml = await resolvePrintCellInnerHtml(contentType, innerArg, cell as any);
const align = String((cell as any)?.align || 'left');
const verticalAlign = String((cell as any)?.verticalAlign || 'middle');
const fontSize = Math.max(8, Number((cell as any)?.fontSize || element.style?.fontSize || 12));
const color = String((cell as any)?.color || '#111111');
const backgroundColor = String((cell as any)?.backgroundColor || '#ffffff');
const rowspanAttr = rs > 1 ? ` rowspan="${rs}"` : '';
const colspanAttr = cs > 1 ? ` colspan="${cs}"` : '';
const spanW = colWidthsMm.slice(cell.col, cell.col + cs).reduce((a, b) => a + b, 0);
const colWidthStyle = `width:${spanW}mm;`;
const sides = resolveFreeTableCellBorderSides(element, anchors, cell, cell.row, cell.col, rs, cs, rowCount, colCount);
const lineKeys = resolveFreeTableCellLineStyleKeys(element, cell.row, cell.col, rs, cs, rowCount, colCount);
const borderCss = borderSidesToCssFragment(sides, borderWidth, borderColor, lineKeys);
const nowrap = (cell as any).autoWrap === false;
const ws = nowrap ? 'nowrap' : 'normal';
const wb = nowrap ? 'normal' : 'break-all';
const ow = nowrap ? 'normal' : 'anywhere';
return `<td${rowspanAttr}${colspanAttr} style="box-sizing:border-box;${borderCss}${colWidthStyle}padding:2mm;text-align:${align};vertical-align:${verticalAlign};font-size:${fontSize}px;color:${color};background:${backgroundColor};white-space:${ws};word-break:${wb};overflow-wrap:${ow};line-height:${
nowrap ? `${rh}mm` : '1.3'
};">${bodyInnerHtml}</td>`;
}),
)
).join('');
return `<tr style="height:${rh}mm;box-sizing:border-box">${rowCells}</tr>`;
}),
)
).join('');
// 不设 table 固定总高:固定 height 时边框/内边距易超出被裁切;行高之和已贴合元素 h。表格外框由单元格边框拼成。
return `<table style="width:${wMm}mm;border-collapse:collapse;border-spacing:0;table-layout:fixed;box-sizing:border-box;">${colgroup}<tbody>${body}</tbody></table>`;
}
async function renderFixedRowsTablePages(element: NativeTableElement, data: Record<string, any>) {
const sourceRows = resolveTableRows(element, data);
const columns = normalizeTableWidths(element);
const pageSize = Math.max(1, Number(element?.fixedRows || 5));
const pages = chunkRows(sourceRows, pageSize);
const chunks = pages.length ? pages : [[]];
const footerMode = String((element as any).footerTotalMode || 'overall');
return Promise.all(
chunks.map((rows, index) => {
const isLastPage = index === chunks.length - 1;
const showFooter = footerMode === 'page' ? true : isLastPage;
const footerRows = footerMode === 'page' ? rows : sourceRows;
return renderTablePage(element, columns as any[], rows, showFooter, footerRows);
}),
);
}
async function renderTablePage(
element: NativeTableElement,
columns: any[],
rows: Record<string, any>[],
showFooter: boolean,
footerRows: Record<string, any>[] = rows,
) {
const rowSpanMap = buildRowSpanMap(rows, element.columns, (element as any).mergeColumnKeys || [], (element as any).strictGrouping !== false);
const headerHtml = element.showHeader ? buildPrintHeaderHtml(element, columns as any[]) : '';
const bodyHtml = (
await Promise.all(
rows.map(async (row, rowIndex) => {
const cells = (
await Promise.all(
columns.map(async (column) => {
const fieldKey = column.bindField || column.field;
const span = rowSpanMap[`${rowIndex}_${fieldKey}`];
if (span === 0) return '';
const rowSpanText = span && span > 1 ? ` rowspan="${span}"` : '';
const cellValue = getValueByPath(row || {}, fieldKey) ?? '';
const contentType = String((column as any).contentType || 'text');
const displayValue = isNumericColumn(column as any) ? formatNumericValue(cellValue, column as any) : String(cellValue);
const bodyInnerHtml = await resolvePrintCellInnerHtml(contentType, displayValue, column as any);
const bodyBaseSize = (column as any)?.useCustomFontSize ? Number((column as any)?.fontSize || 12) : Number(element.bodyFontSize || 12);
const fontSize = resolvePrintAutoFontSize(column as any, displayValue, Number(column?.width || 30), element.rowHeight || 8, bodyBaseSize);
return `<td${rowSpanText} style="border:1px solid #222;padding:2mm;text-align:${column.align || 'left'};font-family:${(column as any).fontFamily || 'inherit'};font-size:${fontSize}px;color:${
(column as any).fontColor || '#111111'
};white-space:${(column as any).autoWrap === false ? 'nowrap' : 'normal'};word-break:${(column as any).autoWrap === false ? 'normal' : 'break-all'};overflow-wrap:${
(column as any).autoWrap === false ? 'normal' : 'anywhere'
};line-height:${(column as any).autoWrap === false ? `${element.rowHeight || 8}mm` : '1.3'};">${bodyInnerHtml}</td>`;
}),
)
).join('');
return `<tr style="height:${element.rowHeight}mm;">${cells}</tr>`;
}),
)
).join('');
const footerHtml = showFooter ? buildFooterHtml(footerRows, columns as any[], element) : '';
return `<table style="width:100%;border-collapse:collapse;table-layout:fixed;"><thead>${headerHtml}</thead><tbody>${bodyHtml}</tbody>${footerHtml}</table>`;
}
function chunkRows(rows: Record<string, any>[], pageSize: number) {
const size = Math.max(1, Number(pageSize || 1));
const list: Record<string, any>[][] = [];
for (let i = 0; i < rows.length; i += size) {
list.push(rows.slice(i, i + size));
}
return list;
}
function buildPrintHeaderHtml(element: NativeTableElement, columns: any[]) {
const headerRows = buildPrintHeaderRows(element, columns);
const rowCount = Math.max(1, headerRows.length);
const rowHeight = (element.headerHeight || 10) / rowCount;
return headerRows
.map((cells) => {
const cellHtml = cells
.map((cell) => {
const column = columns[cell.col] || {};
const widthMm = columns.slice(cell.col, cell.col + cell.colspan).reduce((sum: number, col: any) => sum + Number(col?.width || 0), 0);
const cellHeightMm = rowHeight * Number(cell.rowspan || 1);
const headerFontSize = resolvePrintAutoFontSize(
column as any,
String(cell?.title || ''),
Number(widthMm || column?.width || 30),
cellHeightMm,
Number(element.headerFontSize || 12),
);
return `<th rowspan="${cell.rowspan}" colspan="${cell.colspan}" style="border:1px solid #222;padding:2mm;text-align:${cell.align || column.align || 'center'};font-weight:600;width:${
cell.widthPercent
}%;background:${element.headerBgColor || '#f5f5f5'};color:${(column as any).fontColor || element.headerTextColor || '#111111'};font-family:${
(column as any).fontFamily || 'inherit'
};font-size:${headerFontSize}px;white-space:${(column as any).autoWrap === false ? 'nowrap' : 'normal'};word-break:${
(column as any).autoWrap === false ? 'normal' : 'break-all'
};overflow-wrap:${(column as any).autoWrap === false ? 'normal' : 'anywhere'};line-height:${
(column as any).autoWrap === false ? `${cellHeightMm}mm` : '1.3'
};">${cell.title || ''}</th>`;
})
.join('');
return `<tr style="height:${rowHeight}mm;">${cellHtml}</tr>`;
})
.join('');
}
function buildPrintHeaderRows(element: NativeTableElement, columns: any[]) {
const colCount = columns.length;
if (!colCount) return [];
if (element.enableMultiHeader !== true) {
return [
columns.map((col: any, index: number) => ({
row: 0,
col: index,
rowspan: 1,
colspan: 1,
title: String(col?.title || ''),
align: col?.align || 'center',
widthPercent: Number(col?.widthPercent || 0),
})),
];
}
const headerConfig = (element as any)?.headerConfig;
const rowCount = Math.max(1, Number(headerConfig?.rowCount || 1));
const owner: any[][] = Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => null));
const cells: any[] = [];
const configCells = Array.isArray(headerConfig?.cells) && Number(headerConfig?.colCount || 0) === colCount ? headerConfig.cells : [];
configCells.forEach((item: any) => {
const row = Math.max(0, Number(item?.row || 0));
const col = Math.max(0, Number(item?.col || 0));
const rowspan = Math.max(1, Number(item?.rowspan || 1));
const colspan = Math.max(1, Number(item?.colspan || 1));
if (row >= rowCount || col >= colCount || owner[row][col]) return;
const maxRow = Math.min(rowCount, row + rowspan);
const maxCol = Math.min(colCount, col + colspan);
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
if (owner[r][c]) return;
}
}
const next = { row, col, rowspan: maxRow - row, colspan: maxCol - col, title: String(item?.title || ''), align: String(item?.align || 'center') };
for (let r = row; r < maxRow; r += 1) {
for (let c = col; c < maxCol; c += 1) {
owner[r][c] = next;
}
}
cells.push(next);
});
for (let r = 0; r < rowCount; r += 1) {
for (let c = 0; c < colCount; c += 1) {
if (owner[r][c]) continue;
const fallback = { row: r, col: c, rowspan: 1, colspan: 1, title: r === rowCount - 1 ? String(columns[c]?.title || '') : '', align: columns[c]?.align || 'center' };
owner[r][c] = fallback;
cells.push(fallback);
}
}
const rows = Array.from({ length: rowCount }, () => [] as any[]);
cells.forEach((cell) => {
if (owner[cell.row][cell.col] !== cell) return;
const widthPercent = columns.slice(cell.col, cell.col + cell.colspan).reduce((sum: number, col: any) => sum + Number(col?.widthPercent || 0), 0);
rows[cell.row].push({ ...cell, widthPercent });
});
rows.forEach((list) => list.sort((a, b) => a.col - b.col));
return rows;
}
async function buildQrcodeDataUrl(value: string, column?: any) {
const text = String(value || 'empty');
return QRCode.toDataURL(text, {
errorCorrectionLevel: column?.qrLevel || 'M',
margin: 0,
type: column?.qrRenderType || 'image/png',
width: 220,
});
}
async function resolvePrintCellInnerHtml(contentType: string, value: string, column: any) {
const safeValue = String(value || '');
const fillCell = column?.fillCell !== false;
const scale = Math.max(10, Math.min(100, Number(column?.contentScale || 100)));
if (contentType === 'image') {
const src = safeValue || `https://via.placeholder.com/180x80.png?text=${encodeURIComponent(column?.title || 'Image')}`;
return `<img src="${src}" style="display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:${column?.imageFit || 'contain'};width:${
fillCell ? '100%' : `${scale}%`
};height:${fillCell ? '100%' : `${scale}%`};" />`;
}
if (contentType === 'qrcode') {
try {
const src = await buildQrcodeDataUrl(safeValue, column);
return `<img src="${src}" style="display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;width:${fillCell ? '100%' : `${scale}%`};height:${
fillCell ? '100%' : `${scale}%`
};" />`;
} catch (_error) {
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${fillCell ? '100%' : `${scale}%`};height:${
fillCell ? '100%' : `${scale}%`
};margin:0 auto;">QR:${safeValue}</div>`;
}
}
if (contentType === 'barcode') {
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${fillCell ? '100%' : `${scale}%`};height:${
fillCell ? '100%' : `${Math.max(20, scale * 0.6)}%`
};margin:0 auto;">BAR:${safeValue}</div>`;
}
return safeValue;
}
function resolvePrintAutoFontSize(column: any, text: string, columnWidthMm: number, rowHeightMm: number, baseSize: number) {
const base = Number(baseSize || 12);
if (!column?.autoFitFont) {
return base;
}
const widthPx = Math.max(1, columnWidthMm * 3.7795275591);
const heightPx = Math.max(1, rowHeightMm * 3.7795275591);
const textLen = Math.max(1, text.length);
const byWidth = widthPx / Math.max(1, textLen * 0.62);
const byHeight = column?.autoWrap === false ? heightPx * 0.55 : heightPx * 0.36;
const next = Math.min(base, byWidth, byHeight);
return Math.max(8, Math.round(next));
}
function isNumericColumn(column: any) {
const type = String(column?.contentType || 'text');
return type === 'number' || type === 'amount';
}
function formatNumericValue(value: any, column: any) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value ?? '');
}
const decimals = Math.max(0, Math.min(6, Number(column?.decimalPlaces ?? 2)));
const finalValue = column?.roundHalfUp === false ? Math.trunc(numeric * 10 ** decimals) / 10 ** decimals : Number(numeric.toFixed(decimals));
const formatted = finalValue.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
if (String(column?.contentType || 'text') === 'amount') {
const symbol = column?.amountType === 'USD' ? '$' : column?.amountType === 'EUR' ? 'EUR ' : '¥';
return `${symbol}${formatted}`;
}
return formatted;
}
function buildFooterHtml(rows: Record<string, any>[], columns: any[], element: NativeTableElement) {
if ((element as any)?.footerShowTotal === false) {
return '';
}
const labelColumnKey = String(element?.footerLabelColumnKey || columns?.[0]?.key || '');
const labelText = String(element?.footerLabelText || '合计');
const labelAlign = element?.footerLabelCenter === false ? 'left' : 'center';
const cells = columns
.map((column, index) => {
if (isNumericColumn(column) && !!column?.enableFooterTotal) {
const fieldKey = column?.bindField || column?.field;
const total = rows.reduce((sum, row) => {
const value = Number(getValueByPath(row || {}, fieldKey));
return sum + (Number.isFinite(value) ? value : 0);
}, 0);
return `<td style="border:1px solid #222;padding:2mm;font-weight:600;text-align:${column.align || 'left'};background:#fafafa;">${formatNumericValue(total, column)}</td>`;
}
if (String(column?.key || '') === labelColumnKey || (!labelColumnKey && index === 0)) {
return `<td style="border:1px solid #222;padding:2mm;font-weight:600;background:#fafafa;text-align:${labelAlign};">${labelText}</td>`;
}
return `<td style="border:1px solid #222;padding:2mm;background:#fafafa;"></td>`;
})
.join('');
return `<tfoot><tr>${cells}</tr></tfoot>`;
}
export async function renderNativePrintHtml(schema: NativeTemplateSchema, data: Record<string, any>) {
const pageCount = resolvePrintPageCount(schema, data);
const totalHeight = schema.page.height * pageCount;
const repeatHeaderConfig = resolveRepeatHeaderConfig(schema);
const repeatHeaderByPage = repeatHeaderConfig.enabled;
const headerVisible = repeatHeaderConfig.visible;
const headerBandHeight = resolveHeaderBandHeight(schema);
const sorted = [...schema.elements].sort((a, b) => a.zIndex - b.zIndex);
const content = (
await Promise.all(
sorted.map(async (item) => {
if ((item as any).visible === false) {
return '';
}
const isReportFooter = item.type === 'reportFooter';
const isReportHeader = item.type === 'reportHeader';
const repeatReportHeader = isReportHeader && (item as any).printRepeated === true;
const isHeaderRegionElement = isElementInHeaderRegion(item, repeatHeaderConfig.headerId, headerBandHeight);
if (!headerVisible && isHeaderRegionElement) {
return '';
}
const repeatHeaderElement = isHeaderRegionElement && repeatHeaderByPage;
const renderX = isReportHeader || isReportFooter ? 0 : item.x;
const renderY = isReportHeader ? 0 : isReportFooter && (item as any).printAtPageBottom === true ? Math.max(0, schema.page.height - item.h) : item.y;
const renderW = isReportHeader || isReportFooter ? schema.page.width : item.w;
const styleParts = [
`position:absolute`,
`width:${renderW}mm`,
`height:${item.h}mm`,
`font-size:${item.style?.fontSize || 12}px`,
`font-weight:${item.style?.fontWeight || 400}`,
`color:${item.style?.color || '#111'}`,
`line-height:${item.style?.lineHeight || 1.4}`,
`text-align:${item.style?.textAlign || 'left'}`,
`background:${item.style?.backgroundColor || 'transparent'}`,
item.style?.borderWidth ? `border:${item.style.borderWidth}px solid ${item.style.borderColor || '#222'}` : '',
'overflow:hidden',
]
.filter(Boolean)
.join(';');
const style = (topMm: number) => [`left:${renderX}mm`, `top:${topMm}mm`, styleParts].join(';');
const shouldRepeat = (repeatReportHeader || repeatHeaderElement) && pageCount > 1;
const pages = shouldRepeat ? Array.from({ length: pageCount }, (_v, i) => i + 1) : [1];
const htmlByPage = await Promise.all(
pages.map(async (pageNo) => {
const top = renderY + (shouldRepeat ? (pageNo - 1) * schema.page.height : 0);
if (item.type === 'table' || item.type === 'detailTable') {
const tableMode = String((item as any).tableHeightMode || 'autoPage');
if (tableMode === 'fixedRows') {
const pageTables = await renderFixedRowsTablePages(item, data);
if (shouldRepeat) {
const firstPageTable = pageTables[0] || '';
return `<div style="${style(top)};overflow:visible;height:auto;">${firstPageTable}</div>`;
}
return pageTables
.map((tableHtml, pageIndex) => {
const pageTop = renderY + pageIndex * schema.page.height;
return `<div style="${style(pageTop)};overflow:visible;height:auto;">${tableHtml}</div>`;
})
.join('');
}
return `<div style="${style(top)};overflow:visible;height:auto;">${await renderTable(item, data)}</div>`;
}
if (item.type === 'freeTable') {
// 自由表格:避免 overflow:hidden 裁掉底边/竖线边框;由行高+box-sizing 控制占位
const ftHtml = await renderFreeTable(item as NativeFreeTableElement, data);
return `<div style="${style(top)};overflow:visible;">${ftHtml}</div>`;
}
if (item.type === 'qrcode') {
const value = resolveBoundValue(item, data) ?? (item as any).value;
try {
const src = await buildQrcodeDataUrl(String(value ?? ''), item as any);
return `<img src="${src}" style="${style(top)};object-fit:contain;" />`;
} catch (_error) {
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;border:1px dashed #999;">二维码:${value ?? ''}</div>`;
}
}
if (item.type === 'barcode') {
const value = resolveBoundValue(item, data) ?? (item as any).value;
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;border:1px dashed #999;">条形码:${value ?? ''}</div>`;
}
if (item.type === 'image') {
const image = item as any;
const src = (resolveBoundValue(item, data) ?? image.src) || '';
return `<img src="${src}" style="${style(top)};object-fit:${image.fit || 'contain'};" />`;
}
return `<div style="${style(top)};white-space:pre-wrap;">${resolveText(item, data, pageNo, pageCount)}</div>`;
}),
);
return htmlByPage.join('');
}),
)
).join('');
const pageBreakGuides = pageCount > 1
? Array.from({ length: pageCount - 1 })
.map((_v, index) => `<div style="position:absolute;left:0;top:${(index + 1) * schema.page.height}mm;width:${schema.page.width}mm;height:0;page-break-before:always;"></div>`)
.join('')
: '';
const pageMargin = resolvePageMarginCss(schema.page.margin);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
@page { size: ${schema.page.width}mm ${schema.page.height}mm; margin: ${pageMargin}; }
html, body { margin: 0; padding: 0; overflow: visible; }
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
</style>
</head>
<body>
<div style="position:relative;width:${schema.page.width}mm;min-height:${totalHeight}mm;height:auto;overflow:visible;box-sizing:border-box;">
${content}
${pageBreakGuides}
</div>
</body>
</html>`;
}
function resolvePageMarginCss(margin?: [number, number, number, number]) {
if (!Array.isArray(margin) || margin.length < 4) {
return '0mm';
}
const top = Math.max(0, Number(margin[0] || 0));
const right = Math.max(0, Number(margin[1] || 0));
const bottom = Math.max(0, Number(margin[2] || 0));
const left = Math.max(0, Number(margin[3] || 0));
return `${top}mm ${right}mm ${bottom}mm ${left}mm`;
}
/** 与 renderNativePrintHtml 内部页数计算一致,供列表预览等比缩放使用 */
export function resolvePrintPageCount(schema: NativeTemplateSchema, data: Record<string, any>) {
const tablePages = schema.elements
.filter((item) => item.type === 'table' || item.type === 'detailTable')
.map((item: any) => {
const rows = resolveTableRows(item as NativeTableElement, data);
const mode = String(item?.tableHeightMode || 'autoPage');
if (mode !== 'fixedRows') {
return 1;
}
const pageSize = Math.max(1, Number(item?.fixedRows || 5));
return Math.max(1, Math.ceil(rows.length / pageSize));
});
return Math.max(1, ...tablePages);
}
function resolveRepeatHeaderConfig(schema: NativeTemplateSchema) {
const reportHeader = schema.elements.find((item) => item.type === 'reportHeader') as any;
if (!reportHeader) return { visible: true, enabled: false, headerId: '' };
if (reportHeader.visible === false) return { visible: false, enabled: false, headerId: String(reportHeader.id || '') };
return { visible: true, enabled: reportHeader.printRepeated === true, headerId: String(reportHeader.id || '') };
}
function resolveHeaderBandHeight(schema: NativeTemplateSchema) {
const reportHeader = schema.elements.find((item) => item.type === 'reportHeader') as any;
return Math.max(0, Number(reportHeader?.h || 0));
}
function isElementInHeaderRegion(element: NativeElement, repeatHeaderId: string, headerBandHeight: number) {
if (element.type === 'reportHeader') return true;
if (element.type === 'reportFooter') return false;
const bandId = String((element as any).bandId || '');
if (repeatHeaderId && bandId === repeatHeaderId) return true;
const region = String((element as any).region || '');
if (region === 'header') return true;
if (region === 'body' || region === 'footer') return false;
if (headerBandHeight <= 0) return false;
const topY = Number(element.y || 0);
const bottomY = topY + Number(element.h || 0);
// 兼容旧模板:未标注 region 时,按位置判断元素是否属于报表头区域
return topY < headerBandHeight && bottomY <= headerBandHeight + 0.2;
}

View File

@@ -0,0 +1,64 @@
let printingInProgress = false;
export function printHtml(html: string) {
if (printingInProgress) {
return;
}
printingInProgress = true;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
iframe.style.opacity = '0';
iframe.setAttribute('aria-hidden', 'true');
iframe.setAttribute('sandbox', 'allow-modals allow-same-origin');
let printTriggered = false;
let cleaned = false;
let timeoutId = 0;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (timeoutId) {
window.clearTimeout(timeoutId);
}
iframe.removeEventListener('load', handleLoad);
setTimeout(() => {
iframe.remove();
printingInProgress = false;
}, 0);
};
const handleLoad = () => {
if (printTriggered) {
return;
}
printTriggered = true;
const win = iframe.contentWindow;
if (!win) {
cleanup();
return;
}
const handleAfterPrint = () => {
win.removeEventListener('afterprint', handleAfterPrint);
cleanup();
};
win.addEventListener('afterprint', handleAfterPrint);
setTimeout(() => {
win.focus();
win.print();
}, 50);
};
iframe.addEventListener('load', handleLoad);
iframe.srcdoc = html;
document.body.appendChild(iframe);
timeoutId = window.setTimeout(() => {
cleanup();
}, 60 * 1000);
}

View File

@@ -0,0 +1,25 @@
import type { NativeTableElement } from './types';
export function getValueByPath(data: Record<string, any>, path: string) {
if (!path) return undefined;
return String(path)
.split('.')
.reduce((acc: any, key: string) => acc?.[key], data || {});
}
export function resolveTableRows(element: NativeTableElement, data: Record<string, any>) {
const rows = data?.[element.source];
if (Array.isArray(rows)) {
return rows.filter((item) => item && typeof item === 'object');
}
return [];
}
export function normalizeTableWidths(element: NativeTableElement) {
const total = element.columns.reduce((sum, item) => sum + Number(item.width || 0), 0);
if (!total) return element.columns;
return element.columns.map((item) => ({
...item,
widthPercent: (Number(item.width || 0) / total) * 100,
}));
}

View File

@@ -0,0 +1,61 @@
import type { NativeTableColumn } from './types';
import { getValueByPath } from './tableBuilder';
interface MergeRange {
start: number;
end: number;
}
function resolveMergeFields(columns: NativeTableColumn[], mergeColumnKeys: string[] = []) {
const byKey = new Map(columns.map((col) => [String(col?.key || ''), col] as const));
const orderedFields = mergeColumnKeys
.map((key) => byKey.get(String(key || '')))
.filter(Boolean)
.map((col) => String(col?.bindField || col?.field || ''))
.filter(Boolean);
if (orderedFields.length) {
return orderedFields;
}
return columns
.filter((item) => item.mergeByValue)
.map((item) => String(item.bindField || item.field || ''))
.filter(Boolean);
}
function buildRangesByField(rows: Record<string, any>[], field: string, ranges: MergeRange[]) {
const nextRanges: MergeRange[] = [];
const map: Record<string, number> = {};
ranges.forEach((range) => {
let start = range.start;
while (start < range.end) {
const value = getValueByPath(rows[start] || {}, field);
let end = start + 1;
while (end < range.end && getValueByPath(rows[end] || {}, field) === value) {
end += 1;
}
map[`${start}_${field}`] = end - start;
for (let i = start + 1; i < end; i += 1) {
map[`${i}_${field}`] = 0;
}
nextRanges.push({ start, end });
start = end;
}
});
return { map, nextRanges };
}
export function buildRowSpanMap(rows: Record<string, any>[], columns: NativeTableColumn[], mergeColumnKeys: string[] = [], strictGrouping = false) {
const map: Record<string, number> = {};
if (!rows.length) return map;
const mergeFields = resolveMergeFields(columns, mergeColumnKeys);
if (!mergeFields.length) return map;
let currentRanges: MergeRange[] = [{ start: 0, end: rows.length }];
mergeFields.forEach((field) => {
const { map: fieldMap, nextRanges } = buildRangesByField(rows, field, currentRanges);
Object.assign(map, fieldMap);
if (strictGrouping) {
currentRanges = nextRanges;
}
});
return map;
}

View File

@@ -0,0 +1,267 @@
import type { FreeTableLineStyleKey } from './freeTableLineStyles';
export type NativeElementType =
| 'title'
| 'subtitle'
| 'text'
| 'date'
| 'pageNo'
| 'reportHeader'
| 'reportFooter'
| 'image'
| 'table'
| 'detailTable'
| 'freeTable'
| 'qrcode'
| 'barcode';
export interface NativeElementBase {
id: string;
type: NativeElementType;
bindField?: string;
region?: 'header' | 'body' | 'footer';
bandId?: string;
x: number;
y: number;
w: number;
h: number;
rotate?: number;
zIndex: number;
style?: {
fontSize?: number;
fontWeight?: number | string;
color?: string;
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
borderWidth?: number;
borderColor?: string;
backgroundColor?: string;
};
}
export interface NativeTextElement extends NativeElementBase {
type: 'title' | 'subtitle' | 'text' | 'date' | 'pageNo';
text: string;
format?: string;
}
export interface NativeReportBandElement extends NativeElementBase {
type: 'reportHeader' | 'reportFooter';
text: string;
bookmarkText?: string;
keepTogether?: boolean;
centerWithDetail?: boolean;
refreshPage?: 'none' | 'always' | 'onOverflow';
visible?: boolean;
stretch?: boolean;
shrink?: boolean;
printRepeated?: boolean;
printAtPageBottom?: boolean;
removeBlankWhenNoData?: boolean;
}
export interface NativeImageElement extends NativeElementBase {
type: 'image';
src: string;
fit: 'fill' | 'contain' | 'cover';
}
export interface NativeCodeElement extends NativeElementBase {
type: 'qrcode' | 'barcode';
value: string;
}
export interface NativeTableColumn {
key: string;
title: string;
field: string;
bindField?: string;
width: number;
align?: 'left' | 'center' | 'right';
contentType?: 'text' | 'image' | 'qrcode' | 'barcode' | 'number' | 'amount';
fontFamily?: string;
fontSize?: number;
useCustomFontSize?: boolean;
fontColor?: string;
autoFitFont?: boolean;
autoWrap?: boolean;
fillCell?: boolean;
contentScale?: number;
imageFit?: 'fill' | 'contain' | 'cover';
qrLevel?: 'L' | 'M' | 'Q' | 'H';
qrRenderType?: 'image/png' | 'image/jpeg' | 'image/webp';
barcodeFormat?: string;
decimalPlaces?: number;
roundHalfUp?: boolean;
amountType?: 'CNY' | 'USD' | 'EUR';
enableFooterTotal?: boolean;
mergeByValue?: boolean;
}
export interface NativeTableHeaderCell {
id: string;
row: number;
col: number;
rowspan: number;
colspan: number;
title: string;
align?: 'left' | 'center' | 'right';
}
export interface NativeTableHeaderConfig {
rowCount: number;
colCount: number;
cells: NativeTableHeaderCell[];
}
export interface NativeTableElement extends NativeElementBase {
type: 'table' | 'detailTable';
source: string;
mergeColumnKeys?: string[];
strictGrouping?: boolean;
enableMultiHeader?: boolean;
tableHeightMode?: 'autoPage' | 'fixedRows';
fixedRows?: number;
showHeader: boolean;
rowHeight: number;
headerHeight: number;
headerFontSize?: number;
bodyFontSize?: number;
headerBgColor?: string;
headerTextColor?: string;
footerLabelColumnKey?: string;
footerLabelText?: string;
footerLabelCenter?: boolean;
footerShowTotal?: boolean;
footerTotalMode?: 'overall' | 'page';
headerConfig?: NativeTableHeaderConfig;
columns: NativeTableColumn[];
}
export interface NativeFreeTableCell {
row: number;
col: number;
rowspan?: number;
colspan?: number;
text?: string;
bindField?: string;
/** 单元格内容类型,与明细表列 contentType 一致 */
contentType?: 'text' | 'image' | 'qrcode' | 'barcode' | 'number' | 'amount';
fillCell?: boolean;
contentScale?: number;
imageFit?: 'fill' | 'contain' | 'cover';
qrLevel?: 'L' | 'M' | 'Q' | 'H';
qrRenderType?: 'image/png' | 'image/jpeg' | 'image/webp';
barcodeFormat?: string;
decimalPlaces?: number;
roundHalfUp?: boolean;
amountType?: 'CNY' | 'USD' | 'EUR';
autoWrap?: boolean;
autoFitFont?: boolean;
align?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'middle' | 'bottom';
fontSize?: number;
color?: string;
backgroundColor?: string;
/** 为 true 时强制不绘制该侧边框(与表格局部内线/外框共同作用) */
hideBorderTop?: boolean;
hideBorderRight?: boolean;
hideBorderBottom?: boolean;
hideBorderLeft?: boolean;
}
/** 表格外轮廓:缺省四边均显示(字段为 false 时隐藏该边) */
export interface NativeFreeTableOuterBorder {
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
}
/** 表格内部网格线:缺省横向、纵向均显示 */
export interface NativeFreeTableInnerBorder {
/** 行间横线 */
horizontal?: boolean;
/** 列间竖线 */
vertical?: boolean;
}
export interface NativeFreeTableElement extends NativeElementBase {
type: 'freeTable';
rowCount: number;
colCount: number;
/** 各列宽度mm长度与 colCount 一致;未设置时按元素 w 均分 */
colWidths?: number[];
/** 各行高度mm长度与 rowCount 一致;未设置时按元素 h 均分 */
rowHeights?: number[];
borderColor?: string;
borderWidth?: number;
/** 表格外轮廓线型(四边最外一圈) */
outerBorderLineStyle?: FreeTableLineStyleKey;
/** 行间横线(内部水平网格线)线型 */
innerBorderHorizontalLineStyle?: FreeTableLineStyleKey;
/** 列间竖线(内部垂直网格线)线型 */
innerBorderVerticalLineStyle?: FreeTableLineStyleKey;
/** 表格外框四边显示开关 */
outerBorder?: NativeFreeTableOuterBorder;
/** 内部横线/竖线显示开关 */
innerBorder?: NativeFreeTableInnerBorder;
cells: NativeFreeTableCell[];
}
export type NativeElement =
| NativeTextElement
| NativeReportBandElement
| NativeImageElement
| NativeCodeElement
| NativeTableElement
| NativeFreeTableElement;
export interface NativePageConfig {
width: number;
height: number;
unit: 'mm';
margin: [number, number, number, number];
gridSize: number;
}
/** 非明细类组件可用的绑定参数(主数据字段路径等) */
export interface NativeDataBindingParam {
key: string;
label?: string;
}
/** 明细表下的字段定义 */
export interface NativeDataBindingDetailField {
key: string;
label?: string;
}
/** 明细数据源:表名 + 字段列表(树状维护) */
export interface NativeDataBindingDetailTable {
/** 与明细表格元素的 source 对应,如 detailList */
tableKey: string;
label?: string;
fields: NativeDataBindingDetailField[];
}
export interface NativeTemplateSchema {
engine: 'native';
version: string;
page: NativePageConfig;
elements: NativeElement[];
dataBinding?: {
fieldMap?: Record<string, string>;
tableSources?: string[];
/** 参数:供文本/自由表格等非明细组件 bindField 参考 */
params?: NativeDataBindingParam[];
/** 字段树:供明细表格等按明细 source 绑定参考 */
detailTables?: NativeDataBindingDetailTable[];
};
}
export interface NativeDesignerState {
schema: NativeTemplateSchema;
selectedId: string;
scale: number;
}

View File

@@ -0,0 +1,386 @@
import { computed, reactive } from 'vue';
import type {
NativeDesignerState,
NativeElement,
NativeElementType,
NativeFreeTableElement,
NativeTableElement,
NativeTemplateSchema,
} from './types';
const MM_TO_PX = 3.7795275591;
function uid(prefix: string) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
export function createDefaultSchema(): NativeTemplateSchema {
return {
engine: 'native',
version: '1.0.0',
page: {
width: 210,
height: 297,
unit: 'mm',
margin: [10, 10, 10, 10],
gridSize: 2,
},
elements: [],
dataBinding: {
fieldMap: {},
tableSources: ['mainTable', 'detailList'],
params: [],
detailTables: [],
},
};
}
function createTableColumns() {
return [
{
key: uid('col'),
title: '列1',
field: 'field1',
bindField: 'field1',
width: 30,
align: 'left' as const,
contentType: 'text' as const,
fontFamily: '',
fontSize: 12,
useCustomFontSize: false,
fontColor: '#111111',
autoFitFont: false,
autoWrap: true,
fillCell: true,
contentScale: 100,
imageFit: 'contain' as const,
qrLevel: 'M' as const,
qrRenderType: 'image/png' as const,
barcodeFormat: 'CODE128',
decimalPlaces: 2,
roundHalfUp: true,
amountType: 'CNY' as const,
enableFooterTotal: false,
mergeByValue: false,
},
{
key: uid('col'),
title: '列2',
field: 'field2',
bindField: 'field2',
width: 30,
align: 'left' as const,
contentType: 'text' as const,
fontFamily: '',
fontSize: 12,
useCustomFontSize: false,
fontColor: '#111111',
autoFitFont: false,
autoWrap: true,
fillCell: true,
contentScale: 100,
imageFit: 'contain' as const,
qrLevel: 'M' as const,
qrRenderType: 'image/png' as const,
barcodeFormat: 'CODE128',
decimalPlaces: 2,
roundHalfUp: true,
amountType: 'CNY' as const,
enableFooterTotal: false,
mergeByValue: false,
},
{
key: uid('col'),
title: '列3',
field: 'field3',
bindField: 'field3',
width: 30,
align: 'left' as const,
contentType: 'text' as const,
fontFamily: '',
fontSize: 12,
useCustomFontSize: false,
fontColor: '#111111',
autoFitFont: false,
autoWrap: true,
fillCell: true,
contentScale: 100,
imageFit: 'contain' as const,
qrLevel: 'M' as const,
qrRenderType: 'image/png' as const,
barcodeFormat: 'CODE128',
decimalPlaces: 2,
roundHalfUp: true,
amountType: 'CNY' as const,
enableFooterTotal: false,
mergeByValue: false,
},
];
}
export function createElementByType(type: NativeElementType, zIndex: number, defaultTableSource = 'List1'): NativeElement {
const base = { id: uid(type), type, bindField: '', region: 'body' as const, bandId: '', x: 20, y: 20, w: 60, h: 12, zIndex };
if (type === 'title') {
return { ...base, type, text: '标题', h: 14, style: { fontSize: 20, fontWeight: 700, textAlign: 'center' } };
}
if (type === 'subtitle') {
return { ...base, type, text: '副标题', style: { fontSize: 14, textAlign: 'center' } };
}
if (type === 'text') {
return { ...base, type, text: '文本内容', style: { fontSize: 12 } };
}
if (type === 'date') {
return { ...base, type, text: '日期', format: 'YYYY-MM-DD', style: { fontSize: 12 } };
}
if (type === 'pageNo') {
return { ...base, type, text: '第 {{pageNo}} / {{totalPages}} 页', style: { fontSize: 12 } };
}
if (type === 'reportHeader') {
return {
...base,
type,
x: 10,
y: 10,
w: 190,
h: 16,
region: 'header',
text: '',
bookmarkText: '',
keepTogether: true,
centerWithDetail: true,
refreshPage: 'none',
visible: true,
stretch: false,
shrink: false,
printRepeated: false,
style: { fontSize: 12, fontWeight: 600, textAlign: 'left', backgroundColor: '#ffffff' },
} as any;
}
if (type === 'reportFooter') {
return {
...base,
type,
x: 10,
y: 260,
w: 190,
h: 18,
region: 'footer',
text: '',
bookmarkText: '',
keepTogether: true,
centerWithDetail: true,
refreshPage: 'none',
visible: true,
stretch: false,
shrink: false,
printRepeated: false,
printAtPageBottom: false,
removeBlankWhenNoData: false,
style: { fontSize: 12, fontWeight: 600, textAlign: 'left', backgroundColor: '#ffffff' },
} as any;
}
if (type === 'image') {
return { ...base, type, w: 36, h: 24, src: '', fit: 'contain' };
}
if (type === 'qrcode') {
return { ...base, type, w: 24, h: 24, value: 'https://www.jeecg.com' };
}
if (type === 'barcode') {
return { ...base, type, w: 48, h: 18, value: '1234567890' };
}
if (type === 'freeTable') {
return {
...base,
type,
w: 120,
h: 50,
rowCount: 3,
colCount: 3,
borderColor: '#d9d9d9',
borderWidth: 1,
outerBorderLineStyle: 'solid',
innerBorderHorizontalLineStyle: 'solid',
innerBorderVerticalLineStyle: 'solid',
cells: Array.from({ length: 3 }).flatMap((_, row) =>
Array.from({ length: 3 }).map((_c, col) => ({
row,
col,
rowspan: 1,
colspan: 1,
text: `单元格${row + 1}-${col + 1}`,
bindField: '',
align: 'left',
verticalAlign: 'middle',
fontSize: 12,
color: '#111111',
backgroundColor: '#ffffff',
})),
),
} as NativeFreeTableElement;
}
return {
...base,
type,
w: 120,
h: 36,
source: defaultTableSource,
mergeColumnKeys: [],
strictGrouping: true,
enableMultiHeader: false,
tableHeightMode: 'autoPage',
fixedRows: 5,
showHeader: true,
rowHeight: 8,
headerHeight: 10,
headerFontSize: 12,
bodyFontSize: 12,
headerBgColor: '#f5f5f5',
headerTextColor: '#111111',
footerLabelColumnKey: '',
footerLabelText: '合计',
footerLabelCenter: true,
footerShowTotal: true,
footerTotalMode: 'overall',
columns: createTableColumns(),
} as NativeTableElement;
}
export function useDesignerStore() {
const state = reactive<NativeDesignerState>({
schema: createDefaultSchema(),
selectedId: '',
scale: 1,
});
const selectedElement = computed(() => state.schema.elements.find((item) => item.id === state.selectedId) as NativeElement | undefined);
function setSchema(schema: NativeTemplateSchema) {
const merged: NativeTemplateSchema = {
...schema,
dataBinding: {
fieldMap: { ...(schema.dataBinding?.fieldMap || {}) },
tableSources: Array.isArray(schema.dataBinding?.tableSources)
? [...(schema.dataBinding!.tableSources as string[])]
: ['mainTable', 'detailList'],
params: Array.isArray(schema.dataBinding?.params) ? [...schema.dataBinding!.params!] : [],
detailTables: Array.isArray(schema.dataBinding?.detailTables)
? schema.dataBinding!.detailTables!.map((t) => ({
...t,
fields: Array.isArray(t.fields) ? [...t.fields] : [],
}))
: [],
},
};
state.schema = merged;
if (!merged.elements.some((item) => item.id === state.selectedId)) {
state.selectedId = '';
}
}
function addElement(type: NativeElementType) {
const zIndex = Math.max(0, ...state.schema.elements.map((item) => item.zIndex)) + 1;
const dts = state.schema.dataBinding?.detailTables ?? [];
const firstKey = dts.length && dts[0]?.tableKey ? String(dts[0].tableKey) : '';
const defaultTableSource = type === 'table' || type === 'detailTable' ? firstKey || 'List1' : '';
const element = createElementByType(type, zIndex, defaultTableSource);
state.schema.elements.push(element);
state.selectedId = element.id;
}
function removeSelected() {
if (!state.selectedId) return;
state.schema.elements = state.schema.elements.filter((item) => item.id !== state.selectedId);
state.selectedId = '';
}
function updateElement(id: string, patch: Partial<NativeElement>) {
const target = state.schema.elements.find((item) => item.id === id);
if (!target) return;
Object.assign(target, patch);
}
function setSelected(id: string) {
state.selectedId = id;
}
/** 合并更新模板 dataBinding参数、明细字段树等 */
function patchDataBinding(patch: Partial<NonNullable<NativeTemplateSchema['dataBinding']>>) {
const cur = state.schema.dataBinding || {
fieldMap: {},
tableSources: ['mainTable', 'detailList'],
params: [],
detailTables: [],
};
state.schema.dataBinding = {
...cur,
...patch,
};
}
function duplicateSelected() {
const source = selectedElement.value;
if (!source) return;
const zIndex = Math.max(0, ...state.schema.elements.map((item) => item.zIndex)) + 1;
const copied = JSON.parse(JSON.stringify(source)) as NativeElement;
copied.id = uid(source.type);
copied.x += 6;
copied.y += 6;
copied.zIndex = zIndex;
state.schema.elements.push(copied);
state.selectedId = copied.id;
}
function bringForward() {
const target = selectedElement.value;
if (!target) return;
target.zIndex += 1;
}
function sendBackward() {
const target = selectedElement.value;
if (!target) return;
target.zIndex = Math.max(1, target.zIndex - 1);
}
function setScale(scale: number) {
state.scale = Math.min(2, Math.max(0.2, scale));
}
function serialize() {
return JSON.stringify(state.schema);
}
function deserialize(raw: string) {
const parsed = JSON.parse(raw || '{}') as NativeTemplateSchema;
if (parsed?.engine !== 'native' || !Array.isArray(parsed.elements) || !parsed.page) {
throw new Error('模板 JSON 不是原生设计器格式');
}
setSchema(parsed);
}
function pagePxSize() {
return {
width: state.schema.page.width * MM_TO_PX,
height: state.schema.page.height * MM_TO_PX,
};
}
return {
MM_TO_PX,
state,
selectedElement,
setSchema,
patchDataBinding,
addElement,
removeSelected,
updateElement,
setSelected,
duplicateSelected,
bringForward,
sendBackward,
setScale,
serialize,
deserialize,
pagePxSize,
};
}

View File

@@ -7,7 +7,12 @@ enum Api {
deleteOne = '/print/template/delete',
deleteBatch = '/print/template/deleteBatch',
queryById = '/print/template/queryById',
queryByCode = '/print/template/queryByCode',
queryPrinters = '/print/template/queryPrinters',
directPrint = '/print/template/directPrint',
directPrintPdf = '/print/template/directPrintPdf',
saveJson = '/print/template/saveJson',
analyzeImageForNative = '/print/template/analyzeImageForNative',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
@@ -28,7 +33,52 @@ export const batchDelete = (params, handleSuccess?) => {
});
};
export const queryById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
export const queryById = (id: string) => defHttp.get({ url: Api.queryById, params: { id, _t: Date.now() } });
export const queryByCode = (code: string) => defHttp.get({ url: Api.queryByCode, params: { code, _t: Date.now() } });
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
/** 服务端直打,队列较慢时适当放宽 */
export const directPrint = (data: { templateCode: string; printerName?: string; dataJson: any }) =>
defHttp.post({ url: Api.directPrint, data, timeout: 60 * 1000 });
/** 上传 Base64 PDF + 后端渲染打印,耗时明显长于普通接口 */
export const directPrintPdf = (data: { templateCode: string; printerName?: string; dataJson: any; pdfBase64: string; fileName?: string }) =>
defHttp.post({ url: Api.directPrintPdf, data, timeout: 3 * 60 * 1000 });
export const saveJson = (data: { id: string; templateJson: string }) =>
defHttp.post({ url: Api.saveJson, data }, { successMessageMode: 'message' });
/** 上传图片由后台生成原生模板 JSONBase64避免 FormData 与签名拦截问题) */
export const analyzeImageForNative = (data: { imageBase64: string; filename?: string; mime?: string }) =>
defHttp.post<{
templateJson: string;
mockDataJson: string;
aiUsed: boolean;
hint?: string;
}>({ url: Api.analyzeImageForNative, data, timeout: 120000 }, { successMessageMode: 'none' });
/** 读取本地图片为 Data URL 后调用 analyzeImageForNative */
export function analyzeImageForNativeFile(file: File): Promise<{
templateJson: string;
mockDataJson: string;
aiUsed: boolean;
hint?: string;
}> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const imageBase64 = String(reader.result || '');
const res = await analyzeImageForNative({
imageBase64,
filename: file.name,
mime: file.type || undefined,
});
resolve(res);
} catch (e) {
reject(e);
}
};
reader.onerror = () => reject(new Error('读取图片失败'));
reader.readAsDataURL(file);
});
}

View File

@@ -1,5 +1,52 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const PRINT_TEMPLATE_CATEGORY_DICT = 'print_template_category';
export const PRINT_PAPER_PRESET_DICT = 'print_paper_preset';
export const CATEGORY_OPTIONS = [
{ label: '条码', value: 'barcode' },
{ label: '标签', value: 'label' },
{ label: '快递面单', value: 'waybill' },
{ label: '吊牌', value: 'hangtag' },
{ label: '物料卡', value: 'materialCard' },
{ label: '箱唛', value: 'cartonMark' },
{ label: '质检单', value: 'qc' },
{ label: '入库单', value: 'inbound' },
{ label: '出库单', value: 'outbound' },
{ label: '工单', value: 'workOrder' },
{ label: '表单套打', value: 'form' },
{ label: '报表', value: 'report' },
];
export const CATEGORY_LABEL_MAP = CATEGORY_OPTIONS.reduce<Record<string, string>>((acc, item) => {
acc[item.value] = item.label;
return acc;
}, {});
export const PAPER_PRESET_MAP: Record<
string,
{ width: number; height: number; orientation: 'portrait' | 'landscape' }
> = {
A4: { width: 210, height: 297, orientation: 'portrait' },
A5: { width: 148, height: 210, orientation: 'portrait' },
A6: { width: 105, height: 148, orientation: 'portrait' },
B5: { width: 176, height: 250, orientation: 'portrait' },
B6: { width: 125, height: 176, orientation: 'portrait' },
L_10_12: { width: 10, height: 12, orientation: 'portrait' },
L_20_10: { width: 20, height: 10, orientation: 'landscape' },
L_25_15: { width: 25, height: 15, orientation: 'landscape' },
L_30_20: { width: 30, height: 20, orientation: 'landscape' },
L_40_30: { width: 40, height: 30, orientation: 'landscape' },
L_50_30: { width: 50, height: 30, orientation: 'landscape' },
L_60_40: { width: 60, height: 40, orientation: 'landscape' },
L_70_50: { width: 70, height: 50, orientation: 'landscape' },
L_80_50: { width: 80, height: 50, orientation: 'landscape' },
L_90_60: { width: 90, height: 60, orientation: 'landscape' },
L_100_70: { width: 100, height: 70, orientation: 'landscape' },
L_100_150: { width: 100, height: 150, orientation: 'portrait' },
L_100_180: { width: 100, height: 180, orientation: 'portrait' },
};
export const columns: BasicColumn[] = [
{ title: '模板编码', dataIndex: 'templateCode', width: 140 },
{ title: '模板名称', dataIndex: 'templateName', width: 180 },
@@ -7,10 +54,7 @@ export const columns: BasicColumn[] = [
title: '分类',
dataIndex: 'category',
width: 100,
customRender: ({ text }) => {
const m = { barcode: '条码', form: '表单套打', report: '报表' };
return m[text] || text;
},
customRender: ({ text }) => CATEGORY_LABEL_MAP[text] || text,
},
{
title: '纸张(mm)',
@@ -31,13 +75,9 @@ export const searchFormSchema: FormSchema[] = [
{
label: '分类',
field: 'category',
component: 'Select',
component: 'JDictSelectTag',
componentProps: {
options: [
{ label: '条码', value: 'barcode' },
{ label: '表单套打', value: 'form' },
{ label: '报表', value: 'report' },
],
dictCode: PRINT_TEMPLATE_CATEGORY_DICT,
allowClear: true,
},
colProps: { span: 6 },
@@ -62,17 +102,33 @@ export const formSchema: FormSchema[] = [
{
label: '分类',
field: 'category',
component: 'Select',
component: 'JDictSelectTag',
defaultValue: 'form',
componentProps: {
options: [
{ label: '条码', value: 'barcode' },
{ label: '表单套打', value: 'form' },
{ label: '报表', value: 'report' },
],
dictCode: PRINT_TEMPLATE_CATEGORY_DICT,
},
required: true,
},
{
label: '纸张规格',
field: 'paperPreset',
component: 'JDictSelectTag',
defaultValue: 'A4',
componentProps: ({ formModel }) => ({
dictCode: PRINT_PAPER_PRESET_DICT,
allowClear: true,
placeholder: '选择预设规格或自定义',
onChange: (value: string) => {
const preset = PAPER_PRESET_MAP[String(value || '')];
if (!preset) {
return;
}
formModel.paperWidthMm = preset.width;
formModel.paperHeightMm = preset.height;
formModel.paperOrientation = preset.orientation;
},
}),
},
{
label: '纸宽(mm)',
field: 'paperWidthMm',

View File

@@ -0,0 +1,2 @@
/** 快速打印「设计器同源预览」写入 sessionStorage 的键PrintDesigner 与列表页共用,勿随意改名) */
export const QUICK_PRINT_PREVIEW_STORAGE_KEY = 'qhmes_quick_print_preview_v1';