新增JeecgBoot BPM流程自动生成器,包含流程创建、修改及审批人配置功能,支持自然语言描述转化为BPMN XML,并通过API与JeecgBoot系统交互。

This commit is contained in:
geht
2026-04-08 16:24:41 +08:00
parent 7c60acd679
commit 67104af7de
168 changed files with 207167 additions and 8 deletions

View File

@@ -0,0 +1,867 @@
<template>
<PageWrapper :title="pageTitle" contentBackground dense contentFullHeight>
<template #extra>
<a-space>
<a-button @click="goBack">返回列表</a-button>
<a-button type="primary" v-auth="'print:template:edit'" @click="handleSave" :loading="saving">保存模板</a-button>
<a-button v-auth="'print:template:edit'" @click="handlePreview">浏览器预览打印</a-button>
</a-space>
</template>
<a-card :bordered="false" class="hiprint-card">
<a-alert
type="info"
show-icon
message="从左侧拖拽组件到画布;选中控件后在右侧设置属性。文本中可使用变量占位(与业务传入的 JSON 字段对应)。"
class="mb-2"
/>
<a-row :gutter="8" class="designer-row">
<a-col :span="4" class="left-col">
<div class="col-title">组件库</div>
<a-collapse :bordered="false" defaultActiveKey="basic">
<a-collapse-panel key="basic" header="常用组件">
<div class="ep-draggable-item" tid="defaultModule.text">文本</div>
<div class="ep-draggable-item" tid="defaultModule.longText">长文本</div>
<div class="ep-draggable-item" tid="defaultModule.html">富文本(HTML)</div>
<div class="ep-draggable-item" tid="defaultModule.image">图片</div>
<div class="ep-draggable-item" tid="defaultModule.qrcode">二维码</div>
<div class="ep-draggable-item" tid="defaultModule.barcode">条形码</div>
<div class="ep-draggable-item" tid="defaultModule.hline">横线</div>
<div class="ep-draggable-item" tid="defaultModule.vline">竖线</div>
<div class="ep-draggable-item" tid="defaultModule.rect">矩形</div>
</a-collapse-panel>
<a-collapse-panel key="detail" header="明细表" forceRender>
<div class="ep-draggable-item" tid="qhmesModule.tableSimple">普通明细表(默认列)</div>
<div class="help-tip">默认普通表头字段/绑定在右侧属性里修改</div>
</a-collapse-panel>
<a-collapse-panel key="section" header="报表区块(布局)" forceRender>
<div class="section-tip">这些是更像 HttpPrinter的常用组件预设本质仍是 Hiprint 元素</div>
<div class="ep-draggable-item" tid="qhmesModule.reportTitle">报表标题</div>
<div class="ep-draggable-item" tid="qhmesModule.subTitle">副标题</div>
<div class="ep-draggable-item" tid="qhmesModule.labelValue">标签:</div>
<div class="ep-draggable-item" tid="qhmesModule.pageNo">页码</div>
<div class="ep-draggable-item" tid="qhmesModule.qrcode">二维码(预设)</div>
<div class="ep-draggable-item" tid="qhmesModule.barcode">条形码(预设)</div>
</a-collapse-panel>
</a-collapse>
</a-col>
<a-col :span="13" class="center-col">
<div class="col-title">画布</div>
<div id="hiprint-printTemplate" class="hiprint-printTemplate"></div>
<div class="hiprint-printPagination"></div>
</a-col>
<a-col :span="7" class="right-col">
<div class="col-title">属性</div>
<div id="PrintElementOptionSetting"></div>
</a-col>
</a-row>
<a-divider>预览数据JSON预览打印合并变量</a-divider>
<a-textarea v-model:value="printDataJson" :rows="6" placeholder='例如:{"title":"标题","orderNo":"001"}' />
<a-divider>普通明细表列配置用于 qhmesModule.tableSimple</a-divider>
<a-space direction="vertical" style="width: 100%">
<a-textarea
v-model:value="tableColumnsJson"
:rows="6"
placeholder='例如:[{"title":"物料","field":"name","width":90},{"title":"数量","field":"qty","width":45}]'
/>
<a-space>
<a-button @click="buildColumnsFromSampleData">从预览数据推导列</a-button>
<a-button type="primary" @click="applyTableColumnsConfig">应用到普通明细表组件</a-button>
</a-space>
</a-space>
<a-divider>明细分组配置可视化多级</a-divider>
<a-space direction="vertical" style="width: 100%">
<a-space>
<span>启用分组</span>
<a-switch v-model:checked="groupEnabled" />
</a-space>
<a-space>
<span>保真预览样式优先</span>
<a-switch v-model:checked="preserveDesignStyle" />
</a-space>
<a-select
v-model:value="groupFields"
mode="multiple"
style="width: 100%"
:options="tableFieldOptions"
placeholder="选择分组字段(按选择顺序分层:一级→二级→三级)"
/>
<div class="help-tip">
规则按所选字段顺序做层级分组相同值将隐藏重复内容并居中展示视觉合并效果无需写代码
</div>
</a-space>
</a-card>
</PageWrapper>
</template>
<script lang="ts" setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { PageWrapper } from '/@/components/Page';
import { useMessage } from '/@/hooks/web/useMessage';
import $ from 'jquery';
import { hiprint, defaultElementTypeProvider, disAutoConnect } from 'vue-plugin-hiprint';
import 'vue-plugin-hiprint/dist/print-lock.css';
import { queryById, saveJson } from './printTemplate.api';
import { createQhmesProvider } from './hiprint/qhmesProvider';
defineOptions({ name: 'PrintDesigner' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const tplName = ref('');
const saving = ref(false);
const printDataJson = ref(
'{\n "title": "演示标题",\n "orderNo": "SO20260101001",\n "table": [\n { "name": "物料A", "qty": 1, "amount": 100 }\n ]\n}'
);
const tableColumnsJson = ref(
'[\n { "title": "物料", "field": "name", "width": 90 },\n { "title": "数量", "field": "qty", "width": 45 },\n { "title": "金额", "field": "amount", "width": 45 }\n]'
);
const groupEnabled = ref(false);
const groupFields = ref<string[]>([]);
const preserveDesignStyle = ref(true);
const pageTitle = computed(() => (tplName.value ? `打印设计 - ${tplName.value}` : '打印设计器'));
let hiprintTemplate: any = null;
let hiprintInited = false;
let fieldsTimer: any = null;
function ensureJqueryGlobal() {
(window as any).$ = $;
(window as any).jQuery = $;
}
function destroyDesigner() {
try {
hiprintTemplate = null;
$('#hiprint-printTemplate').empty();
$('#PrintElementOptionSetting').empty();
$('.hiprint-printPagination').empty();
} catch (e) {
console.warn(e);
}
}
function initHiprintRuntime() {
if (hiprintInited) {
return;
}
ensureJqueryGlobal();
disAutoConnect();
hiprint.init({
providers: [new defaultElementTypeProvider(), createQhmesProvider()],
lang: 'cn',
});
hiprintInited = true;
}
function parseTemplateJson(str: string | undefined) {
if (!str || str === '{}') {
return {};
}
try {
return JSON.parse(str);
} catch {
createMessage.warning('模板 JSON 解析失败,已使用空白模板');
return {};
}
}
function parsePrintData(): Record<string, any> {
try {
return JSON.parse(printDataJson.value || '{}');
} catch {
createMessage.error('预览数据不是合法 JSON');
return {};
}
}
const tableFieldOptions = computed(() => {
const data = parsePrintData();
const firstRow = Array.isArray(data?.table) && data.table.length > 0 ? data.table[0] : null;
if (!firstRow || Object.prototype.toString.call(firstRow) !== '[object Object]') {
return [];
}
return Object.keys(firstRow).map((k) => ({ label: k, value: k }));
});
function buildFieldsFromJson(value: any, prefix = ''): Array<{ field: string; text: string }> {
const res: Array<{ field: string; text: string }> = [];
if (value == null) {
return res;
}
const isObj = Object.prototype.toString.call(value) === '[object Object]';
const isArr = Array.isArray(value);
if (!isObj && !isArr) {
if (prefix) {
res.push({ field: prefix, text: prefix });
}
return res;
}
if (isArr) {
// 数组字段允许绑定数组本身也允许绑定数组元素的字段table[0].xx 推导为 table.xx
if (prefix) {
res.push({ field: prefix, text: prefix });
}
const first = value.length > 0 ? value[0] : null;
if (first && Object.prototype.toString.call(first) === '[object Object]') {
Object.keys(first).forEach((k) => {
const f = prefix ? `${prefix}.${k}` : k;
res.push({ field: f, text: f });
});
}
return res;
}
Object.keys(value).forEach((k) => {
const f = prefix ? `${prefix}.${k}` : k;
const v = value[k];
// 先把自己加进去(让属性面板可直接选择)
res.push({ field: f, text: f });
// 再递归对象/数组
res.push(...buildFieldsFromJson(v, f));
});
return res;
}
function applyFieldsToTemplate() {
if (!hiprintTemplate) {
return;
}
const data = parsePrintData();
const fields = buildFieldsFromJson(data);
// 去重
const uniq = new Map<string, { field: string; text: string }>();
fields.forEach((it) => {
if (it?.field && !uniq.has(it.field)) {
uniq.set(it.field, it);
}
});
try {
hiprintTemplate.setFields(Array.from(uniq.values()));
} catch (e) {
// setFields 非强依赖,失败不影响设计器
console.warn(e);
}
}
type SimpleColumn = {
title: string;
field: string;
width?: number;
align?: 'left' | 'center' | 'right';
};
function normalizeColumns(cols: any[]): any[] {
return cols.map((c) => ({
title: c.title || c.field || '列',
field: c.field || '',
width: Number(c.width || 60),
align: c.align || 'left',
colspan: 1,
rowspan: 1,
}));
}
function getTableSimpleFormatter() {
// 与 qhmesProvider.ts 保持一致:多级分组按 groupFields 顺序计算 rowspan
return `
function(t,e,printData){
var opts = (t && t.options) ? t.options : {};
var list = printData && Array.isArray(printData[opts.field || 'table']) ? printData[opts.field || 'table'] : [];
var globalCols = printData && Array.isArray(printData.__qhmesTableColumns) ? printData.__qhmesTableColumns : [];
var columns = Array.isArray(opts.columns) && opts.columns.length ? opts.columns : (globalCols.length ? globalCols : [
{ title: '物料', field: 'name', width: 90, align: 'left' },
{ title: '数量', field: 'qty', width: 45, align: 'right' },
{ title: '金额', field: 'amount', width: 45, align: 'right' }
]);
var globalGroups = printData && Array.isArray(printData.__qhmesGroupFields) ? printData.__qhmesGroupFields : [];
var groupFields = Array.isArray(opts.groupFields) && opts.groupFields.length ? opts.groupFields : globalGroups;
var style = opts.__qhmesStyle || {};
var fontSize = style.fontSize || 10;
var borderColor = style.borderColor || '#000';
var borderWidth = style.borderWidth || 1;
var cellPadding = style.cellPadding || '2pt 4pt';
var headerBg = style.headerBg || '';
var tableWidth = style.tableWidth || '100%';
function esc(v){
if (v === null || v === undefined) return '';
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function isGroupCol(field){ return groupFields.indexOf(field) > -1; }
var rowspanMap = {};
for (var c=0;c<columns.length;c++){ var f = columns[c].field; rowspanMap[f] = new Array(list.length).fill(1); }
for (var g=0; g<groupFields.length; g++){
var gf = groupFields[g];
// 分组字段可能不在 columns 中,需先兜底初始化,避免 rowspanMap[gf] 未定义报错
if (!rowspanMap[gf]) rowspanMap[gf] = new Array(list.length).fill(1);
var i = 0;
while(i < list.length){
var j = i + 1;
while(j < list.length){
var upperOk = true;
for (var up=0; up<g; up++){
var uf = groupFields[up];
if ((list[j][uf] ?? '') !== (list[i][uf] ?? '')) { upperOk = false; break; }
}
if (!upperOk) break;
if ((list[j][gf] ?? '') === (list[i][gf] ?? '')) j++; else break;
}
var span = j - i;
if (!rowspanMap[gf]) rowspanMap[gf] = new Array(list.length).fill(1);
rowspanMap[gf][i] = span;
for (var k=i+1;k<j;k++) rowspanMap[gf][k] = 0;
i = j;
}
}
var html = '<table style="width:'+tableWidth+';border-collapse:collapse;table-layout:fixed;font-size:'+fontSize+'pt;">';
html += '<thead><tr>';
for (var h=0;h<columns.length;h++){
var hc = columns[h];
var hw = hc.width ? ('width:'+hc.width+'pt;') : '';
var hbg = headerBg ? ('background:'+headerBg+';') : '';
html += '<th style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:center;'+hbg+hw+'">'+esc(hc.title || hc.field || '')+'</th>';
}
html += '</tr></thead><tbody>';
for (var r=0;r<list.length;r++){
html += '<tr>';
for (var cc=0;cc<columns.length;cc++){
var col = columns[cc];
var field = col.field;
var align = col.align || 'left';
if (isGroupCol(field)){
var rs = rowspanMap[field][r];
if (rs > 0){
html += '<td rowspan="'+rs+'" style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:center;vertical-align:middle;">'+esc(list[r][field])+'</td>';
}
} else {
html += '<td style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:'+align+';">'+esc(list[r][field])+'</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
`;
}
function parseTemplateObjectFromInstance(): any {
if (!hiprintTemplate) return null;
const json = hiprintTemplate.getJson();
if (typeof json === 'string') {
try {
return JSON.parse(json);
} catch {
return null;
}
}
return json || null;
}
function patchTableSimpleElements(templateObj: any): number {
if (!templateObj) return 0;
let parsedCols: any[] = [];
try {
parsedCols = JSON.parse(tableColumnsJson.value || '[]');
} catch {
parsedCols = [];
}
const mergedCols = normalizeColumns(parsedCols).map((c) => ({
title: c.title,
field: c.field,
width: c.width,
align: c.align,
}));
const mergedGroups = groupEnabled.value ? [...groupFields.value] : [];
let changedCount = 0;
let candidateCount = 0;
const visited = new Set<any>();
const walk = (node: any) => {
if (!node || typeof node !== 'object' || visited.has(node)) return;
visited.add(node);
const hasOptionsObj = node.options && typeof node.options === 'object';
// 安全策略:只处理系统托管组件,避免覆盖用户已有明细组件
const isManagedTableElement =
node?.tid === 'qhmesModule.tableSimple' || (hasOptionsObj && node.options.__qhmesManaged === true);
if (isManagedTableElement) {
candidateCount++;
node.type = 'html';
node.tid = 'qhmesModule.tableSimple';
node.options = node.options || {};
node.options.__qhmesManaged = true;
node.options.field = node.options.field || 'table';
node.options.columns = mergedCols;
node.options.groupFields = mergedGroups;
node.options.formatter = getTableSimpleFormatter();
changedCount++;
}
// 递归所有子属性(兼容 printPanels/panels/printElements 等不同结构)
Object.keys(node).forEach((k) => {
const v = node[k];
if (Array.isArray(v)) {
v.forEach((item) => walk(item));
} else if (v && typeof v === 'object') {
walk(v);
}
});
};
walk(templateObj);
(templateObj as any).__qhmesPatchStat = { changedCount, candidateCount };
return changedCount;
}
function syncExistingTableSimpleElements(showTip = false) {
if (!hiprintTemplate) return;
const templateObj = parseTemplateObjectFromInstance();
if (!templateObj) return;
const changedCount = patchTableSimpleElements(templateObj);
const stat = (templateObj as any).__qhmesPatchStat || { changedCount, candidateCount: 0 };
if (changedCount > 0) {
try {
hiprintTemplate.update(templateObj);
if (showTip) {
createMessage.success(`已同步 ${changedCount} 个明细组件分组配置(识别候选 ${stat.candidateCount}`);
}
} catch (e) {
console.warn(e);
}
} else if (showTip) {
createMessage.warning('未识别到系统托管的分组明细组件,请先拖入“普通明细表(默认列)”后再应用');
}
}
function buildPreviewTemplateWithGrouping() {
const templateObj = parseTemplateObjectFromInstance();
if (!templateObj) return null;
// 仅用于预览的临时模板,避免污染设计器当前画布
const previewTemplate = JSON.parse(JSON.stringify(templateObj));
let parsedCols: any[] = [];
try {
parsedCols = JSON.parse(tableColumnsJson.value || '[]');
} catch {
parsedCols = [];
}
const mergedCols = normalizeColumns(parsedCols).map((c) => ({
title: c.title,
field: c.field,
width: c.width,
align: c.align,
}));
const mergedGroups = groupEnabled.value ? [...groupFields.value] : [];
const extractColumnsFromElement = (el: any) => {
// 1) 新结构options.columns
if (Array.isArray(el?.options?.columns) && el.options.columns.length) {
return el.options.columns.map((c: any) => ({
title: c.title || c.field || '列',
field: c.field || '',
width: Number(c.width || 60),
align: c.align || 'left',
}));
}
// 2) 旧 table 结构element.columns二维表头
if (Array.isArray(el?.columns) && el.columns.length) {
const headerRow = Array.isArray(el.columns[0]) ? el.columns[0] : [];
if (headerRow.length) {
return headerRow.map((c: any) => ({
title: c.title || c.field || '列',
field: c.field || '',
width: Number(c.width || 60),
align: c.align || 'left',
}));
}
}
return [];
};
const visited = new Set<any>();
const walk = (node: any) => {
if (!node || typeof node !== 'object' || visited.has(node)) return;
visited.add(node);
// 预览阶段宽松匹配:尽可能覆盖用户已有明细表组件
const hasOptionsObj = node.options && typeof node.options === 'object';
const maybeTableElement =
node?.tid === 'qhmesModule.tableSimple' ||
node?.tid === 'defaultModule.table' ||
node?.type === 'table' ||
(hasOptionsObj && Array.isArray(node.options.columns) && (node.options.field === 'table' || !node.options.field));
if (maybeTableElement) {
const originOptions = node.options || {};
const originColumns = extractColumnsFromElement(node);
node.type = 'html';
node.tid = 'qhmesModule.tableSimple';
node.options = node.options || {};
node.options.__qhmesManaged = true;
node.options.field = node.options.field || 'table';
// 优先保留用户已有列定义(包括旧 table 的 element.columns其次才用可视化配置列
if (originColumns.length > 0) {
node.options.columns = originColumns;
} else if (!Array.isArray(node.options.columns) || node.options.columns.length === 0) {
node.options.columns = mergedCols;
}
node.options.groupFields = mergedGroups;
node.options.__qhmesStyle = {
fontSize: originOptions.fontSize,
borderColor: originOptions.borderColor,
borderWidth: originOptions.borderWidth,
cellPadding: originOptions.cellPadding,
headerBg: originOptions.headerBg,
tableWidth: originOptions.tableWidth,
};
node.options.formatter = getTableSimpleFormatter();
}
Object.keys(node).forEach((k) => {
const v = node[k];
if (Array.isArray(v)) v.forEach((it) => walk(it));
else if (v && typeof v === 'object') walk(v);
});
};
walk(previewTemplate);
return previewTemplate;
}
function applyTableColumnsConfig(silent = false) {
let cols: SimpleColumn[] = [];
try {
const parsed = JSON.parse(tableColumnsJson.value || '[]');
if (!Array.isArray(parsed) || parsed.length === 0) {
createMessage.warning('列配置必须是非空数组');
return;
}
cols = parsed;
} catch {
createMessage.error('明细列配置 JSON 格式错误');
return;
}
try {
// 动态更新 provider 元素定义:后续拖入的“普通明细表”会使用新列配置
hiprint.updateElementType('qhmesModule.tableSimple', (type: any) => {
const mergedCols = normalizeColumns(cols).map((col) => {
if (groupEnabled.value && groupFields.value.includes(col.field)) {
return { ...col, align: 'center' };
}
return col;
});
type.options = type.options || {};
type.options.columns = mergedCols.map((c) => ({
title: c.title,
field: c.field,
width: c.width,
align: c.align,
}));
type.options.groupFields = groupEnabled.value ? [...groupFields.value] : [];
return type;
});
// 同步到当前画布上已存在的“普通明细表”组件,避免只影响新拖拽组件
syncExistingTableSimpleElements(!silent);
if (!silent) {
createMessage.success('已更新普通明细表列与分组配置');
}
} catch (e) {
console.warn(e);
createMessage.error('更新组件列配置失败');
}
}
function buildColumnsFromSampleData() {
const data = parsePrintData();
const firstRow = Array.isArray(data?.table) && data.table.length > 0 ? data.table[0] : null;
if (!firstRow || Object.prototype.toString.call(firstRow) !== '[object Object]') {
createMessage.warning('预览数据中未找到 table[0] 对象,无法推导');
return;
}
const cols = Object.keys(firstRow).map((k) => ({
title: k,
field: k,
width: 60,
align: typeof firstRow[k] === 'number' ? 'right' : 'left',
}));
tableColumnsJson.value = JSON.stringify(cols, null, 2);
createMessage.success('已根据 table[0] 推导列配置');
}
function buildGroupedRowsVisualMerge(rows: any[], groups: string[]) {
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(groups) || groups.length === 0) {
return rows;
}
const out = rows.map((r) => ({ ...r }));
for (let i = 1; i < out.length; i++) {
for (let level = 0; level < groups.length; level++) {
const field = groups[level];
// 上层一致才比较当前层
let upperSame = true;
for (let up = 0; up < level; up++) {
const upField = groups[up];
if ((out[i][upField] ?? '') !== (out[i - 1][upField] ?? '')) {
upperSame = false;
break;
}
}
if (!upperSame) break;
if ((out[i][field] ?? '') === (out[i - 1][field] ?? '')) {
// 保真模式下只清空重复值,保持原组件样式不变
out[i][field] = '';
} else {
break;
}
}
}
return out;
}
function parseTemplatePayload(str: string | undefined) {
if (!str || str === '{}') {
return { template: {}, ext: null as any };
}
try {
const obj = JSON.parse(str);
// 兼容历史格式:直接存 template json
if (obj?.printPanels || obj?.panels) {
return { template: obj, ext: null as any };
}
// 新格式:{ template, ext }
if (obj?.template && (obj.template.printPanels || obj.template.panels || Object.keys(obj.template).length >= 0)) {
return { template: obj.template, ext: obj.ext || null };
}
return { template: obj, ext: null as any };
} catch {
createMessage.warning('模板 JSON 解析失败,已使用空白模板');
return { template: {}, ext: null as any };
}
}
async function bootDesigner() {
const id = route.query.id as string;
if (!id) {
createMessage.error('缺少模板 id');
goBack();
return;
}
const record = await queryById(id);
if (!record?.id) {
createMessage.error('模板不存在');
goBack();
return;
}
tplName.value = record.templateName || record.templateCode || '';
initHiprintRuntime();
destroyDesigner();
await nextTick();
hiprint.PrintElementTypeManager.buildByHtml($('.ep-draggable-item'));
// 兼容 a-collapse-panel 延迟渲染:初始化后再补一次,确保所有拖拽项都绑定到拖拽事件上
setTimeout(() => {
hiprint.PrintElementTypeManager.buildByHtml($('.ep-draggable-item'));
}, 300);
const payload = parseTemplatePayload(record.templateJson);
const template = payload.template;
if (payload.ext) {
if (typeof payload.ext.printDataJson === 'string' && payload.ext.printDataJson.trim()) {
printDataJson.value = payload.ext.printDataJson;
}
if (typeof payload.ext.tableColumnsJson === 'string') {
tableColumnsJson.value = payload.ext.tableColumnsJson;
}
groupEnabled.value = !!payload.ext.groupEnabled;
if (Array.isArray(payload.ext.groupFields)) {
groupFields.value = payload.ext.groupFields;
}
}
hiprintTemplate = new hiprint.PrintTemplate({
template,
settingContainer: '#PrintElementOptionSetting',
paginationContainer: '.hiprint-printPagination',
history: true,
});
hiprintTemplate.design('#hiprint-printTemplate');
applyFieldsToTemplate();
applyTableColumnsConfig(true);
}
function goBack() {
router.push('/print/template');
}
async function handleSave() {
if (!hiprintTemplate || !route.query.id) {
return;
}
const json = hiprintTemplate.getJson();
let templateObj: any = {};
if (typeof json === 'string') {
try {
templateObj = JSON.parse(json);
} catch {
templateObj = {};
}
} else {
templateObj = json;
}
const payload = {
template: templateObj,
ext: {
printDataJson: printDataJson.value,
tableColumnsJson: tableColumnsJson.value,
groupEnabled: groupEnabled.value,
groupFields: groupFields.value,
},
};
const templateJson = JSON.stringify(payload);
saving.value = true;
try {
await saveJson({ id: route.query.id as string, templateJson });
createMessage.success('模板已保存');
} finally {
saving.value = false;
}
}
function handlePreview() {
if (!hiprintTemplate) {
return;
}
const data = parsePrintData();
if (preserveDesignStyle.value) {
// 样式优先:不改模板结构,只改分组字段的显示数据
if (groupEnabled.value && Array.isArray(data.table) && groupFields.value.length > 0) {
data.table = buildGroupedRowsVisualMerge(data.table, groupFields.value);
}
hiprintTemplate.print(data, {}, { callback: () => {} });
return;
}
// 兜底:即使元素 options 未同步,预览时也从全局参数读取列和分组配置
let parsedCols: any[] = [];
try {
parsedCols = JSON.parse(tableColumnsJson.value || '[]');
} catch {
parsedCols = [];
}
data.__qhmesTableColumns = normalizeColumns(parsedCols).map((c) => ({
title: c.title,
field: c.field,
width: c.width,
align: c.align,
}));
data.__qhmesGroupFields = groupEnabled.value ? [...groupFields.value] : [];
const previewTemplate = buildPreviewTemplateWithGrouping();
if (previewTemplate) {
const previewPrintTpl = new (hiprint as any).PrintTemplate({ template: previewTemplate });
previewPrintTpl.print(data, {}, { callback: () => {} });
return;
}
hiprintTemplate.print(data, {}, { callback: () => {} });
}
watch(
() => printDataJson.value,
() => {
// 输入过程中做一个简单防抖
if (fieldsTimer) {
clearTimeout(fieldsTimer);
}
fieldsTimer = setTimeout(() => {
applyFieldsToTemplate();
}, 400);
}
);
// 分组配置不再自动覆盖画布组件,避免误改用户自定义明细
// 首次进入与 query.id 变化时加载(避免 onMounted + watch 重复初始化)
watch(
() => route.query.id as string,
(id) => {
if (id) {
bootDesigner();
}
},
{ immediate: true }
);
onUnmounted(() => {
if (fieldsTimer) {
clearTimeout(fieldsTimer);
fieldsTimer = null;
}
destroyDesigner();
});
</script>
<style scoped lang="less">
.hiprint-card {
min-height: calc(100vh - 120px);
}
.designer-row {
min-height: 520px;
}
.col-title {
font-weight: 600;
margin-bottom: 8px;
}
.left-col .ep-draggable-item {
padding: 8px 10px;
margin-bottom: 6px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
text-align: center;
cursor: move;
background: #fafafa;
font-size: 12px;
user-select: none;
}
.help-tip,
.section-tip {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
margin: 6px 0 0;
line-height: 18px;
}
.center-col {
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 8px;
background: #fff;
min-height: 520px;
overflow: auto;
}
.right-col {
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 8px;
background: #fff;
min-height: 520px;
overflow: auto;
}
#hiprint-printTemplate {
min-height: 400px;
}
.mb-2 {
margin-bottom: 8px;
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<BasicModal v-bind="$attrs" @register="register" :title="title" width="640px" @ok="handleSubmit" destroyOnClose>
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form';
import { formSchema } from '../printTemplate.data';
import { add, edit } from '../printTemplate.api';
const emit = defineEmits(['success', 'register']);
const isUpdate = ref(false);
const title = computed(() => (!unref(isUpdate) ? '新增打印模板' : '编辑打印模板'));
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 110,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [register, { setModalProps, closeModal }] = useModalInner(async (data) => {
resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate) && data?.record) {
setFieldsValue({
...data.record,
});
}
});
async function handleSubmit() {
try {
const values = await validate();
if (!unref(isUpdate)) {
delete values.id;
if (!values.templateJson) {
values.templateJson = '{}';
}
}
setModalProps({ confirmLoading: true });
if (unref(isUpdate)) {
await edit(values);
} else {
await add(values);
}
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>

View File

@@ -0,0 +1,187 @@
import { hiprint } from 'vue-plugin-hiprint';
/**
* QH-MES 自定义 provider参考 vue-plugin-hiprint 动态 provider 机制)
* - 新增一组“报表/套打”常用组件
* - 提供一个默认的“普通明细表”(单行表头)
*
* 注意:此 provider 不替换 defaultElementTypeProvider只做补充。
*/
export function createQhmesProvider() {
const key = 'qhmesModule';
const addElementTypes = function (context: any) {
// 避免重复注册
context.removePrintElementTypes(key);
const commonText = (tid: string, title: string, extraOptions: Record<string, any> = {}) => {
return {
tid,
title,
type: 'text',
options: {
title,
field: '',
testData: title,
...extraOptions,
},
};
};
const elements: any[] = [
commonText(`${key}.reportTitle`, '报表标题', { fontSize: 18, fontWeight: 'bold', textAlign: 'center' }),
commonText(`${key}.subTitle`, '副标题', { fontSize: 12, textAlign: 'center' }),
commonText(`${key}.labelValue`, '标签:值', { fontSize: 10 }),
commonText(`${key}.pageNo`, '页码', { field: 'pageNumber', testData: '1/1' }),
// 二维码/条码(用 text + textType
{
tid: `${key}.qrcode`,
title: '二维码',
type: 'text',
options: {
title: '二维码',
field: 'qrcode',
testData: 'QRCODE_DEMO',
textType: 'qrcode',
width: 35,
height: 35,
},
},
{
tid: `${key}.barcode`,
title: '条形码',
type: 'text',
options: {
title: '条形码',
field: 'barcode',
testData: '1234567890',
textType: 'barcode',
width: 80,
height: 25,
},
},
// 普通明细表(单行表头,支持多级分组合并)
{
tid: `${key}.tableSimple`,
title: '普通明细表',
type: 'html',
options: {
title: '普通明细表',
field: 'table',
testData: '',
width: 180,
height: 60,
__qhmesManaged: true,
columns: [
{ title: '物料', field: 'name', width: 90, align: 'left' },
{ title: '数量', field: 'qty', width: 45, align: 'right' },
{ title: '金额', field: 'amount', width: 45, align: 'right' },
],
groupFields: [],
formatter: `
function(t,e,printData){
var opts = (t && t.options) ? t.options : {};
var list = printData && Array.isArray(printData[opts.field || 'table']) ? printData[opts.field || 'table'] : [];
var globalCols = printData && Array.isArray(printData.__qhmesTableColumns) ? printData.__qhmesTableColumns : [];
var columns = Array.isArray(opts.columns) && opts.columns.length ? opts.columns : (globalCols.length ? globalCols : [
{ title: '物料', field: 'name', width: 90, align: 'left' },
{ title: '数量', field: 'qty', width: 45, align: 'right' },
{ title: '金额', field: 'amount', width: 45, align: 'right' }
]);
var globalGroups = printData && Array.isArray(printData.__qhmesGroupFields) ? printData.__qhmesGroupFields : [];
var groupFields = Array.isArray(opts.groupFields) && opts.groupFields.length ? opts.groupFields : globalGroups;
var style = opts.__qhmesStyle || {};
var fontSize = style.fontSize || 10;
var borderColor = style.borderColor || '#000';
var borderWidth = style.borderWidth || 1;
var cellPadding = style.cellPadding || '2pt 4pt';
var headerBg = style.headerBg || '';
var tableWidth = style.tableWidth || '100%';
function esc(v){
if (v === null || v === undefined) return '';
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function isGroupCol(field){
return groupFields.indexOf(field) > -1;
}
// 计算每个分组列在每行的 rowspan多级上层一致前提下再判断下层
var rowspanMap = {};
for (var c=0;c<columns.length;c++){
var f = columns[c].field;
rowspanMap[f] = new Array(list.length).fill(1);
}
for (var g=0; g<groupFields.length; g++){
var gf = groupFields[g];
// 分组字段可能不在 columns 中,需先兜底初始化,避免 rowspanMap[gf] 未定义报错
if (!rowspanMap[gf]) rowspanMap[gf] = new Array(list.length).fill(1);
var i = 0;
while(i < list.length){
var j = i + 1;
while(j < list.length){
var upperOk = true;
for (var up=0; up<g; up++){
var uf = groupFields[up];
if ((list[j][uf] ?? '') !== (list[i][uf] ?? '')) { upperOk = false; break; }
}
if (!upperOk) break;
if ((list[j][gf] ?? '') === (list[i][gf] ?? '')) j++;
else break;
}
var span = j - i;
if (!rowspanMap[gf]) rowspanMap[gf] = new Array(list.length).fill(1);
rowspanMap[gf][i] = span;
for (var k=i+1;k<j;k++) rowspanMap[gf][k] = 0;
i = j;
}
}
var html = '<table style="width:'+tableWidth+';border-collapse:collapse;table-layout:fixed;font-size:'+fontSize+'pt;">';
html += '<thead><tr>';
for (var h=0;h<columns.length;h++){
var hc = columns[h];
var hw = hc.width ? ('width:'+hc.width+'pt;') : '';
var hbg = headerBg ? ('background:'+headerBg+';') : '';
html += '<th style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:center;'+hbg+hw+'">'+esc(hc.title || hc.field || '')+'</th>';
}
html += '</tr></thead><tbody>';
for (var r=0;r<list.length;r++){
html += '<tr>';
for (var cc=0;cc<columns.length;cc++){
var col = columns[cc];
var field = col.field;
var align = col.align || 'left';
if (isGroupCol(field)){
var rs = rowspanMap[field][r];
if (rs > 0){
html += '<td rowspan="'+rs+'" style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:center;vertical-align:middle;">'+esc(list[r][field])+'</td>';
}
}else{
html += '<td style="border:'+borderWidth+'px solid '+borderColor+';padding:'+cellPadding+';text-align:'+align+';">'+esc(list[r][field])+'</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
`,
},
},
];
// 分组(左侧面板展示更友好)
const groups = [
new (hiprint as any).PrintElementTypeGroup('HttpPrinter风格组件', elements),
];
context.addPrintElementTypes(key, groups);
};
return { addElementTypes };
}

View File

@@ -0,0 +1,96 @@
<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="primary" preIcon="ant-design:plus-outlined" @click="handleCreate" v-auth="'print:template:add'"> 新增</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete" v-auth="'print:template:delete'">
<Icon icon="ant-design:delete-outlined" />
删除
</a-menu-item>
</a-menu>
</template>
<a-button>
批量操作
<Icon icon="mdi:chevron-down" />
</a-button>
</a-dropdown>
</template>
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" />
</template>
</BasicTable>
<PrintTemplateModal @register="registerModal" @success="handleSuccess" />
</div>
</template>
<script lang="ts" name="PrintTemplateList" setup>
import { Icon } from '/@/components/Icon';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useRouter } from 'vue-router';
import { columns, searchFormSchema } from './printTemplate.data';
import { list, deleteOne, batchDelete } from './printTemplate.api';
import PrintTemplateModal from './components/PrintTemplateModal.vue';
defineOptions({ name: 'PrintTemplateList' });
const router = useRouter();
const [registerModal, { openModal }] = useModal();
const { tableContext } = useListPage({
tableProps: {
title: '打印模板',
api: list,
columns,
rowKey: 'id',
formConfig: { schemas: searchFormSchema },
actionColumn: { width: 220 },
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
function handleCreate() {
openModal(true, { isUpdate: false });
}
function handleEdit(record: Recordable) {
openModal(true, { isUpdate: true, record });
}
function handleDesign(record: Recordable) {
router.push({ path: '/print/designer', query: { id: record.id } });
}
async function handleDelete(record: Recordable) {
await deleteOne({ id: record.id }, reload);
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value.join(',') }, reload);
}
function handleSuccess() {
reload();
}
function getTableAction(record: Recordable) {
return [
{ label: '设计', onClick: handleDesign.bind(null, record), auth: 'print:template:edit' },
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'print:template:edit' },
{
label: '删除',
color: 'error',
popConfirm: {
title: '确认删除?',
confirm: handleDelete.bind(null, record),
},
auth: 'print:template:delete',
},
];
}
</script>

View File

@@ -0,0 +1,34 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/print/template/list',
add = '/print/template/add',
edit = '/print/template/edit',
deleteOne = '/print/template/delete',
deleteBatch = '/print/template/deleteBatch',
queryById = '/print/template/queryById',
saveJson = '/print/template/saveJson',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const add = (params) => defHttp.post({ url: Api.add, params });
export const edit = (params) => defHttp.put({ url: Api.edit, params });
export const deleteOne = (params, handleSuccess?) => {
return defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => {
handleSuccess?.();
});
};
export const batchDelete = (params, handleSuccess?) => {
return defHttp.delete({ url: Api.deleteBatch, params }, { joinParamsToUrl: true }).then(() => {
handleSuccess?.();
});
};
export const queryById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
export const saveJson = (data: { id: string; templateJson: string }) =>
defHttp.post({ url: Api.saveJson, data }, { successMessageMode: 'message' });

View File

@@ -0,0 +1,110 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '模板编码', dataIndex: 'templateCode', width: 140 },
{ title: '模板名称', dataIndex: 'templateName', width: 180 },
{
title: '分类',
dataIndex: 'category',
width: 100,
customRender: ({ text }) => {
const m = { barcode: '条码', form: '表单套打', report: '报表' };
return m[text] || text;
},
},
{
title: '纸张(mm)',
dataIndex: 'paperWidthMm',
width: 130,
customRender: ({ record }) =>
record?.paperWidthMm != null && record?.paperHeightMm != null
? `${record.paperWidthMm}×${record.paperHeightMm}`
: '-',
},
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '创建时间', dataIndex: 'createTime', width: 165 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '模板编码', field: 'templateCode', component: 'Input', colProps: { span: 6 } },
{ label: '模板名称', field: 'templateName', component: 'Input', colProps: { span: 6 } },
{
label: '分类',
field: 'category',
component: 'Select',
componentProps: {
options: [
{ label: '条码', value: 'barcode' },
{ label: '表单套打', value: 'form' },
{ label: '报表', value: 'report' },
],
allowClear: true,
},
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{
label: '模板编码',
field: 'templateCode',
component: 'Input',
required: true,
componentProps: { maxlength: 64 },
dynamicDisabled: ({ values }) => !!values?.id,
},
{
label: '模板名称',
field: 'templateName',
component: 'Input',
required: true,
},
{
label: '分类',
field: 'category',
component: 'Select',
defaultValue: 'form',
componentProps: {
options: [
{ label: '条码', value: 'barcode' },
{ label: '表单套打', value: 'form' },
{ label: '报表', value: 'report' },
],
},
required: true,
},
{
label: '纸宽(mm)',
field: 'paperWidthMm',
component: 'InputNumber',
defaultValue: 210,
componentProps: { min: 10, max: 2000, style: { width: '100%' } },
},
{
label: '纸高(mm)',
field: 'paperHeightMm',
component: 'InputNumber',
defaultValue: 297,
componentProps: { min: 10, max: 2000, style: { width: '100%' } },
},
{
label: '方向',
field: 'paperOrientation',
component: 'Select',
defaultValue: 'portrait',
componentProps: {
options: [
{ label: '纵向', value: 'portrait' },
{ label: '横向', value: 'landscape' },
],
},
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { rows: 2 },
},
{ label: '', field: 'id', component: 'Input', show: false },
{ label: '', field: 'templateJson', component: 'Input', show: false, defaultValue: '{}' },
];