基于element-plus一个vue搞定自定义报表
报表功能:根据数据源配置报表

代码入学:
<template>
<ele-page>
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-title">报表设计器</div>
<div class="toolbar-buttons">
<button class="toolbar-btn" @click="showDtaSource">
<i>🗄️</i> 数据源
</button>
<button class="toolbar-btn" @click="showNewReportDialog">
<i>📄</i> 新建
</button>
<button class="toolbar-btn" @click="saveReport">
<i>💾</i> 保存
</button>
<button class="toolbar-btn" @click="showPreviewDialog">
<i>🔍</i> 预览
</button>
<button class="toolbar-btn" @click="printReport">
<i>🖨️</i> 打印
</button>
<button class="toolbar-btn" @click="toggleZoomMode" :class="{ active: isZoomMode }">
<i>🔍</i> {{ isZoomMode ? '退出缩放' : '缩放模式' }}
</button>
<button class="toolbar-btn btn-danger" @click="deleteSelectedElement" :disabled="!selectedElement">
<i>🗑️</i> 删除
</button>
<button class="toolbar-btn" @click="showSettingsDialog">
<i>⚙️</i> 设置
</button>
</div>
<div class="zoom-controls">
<button class="zoom-btn" @click="zoomOut" title="缩小">−</button>
<span class="zoom-level">{{ Math.round(zoom * 100) }}%</span>
<button class="zoom-btn" @click="zoomIn" title="放大">+</button>
<button class="zoom-btn" @click="resetZoom" title="重置缩放">↺</button>
</div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 左侧面板 -->
<div class="left-panel">
<div class="panel-header">报表组件</div>
<div class="panel-content">
<div class="component-list">
<div
v-for="component in reportComponents"
:key="component.id"
class="component-item"
draggable="true"
@dragstart="onDragStart($event, component)"
>
<i>{{ component.icon }}</i> {{ component.name }}
</div>
</div>
</div>
</div>
<!-- 中间设计区域 -->
<div
class="design-area"
@click="clearSelection"
@dragover.prevent
@drop="onDrop"
@wheel="onWheel"
:class="{ 'zoom-cursor': isZoomMode }"
>
<div
class="report-paper"
ref="paperRef"
:style="{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
width: paperSize.width + 'px',
height: paperSize.height + 'px'
}"
>
<!-- 渲染所有组件 -->
<div
v-for="element in sortedReportElements"
:key="element.id"
class="report-element"
:class="{
selected: selectedElement?.id === element.id,
resizing: resizingElement?.id === element.id,
'data-table': element.type === 'data-table',
'group-marker': element.type === 'group-marker',
'line-element': element.type === 'line',
'rectangle-element': element.type === 'rectangle',
'field-image-element': element.type === 'field-image',
'statistics-element': element.type === 'statistics'
}"
:style="getElementStyle(element)"
@click.stop="selectElement(element)"
@mousedown="startDrag($event, element)"
@contextmenu.prevent="showContextMenu($event, element)"
>
<!-- 分组标记组件 -->
<div v-if="element.type === 'group-marker'" class="group-marker-container">
<div class="group-marker-header">
<div class="group-marker-title">分组标记: {{ element.name }}</div>
<div class="group-marker-info">分组字段: {{ element.groupField || '未设置' }}</div>
<div class="group-marker-info">每页组数: {{ element.groupsPerPage || 1 }}</div>
</div>
</div>
<!-- 数据表格 -->
<div v-else-if="element.type === 'data-table'" class="data-table-container">
<table class="data-table" v-if="element.tableConfig" :style="{'--header-font-family': element.headerFontFamily,'--header-color': element.headerColor}">
<thead>
<tr>
<th v-for="(column, index) in element.tableConfig.columns" :key="index"
:style="getTableHeaderStyle(element, column)">
{{ column.title }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in getTableData(element)" :key="rowIndex">
<td v-for="(column, colIndex) in element.tableConfig.columns" :key="colIndex"
:style="getTableCellStyle(column)">
{{ formatTableCell(row[column.field], column) }}
</td>
</tr>
</tbody>
</table>
<div v-else class="table-placeholder">
请配置表格字段
</div>
</div>
<!-- 字段文本框 -->
<div v-else-if="element.type === 'field-text'" :style="{ color: element.color || '#000000' }">
{{ getFieldTextContent(element) }}
</div>
<!-- 静态文本框 -->
<div v-else-if = " element.type === 'static-text'" :style="{ color: element.color || '#000000' }">
{{ getFormattedTextContent(element) }}
</div>
<!-- 字段二维码 -->
<div v-else-if="element.type === 'field-qrcode'" class="field-qrcode-container">
<div class="field-qrcode-placeholder" v-if="!getFieldQRCodeContent(element)">
请配置数据字段
</div>
<ele-qr-code v-else :value="getFieldQRCodeContent(element)" :size="element.width" tag="svg"/>
</div>
<!--静态二维码-->
<div v-else-if = "element.type === 'qr-code'" class="qe-code-container">
<ele-qr-code :value="element.content" :size="element.width" tag="svg"/>
</div>
<!-- 线条组件 -->
<div v-else-if="element.type === 'line'" class="line-container">
<div class="line-preview" :style="getLineStyle(element)"></div>
</div>
<!-- 矩形组件 -->
<div v-else-if="element.type === 'rectangle'" class="rectangle-container">
<div class="rectangle-preview" :style="getRectangleStyle(element)"></div>
</div>
<!-- 图片组件 -->
<div v-else-if="element.type === 'image'" class="image-container">
<div style="width: 100%;height: 100%;position: absolute"><!--占位框,方便拖动--></div>
<img
v-if="element.imageUrl || element.imageBase64"
:src="element.imageBase64 || element.imageUrl"
:alt="element.name"
style="width: 100%; height: 100%; object-fit: contain;"
/>
<div v-else class="image-placeholder">
<i>🖼️</i>
<div>请设置图片</div>
</div>
</div>
<!-- 字段图片组件 -->
<div v-else-if="element.type === 'field-image'" class="field-image-container">
<div style="width: 100%;height: 100%;position: absolute"><!--占位框,方便拖动--></div>
<img
v-if="getFieldImageContent(element)"
:src="getFieldImageContent(element)"
:alt="element.name"
style="width: 100%; height: 100%; object-fit: contain;"
/>
<div v-else class="image-placeholder">
<i>📷</i>
<div>请设置数据字段</div>
</div>
</div>
<!-- 统计组件 -->
<div v-else-if="element.type === 'statistics'" class="statistics-container">
<div :style="{ color: element.color || '#000000' }">
{{ getStatisticsContent(element) }}
</div>
</div>
<div
class="element-handle nw"
@mousedown.stop="startResize($event, element, 'nw')"
></div>
<div
class="element-handle ne"
@mousedown.stop="startResize($event, element, 'ne')"
></div>
<div
class="element-handle sw"
@mousedown.stop="startResize($event, element, 'sw')"
></div>
<div
class="element-handle se"
@mousedown.stop="startResize($event, element, 'se')"
></div>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{
left: contextMenu.x + 'px',
top: contextMenu.y + 'px'
}"
>
<div class="context-item" @click="deleteElement(contextMenu.element)">
<i>🗑️</i> 删除元素
</div>
<div class="context-item" @click="copyElement(contextMenu.element)">
<i>📋</i> 复制元素
</div>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<div class="tabs">
<div
class="tab"
:class="{ active: activeTab === 'properties' }"
@click="activeTab = 'properties'"
>属性</div>
<div
class="tab"
:class="{ active: activeTab === 'data' }"
@click="activeTab = 'data'"
>数据</div>
</div>
<div class="tab-content">
<!-- 属性面板 -->
<div v-if="activeTab === 'properties' && selectedElement" class="property-group">
<div class="property-label">基础设置</div>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
<el-input
type="text"
v-model="selectedElement.name"
@input="updateElement"
size="small"
>
<template #prepend>名称</template>
</el-input>
</div>
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<el-input
type="number"
v-model.number="selectedElement.x"
@input="updateElement"
size="small"
>
<template #prepend>横轴</template>
</el-input>
<el-input
type="number"
v-model.number="selectedElement.y"
@input="updateElement"
size="small"
>
<template #prepend>竖轴</template>
</el-input>
</div>
<div style="display: flex; gap: 5px;">
<el-input
type="number"
v-model.number="selectedElement.width"
@input="updateElement"
size="small"
>
<template #prepend>宽度</template>
</el-input>
<el-input
type="number"
v-model.number="selectedElement.height"
@input="updateElement"
size="small"
>
<template #prepend>高度</template>
</el-input>
</div>
<!-- 分组关联 -->
<div v-if="selectedElement.type !== 'group-marker'" class="property-group">
<div class="property-label">分组关联</div>
<el-select v-model="selectedElement.groupId" @change="updateElement" size="small" style="width: 100%">
<el-option value="" label="无分组(独立显示)" />
<el-option
v-for="group in availableGroups"
:key="group.id"
:value="group.id"
:label="group.name"
/>
<template #prepend>分组关联</template>
</el-select>
<div class="property-hint" v-if="selectedElement.groupId">
此组件将根据分组字段显示数据
</div>
</div>
<!-- 统计组件特殊属性 -->
<div v-if="selectedElement.type === 'statistics'" class="property-group">
<div class="property-label">统计字段</div>
<el-select
v-model="selectedElement.dataField"
@change="validateStatisticsField"
size="small"
style="width: 100%"
>
<el-option value="" label="选择统计字段" />
<el-option
v-for="field in availableFields"
:key="field.value"
:value="field.value"
:label="field.label"
/>
</el-select>
<div class="property-label" v-if="selectedElement.dataField">统计结果</div>
<div class="property-preview" v-if="selectedElement.dataField">
{{ getStatisticsContent(selectedElement) }}
</div>
<div class="property-label">统计类型</div>
<el-select
v-model="selectedElement.statisticsType"
@change="updateElement"
size="small"
style="width: 100%"
>
<el-option value="sum" label="合计" />
<el-option value="average" label="平均" />
<el-option value="max" label="最大" />
<el-option value="min" label="最小" />
<el-option value="count" label="个数" />
</el-select>
<div class="property-label">字体颜色</div>
<el-color-picker v-model="selectedElement.color" @change="updateElement" />
</div>
<!-- 图片组件特殊属性 -->
<div v-if="selectedElement.type === 'image'" class="property-group">
<div class="property-label">图片显示方式</div>
<el-select v-model="selectedElement.objectFit" @change="updateElement" size="small" style="width: 100%">
<el-option value="contain" label="包含(保持比例)" />
<el-option value="cover" label="覆盖(填充)" />
<el-option value="fill" label="填充(拉伸)" />
</el-select>
</div>
<!-- 字段图片组件特殊属性 -->
<div v-if="selectedElement.type === 'field-image'" class="property-group">
<div class="property-label">图片显示方式</div>
<el-select v-model="selectedElement.objectFit" @change="updateElement" size="small" style="width: 100%">
<el-option value="contain" label="包含(保持比例)" />
<el-option value="cover" label="覆盖(填充)" />
<el-option value="fill" label="填充(拉伸)" />
</el-select>
</div>
<!-- 分组标记组件特殊属性 -->
<div v-if="selectedElement.type === 'group-marker'" class="property-group">
<div class="property-label">分组字段</div>
<el-select v-model="selectedElement.groupField" @change="updateElement" size="small" style="width: 100%">
<el-option value="" label="选择分组字段" />
<el-option
v-for="field in availableFields"
:key="field.value"
:value="field.value"
:label="field.label"
/>
</el-select>
<div class="property-label">每页组数</div>
<el-input
type="number"
v-model.number="selectedElement.groupsPerPage"
@input="updateElement"
size="small"
min="1"
max="10"
/>
</div>
<!-- 线条组件特殊属性 -->
<div v-if="selectedElement.type === 'line'" class="property-group">
<div class="property-label">线条颜色</div>
<el-color-picker v-model="selectedElement.lineColor" show-alpha @change="updateElement" />
<div class="property-label">线条类型</div>
<el-select v-model="selectedElement.lineStyle" @change="updateElement" size="small" style="width: 100%">
<el-option value="solid" label="实线" />
<el-option value="dashed" label="虚线" />
<el-option value="dotted" label="点状" />
</el-select>
<div class="property-label">线条粗细</div>
<el-select v-model="selectedElement.lineWidth" @change="updateElement" size="small" style="width: 100%">
<el-option
v-for="width in [1,2,3,4,5]"
:key="width"
:value="width"
:label="`${width}px`"
/>
</el-select>
<div class="property-label">线条方向</div>
<el-select v-model="selectedElement.lineDirection" @change="updateElement" size="small" style="width: 100%">
<el-option value="horizontal" label="水平" />
<el-option value="vertical" label="垂直" />
<el-option value="diagonal" label="对角线" />
</el-select>
</div>
<!-- 矩形组件特殊属性 -->
<div v-if="selectedElement.type === 'rectangle'" class="property-group">
<div class="property-label">背景颜色</div>
<el-color-picker v-model="selectedElement.backgroundColor" show-alpha @change="updateElement" />
<div class="property-label">边框颜色</div>
<el-color-picker v-model="selectedElement.borderColor" show-alpha @change="updateElement" />
<div class="property-label">边框类型</div>
<el-select v-model="selectedElement.borderStyle" @change="updateElement" size="small" style="width: 100%">
<el-option value="solid" label="实线" />
<el-option value="dashed" label="虚线" />
<el-option value="dotted" label="点状" />
</el-select>
<div class="property-label">边框粗细</div>
<el-select v-model="selectedElement.borderWidth" @change="updateElement" size="small" style="width: 100%">
<el-option
v-for="width in [1,2,3,4,5]"
:key="width"
:value="width"
:label="`${width}px`"
/>
</el-select>
</div>
<!-- 表格组件样式设置 -->
<div v-if="selectedElement.type === 'data-table'" class="property-group">
<!-- 表头样式 -->
<div class="property-label">表头样式设置</div>
<div class="table-top-column-style-config">
<!-- 表头字体 -->
<div class="property-label">表头字体</div>
<el-select v-model="selectedElement.headerFontFamily" @change="updateElement" size="small" style="width: 100%">
<el-option value="微软雅黑" label="微软雅黑" />
<el-option value="宋体" label="宋体" />
<el-option value="黑体" label="黑体" />
<el-option value="楷体" label="楷体" />
</el-select>
<!-- 表头字号 -->
<div class="property-label">表头字号</div>
<el-select v-model="selectedElement.headerFontSize" @change="updateElement" size="small" style="width: 100%">
<el-option
v-for="size in [12,14,16,18,20,24,26,28,30]"
:key="size"
:value="size"
:label="size"
/>
</el-select>
<!-- 表头字体样式 -->
<div class="property-label">表头字体样式</div>
<div>
<el-checkbox v-model="selectedElement.headerBold" @change="updateElement">粗体</el-checkbox>
<el-checkbox v-model="selectedElement.headerItalic" @change="updateElement">斜体</el-checkbox>
<el-checkbox v-model="selectedElement.headerUnderline" @change="updateElement">下划线</el-checkbox>
</div>
<!-- 表头对齐方式 -->
<div class="property-label">表头对齐方式</div>
<el-select v-model="selectedElement.headerTextAlign" @change="updateElement" size="small" style="width: 100%">
<el-option value="left" label="左对齐" />
<el-option value="center" label="居中对齐" />
<el-option value="right" label="右对齐" />
</el-select>
<!-- 表头颜色 -->
<div class="property-label">表头颜色</div>
<el-color-picker v-model="selectedElement.headerColor" @change="updateElement" />
</div>
<!-- 列样式设置 -->
<div class="property-label">列样式设置</div>
<div
v-for="(column, index) in selectedElement.tableConfig.columns"
:key="index"
class="column-style-config"
>
<!-- 列标题折叠面板 -->
<div
class="column-style-header"
@click="toggleColumnStyle(index)"
:class="{ expanded: expandedColumns[index] }"
>
<div class="column-header-content">
<span class="column-title">{{ column.title || '未命名列' }}</span>
<span class="column-field" v-if="column.field">({{ column.field }})</span>
<i class="expand-icon" :class="expandedColumns[index] ? 'el-icon-arrow-down' : 'el-icon-arrow-right'"></i>
</div>
</div>
<!-- 列样式设置内容 -->
<div class="column-style-content" v-show="expandedColumns[index]">
<!-- 列字体 -->
<div class="property-label">字体</div>
<el-select v-model="column.fontFamily" @change="updateElement" size="small" style="width: 100%; margin-bottom: 5px;">
<el-option value="微软雅黑" label="微软雅黑" />
<el-option value="宋体" label="宋体" />
<el-option value="黑体" label="黑体" />
<el-option value="楷体" label="楷体" />
</el-select>
<!-- 列字号 -->
<div class="property-label">字号</div>
<el-select v-model="column.fontSize" @change="updateElement" size="small" style="width: 100%; margin-bottom: 5px;">
<el-option
v-for="size in [12,14,16,18,20,24,26,28,30]"
:key="size"
:value="size"
:label="size"
/>
</el-select>
<!-- 列字体样式 -->
<div class="property-label">字体样式</div>
<div style="margin-bottom: 5px;">
<el-checkbox v-model="column.bold" @change="updateElement">粗体</el-checkbox>
<el-checkbox v-model="column.italic" @change="updateElement">斜体</el-checkbox>
<el-checkbox v-model="column.underline" @change="updateElement">下划线</el-checkbox>
</div>
<!-- 列对齐方式 -->
<div class="property-label">对齐方式</div>
<el-select v-model="column.textAlign" @change="updateElement" size="small" style="width: 100%; margin-bottom: 5px;">
<el-option value="left" label="左对齐" />
<el-option value="center" label="居中对齐" />
<el-option value="right" label="右对齐" />
</el-select>
<!-- 列颜色 -->
<div class="property-label">字体颜色</div>
<el-color-picker v-model="column.color" @change="updateElement" style="margin-bottom: 5px;" />
<!-- 列格式设置 -->
<div class="property-label">列格式</div>
<el-select v-model="column.formatType" @change="updateElement" size="small" style="width: 100%; margin-bottom: 5px;">
<el-option value="text" label="文本" />
<el-option value="number" label="数字" />
<el-option value="date" label="日期" />
<el-option value="currency" label="货币" />
</el-select>
<!-- 数字格式设置 -->
<div v-if="column.formatType === 'number'" class="property-group">
<div class="property-label">小数位数</div>
<el-input-number
v-model="column.decimalPlaces"
@change="updateElement"
:min="0"
:max="6"
size="small"
style="width: 100%"
/>
</div>
<!-- 货币格式设置 -->
<div v-if="column.formatType === 'currency'" class="property-group">
<div class="property-label">货币格式</div>
<el-checkbox v-model="column.useChineseAmount" @change="updateElement">大写汉字金额</el-checkbox>
<el-checkbox v-model="column.useThousandSeparator" @change="updateElement">千分位分隔</el-checkbox>
</div>
<!-- 日期格式设置 -->
<div v-if="column.formatType === 'date'" class="property-group">
<div class="property-label">日期格式</div>
<el-select v-model="column.dateFormat" @change="updateElement" size="small" style="width: 100%">
<el-option value="yyyy-MM-dd" label="年-月-日 (2023-12-31)" />
<el-option value="yyyy/MM/dd" label="年/月/日 (2023/12/31)" />
<el-option value="yyyy年MM月dd日" label="年月日 (2023年12月31日)" />
<el-option value="yyyy-MM-dd HH:mm:ss" label="年-月-日 时:分:秒 (2023-12-31 23:59:59)" />
<el-option value="yyyy/MM/dd HH:mm:ss" label="年/月/日 时:分:秒 (2023/12/31 23:59:59)" />
<el-option value="yyyy年MM月dd日 HH时mm分ss秒" label="年月日 时分秒 (2023年12月31日 23时59分59秒)" />
<el-option value="MM-dd" label="月-日 (12-31)" />
<el-option value="HH:mm:ss" label="时:分:秒 (23:59:59)" />
</el-select>
</div>
</div>
</div>
</div>
<!-- 静态文本框内容 -->
<div v-if="['qr-code','static-text'].includes(selectedElement.type)" class="property-group">
<div class="property-label">文本内容</div>
<el-input
type="text"
v-model="selectedElement.content"
@input="updateElement"
size="small"
/>
</div>
<!-- 文本框格式设置 -->
<div v-if="['field-text','static-text'].includes(selectedElement.type)" class="property-group">
<div class="property-label">文本格式</div>
<el-select v-model="selectedElement.formatType" @change="updateElement" size="small" style="width: 100%">
<el-option value="text" label="文本" />
<el-option value="number" label="数字" />
<el-option value="date" label="日期" />
<el-option value="currency" label="货币" />
</el-select>
<!-- 数字格式设置 -->
<div v-if="selectedElement.formatType === 'number'" class="property-group">
<div class="property-label">小数位数</div>
<el-input-number
v-model="selectedElement.decimalPlaces"
@change="updateElement"
:min="0"
:max="6"
size="small"
style="width: 100%"
/>
</div>
<!-- 货币格式设置 -->
<div v-if="selectedElement.formatType === 'currency'" class="property-group">
<div class="property-label">货币格式</div>
<el-checkbox v-model="selectedElement.useChineseAmount" @change="updateElement">大写汉字金额</el-checkbox>
<el-checkbox v-model="selectedElement.useThousandSeparator" @change="updateElement">千分位分隔</el-checkbox>
</div>
<!-- 日期格式设置 -->
<div v-if="selectedElement.formatType === 'date'" class="property-group">
<div class="property-label">日期格式</div>
<el-select v-model="selectedElement.dateFormat" @change="updateElement" size="small" style="width: 100%">
<el-option value="yyyy-MM-dd" label="年-月-日 (2023-12-31)" />
<el-option value="yyyy/MM/dd" label="年/月/日 (2023/12/31)" />
<el-option value="yyyy年MM月dd日" label="年月日 (2023年12月31日)" />
<el-option value="yyyy-MM-dd HH:mm:ss" label="年-月-日 时:分:秒 (2023-12-31 23:59:59)" />
<el-option value="yyyy/MM/dd HH:mm:ss" label="年/月/日 时:分:秒 (2023/12/31 23:59:59)" />
<el-option value="yyyy年MM月dd日 HH时mm分ss秒" label="年月日 时分秒 (2023年12月31日 23时59分59秒)" />
<el-option value="MM-dd" label="月-日 (12-31)" />
<el-option value="HH:mm:ss" label="时:分:秒 (23:59:59)" />
</el-select>
<div class="property-hint">
支持格式: yyyy-年, MM-月, dd-日, HH-时, mm-分, ss-秒
</div>
</div>
<!-- 字体颜色 -->
<div class="property-label">字体颜色</div>
<el-color-picker v-model="selectedElement.color" @change="updateElement" />
</div>
<!--字体-->
<div v-if="['field-text','static-text','statistics'].includes(selectedElement.type)">
<div class="property-label">字体</div>
<el-select v-model="selectedElement.fontFamily" @change="updateElement" size="small" style="width: 100%">
<el-option value="微软雅黑" label="微软雅黑" />
<el-option value="宋体" label="宋体" />
<el-option value="黑体" label="黑体" />
<el-option value="楷体" label="楷体" />
</el-select>
</div>
<!--字号-->
<div v-if="['field-text','static-text','statistics'].includes(selectedElement.type)">
<div class="property-label">字号</div>
<el-select v-model="selectedElement.fontSize" @change="updateElement" size="small" style="width: 100%">
<el-option
v-for="size in [12,14,16,18,20,24,26,28,30]"
:key="size"
:value="size"
:label="size"
/>
</el-select>
</div>
<!--字体样式-->
<div v-if="['field-text','static-text','statistics'].includes(selectedElement.type)">
<div class="property-label">字体样式</div>
<div>
<el-checkbox v-model="selectedElement.bold" @change="updateElement">粗体</el-checkbox>
<el-checkbox v-model="selectedElement.italic" @change="updateElement">斜体</el-checkbox>
<el-checkbox v-model="selectedElement.underline" @change="updateElement">下划线</el-checkbox>
</div>
</div>
<!--对齐方式-->
<div v-if="['field-text','static-text','statistics'].includes(selectedElement.type)">
<div class="property-label">对齐方式</div>
<el-select v-model="selectedElement.textAlign" @change="updateElement" size="small" style="width: 100%">
<el-option value="left" label="左对齐" />
<el-option value="center" label="居中对齐" />
<el-option value="right" label="右对齐" />
</el-select>
</div>
</div>
<!-- 数据面板 -->
<div v-if="activeTab === 'data' && selectedElement" class="property-group">
<!-- 图片组件数据配置 -->
<div v-if="selectedElement.type === 'image'" class="property-group">
<div class="property-label">图片地址</div>
<el-input
type="text"
v-model="selectedElement.imageUrl"
@input="updateElement"
placeholder="输入图片URL地址"
size="small"
style="width: 100%; margin-bottom: 10px;"
/>
<div class="property-label">上传图片</div>
<el-upload
class="image-upload"
action="#"
:show-file-list="false"
:before-upload="beforeImageUpload"
:http-request="handleImageUpload"
>
<el-button size="small" type="primary">
<i>📁</i> 选择图片
</el-button>
<template #tip>
<div class="el-upload__tip" style="font-size: 12px; color: #666; margin-top: 5px;">
支持 jpg、png、gif 格式,大小不超过 2MB
</div>
</template>
</el-upload>
<div class="property-label" v-if="selectedElement.imageUrl || selectedElement.imageBase64">图片预览</div>
<div class="image-preview" v-if="selectedElement.imageUrl || selectedElement.imageBase64">
<img
:src="selectedElement.imageBase64 || selectedElement.imageUrl"
style="max-width: 100%; max-height: 150px; display: block; margin: 0 auto;"
/>
</div>
</div>
<!-- 字段图片组件数据配置 -->
<div v-else-if="selectedElement.type === 'field-image'" class="property-group">
<div class="property-label">数据字段</div>
<el-select v-model="selectedElement.dataField" @change="updateElement" size="small" style="width: 100%">
<el-option value="" label="选择字段" />
<el-option
v-for="field in availableFields"
:key="field.value"
:value="field.value"
:label="field.label"
/>
</el-select>
<div class="property-label" v-if="selectedElement.dataField">预览值</div>
<div class="property-preview" v-if="selectedElement.dataField">
{{ getFieldPreview(selectedElement.dataField) }}
</div>
<div class="property-label">图片显示方式</div>
<el-select v-model="selectedElement.objectFit" @change="updateElement" size="small" style="width: 100%">
<el-option value="contain" label="包含(保持比例)" />
<el-option value="cover" label="覆盖(填充)" />
<el-option value="fill" label="填充(拉伸)" />
</el-select>
</div>
<!-- 数据表格配置 -->
<div v-if="selectedElement.type === 'data-table'" class="property-group">
<div class="property-label">表格列配置</div>
<div
v-for="(column, index) in selectedElement.tableConfig.columns"
:key="index"
class="column-config"
draggable="true"
@dragstart="onColumnDragStart($event, index)"
@dragover.prevent="onColumnDragOver($event, index)"
@drop="onColumnDrop($event, index)"
@dragenter.prevent
@dragleave.prevent
>
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<el-input
placeholder="列标题"
v-model="column.title"
@input="updateElement"
size="small"
/>
<el-select
v-model="column.field"
@change="updateElement"
size="small"
>
<el-option value="" label="选择字段" />
<el-option
v-for="field in availableFields"
:key="field.value"
:value="field.value"
:label="field.label"
/>
</el-select>
<el-button
type="danger"
@click="removeColumn(index)"
size="small"
style="padding: 4px 8px;"
>
×
</el-button>
</div>
</div>
<el-button @click="addColumn" style="width: 100%; margin-top: 10px;" size="small">
<i>➕</i> 添加列
</el-button>
</div>
<!-- 字段文本框配置 -->
<div v-else-if="['field-text','field-qrcode'].includes(selectedElement.type)" class="property-group">
<div class="property-label">数据字段</div>
<el-select v-model="selectedElement.dataField" @change="updateElement" size="small" style="width: 100%">
<el-option value="" label="选择字段" />
<el-option
v-for="field in availableFields"
:key="field.value"
:value="field.value"
:label="field.label"
/>
</el-select>
<div class="property-label" v-if="selectedElement.dataField">预览值</div>
<div class="property-preview" v-if="selectedElement.dataField">
{{ getFieldPreview(selectedElement.dataField) }}
</div>
</div>
<!-- 统计组件数据配置 -->
<div v-else-if="selectedElement.type === 'statistics'" class="property-group">
<div class="property-label">统计组件</div>
<div class="property-hint">统计配置在属性面板中设置</div>
</div>
<!-- 静态文本框无数据配置 -->
<div v-else-if="selectedElement.type === 'static-text'" class="property-group">
<div class="property-label">静态文本</div>
<div class="property-hint">此组件为静态文本,无需数据绑定</div>
</div>
<!-- 静态二维码无数据配置 -->
<div v-else-if="selectedElement.type === 'qr-code'" class="property-group">
<div class="property-label">静态二维码</div>
<div class="property-hint">此组件为静态二维码,无需数据绑定</div>
</div>
<!-- 线条组件无数据配置 -->
<div v-else-if="selectedElement.type === 'line'" class="property-group">
<div class="property-label">线条组件</div>
<div class="property-hint">此组件为装饰线条,无需数据绑定</div>
</div>
<!-- 矩形组件无数据配置 -->
<div v-else-if="selectedElement.type === 'rectangle'" class="property-group">
<div class="property-label">矩形组件</div>
<div class="property-hint">此组件为装饰矩形,无需数据绑定</div>
</div>
<!-- 分组标记组件无数据配置 -->
<div v-else-if="selectedElement.type === 'group-marker'" class="property-group">
<div class="property-label">分组配置</div>
<div class="property-hint">分组配置在属性面板中设置</div>
</div>
</div>
<div v-if="!selectedElement" class="no-selection">
请选择一个报表元素进行编辑
</div>
</div>
</div>
</div>
<!-- 底部状态栏 -->
<div class="status-bar">
<div>{{ statusMessage }}</div>
<div v-if="selectedElement">
X: {{ selectedElement.x }}, Y: {{ selectedElement.y }} | 宽度: {{ selectedElement.width }}, 高度: {{ selectedElement.height }}
</div>
<div v-else>就绪</div>
<div class="zoom-hint" v-if="isZoomMode">
🔍 缩放模式:鼠标滚轮缩放,按住Ctrl加速
</div>
</div>
<!-- 打印预览 -->
<ele-printer v-model="printing" target="_iframe">
<div v-html="printHtml"></div>
</ele-printer>
<!-- 新建报表对话框 -->
<el-dialog
v-model="newReportDialogVisible"
title="新建报表"
width="500px"
:before-close="handleNewReportDialogClose"
>
<div class="dialog-content">
<div class="form-group">
<div class="form-label">选择纸张大小</div>
<el-select v-model="selectedPaperSize" placeholder="请选择纸张大小" style="width: 100%">
<el-option
v-for="size in paperSizes"
:key="size.name"
:label="size.name"
:value="size.name"
/>
</el-select>
</div>
<div class="form-group" v-if="selectedPaperSize === '自定义'">
<div class="form-row">
<div class="form-item">
<div class="form-label">宽度 (px)</div>
<el-input-number
v-model="customPaperSize.width"
:min="100"
:max="2000"
:step="10"
controls-position="right"
style="width: 100%"
/>
</div>
<div class="form-item">
<div class="form-label">高度 (px)</div>
<el-input-number
v-model="customPaperSize.height"
:min="100"
:max="3000"
:step="10"
controls-position="right"
style="width: 100%"
/>
</div>
</div>
</div>
<div class="paper-preview">
<div class="preview-label">预览</div>
<div class="preview-container">
<div
class="preview-paper"
:style="{
width: getPreviewPaperSize().width + 'px',
height: getPreviewPaperSize().height + 'px'
}"
>
<div class="paper-info">
{{ getPreviewPaperSize().width }} × {{ getPreviewPaperSize().height }} px
</div>
</div>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="newReportDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmNewReport">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 预览对话框 -->
<el-dialog
v-model="previewDialogVisible"
title="报表预览"
:width="getPreviewDialogWidth()"
:fullscreen="isPreviewFullscreen"
:before-close="handlePreviewDialogClose"
>
<div class="preview-header">
<el-button
size="small"
@click="togglePreviewFullscreen"
style="margin-bottom: 10px;"
>
{{ isPreviewFullscreen ? '退出全屏' : '全屏' }}
</el-button>
</div>
<div
class="preview-content"
:style="{ width: paperSize.width + 'px' }"
v-html="previewHtml"
></div>
<template #footer>
<span class="dialog-footer">
<el-button @click="previewDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="printReportFromPreview">打印</el-button>
</span>
</template>
</el-dialog>
<!-- 设置对话框 -->
<el-dialog
v-model="settingsDialogVisible"
title="页面设置"
width="500px"
:before-close="handleSettingsDialogClose"
>
<div class="dialog-content">
<div class="form-group">
<div class="form-label">页面宽度 (px)</div>
<el-input-number
v-model="paperSize.width"
:min="100"
:max="2000"
:step="10"
controls-position="right"
style="width: 100%"
/>
</div>
<div class="form-group">
<div class="form-label">页面高度 (px)</div>
<el-input-number
v-model="paperSize.height"
:min="100"
:max="3000"
:step="10"
controls-position="right"
style="width: 100%"
/>
</div>
<div class="paper-preview">
<div class="preview-label">当前页面大小</div>
<div class="preview-container">
<div
class="preview-paper"
:style="{
width: (paperSize.width / 4) + 'px',
height: (paperSize.height / 4) + 'px'
}"
>
<div class="paper-info">
{{ paperSize.width }} × {{ paperSize.height }} px
</div>
</div>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="settingsDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmSettings">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 数据源对话框 -->
<el-dialog
v-model="dataSourceDialogVisible"
title="数据源配置"
width="600px"
:before-close="handleDataSourceDialogClose"
>
<div class="dialog-content">
<div class="form-group">
<div class="form-label">数据源类型</div>
<el-select
v-model="selectedDataSourceType"
placeholder="请选择数据源类型"
style="width: 100%"
@change="handleDataSourceTypeChange"
>
<el-option label="JSON数据" value="json" />
<el-option label="SQL查询数据" value="sql" />
<el-option label="接口查询数据" value="api" />
</el-select>
</div>
<!-- JSON数据配置 -->
<div v-if="selectedDataSourceType === 'json'" class="form-group">
<div class="form-label">JSON数据</div>
<el-input
v-model="jsonData"
type="textarea"
:rows="12"
placeholder='请输入JSON数据,例如: [{"name": "张三", "age": 25}, {"name": "李四", "age": 30}]'
style="width: 100%"
/>
<div class="form-hint">
提示:请输入有效的JSON数组格式数据
</div>
</div>
<!-- SQL数据配置 -->
<div v-if="selectedDataSourceType === 'sql'" class="form-group">
<div class="form-row">
<div class="form-item">
<div class="form-label">数据库地址</div>
<el-input
v-model="sqlConfig.host"
placeholder="例如: localhost:3306"
style="width: 100%"
/>
</div>
<div class="form-item">
<div class="form-label">数据库名称</div>
<el-input
v-model="sqlConfig.database"
placeholder="例如: test_db"
style="width: 100%"
/>
</div>
</div>
<div class="form-row">
<div class="form-item">
<div class="form-label">数据库账号</div>
<el-input
v-model="sqlConfig.username"
placeholder="请输入数据库账号"
style="width: 100%"
/>
</div>
<div class="form-item">
<div class="form-label">数据库密码</div>
<el-input
v-model="sqlConfig.password"
type="password"
placeholder="请输入数据库密码"
style="width: 100%"
/>
</div>
</div>
<div class="form-group">
<div class="form-label">SQL查询语句</div>
<el-input
v-model="sqlConfig.sql"
type="textarea"
:rows="4"
placeholder="请输入SQL查询语句,例如: SELECT * FROM users WHERE status = 1"
style="width: 100%"
/>
</div>
</div>
<!-- 接口数据配置 -->
<div v-if="selectedDataSourceType === 'api'" class="form-group">
<div class="form-group">
<div class="form-label">接口地址</div>
<el-input
v-model="apiConfig.url"
placeholder="请输入接口URL地址"
style="width: 100%"
>
<template #prepend>HTTP</template>
</el-input>
</div>
<div class="form-row">
<div class="form-item">
<div class="form-label">请求方法</div>
<el-select v-model="apiConfig.method" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</div>
<div class="form-item">
<div class="form-label">请求超时(秒)</div>
<el-input-number
v-model="apiConfig.timeout"
:min="5"
:max="60"
style="width: 100%"
/>
</div>
</div>
<div class="form-group">
<div class="form-label">请求头</div>
<el-input
v-model="apiConfig.headers"
type="textarea"
:rows="3"
placeholder='请输入请求头JSON,例如: {"Content-Type": "application/json", "Authorization": "Bearer token"}'
style="width: 100%"
/>
</div>
<div class="form-group" v-if="apiConfig.method === 'POST' || apiConfig.method === 'PUT'">
<div class="form-label">请求参数</div>
<el-input
v-model="apiConfig.body"
type="textarea"
:rows="3"
placeholder='请输入请求参数JSON,例如: {"page": 1, "size": 20}'
style="width: 100%"
/>
</div>
</div>
<!-- 数据预览 -->
<div class="form-group" v-if="previewData.length > 0">
<div class="form-label">数据预览 (前5行)</div>
<div class="data-preview">
<el-table
:data="previewData"
border
size="small"
style="width: 100%"
max-height="200"
>
<el-table-column
v-for="key in Object.keys(previewData[0] || {})"
:key="key"
:prop="key"
:label="key"
min-width="100"
/>
</el-table>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<!-- JSON数据源显示验证按钮 -->
<el-button
v-if="selectedDataSourceType === 'json'"
@click="validateJsonData"
:loading="validatingJson"
:disabled="!jsonData.trim()"
>
{{ validatingJson ? '验证中...' : '验证数据' }}
</el-button>
<el-button
v-if="selectedDataSourceType === 'sql'"
@click="testSqlConnection"
:loading="testingConnection"
:disabled="!sqlConfig.host || !sqlConfig.database "
>
{{ testingConnection ? '测试中...' : '连接测试' }}
</el-button>
<el-button
v-if="selectedDataSourceType === 'api'"
@click="testApiConnection"
:loading="testingApi"
:disabled="!apiConfig.url"
>
{{ testingApi ? '测试中...' : '接口测试' }}
</el-button>
<el-button @click="dataSourceDialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="saveDataSource"
:loading="savingDataSource"
:disabled="!isDataSourceValid || (selectedDataSourceType === 'json' && previewData.length === 0)"
>
{{ savingDataSource ? '保存中...' : '保存' }}
</el-button>
</span>
</template>
</el-dialog>
</ele-page>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, nextTick, computed ,watch} from 'vue'
import QRCode from 'qrcode';
import { ElMessage } from 'element-plus';
// 数据
const data = ref([
{
"tenantId": 2000017,
"createBy": "lq3",
"createTime": "2025-10-30 14:05:26",
"updateBy": "lq3",
"updateTime": "2025-10-30 14:05:44",
"isDeleted": null,
"inOrderDetailId": 66,
"inOrderId": 54,
"materialId": 11,
"materialName": "5",
"materialSpecification": "",
"supplierId": 2,
"supplierName": "供应商A",
"batchNumber": "P2025103000017",
"productionDate": "2025-10-30",
"expiryDate": "2025-11-09",
"inputQuantity": 5.0000,
"inputUnit": "支",
"quantity": 5.0000,
"unitPrice": 1.0000,
"amount": 5.0000,
"inOrderNumber": "RKD-20251030-00018",
"applicantName": "lq3",
"reviewerName": "lq3",
"applyTime": "2025-10-30 14:05:26",
"warehouseId": 7,
"prefix": null,
"lockKey": null,
"img":"https://cdn.eleadmin.com/20200610/avatar.jpg"
},
{
"tenantId": 2000017,
"createBy": "lq3",
"createTime": "2025-11-13 14:03:43",
"updateBy": "lq3",
"updateTime": "2025-11-13 14:54:15",
"isDeleted": null,
"inOrderDetailId": 73,
"inOrderId": 59,
"materialId": 14,
"materialName": "笔记本",
"materialSpecification": "",
"supplierId": 2,
"supplierName": "供应商B",
"batchNumber": null,
"productionDate": "2025-11-13",
"expiryDate": "2026-11-08",
"inputQuantity": 1000.0000,
"inputUnit": "个",
"quantity": 1000.0000,
"unitPrice": 29.0000,
"amount": 29000.0000,
"inOrderNumber": "RKD-20251113-00001",
"applicantName": "lq3",
"reviewerName": "",
"applyTime": "2025-11-13 14:03:43",
"warehouseId": 7,
"prefix": null,
"lockKey": null,
"img":"http://192.168.0.243:8022/group1/M00/00/00/wKgA82kcAsOAd4MdAABQoHY25rw105.png"
},
{
"tenantId": 2000017,
"createBy": "lq3",
"createTime": "2025-11-13 14:03:43",
"updateBy": "lq3",
"updateTime": "2025-11-13 14:54:15",
"isDeleted": null,
"inOrderDetailId": 74,
"inOrderId": 59,
"materialId": 15,
"materialName": "水泥",
"materialSpecification": "",
"supplierId": 2,
"supplierName": "供应商C",
"batchNumber": null,
"productionDate": "2025-11-13",
"expiryDate": "2026-11-08",
"inputQuantity": 110.0000,
"inputUnit": "个",
"quantity": 110.0000,
"unitPrice": 80.0000,
"amount": 8800.0000,
"inOrderNumber": "RKD-20251113-00001",
"applicantName": "lq3",
"reviewerName": "",
"applyTime": "2025-11-13 14:03:43",
"warehouseId": 7,
"prefix": null,
"lockKey": null
}
])
// 报表元素 - 使用分组标记结构
const reportElements = reactive([
{
"id": 0, // 使用 0 作为纸张配置的ID
"name": "纸张配置",
"type": "paper-config",
"paperSize": {
"width": 794,
"height": 1123,
"name": "A4"
}
},
{
"id": 1,
"name": "订单分组",
"type": "group-marker",
"x": 0,
"y": 0,
"width": 790,
"height": 400,
"fontFamily": "微软雅黑",
"fontSize": 12,
"bold": false,
"italic": false,
"underline": false,
"textAlign": "left",
"borderStyle": "none",
"groupField": "inOrderId",
"groupsPerPage": 1
},
{
"id": 2,
"name": "报表标题",
"content": "入库明细报表",
"type": "static-text",
"x": 336,
"y": 39,
"width": 200,
"height": 30,
"fontFamily": "微软雅黑",
"fontSize": 16,
"bold": true,
"italic": false,
"underline": false,
"textAlign": "center",
"borderStyle": "none",
"groupId": 1,
"color": "#000000",
"formatType": "text"
},
{
"id": 3,
"name": "订单编号",
"content": "",
"type": "field-text",
"dataField": "inOrderNumber",
"x": 362,
"y": 88,
"width": 144,
"height": 20,
"fontFamily": "微软雅黑",
"fontSize": 12,
"bold": true,
"italic": false,
"underline": false,
"textAlign": "left",
"borderStyle": "none",
"groupId": 1,
"color": "#000000",
"formatType": "text"
},
{
"id": 4,
"name": "数据表格",
"content": "入库明细表",
"type": "data-table",
"x": 0,
"y": 124,
"width": 790,
"height": 200,
"fontFamily": "宋体",
"fontSize": 12,
"bold": false,
"italic": false,
"underline": false,
"textAlign": "left",
"borderStyle": "none",
"groupId": 1,
"headerFontFamily": "微软雅黑",
"headerColor": "#000000",
"tableConfig": {
"columns": [
{
"title": "序号",
"field": "inOrderDetailId",
"fontFamily": "宋体",
"color": "#000000"
},
{
"title": "物资名称",
"field": "materialName",
"fontFamily": "宋体",
"color": "#000000"
},
{
"title": "规格型号",
"field": "materialSpecification",
"fontFamily": "宋体",
"color": "#000000"
},
{
"title": "有效期",
"field": "expiryDate",
"fontFamily": "宋体",
"color": "#000000"
},
{
"title": "数量",
"field": "inputQuantity",
"fontFamily": "宋体",
"color": "#000000"
},
{
"title": "单位",
"field": "inputUnit",
"fontFamily": "宋体",
"color": "#000000"
}
]
}
},
{
"id": 5,
"name": "分割线",
"type": "line",
"x": 0,
"y": 350,
"width": 790,
"height": 2,
"lineColor": "#000000",
"lineStyle": "solid",
"lineWidth": 1,
"lineDirection": "horizontal"
},
{
"id": 6,
"name": "边框矩形",
"type": "rectangle",
"x": 0,
"y": 284,
"width": 790,
"height": 20,
"backgroundColor": "rgba(15, 150, 78, 0.98)",
"borderColor": "#000000",
"borderStyle": "solid",
"borderWidth": 1
},
{
"id": 7,
"name": "金额统计",
"type": "statistics",
"x": 639,
"y": 360,
"width": 150,
"height": 40,
"dataField": "amount",
"statisticsType": "sum",
"color": "#000000"
}
]);
// 纸张大小配置
const paperSizes = [
{ name: 'A4', width: 794, height: 1123 },
{ name: 'A3', width: 1123, height: 1587 },
{ name: 'A5', width: 559, height: 794 },
{ name: 'B5', width: 693, height: 984 },
{ name: '信纸', width: 756, height: 1056 },
{ name: '自定义', width: 794, height: 1123 }
];
// 响应式数据
const activeTab = ref('properties')
const statusMessage = ref('就绪')
const paperRef = ref()
const selectedElement = ref<any>(null)
const resizingElement = ref<any>(null)
const isZoomMode = ref(false)
const zoom = ref(1)
const printHtml = ref();
const printing = ref(false);
// 预览相关响应式数据
const isPreviewFullscreen = ref(false);
// 对话框状态
const newReportDialogVisible = ref(false);
const previewDialogVisible = ref(false);
const settingsDialogVisible = ref(false);
const previewHtml = ref('');
// 纸张大小
const paperSize = reactive({
width: 794,
height: 1123
});
const selectedPaperSize = ref('A4');
const customPaperSize = reactive({
width: 794,
height: 1123
});
// 表格列拖拽相关
const draggingColumnIndex = ref(-1);
// 计算属性
const availableFields = computed(() => {
if (data.value.length === 0) return [];
const sample = data.value[0];
return Object.keys(sample).map(key => ({
value: key,
label: key
}));
});
const availableGroups = computed(() => {
return reportElements.filter(element => element.type === 'group-marker');
});
// 按id排序的报表元素
const sortedReportElements = computed(() => {
return [...reportElements].filter(element => element.type !== 'paper-config').sort((a, b) => a.id - b.id);
});
// 右键菜单
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
element: null as any
})
// 缩放相关
const zoomStep = 0.1
const minZoom = 0.3
const maxZoom = 3
// 报表组件列表
const reportComponents = reactive([
{ id: 1, name: '分组标记', icon: '📦', type: 'group-marker' },
{ id: 2, name: '数据表格', icon: '📊', type: 'data-table' },
{ id: 3, name: '字段文本框', icon: '🔢', type: 'field-text' },
{ id: 4, name: '静态文本框', icon: '📝', type: 'static-text' },
/*{ id: 5, name: '日期字段', icon: '📅', type: 'date' },*/
{ id: 6, name: '静态图片', icon: '🖼️', type: 'image' },
{ id: 7, name: '字段图片', icon: '📷', type: 'field-image' }, // 新增字段图片组件
{ id: 8, name: '矩形框', icon: '🔲', type: 'rectangle' },
{ id: 9, name: '线条', icon: '➖', type: 'line' },
{ id: 10, name: '静态二维码', icon: '📱', type: 'qr-code'},
{ id: 11, name: '字段二维码', icon: '🔗', type: 'field-qrcode' }, // 新增字段二维码组件
{ id: 12, name: '统计组件', icon: '📈', type: 'statistics' } // 新增统计组件
])
const dataSourceDialogVisible = ref(false);
const selectedDataSourceType = ref('json');
const savingDataSource = ref(false);
const testingConnection = ref(false);
const testingApi = ref(false);
const validatingJson = ref(false);
// JSON数据配置
const jsonData = ref('');
// SQL配置
const sqlConfig = reactive({
host: '',
database: '',
username: '',
password: '',
sql: ''
});
// 接口配置
const apiConfig = reactive({
url: '',
method: 'GET',
timeout: 30,
headers: '',
body: ''
});
// 数据预览
const previewData = ref([]);
// 拖拽相关状态
let isDragging = false
let dragOffsetX = 0
let dragOffsetY = 0
// 缩放相关状态
let isResizing = false
let resizeDirection = ''
let startWidth = 0
let startHeight = 0
let startX = 0
let startY = 0
let startMouseX = 0
let startMouseY = 0
// 新建报表功能
const showNewReportDialog = () => {
selectedPaperSize.value = 'A4';
customPaperSize.width = 794;
customPaperSize.height = 1123;
newReportDialogVisible.value = true;
}
// 纸张配置相关
const paperConfig = computed(() => {
return reportElements.find(el => el.type === 'paper-config') || {
paperSize: { width: 794, height: 1123, name: 'A4' }
};
});
// 更新纸张大小的方法
const updatePaperSize = (width, height, name) => {
let paperConfigElement = reportElements.find(el => el.type === 'paper-config');
if (!paperConfigElement) {
// 如果没有纸张配置元素,创建一个
paperConfigElement = {
id: 0,
name: "纸张配置",
type: "paper-config",
paperSize: { width, height, name }
};
reportElements.unshift(paperConfigElement);
} else {
paperConfigElement.paperSize = { width, height, name };
}
// 同时更新 paperSize 响应式对象
paperSize.width = width;
paperSize.height = height;
statusMessage.value = `纸张大小已更新: ${name} (${width}×${height}px)`;
};
const handleNewReportDialogClose = () => {
newReportDialogVisible.value = false;
}
const getPreviewPaperSize = () => {
if (selectedPaperSize.value === '自定义') {
return customPaperSize;
} else {
const size = paperSizes.find(s => s.name === selectedPaperSize.value);
return size || { width: 794, height: 1123 };
}
}
const confirmNewReport = () => {
const newSize = getPreviewPaperSize();
// 使用新的更新方法
updatePaperSize(newSize.width, newSize.height, selectedPaperSize.value);
// 清空现有元素(保留纸张配置)
const paperConfig = reportElements.find(el => el.type === 'paper-config');
reportElements.length = 0;
if (paperConfig) {
reportElements.push(paperConfig);
}
selectedElement.value = null;
newReportDialogVisible.value = false;
statusMessage.value = `已创建新报表 (${selectedPaperSize.value}: ${newSize.width}×${newSize.height}px)`;
ElMessage.success('已创建新报表');
}
const printReportFromPreview = () => {
printHtml.value = previewHtml.value;
printing.value = true;
previewDialogVisible.value = false;
}
// 计算属性:验证数据源是否有效
const isDataSourceValid = computed(() => {
switch (selectedDataSourceType.value) {
case 'json':
return jsonData.value.trim() !== '';
case 'sql':
return sqlConfig.host && sqlConfig.database && sqlConfig.username && sqlConfig.sql;
case 'api':
return apiConfig.url;
default:
return false;
}
});
// 方法:显示数据源对话框
const showDtaSource = () => {
dataSourceDialogVisible.value = true;
// 重置表单
selectedDataSourceType.value = 'json';
};
// 方法:处理数据源类型变化
const handleDataSourceTypeChange = () => {
previewData.value = [];
};
// 方法:处理数据源对话框关闭
const handleDataSourceDialogClose = () => {
dataSourceDialogVisible.value = false;
};
// 方法:测试SQL连接
const testSqlConnection = async () => {
testingConnection.value = true;
try {
// 这里应该是实际的SQL连接测试逻辑
// 模拟测试
await new Promise(resolve => setTimeout(resolve, 1000));
const mockData = data.value.slice(0, 3).map(item => ({ ...item }));
previewData.value = mockData;
} catch (error) {
ElMessage.error('数据库连接失败:' + error.message);
} finally {
testingConnection.value = false;
}
};
// 方法:测试接口连接
const testApiConnection = async () => {
testingApi.value = true;
try {
// 解析请求头
let headers = {};
if (apiConfig.headers) {
try {
headers = JSON.parse(apiConfig.headers);
} catch (e) {
throw new Error('请求头格式错误,必须是有效的JSON');
}
}
// 解析请求体
let body = null;
if (apiConfig.body && (apiConfig.method === 'POST' || apiConfig.method === 'PUT')) {
try {
body = JSON.parse(apiConfig.body);
} catch (e) {
throw new Error('请求参数格式错误,必须是有效的JSON');
}
}
// 模拟API请求
await new Promise(resolve => setTimeout(resolve, 1000));
// 使用模拟数据作为预览
const mockData = data.value.slice(0, 3).map(item => ({ ...item }));
previewData.value = mockData;
ElMessage.success('接口请求成功!');
} catch (error) {
ElMessage.error('接口请求失败:' + error.message);
previewData.value = [];
} finally {
testingApi.value = false;
}
};
// 方法:验证JSON数据
const validateJsonData = () => {
validatingJson.value = true;
try {
// 验证并解析JSON数据
const parsedData = JSON.parse(jsonData.value);
if (!Array.isArray(parsedData)) {
throw new Error('JSON数据必须是数组格式');
}
// 显示数据预览
previewData.value = parsedData.slice(0, 5);
ElMessage.success(`JSON数据验证成功!共${parsedData.length}条数据`);
} catch (error) {
ElMessage.error('JSON格式错误:' + error.message);
previewData.value = [];
} finally {
validatingJson.value = false;
}
};
// 方法:保存数据源
const saveDataSource = async () => {
// 如果是JSON数据源且没有预览数据,先验证数据
if (selectedDataSourceType.value === 'json' && previewData.value.length === 0) {
ElMessage.warning('请先验证JSON数据');
return;
}
savingDataSource.value = true;
try {
let newData = [];
switch (selectedDataSourceType.value) {
case 'json':
// 使用已验证的数据
try {
const parsedData = JSON.parse(jsonData.value);
if (!Array.isArray(parsedData)) {
throw new Error('JSON数据必须是数组格式');
}
newData = parsedData;
} catch (error) {
throw new Error('JSON格式错误:' + error.message);
}
break;
case 'sql':
// ... SQL数据源处理逻辑保持不变 ...
newData = [...data.value];
ElMessage.success('SQL数据源配置已保存');
break;
case 'api':
// ... API数据源处理逻辑保持不变 ...
newData = [...data.value];
ElMessage.success('接口数据源配置已保存');
break;
}
// 更新全局数据
if (newData.length > 0) {
// 在实际应用中更新数据
data.value = newData;
ElMessage.success(`数据源已更新,共${newData.length}条数据`);
}
dataSourceDialogVisible.value = false;
} catch (error) {
ElMessage.error('保存数据源失败:' + error.message);
} finally {
savingDataSource.value = false;
}
};
// 设置功能
const showSettingsDialog = () => {
settingsDialogVisible.value = true;
}
const handleSettingsDialogClose = () => {
settingsDialogVisible.value = false;
}
const confirmSettings = () => {
settingsDialogVisible.value = false;
statusMessage.value = `页面大小已更新: ${paperSize.width}×${paperSize.height}px`;
ElMessage.success('页面设置已保存');
}
// 原有的 newReport 方法改为直接清空
const newReport = () => {
reportElements.length = 0
selectedElement.value = null
statusMessage.value = '已创建新报表'
}
// 原有的 previewReport 方法改为调用预览对话框
const previewReport = () => {
showPreviewDialog();
}
// ... 其余所有原有方法保持不变(包括 saveReport, printReport, 表格列拖拽方法,统计组件方法等)...
// 表格列拖拽方法
const onColumnDragStart = (event: DragEvent, index: number) => {
draggingColumnIndex.value = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
const onColumnDragOver = (event: DragEvent, index: number) => {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
const onColumnDrop = (event: DragEvent, targetIndex: number) => {
event.preventDefault();
if (draggingColumnIndex.value === -1 || draggingColumnIndex.value === targetIndex) {
return;
}
if (selectedElement.value && selectedElement.value.type === 'data-table') {
const columns = selectedElement.value.tableConfig.columns;
// 移动列
const draggedColumn = columns[draggingColumnIndex.value];
columns.splice(draggingColumnIndex.value, 1);
columns.splice(targetIndex, 0, draggedColumn);
updateElement();
statusMessage.value = '列顺序已更新';
}
draggingColumnIndex.value = -1;
}
// 统计组件相关方法
// 验证统计字段是否为数字
const validateStatisticsField = () => {
if (!selectedElement.value.dataField) return;
const field = selectedElement.value.dataField;
const sampleValue = data.value[0]?.[field];
if (sampleValue !== undefined && isNaN(Number(sampleValue))) {
ElMessage.error(`字段 "${field}" 的值不是数字,无法进行统计`);
selectedElement.value.dataField = '';
return;
}
updateElement();
}
// 获取统计内容
const getStatisticsContent = (element: any) => {
if (!element.dataField) return '请配置统计字段';
let targetData = data.value;
// 如果有关联分组,只统计当前分组的数据
if (element.groupId) {
const groupMarker = reportElements.find(el => el.id === element.groupId);
if (groupMarker && groupMarker.groupField) {
// 获取分组数据
const groupedData = getGroupedData(groupMarker);
if (groupedData.length > 0) {
// 在预览时,使用第一组数据来显示效果
targetData = groupedData[0].data;
} else {
return '无分组数据';
}
}
}
const values = targetData.map(item => Number(item[element.dataField])).filter(val => !isNaN(val));
if (values.length === 0) return '无有效数据';
let result;
switch (element.statisticsType) {
case 'sum':
result = values.reduce((a, b) => a + b, 0);
break;
case 'average':
result = (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2);
break;
case 'max':
result = Math.max(...values);
break;
case 'min':
result = Math.min(...values);
break;
case 'count':
result = values.length;
break;
default:
return '';
}
return `${result}`;
}
// 数字转大写汉字金额
const numberToChineseAmount = (num: number): string => {
const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const units = ['', '拾', '佰', '仟'];
const bigUnits = ['', '万', '亿'];
// 处理小数部分
let integerPart = Math.floor(num);
let decimalPart = Math.round((num - integerPart) * 100);
// 处理整数部分
let integerStr = '';
if (integerPart === 0) {
integerStr = '零';
} else {
let tempStr = '';
let unitIndex = 0;
let bigUnitIndex = 0;
while (integerPart > 0) {
const digit = integerPart % 10;
if (digit === 0) {
if (tempStr && !tempStr.startsWith('零')) {
tempStr = digits[0] + tempStr;
}
} else {
tempStr = digits[digit] + units[unitIndex] + tempStr;
}
integerPart = Math.floor(integerPart / 10);
unitIndex++;
if (unitIndex === 4) {
if (tempStr.startsWith('零')) {
tempStr = tempStr.substring(1);
}
integerStr = tempStr + bigUnits[bigUnitIndex] + integerStr;
tempStr = '';
unitIndex = 0;
bigUnitIndex++;
}
}
if (tempStr) {
integerStr = tempStr + bigUnits[bigUnitIndex] + integerStr;
}
}
// 处理小数部分(角和分)
let decimalStr = '';
if (decimalPart > 0) {
const jiao = Math.floor(decimalPart / 10);
const fen = decimalPart % 10;
if (jiao > 0) {
decimalStr += digits[jiao] + '角';
}
if (fen > 0) {
decimalStr += digits[fen] + '分';
}
} else {
decimalStr = '整';
}
return integerStr + '元' + decimalStr;
}
// 根据类型格式化值
const formatValueByType = (value: any, formatType: string, options: any = {}) => {
if (value === undefined || value === null) return '';
switch (formatType) {
case 'number':
const decimalPlaces = options.decimalPlaces !== undefined ? options.decimalPlaces : 2;
const numValue = Number(value);
return isNaN(numValue) ? value : numValue.toFixed(decimalPlaces);
case 'currency':
let currencyValue = Number(value);
if (isNaN(currencyValue)) return value;
// 大写汉字金额
if (options.useChineseAmount) {
return numberToChineseAmount(currencyValue);
}
// 千分位分隔
if (options.useThousandSeparator) {
return `¥${currencyValue.toLocaleString()}`;
}
return `¥${currencyValue}`;
case 'date':
// 日期格式化 - 使用自定义格式
const date = new Date(value);
if (isNaN(date.getTime())) return value;
let format = options.dateFormat || 'yyyy-MM-dd';
// 替换格式字符串
return format
.replace('yyyy', date.getFullYear().toString())
.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0'))
.replace('dd', date.getDate().toString().padStart(2, '0'))
.replace('HH', date.getHours().toString().padStart(2, '0'))
.replace('mm', date.getMinutes().toString().padStart(2, '0'))
.replace('ss', date.getSeconds().toString().padStart(2, '0'));
default:
return value;
}
}
// 格式化表格单元格内容
const formatTableCell = (value, column) => {
if (value === undefined || value === null) return '';
// 如果列有格式设置,按照列格式格式化
if (column.formatType) {
return formatValueByType(value, column.formatType, column);
}
return value;
}
const saveReport = () => {
console.log(reportElements)
console.log(data.value)
statusMessage.value = '报表已保存'
}
const printReport = async () => {
try {
statusMessage.value = '正在生成打印内容...';
const paperContent = await generatePrintContent();
printHtml.value = paperContent;
console.log(reportElements);
console.log(paperContent);
statusMessage.value = '正在打印报表...';
printing.value = true;
} catch (error) {
console.error('生成打印内容失败:', error);
statusMessage.value = '生成打印内容失败';
}
}
// 新增:带参数的打印函数
const printReportByParam = async (customData, customElements, options = {}) => {
try {
// 保存当前状态
const originalData = [...data.value];
const originalElements = [...reportElements];
// 使用传入的参数
data.value = customData || data.value;
if (customElements && Array.isArray(customElements)) {
// 清空现有元素
reportElements.length = 0;
// 如果有纸张配置元素,优先添加
const paperConfigElement = customElements.find(el => el.type === 'paper-config');
if (paperConfigElement) {
reportElements.push(paperConfigElement);
// 更新纸张大小
if (paperConfigElement.paperSize) {
paperSize.width = paperConfigElement.paperSize.width;
paperSize.height = paperConfigElement.paperSize.height;
}
} else {
// 如果没有纸张配置,添加一个默认的
reportElements.unshift({
id: 0,
name: "纸张配置",
type: "paper-config",
paperSize: {
width: paperSize.width,
height: paperSize.height,
name: 'A4'
}
});
}
// 添加其他元素
const otherElements = customElements.filter(el => el.type !== 'paper-config');
reportElements.push(...otherElements);
}
// 设置状态消息
statusMessage.value = '正在生成打印内容...';
// 生成打印内容
const paperContent = await generatePrintContent();
printHtml.value = paperContent;
// 可选:打印前预览
if (options.showPreview) {
previewHtml.value = paperContent;
previewDialogVisible.value = true;
statusMessage.value = '打印预览已打开';
} else {
// 直接打印
statusMessage.value = '正在打印报表...';
printing.value = true;
}
// 打印完成后恢复原始数据
const restoreData = () => {
data.value = originalData;
reportElements.length = 0;
reportElements.push(...originalElements);
};
// 监听打印完成事件
if (options.autoRestore !== false) {
// 通过监听 printing 值变化来恢复数据
watchOnce(printing, (newVal) => {
if (!newVal) {
restoreData();
statusMessage.value = '打印完成,数据已恢复';
}
});
}
return paperContent;
} catch (error) {
console.error('生成打印内容失败:', error);
statusMessage.value = '生成打印内容失败';
ElMessage.error('打印失败: ' + error.message);
// 恢复原始数据
data.value = originalData;
reportElements.length = 0;
reportElements.push(...originalElements);
throw error;
}
}
// 生成分页打印内容
const generatePrintContent = async () => {
let content = '';
// 首先处理独立元素(没有分组的元素)
const independentElements = reportElements.filter(el => !el.groupId && el.type !== 'group-marker');
// 处理所有二维码、字段二维码、图片和字段图片元素
const specialElements = independentElements.filter(el =>
el.type === 'qr-code' ||
el.type === 'field-qrcode'
);
const specialElementPromises = specialElements.map(async (element) => {
const elementDiv = document.createElement('div');
elementDiv.className = 'print-element';
elementDiv.style.cssText = `
position: absolute;
left: ${element.x}px;
top: ${element.y}px;
width: ${element.width}px;
height: ${element.height}px;
z-index: ${element.id};
`;
if (element.type === 'qr-code') {
elementDiv.innerHTML = await generateQRCodeHTML(element);
} else if (element.type === 'field-qrcode') {
const qrContent = getFieldQRCodeContent(element);
if (qrContent) {
elementDiv.innerHTML = await generateQRCodeHTML(element, qrContent);
} else {
elementDiv.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">无数据</div>';
}
}
return elementDiv.outerHTML;
});
// 等待所有特殊元素生成完成
const specialElementContents = await Promise.all(specialElementPromises);
content += specialElementContents.join('');
// 处理其他非特殊独立元素
const otherElements = independentElements.filter(el =>
el.type !== 'qr-code' &&
el.type !== 'field-qrcode'
);
otherElements.forEach(element => {
const elementDiv = document.createElement('div');
elementDiv.className = 'print-element';
elementDiv.style.cssText = `
position: absolute;
left: ${element.x}px;
top: ${element.y}px;
width: ${element.width}px;
height: ${element.height}px;
font-family: ${element.fontFamily};
font-size: ${element.fontSize}px;
font-weight: ${element.bold ? 'bold' : 'normal'};
font-style: ${element.italic ? 'italic' : 'normal'};
text-decoration: ${element.underline ? 'underline' : 'none'};
text-align: ${element.textAlign};
color: ${element.color || '#000000'};
z-index: ${element.id};
`;
if (element.type === 'data-table') {
const tableData = getTableData(element);
elementDiv.innerHTML = generateTableHTML(element, tableData);
} else if (element.type === 'field-text') {
// 应用格式设置
elementDiv.textContent = getFormattedTextForPrint(element);
} else if (element.type === 'static-text') {
// 应用格式设置
elementDiv.textContent = getFormattedTextForPrint(element);
} else if (element.type === 'line') {
elementDiv.innerHTML = generateLineHTML(element);
} else if (element.type === 'rectangle') {
elementDiv.innerHTML = generateRectangleHTML(element);
} else if (element.type === 'image') {
if (element.imageUrl || element.imageBase64) {
elementDiv.innerHTML = `<img src="${element.imageBase64 || element.imageUrl}" style="width: 100%; height: 100%; object-fit: ${element.objectFit || 'contain'};" />`;
} else {
elementDiv.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">无图片</div>';
}
} else if (element.type === 'field-image') {
const imageContent = getFieldImageContent(element);
if (imageContent) {
elementDiv.innerHTML = `<img src="${imageContent}" style="width: 100%; height: 100%; object-fit: ${element.objectFit || 'contain'};" />`;
} else {
elementDiv.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">无数据</div>';
}
} else if (element.type === 'statistics') {
// 独立统计组件 - 统计所有数据
const statisticsData = getIndependentStatisticsData(element);
elementDiv.innerHTML = generateStatisticsHTML(element,statisticsData);
}else {
elementDiv.textContent = element.content || '';
}
content += elementDiv.outerHTML;
});
// 处理分组标记
const groupMarkers = reportElements.filter(el => el.type === 'group-marker');
for (const groupMarker of groupMarkers) {
const groupedData = getGroupedData(groupMarker);
const groupsPerPage = groupMarker.groupsPerPage || 1;
const totalPages = Math.ceil(groupedData.length / groupsPerPage);
for (let page = 0; page < totalPages; page++) {
const startIndex = page * groupsPerPage;
const endIndex = Math.min(startIndex + groupsPerPage, groupedData.length);
const pageGroups = groupedData.slice(startIndex, endIndex);
for (const group of pageGroups) {
const groupElements = reportElements.filter(el => el.groupId === groupMarker.id);
const groupDiv = document.createElement('div');
groupDiv.className = 'print-group';
groupDiv.style.cssText = `
position: relative;
width: ${groupMarker.width}px;
height: ${groupMarker.height}px;
margin-bottom: 20px;
page-break-inside: avoid;
`;
// 处理分组中的特殊元素(二维码、图片等)
const groupSpecialElements = groupElements.filter(el =>
el.type === 'qr-code' ||
el.type === 'field-qrcode'
);
const groupSpecialPromises = groupSpecialElements.map(async (child) => {
const childDiv = document.createElement('div');
childDiv.className = 'print-child';
childDiv.style.cssText = `
position: absolute;
left: ${child.x}px;
top: ${child.y}px;
width: ${child.width}px;
height: ${child.height}px;
z-index: ${child.id};
`;
if (child.type === 'qr-code') {
childDiv.innerHTML = await generateQRCodeHTML(child);
} else if (child.type === 'field-qrcode') {
const fieldData = getGroupFieldData(child, group);
const qrContent = fieldData[child.dataField] || '';
if (qrContent) {
childDiv.innerHTML = await generateQRCodeHTML(child, qrContent);
} else {
childDiv.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">无数据</div>';
}
}
return childDiv.outerHTML;
});
const groupSpecialContents = await Promise.all(groupSpecialPromises);
groupDiv.innerHTML = groupSpecialContents.join('');
// 处理分组中的其他元素
const otherGroupElements = groupElements.filter(el =>
el.type !== 'qr-code' &&
el.type !== 'field-qrcode'
);
otherGroupElements.forEach(child => {
const childDiv = document.createElement('div');
childDiv.className = 'print-child';
childDiv.style.cssText = `
position: absolute;
left: ${child.x}px;
top: ${child.y}px;
width: ${child.width}px;
height: ${child.height}px;
font-family: ${child.fontFamily};
font-size: ${child.fontSize}px;
font-weight: ${child.bold ? 'bold' : 'normal'};
font-style: ${child.italic ? 'italic' : 'normal'};
text-decoration: ${child.underline ? 'underline' : 'none'};
text-align: ${child.textAlign};
color: ${child.color || '#000000'};
z-index: ${child.id};
`;
if (child.type === 'data-table') {
const tableData = getGroupTableData(child, group);
childDiv.innerHTML = generateTableHTML(child, tableData);
} else if (child.type === 'field-text') {
const fieldData = getGroupFieldData(child, group);
const rawValue = fieldData[child.dataField] || '';
// 应用格式设置
childDiv.textContent = formatValueByType(rawValue, child.formatType, child);
} else if (child.type === 'static-text') {
// 应用格式设置
childDiv.textContent = formatValueByType(child.content || '', child.formatType, child);
} else if (child.type === 'line') {
childDiv.innerHTML = generateLineHTML(child);
} else if (child.type === 'rectangle') {
childDiv.innerHTML = generateRectangleHTML(child);
} else if (child.type === 'image') {
if (child.imageUrl || child.imageBase64) {
childDiv.innerHTML = `<img src="${child.imageBase64 || child.imageUrl}" style="width: 100%; height: 100%; object-fit: ${child.objectFit || 'contain'};" />`;
} else {
childDiv.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">无图片</div>';
}
} else if (child.type === 'field-image') {
const fieldData = getGroupFieldData(child, group);
const imageContent = fieldData[child.dataField] || '';
if (imageContent) {
childDiv.innerHTML = `<img src="${imageContent}" style="width: 100%; height: 100%; object-fit: ${child.objectFit || 'contain'};" />`;
} else {
childDiv.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">无数据</div>';
}
} else if (child.type === 'statistics') {
// 获取分组内的统计数据
const statisticsData = getGroupStatisticsData(child, group);
childDiv.innerHTML = generateStatisticsHTML(child, statisticsData);;
} else {
childDiv.textContent = child.content || '';
}
groupDiv.innerHTML += childDiv.outerHTML;
});
content += groupDiv.outerHTML;
}
if (page < totalPages - 1) {
content += '<div style="page-break-after: always;"></div>';
}
}
}
return content;
}
// 生成统计组件的 HTML 内容(优化版)
const generateStatisticsHTML = (element: any, statisticsData: string) => {
const {
textAlign = 'center',
fontSize = 14,
fontFamily = 'inherit',
bold = false,
italic = false,
underline = false,
color = '#000000'
} = element;
const styles = {
padding: '8px',
textAlign,
fontSize: `${fontSize}px`,
fontFamily,
fontWeight: bold ? 'bold' : 'normal',
fontStyle: italic ? 'italic' : 'normal',
textDecoration: underline ? 'underline' : 'none',
color,
height: 'calc(100% - 32px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
};
const styleString = Object.entries(styles)
.map(([key, value]) => `${key}: ${value};`)
.join(' ');
return `<div style="width: 100%; height: 100%; overflow: hidden;">
<div style="${styleString}">
${statisticsData}
</div>
</div>`;
}
// 获取独立统计数据(无分组)
const getIndependentStatisticsData = (element: any) => {
if (!element.dataField) return '请配置统计字段';
const values = data.value.map(item => Number(item[element.dataField])).filter(val => !isNaN(val));
if (values.length === 0) return '无有效数据';
let result;
switch (element.statisticsType) {
case 'sum':
result = values.reduce((a, b) => a + b, 0);
break;
case 'average':
result = (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2);
break;
case 'max':
result = Math.max(...values);
break;
case 'min':
result = Math.min(...values);
break;
case 'count':
result = values.length;
break;
default:
return '';
}
return `${result}`;
}
// 获取分组统计数据
const getGroupStatisticsData = (element: any, groupData: any) => {
if (!element.dataField) return '请配置统计字段';
const targetData = groupData?.data || [];
const values = targetData.map(item => Number(item[element.dataField])).filter(val => !isNaN(val));
if (values.length === 0) return '无有效数据';
let result;
switch (element.statisticsType) {
case 'sum':
result = values.reduce((a, b) => a + b, 0);
break;
case 'average':
result = (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2);
break;
case 'max':
result = Math.max(...values);
break;
case 'min':
result = Math.min(...values);
break;
case 'count':
result = values.length;
break;
default:
return '';
}
return `${result}`;
}
// 获取格式化文本内容用于打印
const getFormattedTextForPrint = (element: any) => {
let value = '';
if (element.type === 'static-text') {
value = element.content || '';
} else if (element.type === 'field-text') {
if (!element.dataField || data.value.length === 0) {
value = element.content || '';
} else {
value = data.value[0][element.dataField] || '';
}
}
if (element.formatType) {
return formatValueByType(value, element.formatType, element);
}
return value;
}
// 生成线条HTML
const generateLineHTML = (element: any) => {
let borderStyle = '';
let transform = '';
let actualWidth = element.width;
let actualHeight = element.height;
switch (element.lineDirection) {
case 'horizontal':
borderStyle = `border-top: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
actualHeight = element.lineWidth || 1;
break;
case 'vertical':
borderStyle = `border-left: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
actualWidth = element.lineWidth || 1;
break;
case 'diagonal':
// 计算对角线长度 - 勾股定理
const diagonalLength = Math.sqrt(element.width * element.width + element.height * element.height);
borderStyle = `border-top: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
transform = `transform: rotate(${Math.atan2(element.height, element.width) * 180 / Math.PI}deg);`;
actualWidth = diagonalLength;
actualHeight = element.lineWidth || 1;
break;
default:
borderStyle = `border-top: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
actualHeight = element.lineWidth || 1;
}
return `<div style="width: ${actualWidth}px; height: ${actualHeight}px; ${borderStyle}; ${transform}"></div>`;
}
// 生成矩形HTML
const generateRectangleHTML = (element: any) => {
const rectangleStyle = `
width: 100%;
height: 100%;
background-color: ${element.backgroundColor || 'rgba(255, 255, 255, 0)'};
border: ${element.borderWidth || 1}px ${element.borderStyle || 'solid'} ${element.borderColor || '#000000'};
`;
return `<div style="${rectangleStyle}"></div>`;
}
// 生成二维码HTML
const generateQRCodeHTML = async (element: any, content?: string) => {
const qrContent = content || element.content
if (!qrContent) {
return '<div style="color: #999; text-align: center; padding: 10px;">请输入二维码内容</div>'
}
try {
const svgString = await QRCode.toString(qrContent, {
type: 'svg',
width: element.width,
margin: 0,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
return svgString
} catch (error) {
console.error('生成二维码失败:', error)
return `<div style="color: #ff0000; text-align: center; padding: 10px;">生成二维码失败</div>`
}
}
// 生成表格HTML
const generateTableHTML = (element: any, tableData: any[]) => {
if (!element.tableConfig) return '';
let html = '<table style="width: 100%; border-collapse: collapse;">';
// 表头
html += '<thead><tr>';
element.tableConfig.columns.forEach((column: any) => {
const headerStyle = `
border: 1px solid #ddd;
padding: 4px 8px;
background-color: #f5f5f5;
font-family: ${element.headerFontFamily || 'inherit'};
font-size: ${element.headerFontSize || 12}px;
font-weight: ${element.headerBold ? 'bold' : 'normal'};
font-style: ${element.headerItalic ? 'italic' : 'normal'};
text-decoration: ${element.headerUnderline ? 'underline' : 'none'};
text-align: ${element.headerTextAlign || 'center'};
color: ${element.headerColor || '#000000'};
`;
html += `<th style="${headerStyle}">${column.title}</th>`;
});
html += '</tr></thead>';
// 表格内容
html += '<tbody>';
tableData.forEach((row, index) => {
html += '<tr>';
element.tableConfig.columns.forEach((column: any) => {
const rawValue = row[column.field] || '';
// 应用列格式设置
const formattedValue = column.formatType ?
formatValueByType(rawValue, column.formatType, column) :
rawValue;
const cellStyle = `
border: 1px solid #ddd;
padding: 4px 8px;
font-family: ${column.fontFamily || 'inherit'};
font-size: ${column.fontSize || 12}px;
font-weight: ${column.bold ? 'bold' : 'normal'};
font-style: ${column.italic ? 'italic' : 'normal'};
text-decoration: ${column.underline ? 'underline' : 'none'};
text-align: ${column.textAlign || 'left'};
color: ${column.color || '#000000'};
`;
html += `<td style="${cellStyle}">${formattedValue}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
// 获取分组数据
const getGroupedData = (groupMarker: any) => {
if (!groupMarker.groupField) return [];
const groups: any = {};
data.value.forEach(item => {
const groupValue = item[groupMarker.groupField];
if (!groups[groupValue]) {
groups[groupValue] = {
groupValue: groupValue,
data: []
};
}
groups[groupValue].data.push(item);
});
return Object.values(groups);
}
// 获取分组表格数据
const getGroupTableData = (tableElement: any, groupData: any) => {
if (!tableElement.tableConfig || !tableElement.tableConfig.columns) return [];
return groupData.data.map((item: any) => {
const row: any = {};
tableElement.tableConfig.columns.forEach((column: any) => {
if (column.field && item.hasOwnProperty(column.field)) {
row[column.field] = item[column.field];
} else {
row[column.field] = '';
}
});
return row;
});
}
// 获取分组字段数据
const getGroupFieldData = (fieldElement: any, groupData: any) => {
return groupData?.data?.[0] || {};
}
// 获取表格数据(独立表格)
const getTableData = (element: any) => {
if (!element.tableConfig || !element.tableConfig.columns) return [];
// 如果表格关联了分组,只显示对应分组的第一组数据
if (element.groupId) {
const groupMarker = reportElements.find(el => el.id === element.groupId);
if (groupMarker && groupMarker.groupField) {
// 获取分组数据
const groupedData = getGroupedData(groupMarker);
if (groupedData.length > 0) {
// 返回第一组的数据
return groupedData[0].data.map((item: any) => {
const row: any = {};
element.tableConfig.columns.forEach((column: any) => {
if (column.field && item.hasOwnProperty(column.field)) {
row[column.field] = item[column.field];
} else {
row[column.field] = '';
}
});
return row;
});
}
}
}
// 独立表格显示所有数据
return data.value.map(item => {
const row: any = {};
element.tableConfig.columns.forEach((column: any) => {
if (column.field && item.hasOwnProperty(column.field)) {
row[column.field] = item[column.field];
} else {
row[column.field] = '';
}
});
return row;
});
}
// 获取字段文本框内容
const getFieldTextContent = (element: any) => {
if (element.type === 'static-text') {
const value = element.content || '';
if (element.formatType) {
return formatValueByType(value, element.formatType, element);
}
return value;
} else {
if (!element.dataField || data.value.length === 0) {
return element.content || '';
}
const value = data.value[0][element.dataField] || '';
if (element.formatType) {
return formatValueByType(value, element.formatType, element);
}
return value;
}
}
// 获取格式化文本内容(响应式)
const getFormattedTextContent = (element: any) => {
let value = '';
if (element.type === 'static-text') {
value = element.content || '';
} else if (element.type === 'field-text') {
if (!element.dataField || data.value.length === 0) {
value = element.content || '';
} else {
value = data.value[0][element.dataField] || '';
}
}
if (element.formatType) {
return formatValueByType(value, element.formatType, element);
}
return value;
};
// 获取字段预览值
const getFieldPreview = (field: string) => {
if (data.value.length === 0) return '';
return data.value[0][field] || '';
}
const toggleZoomMode = () => {
isZoomMode.value = !isZoomMode.value
statusMessage.value = isZoomMode.value ? '缩放模式已启用' : '缩放模式已关闭'
}
const deleteSelectedElement = () => {
if (selectedElement.value) {
deleteElement(selectedElement.value)
}
}
const selectElement = (element: any) => {
selectedElement.value = element
statusMessage.value = `已选择: ${element.name}`
hideContextMenu()
}
const clearSelection = () => {
selectedElement.value = null
statusMessage.value = '就绪'
hideContextMenu()
}
const updateElement = () => {
statusMessage.value = `已更新: ${selectedElement.value?.name}`
}
// 获取元素样式
const getElementStyle = (element: any) => {
const baseStyle = {
top: element.y + 'px',
left: element.x + 'px',
width: element.width + 'px',
height: element.height + 'px',
fontSize: (element.fontSize || 12) + 'px',
fontFamily: element.fontFamily || '微软雅黑',
fontWeight: element.bold ? 'bold' : 'normal',
fontStyle: element.italic ? 'italic' : 'normal',
textDecoration: element.underline ? 'underline' : 'none',
textAlign: element.textAlign || 'left',
borderStyle: element.borderStyle,
zIndex: element.id
};
// 分组标记特殊样式
if (element.type === 'group-marker') {
baseStyle.backgroundColor = 'rgba(52, 152, 219, 0.05)';
}
// 线条和矩形不需要背景色
if (element.type === 'line' || element.type === 'rectangle') {
baseStyle.backgroundColor = 'transparent';
}
return baseStyle;
}
// 获取线条样式
const getLineStyle = (element: any) => {
let borderStyle = '';
let transform = '';
let actualWidth = element.width;
let actualHeight = element.height;
switch (element.lineDirection) {
case 'horizontal':
borderStyle = `border-top: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
actualHeight = element.lineWidth || 1;
break;
case 'vertical':
borderStyle = `border-left: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
actualWidth = element.lineWidth || 1;
break;
case 'diagonal':
// 计算对角线长度 - 勾股定理
const diagonalLength = Math.sqrt(element.width * element.width + element.height * element.height);
borderStyle = `border-top: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
transform = `transform: rotate(${Math.atan2(element.height, element.width) * 180 / Math.PI}deg);`;
actualWidth = diagonalLength;
actualHeight = element.lineWidth || 1;
break;
default:
borderStyle = `border-top: ${element.lineWidth || 1}px ${element.lineStyle || 'solid'} ${element.lineColor || '#000000'}`;
actualHeight = element.lineWidth || 1;
}
return `
width: ${actualWidth}px;
height: ${actualHeight}px;
${borderStyle};
${transform}
`;
}
// 获取表头样式
const getTableHeaderStyle = (element: any, column: any) => {
return {
'font-family': element.headerFontFamily || 'inherit',
'font-size': (element.headerFontSize || 12) + 'px',
'font-weight': element.headerBold ? 'bold' : 'normal',
'font-style': element.headerItalic ? 'italic' : 'normal',
'text-decoration': element.headerUnderline ? 'underline' : 'none',
'text-align': element.headerTextAlign || 'center',
'color': element.headerColor || '#000000'
};
}
// 获取表格单元格样式
const getTableCellStyle = (column: any) => {
return {
'font-family': column.fontFamily || 'inherit',
'font-size': (column.fontSize || 12) + 'px',
'font-weight': column.bold ? 'bold' : 'normal',
'font-style': column.italic ? 'italic' : 'normal',
'text-decoration': column.underline ? 'underline' : 'none',
'text-align': column.textAlign || 'left',
'color': column.color || '#000000'
};
}
// 获取矩形样式
const getRectangleStyle = (element: any) => {
return `
width: 100%;
height: 100%;
background-color: ${element.backgroundColor || 'rgba(255, 255, 255, 0)'};
border: ${element.borderWidth || 1}px ${element.borderStyle || 'solid'} ${element.borderColor || '#000000'};
`;
}
// 获取字段图片内容
const getFieldImageContent = (element: any) => {
if (!element.dataField) return '';
if (element.groupId) {
const groupMarker = reportElements.find(el => el.id === element.groupId);
if (groupMarker && groupMarker.groupField) {
return data.value[0]?.[element.dataField] || '';
}
}
return data.value[0]?.[element.dataField] || '';
};
// 图片上传处理
const beforeImageUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return false
}
return true
}
const handleImageUpload = (options: any) => {
const file = options.file
const reader = new FileReader()
reader.onload = (e) => {
if (e.target && e.target.result && selectedElement.value) {
// 保存为 base64
selectedElement.value.imageBase64 = e.target.result as string
// 清空 URL,优先使用 base64
selectedElement.value.imageUrl = ''
updateElement()
ElMessage.success('图片上传成功!')
}
}
reader.onerror = () => {
ElMessage.error('图片读取失败!')
}
reader.readAsDataURL(file)
}
// 表格列操作
const addColumn = () => {
if (selectedElement.value && selectedElement.value.type === 'data-table') {
if (!selectedElement.value.tableConfig) {
selectedElement.value.tableConfig = { columns: [] };
}
selectedElement.value.tableConfig.columns.push({
title: '新列',
field: '',
fontFamily: '',
color: ''
});
updateElement();
}
}
const removeColumn = (index: number) => {
if (selectedElement.value && selectedElement.value.type === 'data-table') {
selectedElement.value.tableConfig.columns.splice(index, 1);
updateElement();
}
}
// 拖拽相关方法
const onDragStart = (event: DragEvent, component: any) => {
if (event.dataTransfer) {
event.dataTransfer.setData('text/plain', JSON.stringify(component))
}
}
const onDrop = (event: DragEvent) => {
if (!event.dataTransfer) return
try {
const componentData = JSON.parse(event.dataTransfer.getData('text/plain'))
const paperRect = paperRef.value?.getBoundingClientRect()
if (paperRect) {
const x = (event.clientX - paperRect.left - 20) / zoom.value
const y = (event.clientY - paperRect.top - 10) / zoom.value
const baseElement = {
id: Date.now(),
name: componentData.name,
content: componentData.name,
type: componentData.type,
x: Math.max(0, x),
y: Math.max(0, y),
width: 120,
height: 40,
fontFamily: '微软雅黑',
fontSize: 12,
bold: false,
italic: false,
underline: false,
textAlign: 'left',
borderStyle: 'none',
groupId: null
}
if (componentData.type === 'image') {
baseElement.width = 150
baseElement.height = 100
baseElement.imageUrl = ''
baseElement.imageBase64 = ''
baseElement.objectFit = 'contain'
} else if (componentData.type === 'field-image') {
baseElement.width = 150
baseElement.height = 100
baseElement.dataField = ''
baseElement.objectFit = 'contain'
} else if (componentData.type === 'group-marker') {
baseElement.width = 200
baseElement.height = 60
baseElement.borderStyle = 'dashed'
baseElement.groupField = ''
baseElement.groupsPerPage = 1
} else if (componentData.type === 'data-table') {
baseElement.tableConfig = {
columns: [
{
title: '列1',
field: '',
fontFamily: '宋体',
fontSize: 12,
bold: false,
italic: false,
underline: false,
textAlign: 'left',
color: '#000000',
formatType: 'text',
decimalPlaces: 2,
useChineseAmount: false,
useThousandSeparator: false,
dateFormat: 'yyyy-MM-dd'
},
{
title: '列2',
field: '',
fontFamily: '宋体',
fontSize: 12,
bold: false,
italic: false,
underline: false,
textAlign: 'left',
color: '#000000',
formatType: 'text',
decimalPlaces: 2,
useChineseAmount: false,
useThousandSeparator: false,
dateFormat: 'yyyy-MM-dd'
}
]
};
baseElement.width = 400
baseElement.height = 150
baseElement.headerFontFamily = '微软雅黑'
baseElement.headerFontSize = 12
baseElement.headerBold = true
baseElement.headerItalic = false
baseElement.headerUnderline = false
baseElement.headerTextAlign = 'center'
baseElement.headerColor = '#000000'
} else if (componentData.type === 'field-text') {
baseElement.dataField = ''
baseElement.content = ''
baseElement.formatType = 'text'
baseElement.color = '#000000'
baseElement.decimalPlaces = 2
baseElement.useChineseAmount = false
baseElement.useThousandSeparator = false
} else if (componentData.type === 'static-text') {
baseElement.content = '静态文本'
baseElement.formatType = 'text'
baseElement.color = '#000000'
baseElement.decimalPlaces = 2
baseElement.useChineseAmount = false
baseElement.useThousandSeparator = false
baseElement.dateFormat = 'yyyy-MM-dd' // 添加默认日期格式
} else if (componentData.type === 'qr-code') {
baseElement.width = 120
baseElement.height = 120
baseElement.content = '二维码'
} else if (componentData.type === 'field-qrcode') {
baseElement.dataField = ''
baseElement.width = 120
baseElement.height = 120
baseElement.content = ''
} else if (componentData.type === 'line') {
baseElement.width = 200
baseElement.height = 2
baseElement.lineColor = '#000000'
baseElement.lineStyle = 'solid'
baseElement.lineWidth = 1
baseElement.lineDirection = 'horizontal'
} else if (componentData.type === 'rectangle') {
baseElement.width = 150
baseElement.height = 100
baseElement.backgroundColor = 'rgba(255, 255, 255, 0)' // 默认透明背景
baseElement.borderColor = '#000000'
baseElement.borderStyle = 'solid'
baseElement.borderWidth = 1
} else if (componentData.type === 'statistics') {
baseElement.width = 90
baseElement.height = 20
baseElement.dataField = ''
baseElement.statisticsType = 'sum'
baseElement.color = '#000000'
}
reportElements.push(baseElement)
selectedElement.value = baseElement
statusMessage.value = `已添加: ${componentData.name}`
}
} catch (error) {
console.error('拖拽数据解析错误:', error)
statusMessage.value = '拖拽失败: 数据格式错误'
}
}
// 获取字段二维码内容
const getFieldQRCodeContent = (element: any) => {
if (!element.dataField) return '';
if (element.groupId) {
const groupMarker = reportElements.find(el => el.id === element.groupId);
if (groupMarker && groupMarker.groupField) {
return data.value[0]?.[element.dataField] || '';
}
}
return data.value[0]?.[element.dataField] || '';
};
// 其他所有方法保持不变...
const startDrag = (event: MouseEvent, element: any) => {
if ((event.target as HTMLElement).classList.contains('element-handle')) {
return
}
isDragging = true
selectedElement.value = element
dragOffsetX = event.offsetX
dragOffsetY = event.offsetY
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const onMouseMove = (event: MouseEvent) => {
if (isDragging && selectedElement.value) {
const paperRect = paperRef.value?.getBoundingClientRect()
if (paperRect) {
const x = (event.clientX - paperRect.left - dragOffsetX) / zoom.value
const y = (event.clientY - paperRect.top - dragOffsetY) / zoom.value
if (x >= 0 && y >= 0 &&
x + selectedElement.value.width <= paperRect.width / zoom.value &&
y + selectedElement.value.height <= paperRect.height / zoom.value) {
selectedElement.value.x = Math.round(x)
selectedElement.value.y = Math.round(y)
}
}
}
if (isResizing && resizingElement.value) {
const paperRect = paperRef.value?.getBoundingClientRect()
if (paperRect) {
const mouseX = (event.clientX - paperRect.left) / zoom.value
const mouseY = (event.clientY - paperRect.top) / zoom.value
let newWidth = startWidth
let newHeight = startHeight
let newX = startX
let newY = startY
const deltaX = mouseX - startMouseX
const deltaY = mouseY - startMouseY
switch (resizeDirection) {
case 'se':
newWidth = Math.max(20, startWidth + deltaX)
newHeight = Math.max(20, startHeight + deltaY)
break
case 'sw':
newWidth = Math.max(20, startWidth - deltaX)
newHeight = Math.max(20, startHeight + deltaY)
newX = startX + deltaX
break
case 'ne':
newWidth = Math.max(20, startWidth + deltaX)
newHeight = Math.max(20, startHeight - deltaY)
newY = startY + deltaY
break
case 'nw':
newWidth = Math.max(20, startWidth - deltaX)
newHeight = Math.max(20, startHeight - deltaY)
newX = startX + deltaX
newY = startY + deltaY
break
}
if (newX >= 0 && newY >= 0 &&
newX + newWidth <= paperRect.width / zoom.value &&
newY + newHeight <= paperRect.height / zoom.value) {
resizingElement.value.width = Math.round(newWidth)
resizingElement.value.height = Math.round(newHeight)
resizingElement.value.x = Math.round(newX)
resizingElement.value.y = Math.round(newY)
}
}
}
}
const onMouseUp = () => {
isDragging = false
isResizing = false
resizingElement.value = null
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
const startResize = (event: MouseEvent, element: any, direction: string) => {
isResizing = true
resizeDirection = direction
resizingElement.value = element
selectedElement.value = element
startWidth = element.width
startHeight = element.height
startX = element.x
startY = element.y
const paperRect = paperRef.value?.getBoundingClientRect()
if (paperRect) {
startMouseX = (event.clientX - paperRect.left) / zoom.value
startMouseY = (event.clientY - paperRect.top) / zoom.value
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 缩放功能
const zoomIn = () => {
zoom.value = Math.min(maxZoom, zoom.value + zoomStep)
}
const zoomOut = () => {
zoom.value = Math.max(minZoom, zoom.value - zoomStep)
}
const resetZoom = () => {
zoom.value = 1
}
const onWheel = (event: WheelEvent) => {
if (isZoomMode.value || event.ctrlKey) {
event.preventDefault()
const delta = -Math.sign(event.deltaY) * zoomStep * (event.ctrlKey ? 2 : 1)
zoom.value = Math.min(maxZoom, Math.max(minZoom, zoom.value + delta))
}
}
// 右键菜单功能
const showContextMenu = (event: MouseEvent, element: any) => {
contextMenu.visible = true
contextMenu.x = event.clientX
contextMenu.y = event.clientY
contextMenu.element = element
selectedElement.value = element
}
const hideContextMenu = () => {
contextMenu.visible = false
}
const deleteElement = (element: any) => {
const index = reportElements.findIndex(el => el.id === element.id)
if (index !== -1) {
reportElements.splice(index, 1)
statusMessage.value = `已删除: ${element.name}`
if (selectedElement.value?.id === element.id) {
selectedElement.value = null
}
}
hideContextMenu()
}
const copyElement = (element: any) => {
const newElement = {
...element,
id: Date.now(),
x: element.x + 20,
y: element.y + 20
}
reportElements.push(newElement)
selectedElement.value = newElement
statusMessage.value = `已复制: ${element.name}`
hideContextMenu()
}
// 响应式数据 - 在 existing refs 后面添加
const expandedColumns = ref({});
// 切换列样式展开状态
const toggleColumnStyle = (index: number) => {
expandedColumns.value[index] = !expandedColumns.value[index];
}
// 获取预览对话框宽度
const getPreviewDialogWidth = () => {
if (isPreviewFullscreen.value) {
return '100%';
}
// 对话框宽度 = 内容宽度 + 一些边距(约100px)
const contentWidth = paperSize.width + 100;
// 限制最小和最大宽度
return Math.min(Math.max(contentWidth, 600), window.innerWidth - 40) + 'px';
}
// 切换预览全屏状态
const togglePreviewFullscreen = () => {
isPreviewFullscreen.value = !isPreviewFullscreen.value;
}
// 预览功能
const showPreviewDialog = async () => {
try {
statusMessage.value = '正在生成预览内容...';
previewHtml.value = await generatePrintContent();
isPreviewFullscreen.value = false; // 重置全屏状态
previewDialogVisible.value = true;
statusMessage.value = '预览已打开';
} catch (error) {
console.error('生成预览内容失败:', error);
statusMessage.value = '生成预览内容失败';
ElMessage.error('生成预览内容失败');
}
}
const handlePreviewDialogClose = () => {
previewDialogVisible.value = false;
isPreviewFullscreen.value = false; // 关闭时重置全屏状态
}
// 加载报表的方法示例
const loadReport = (savedElements) => {
reportElements.length = 0;
reportElements.push(...savedElements);
// 恢复纸张大小
const paperConfig = reportElements.find(el => el.type === 'paper-config');
if (paperConfig && paperConfig.paperSize) {
paperSize.width = paperConfig.paperSize.width;
paperSize.height = paperConfig.paperSize.height;
}
selectedElement.value = null;
statusMessage.value = '报表已加载';
};
// 添加计算属性来获取当前纸张大小
const currentPaperSize = computed(() => {
const config = paperConfig.value;
return config.paperSize || { width: 794, height: 1123, name: 'A4' };
});
// 当选择元素改变时,重置展开状态
watch(selectedElement, (newElement) => {
if (newElement && newElement.type === 'data-table') {
// 重置所有列的展开状态为 false
expandedColumns.value = {};
}
});
watch(currentPaperSize, (newSize) => {
paperSize.width = newSize.width;
paperSize.height = newSize.height;
}, { immediate: true });
onMounted(() => {
document.addEventListener('click', hideContextMenu);
statusMessage.value = '报表设计器已就绪';
// 初始化纸张配置
if (!reportElements.find(el => el.type === 'paper-config')) {
reportElements.unshift({
id: 0,
name: "纸张配置",
type: "paper-config",
paperSize: {
width: paperSize.width,
height: paperSize.height,
name: 'A4'
}
});
}
})
</script>
<style scoped>
.tab-content :deep(.el-input-group__prepend){
font-size: 12px;
font-weight: bold;
color: #0d68a6;
}
.property-group :deep(.el-color-picker){
width: 100%;
}
:deep(.el-color-picker){
width: 25px;
}
:deep(.el-color-picker__trigger){
height: 24px;
}
.data-table-container :deep(.data-table th){
text-align: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
font-style: inherit;
text-decoration: inherit;
}
.data-table-container :deep(.data-table td){
text-align: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
font-style: inherit;
text-decoration: inherit;
}
.data-table-container :deep(.data-table){
font-size: inherit;
}
/* 图片容器样式 */
.image-container, .field-image-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
}
.image-placeholder {
color: #999;
font-style: italic;
text-align: center;
padding: 10px;
}
.image-placeholder i {
font-size: 24px;
display: block;
margin-bottom: 5px;
}
/* 统计组件样式 */
.statistics-container {
width: 100%;
height: 100%;
overflow: hidden;
}
/* 列样式配置 */
.column-style-config {
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
background: rgba(255,255,255,0.5);
}
.column-style-header {
padding: 12px 8px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
}
.column-header-content {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: bold;
}
.column-title {
flex: 1;
font-size: 14px;
}
.column-field {
font-size: 12px;
opacity: 0.7;
margin-left: 8px;
font-weight: normal;
}
.expand-icon {
transition: transform 0.3s ease;
font-size: 12px;
}
.column-style-header.expanded .expand-icon {
transform: rotate(0deg);
}
.column-style-header:not(.expanded) .expand-icon {
transform: rotate(-90deg);
}
.column-style-content {
padding: 12px;
background: white;
border-top: 1px solid #e9ecef;
}
.table-top-column-style-config {
margin-bottom: 10px;
padding: 12px;
background: rgba(255,255,255,0.5);
border-radius: 4px;
border: 1px solid #ddd;
}
.column-style-header:hover {
background: #e9ecef;
}
.column-style-header.expanded {
background: #3498db;
color: white;
}
.column-style-header.expanded:hover {
background: #2980b9;
}
/* 图片上传样式 */
.image-upload {
margin-bottom: 10px;
}
.image-preview {
margin-top: 10px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f8f9fa;
}
.field-qrcode-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.field-qrcode-placeholder {
color: #999;
font-style: italic;
text-align: center;
padding: 10px;
}
/* 线条容器 */
.line-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.line-preview {
width: 100%;
height: 100%;
}
/* 矩形容器 */
.rectangle-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.rectangle-preview {
width: 100%;
height: 100%;
}
/* 分组标记样式 */
.group-marker-container {
width: 100%;
height: 100%;
}
.group-marker-header {
background-color: rgba(52, 152, 219, 0.1);
padding: 5px 8px;
border-bottom: 1px dashed #3498db;
font-size: 11px;
overflow: hidden;
color: #2c3e50;
}
.group-marker-title {
font-weight: bold;
float: left;
margin-bottom: 2px;
margin-right: 10px;
}
.group-marker-info {
float: left;
font-size: 10px;
color: #7f8c8d;
margin-right: 10px;
}
/* 表格列配置拖拽样式 */
.column-config {
margin-bottom: 10px;
padding: 8px;
background: rgba(255,255,255,0.5);
border-radius: 4px;
border: 1px solid transparent;
transition: all 0.2s;
cursor: move;
}
.column-config:hover {
background: rgba(52, 152, 219, 0.05);
border-color: #3498db;
}
.column-config[draggable="true"]:active {
background: rgba(52, 152, 219, 0.1);
}
/* 对话框样式 */
.dialog-content {
padding: 10px 0;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
margin-bottom: 8px;
font-weight: bold;
color: #2c3e50;
}
.form-row {
display: flex;
gap: 15px;
}
.form-item {
flex: 1;
}
.paper-preview {
margin-top: 20px;
}
.preview-label {
margin-bottom: 10px;
font-weight: bold;
color: #2c3e50;
}
.preview-container {
display: flex;
justify-content: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.preview-paper {
background: white;
border: 1px solid #ddd;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.paper-info {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
/* 预览内容样式 */
.preview-content {
margin: 0 auto;
padding: 0px;
background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
overflow: auto;
max-width: 100%;
position: relative;
}
.preview-header {
display: flex;
justify-content: flex-end;
padding: 0 10px;
}
/* 其他样式保持不变 */
.ele-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.toolbar {
background-color: #2c3e50;
color: white;
padding: 8px 15px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.toolbar-title {
font-size: 18px;
font-weight: bold;
margin-right: 20px;
}
.toolbar-buttons {
display: flex;
gap: 10px;
flex: 1;
}
.toolbar-btn {
background-color: #3498db;
border: none;
color: white;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background-color: #2980b9;
}
.toolbar-btn.active {
background-color: #e74c3c;
}
.toolbar-btn:disabled {
background-color: #7f8c8d;
cursor: not-allowed;
}
.btn-danger {
background-color: #e74c3c;
}
.btn-danger:hover {
background-color: #c0392b;
}
.toolbar-btn i {
font-size: 14px;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 4px;
}
.zoom-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.zoom-btn:hover {
background: rgba(255,255,255,0.3);
}
.zoom-level {
font-size: 12px;
min-width: 50px;
text-align: center;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
.left-panel {
width: 250px;
background-color: #ecf0f1;
border-right: 1px solid #bdc3c7;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
background-color: #34495e;
color: white;
padding: 8px 12px;
font-weight: bold;
font-size: 14px;
}
.panel-content {
flex: 1;
padding: 10px;
overflow-y: auto;
}
.panel-content2 {
padding: 10px;
overflow-y: auto;
}
.component-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.component-item {
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
cursor: move;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.component-item:hover {
background-color: #f8f9fa;
border-color: #3498db;
}
.component-item i {
color: #3498db;
font-size: 16px;
}
.design-area {
flex: 1;
background-color: white;
overflow: auto;
position: relative;
background-image:
linear-gradient(#e0e0e0 1px, transparent 1px),
linear-gradient(90deg, #e0e0e0 1px, transparent 1px);
background-size: 20px 20px;
}
.design-area.zoom-cursor {
cursor: zoom-in;
}
.report-paper {
width: 794px; /* A4宽度 */
height: 1123px; /* A4高度 */
margin: 20px auto;
background-color: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
padding: 0px;
transition: transform 0.1s ease;
}
.report-element {
position: absolute;
border: 1px dashed rgba(52, 152, 219, 0.1);
background-color: rgba(52, 152, 219, 0.1);
padding: 0px;
font-size: 12px;
cursor: move;
user-select: none;
min-width: 20px;
min-height: 20px;
overflow: hidden;
}
.report-element.selected {
border: 1px solid rgba(231, 76, 60, 0.1) !important;
background-color: rgba(231, 76, 60, 0.1) !important;
}
.report-element.resizing {
border: 2px solid #f39c12;
background-color: rgba(243, 156, 18, 0.1);
}
.data-table-container {
width: 100%;
height: 100%;
overflow: auto;
}
.qe-code-container{
width: 120px;
height: 120px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table th {
border: 1px solid #ddd;
padding: 4px 8px;
text-align: left;
background-color: #f5f5f5;
font-weight: bold;
font-family: var(--header-font-family, inherit);
color: var(--header-color, inherit);
}
.data-table td {
border: 1px solid #ddd;
padding: 4px 8px;
text-align: left;
}
.data-table tr:nth-child(even) {
background-color: #f9f9f9;
}
.table-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-style: italic;
}
.element-handle {
position: absolute;
width: 8px;
height: 8px;
background-color: #3498db;
border: 1px solid white;
border-radius: 1px;
}
.element-handle.nw { top: -4px; left: -4px; cursor: nw-resize; }
.element-handle.ne { top: -4px; right: -4px; cursor: ne-resize; }
.element-handle.sw { bottom: -4px; left: -4px; cursor: sw-resize; }
.element-handle.se { bottom: -4px; right: -4px; cursor: se-resize; }
.context-menu {
position: fixed;
background: white;
border: 1px solid #bdc3c7;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
min-width: 150px;
}
.context-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s;
}
.context-item:hover {
background-color: #ecf0f1;
}
.context-item i {
font-size: 14px;
width: 16px;
}
.right-panel {
width: 300px;
background-color: #ecf0f1;
border-left: 1px solid #bdc3c7;
display: flex;
flex-direction: column;
overflow: hidden;
}
.property-group {
margin-bottom: 15px;
}
.property-label {
margin-bottom: 5px;
font-size: 13px;
margin-top: 10px;
font-weight: bold;
color: #2c3e50;
}
.property-preview {
padding: 6px 8px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 13px;
color: #6c757d;
}
.property-hint {
padding: 8px;
background: #e9ecef;
border-radius: 4px;
font-size: 12px;
color: #6c757d;
text-align: center;
}
.status-bar {
background-color: #34495e;
color: white;
padding: 5px 15px;
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.zoom-hint {
background: rgba(52, 152, 219, 0.3);
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
}
.tabs {
display: flex;
border-bottom: 1px solid #bdc3c7;
}
.tab {
padding: 8px 15px;
cursor: pointer;
font-size: 13px;
border-bottom: 2px solid transparent;
}
.tab.active {
border-bottom: 2px solid #3498db;
color: #3498db;
font-weight: bold;
}
.tab-content {
flex: 1;
padding: 10px;
overflow-y: auto;
}
.no-selection {
text-align: center;
color: #7f8c8d;
padding: 20px;
font-style: italic;
}
/* 数据源对话框样式 */
.data-preview {
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 10px;
background: #fafafa;
max-height: 250px;
overflow: auto;
}
.form-hint {
font-size: 12px;
color: #666;
margin-top: 5px;
font-style: italic;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-item {
flex: 1;
}
.dialog-content {
max-height: 70vh;
overflow-y: auto;
}
</style>

基于element-plus一个vue页面搞定自定义报表
浙公网安备 33010602011771号