新增打印模块功能,支持图片分析生成原生模板JSON,查询可用打印机,服务端直打功能,优化打印设计器界面,添加打印机选择和快速打印选项,同时更新依赖项以支持PDF处理。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
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
122
jeecgboot-vue3/src/views/print/template/lodopLoader.ts
Normal file
122
jeecgboot-vue3/src/views/print/template/lodopLoader.ts
Normal 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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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};`;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
267
jeecgboot-vue3/src/views/print/template/native/core/types.ts
Normal file
267
jeecgboot-vue3/src/views/print/template/native/core/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
/** 上传图片由后台生成原生模板 JSON(Base64,避免 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 快速打印「设计器同源预览」写入 sessionStorage 的键(PrintDesigner 与列表页共用,勿随意改名) */
|
||||
export const QUICK_PRINT_PREVIEW_STORAGE_KEY = 'qhmes_quick_print_preview_v1';
|
||||
Reference in New Issue
Block a user