Merge remote-tracking branch 'origin/main' into cxversion

This commit is contained in:
2026-05-18 14:13:47 +08:00
27 changed files with 1744 additions and 126 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -12,6 +12,9 @@ enum Api {
queryById = '/mes/material/rawMaterialInspectStd/queryById',
queryLineList = '/mes/material/rawMaterialInspectStd/queryLineListByStdId',
setEnable = '/mes/material/rawMaterialInspectStd/setEnable',
queryPrinters = '/mes/material/rawMaterialInspectStd/queryPrinters',
prepareNativePrint = '/mes/material/rawMaterialInspectStd/prepareNativePrint',
printPdf = '/mes/material/rawMaterialInspectStd/printPdf',
}
export const getExportUrl = Api.exportXls;
@@ -37,3 +40,15 @@ export const batchDelete = (params, handleSuccess) => {
export const saveOrUpdate = (params, isUpdate) => defHttp.post({ url: isUpdate ? Api.edit : Api.save, params });
export const setEnable = (params) => defHttp.post({ url: Api.setEnable, params });
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
export const prepareNativePrint = (id: string) =>
defHttp.get({
url: Api.prepareNativePrint,
params: { id, _t: Date.now() },
});
/** id + 前端生成的 pdfBase64printerName 空则用默认队列(与原材料卡片一致) */
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });

View File

@@ -2,32 +2,107 @@
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="primary" v-auth="'mes:mes_raw_material_inspect_std:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"
>新增</a-button
<a-button type="primary" v-auth="'mes:mes_raw_material_inspect_std:add'" @click="handleAdd" preIcon="ant-design:plus-outlined">
新增
</a-button>
<JDictSelectTag
v-model:value="printDotWsUrl"
dictCode="xslmes_print_dot_ws"
:showChooseOption="false"
style="width: 280px; margin-left: 8px"
placeholder="选择 PrintDot 地址"
@change="onPrintDotWsUrlChange"
/>
<a-button v-if="!printDotConnected" style="margin-left: 8px" @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 220px; margin-left: 8px"
allow-clear
show-search
option-filter-prop="label"
:placeholder="printerSelectPlaceholder"
/>
<a-button style="margin-left: 8px" @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-button
type="primary"
ghost
v-auth="'mes:mes_raw_material_inspect_std:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
打印选中
</a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'mes:mes_raw_material_inspect_std:edit' },
]"
:actions="getTableAction(record)"
:dropDownActions="getDropDownAction(record)"
/>
</template>
</BasicTable>
<MesRawMaterialInspectStdModal @register="registerModal" @success="reload" />
<MesRawMaterialInspectStdPrintPreviewModal
v-model:open="printPreviewOpen"
:std-id="printPreviewStdId"
:standard-no="printPreviewStandardNo"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { initDictOptions } from '/@/utils/dict';
import { JDictSelectTag } from '/@/components/Form';
import MesRawMaterialInspectStdModal from './modules/MesRawMaterialInspectStdModal.vue';
import MesRawMaterialInspectStdPrintPreviewModal from './modules/MesRawMaterialInspectStdPrintPreviewModal.vue';
import { columns, searchFormSchema } from './MesRawMaterialInspectStd.data';
import { list, deleteOne, setEnable } from './MesRawMaterialInspectStd.api';
import { list, deleteOne, setEnable, prepareNativePrint } from './MesRawMaterialInspectStd.api';
import { useMessage } from '/@/hooks/web/useMessage';
import {
PRINT_TEMPLATE_SELECTED_PRINTER_KEY,
printNativeSchemaViaPrintDot,
} from '/@/views/print/template/utils/printNativeViaPrintDot';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
import {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
setPrintDotBridgeConfig,
} from '/@/views/print/template/utils/printDotBridge';
const { createMessage } = useMessage();
const PRINT_DOT_WS_DICT = 'xslmes_print_dot_ws';
const printDotWsUrl = ref('');
const printDotConnected = ref(false);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(String(printDotWsUrl.value || '').trim(), '');
void refreshPrinterOptions(false);
}
function onPrintDotWsUrlChange() {
printDotConnected.value = false;
persistPrintDotConfig();
}
function downloadPrintPlugin() {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'XSL-PrintDot.exe');
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
const [registerModal, { openModal }] = useModal();
const { tableContext } = useListPage({
tableProps: {
@@ -36,10 +111,208 @@
columns,
canResize: true,
formConfig: { labelWidth: 100, schemas: searchFormSchema, autoSubmitOnEnter: true },
actionColumn: { width: 200 },
actionColumn: { width: 280, fixed: 'right' },
},
});
const [registerTable, { reload }, { rowSelection }] = tableContext;
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
const printPreviewOpen = ref(false);
const printPreviewStdId = ref<string | null>(null);
const printPreviewStandardNo = ref<string | undefined>(undefined);
function handlePrintPreview(record: Recordable) {
printPreviewStdId.value = record.id as string;
printPreviewStandardNo.value = record.standardNo as string | undefined;
printPreviewOpen.value = true;
}
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedPrinterName = ref<string>('__system_default__');
const printLoading = ref(false);
const PRINT_ROW_LOADING_KEY = 'mesRawMaterialInspectStd-print-row';
const PRINT_BATCH_LOADING_KEY = 'mesRawMaterialInspectStd-print-batch';
const printerSelectPlaceholder = '选择打印机PrintDot 桥接)';
watch(selectedPrinterName, (v) => {
if (v) {
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
}
});
async function refreshPrinterOptions(showMessage = true) {
const optionMap = new Map<string, { label: string; value: string }>();
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
try {
const dotList = await fetchPrintDotPrinters();
printDotConnected.value = true;
dotList.forEach((p) => {
const name = String(p.name || '').trim();
if (!name) return;
const defMark = p.isDefault ? '(默认)' : '';
optionMap.set(name, { label: `${name}${defMark}`, value: name });
});
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
if (dotList.length) {
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
} else {
createMessage.warning('PrintDot 已连接但未返回打印机列表');
}
}
} catch (e: unknown) {
printDotConnected.value = false;
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
createMessage.warning(`PrintDot${e instanceof Error ? e.message : String(e)}`);
}
}
}
async function executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
try {
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
if (paperWidthMm > 0 && paperHeightMm > 0) {
const orient = paperOrientation === 'landscape' ? 'landscape' : paperOrientation === 'portrait' ? 'portrait' : '';
const normalized =
orient === 'landscape'
? {
width: Math.max(paperWidthMm, paperHeightMm),
height: Math.min(paperWidthMm, paperHeightMm),
}
: orient === 'portrait'
? {
width: Math.min(paperWidthMm, paperHeightMm),
height: Math.max(paperWidthMm, paperHeightMm),
}
: {
width: paperWidthMm,
height: paperHeightMm,
};
schema.page.width = normalized.width;
schema.page.height = normalized.height;
}
const no = String(record.standardNo || '').trim();
await printNativeSchemaViaPrintDot({
schema,
data: printData as Record<string, unknown>,
jobName: `原材料检验标准-${no || record.id}.pdf`,
printerSelection:
selectedPrinterName.value ||
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
'__system_default__',
});
if (!options?.silentSuccess) {
createMessage.success('已通过 PrintDot 提交打印');
}
} catch (e: unknown) {
throw new Error(e instanceof Error ? e.message : String(e));
}
}
function handlePrintSelected() {
const rows = selectedRows.value || [];
if (!rows.length) {
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
return;
}
printLoading.value = true;
createMessage.destroy(PRINT_BATCH_LOADING_KEY);
createMessage.loading({
content: `正在打印 ${rows.length} 条记录,请稍候…`,
key: PRINT_BATCH_LOADING_KEY,
duration: 0,
});
(async () => {
try {
let ok = 0;
let firstError = '';
for (const row of rows) {
try {
await executePrint(row, { silentSuccess: true });
ok += 1;
} catch (e: unknown) {
if (!firstError) {
firstError = e instanceof Error ? e.message : String(e);
}
}
}
if (ok === rows.length) {
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
} else {
createMessage.warning(
`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`,
);
}
} finally {
createMessage.destroy(PRINT_BATCH_LOADING_KEY);
printLoading.value = false;
}
})();
}
async function handlePrintRow(record: Recordable) {
printLoading.value = true;
createMessage.destroy(PRINT_ROW_LOADING_KEY);
createMessage.loading({
content: '正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…',
key: PRINT_ROW_LOADING_KEY,
duration: 0,
});
try {
await executePrint(record, { silentSuccess: true });
createMessage.success('已通过 PrintDot 提交打印');
} catch (e: unknown) {
createMessage.error(e instanceof Error ? e.message : String(e));
} finally {
createMessage.destroy(PRINT_ROW_LOADING_KEY);
printLoading.value = false;
}
}
onMounted(async () => {
const cfg = getPrintDotBridgeConfig();
setPrintDotBridgeConfig(cfg.wsUrl, '');
printDotWsUrl.value = cfg.wsUrl || '';
try {
const raw = await initDictOptions(PRINT_DOT_WS_DICT);
const items = Array.isArray(raw) ? raw : [];
const values = items
.map((it: Recordable) => String(it.value ?? it.itemValue ?? '').trim())
.filter(Boolean);
const valueSet = new Set(values);
if (valueSet.size && printDotWsUrl.value && !valueSet.has(String(printDotWsUrl.value).trim())) {
printDotWsUrl.value = values[0];
setPrintDotBridgeConfig(printDotWsUrl.value, '');
} else if (valueSet.size && !printDotWsUrl.value.trim()) {
printDotWsUrl.value = values[0];
setPrintDotBridgeConfig(printDotWsUrl.value, '');
}
} catch {
/* 字典未配置时沿用 localStorage */
}
const savedPrinter = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (savedPrinter) {
selectedPrinterName.value = savedPrinter;
}
await refreshPrinterOptions(false);
});
function handleAdd() {
openModal(true, { isUpdate: false, showFooter: true });
@@ -63,7 +336,28 @@
createMessage.success('已停用');
reload();
}
function getDropDownAction(record) {
function getTableAction(record: Recordable) {
return [
{
label: '编辑',
onClick: handleEdit.bind(null, record),
auth: 'mes:mes_raw_material_inspect_std:edit',
},
{
label: '打印预览',
onClick: handlePrintPreview.bind(null, record),
auth: 'mes:mes_raw_material_inspect_std:edit',
},
{
label: '打印',
onClick: handlePrintRow.bind(null, record),
auth: 'mes:mes_raw_material_inspect_std:edit',
},
];
}
function getDropDownAction(record: Recordable) {
const actions: any[] = [
{ label: '详情', onClick: handleDetail.bind(null, record) },
{

View File

@@ -0,0 +1,203 @@
<template>
<a-modal
v-model:open="innerOpen"
:title="modalTitle"
width="960px"
:footer="null"
destroy-on-close
wrap-class-name="mes-raw-material-inspect-std-print-preview-modal"
@cancel="onClose"
>
<a-spin :spinning="loading">
<div v-if="errorText" class="preview-error">{{ errorText }}</div>
<div v-else class="preview-body">
<iframe
v-if="previewHtml"
class="preview-iframe"
title="原材料检验标准打印预览"
:srcdoc="previewHtml"
/>
<a-empty v-else-if="!loading" description="暂无预览内容" />
</div>
</a-spin>
<div class="preview-footer">
<a-space>
<a-button @click="innerOpen = false">关闭</a-button>
<a-button type="primary" :disabled="!previewHtml || !!errorText" @click="handleBrowserPrint">
<Icon icon="ant-design:printer-outlined" />
浏览器打印
</a-button>
</a-space>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { prepareNativePrint } from '../MesRawMaterialInspectStd.api';
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
const props = defineProps<{
open: boolean;
/** 检验标准主键 */
stdId: string | null;
/** 展示在标题(如标准编号) */
standardNo?: string;
}>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
}>();
const { createMessage } = useMessage();
const innerOpen = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
const modalTitle = computed(() => {
const b = String(props.standardNo || '').trim();
return b ? `原材料检验标准打印预览(标准编号:${b}` : '原材料检验标准打印预览';
});
const loading = ref(false);
const errorText = ref('');
const previewHtml = ref('');
async function loadPreview(id: string) {
loading.value = true;
errorText.value = '';
previewHtml.value = '';
try {
const prep = (await prepareNativePrint(id)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空,请检查「业务打印绑定」是否已配置');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
previewHtml.value = await renderNativePrintHtml(schema, printData as Record<string, unknown>);
} catch (e: unknown) {
errorText.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
function handleBrowserPrint() {
const html = previewHtml.value;
if (!html?.trim()) {
createMessage.warning('预览未就绪,请稍后再试');
return;
}
const iframe = document.createElement('iframe');
iframe.setAttribute(
'style',
'position:fixed;left:0;top:0;width:0;height:0;border:0;opacity:0;pointer-events:none;',
);
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) {
document.body.removeChild(iframe);
createMessage.error('无法创建打印文档');
return;
}
try {
doc.open();
doc.write(html);
doc.close();
} catch {
document.body.removeChild(iframe);
createMessage.error('写入打印内容失败');
return;
}
const cleanup = () => {
try {
if (iframe.parentNode) {
document.body.removeChild(iframe);
}
} catch {
// ignore
}
};
const runPrint = () => {
try {
const w = iframe.contentWindow;
if (!w) {
createMessage.error('无法唤起打印窗口');
cleanup();
return;
}
w.focus();
w.print();
w.addEventListener('afterprint', cleanup, { once: true });
window.setTimeout(cleanup, 120000);
} catch {
createMessage.error('无法唤起打印,请检查浏览器弹窗/打印权限');
cleanup();
}
};
window.setTimeout(runPrint, 100);
}
function onClose() {
errorText.value = '';
previewHtml.value = '';
}
watch(
() => [props.open, props.stdId] as const,
([isOpen, id]) => {
if (isOpen && id) {
void loadPreview(id);
}
if (!isOpen) {
onClose();
}
},
);
</script>
<style lang="less" scoped>
.preview-error {
color: #cf1322;
padding: 8px 0;
}
.preview-body {
min-height: 420px;
max-height: 72vh;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fafafa;
}
.preview-iframe {
display: block;
width: 100%;
min-height: 400px;
border: 0;
background: #fff;
}
.preview-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>

View File

@@ -9,6 +9,7 @@ enum Api {
bizTypesForBinding = '/print/bizTemplateBind/bizTypesForBinding',
permWhitelist = '/print/bizTemplateBind/permWhitelist',
parseTemplateFields = '/print/bizTemplateBind/parseTemplateFields',
parseTemplateStructure = '/print/bizTemplateBind/parseTemplateStructure',
previewMappedData = '/print/bizTemplateBind/previewMappedData',
detailSlots = '/print/bizTemplateBind/detailSlots',
bizFieldsForDetailSlot = '/print/bizTemplateBind/bizFieldsForDetailSlot',
@@ -36,6 +37,16 @@ export const parseTemplateFields = (templateId: string) =>
params: { templateId, _t: Date.now() },
});
/** 主表参数 + 按模板明细表分组(多标签映射) */
export const parseTemplateStructure = (templateId: string) =>
defHttp.get<{
params: { bindField: string; elementType?: string; titleHint?: string }[];
detailTables: { tableKey: string; label?: string; fields: { bindField: string; elementType?: string; titleHint?: string }[] }[];
}>({
url: Api.parseTemplateStructure,
params: { templateId, _t: Date.now() },
});
/** 预览映射后的打印数据 */
export const previewMappedData = (data: { bizCode: string; bizDataJson: Record<string, unknown> }) =>
defHttp.post({ url: Api.previewMappedData, data });

View File

@@ -47,7 +47,7 @@
show-icon
class="bind-alert"
message="配置说明"
description="按卡片顺序操作:先选业务与模板 → 若模板含明细占位,在「明细数据来源」中选择主实体上的集合/嵌套对象 → 点击「解析模板占位字段」→ 在下方「主表参数」「明细与表格」中分别为每个占位选择业务字段业务字段下拉第一项为「空占位符」,表示不参与业务 JSON 取值(等同输出空)。主表参数一般映射主实体字段;明细占位可选带「明细前缀」的路径(如 lines.qty。支持 lines.qty首行或 lines.0.qty。"
description="按卡片顺序操作:先选业务与模板 → 点击「解析模板占位字段」→ 主表参数映射主实体字段 → 若模板含多个明细表,在「明细与表格」标签页中逐表选择与模板明细键对应的业务明细集合,再映射列字段业务字段下拉第一项为「空占位符」,表示不参与业务 JSON明细占位多为「模板明细键.列」(如 List2.Field1业务侧选「明细属性.列」(如 lineList.qty打印时会按数组展开。"
/>
<a-card title="基础信息" size="small" :bordered="true" class="bind-card">
@@ -89,30 +89,6 @@
</a-form>
</a-card>
<a-card size="small" :bordered="true" class="bind-card">
<template #title>
<span class="bind-card-head">明细数据来源</span>
<span class="bind-card-head-extra">可选</span>
</template>
<template #extra>
<span class="bind-card-sub">有明细/表格占位时需配置</span>
</template>
<p class="bind-card-desc">
选择主实体类上的明细集合属性 List&lt;明细实体&gt;或嵌套对象系统将明细类字段并入下方明细与表格中的业务字段下拉
</p>
<a-select
v-model:value="selectedDetailProperty"
allow-clear
show-search
option-filter-prop="label"
placeholder="无需明细请留空"
:options="detailSlotSelectOptions"
:loading="detailFieldsLoading"
style="width: 100%"
@change="onDetailSlotChange"
/>
</a-card>
<a-card size="small" :bordered="true" class="bind-card bind-card--mapping">
<template #title>
<span class="bind-card-head">字段映射</span>
@@ -125,7 +101,7 @@
<a-button
size="small"
@click="autoMatchFields"
:disabled="(!bizFields.length && !detailBizFields.length) || !tplFields.length"
:disabled="(!bizFields.length && !hasAnyDetailBizFields()) || !tplFields.length"
>
同名自动匹配
</a-button>
@@ -170,35 +146,59 @@
<div class="bind-map-section bind-map-section--detail">
<div class="bind-section-bar">
<span class="bind-section-title"> 明细与表格列</span>
<span class="bind-section-hint">对应明细字段表格列等可选主表字段或上方明细来源生成的前缀字段</span>
<span class="bind-section-hint">
按模板明细表tableKey分页配置每个标签页先选业务明细集合再映射列多表明细互不影响
</span>
</div>
<a-table
v-if="mappingRowsDetail.length"
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumnsDetail"
:data-source="mappingRowsDetail"
bordered
class="bind-map-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tplKind'">
{{ templateFieldKindLabel(record.elementType) }}
</template>
<template v-if="column.key === 'bizField'">
<template v-if="detailTablesStructure.length">
<a-tabs v-model:activeKey="detailTabKey" type="card" size="small" class="bind-detail-tabs">
<a-tab-pane v-for="dt in detailTablesStructure" :key="dt.tableKey" :tab="detailTabTitle(dt)">
<p class="bind-card-desc">
模板明细键 <code>{{ dt.tableKey }}</code>
对应画布表格等组件的数据源请选择主实体上要绑定到该明细表的业务集合或嵌套对象
</p>
<a-select
v-model:value="record.bizField"
:options="bizFieldOptions"
v-model:value="detailSlotByTable[dt.tableKey]"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择业务字段"
placeholder="选择业务明细属性(如 lineList"
:options="detailSlotSelectOptions"
:loading="!!detailFieldsLoadingMap[dt.tableKey]"
style="width: 100%; margin-bottom: 12px"
@change="(v) => onDetailSlotChangeForTable(dt.tableKey, v as string | undefined)"
/>
</template>
</template>
</a-table>
<a-table
v-if="mappingRowsForDetailTable(dt).length"
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumnsDetail"
:data-source="mappingRowsForDetailTable(dt)"
bordered
class="bind-map-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tplKind'">
{{ templateFieldKindLabel(record.elementType) }}
</template>
<template v-if="column.key === 'bizField'">
<a-select
v-model:value="record.bizField"
:options="bizFieldOptionsForTable(dt.tableKey)"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择业务字段"
/>
</template>
</template>
</a-table>
<a-empty v-else class="bind-empty" description="该模板明细表下暂无占位字段(可在设计器中维护 dataBinding.detailTables" />
</a-tab-pane>
</a-tabs>
</template>
<a-empty v-else class="bind-empty" description="本模板未解析到明细/表格列占位" />
</div>
</template>
@@ -269,7 +269,7 @@
</template>
<script lang="ts" setup>
import { computed, ref, unref } from 'vue';
import { computed, reactive, ref, unref } from 'vue';
import { BasicTable, TableAction, useTable } from '/@/components/Table';
import { BasicModal, useModal } from '/@/components/Modal';
import { BasicTree, TreeItem } from '/@/components/Tree';
@@ -313,6 +313,12 @@
label: string;
}
interface TemplateDetailTableItem {
tableKey: string;
label?: string;
fields: TplFieldItem[];
}
const bizTypesRef = ref<BizTypeItem[]>([]);
const tplListRef = ref<{ id: string; templateCode: string; templateName: string }[]>([]);
/** 弹窗内:业务列表 + 模板下拉并行加载中(不再阻塞 openModal避免点按钮好几秒才出框 */
@@ -335,9 +341,12 @@
const bizFields = ref<BizTypeItem['fields']>([]);
const mappingRows = ref<MappingRow[]>([]);
const detailSlots = ref<DetailSlotItem[]>([]);
const selectedDetailProperty = ref<string | undefined>(undefined);
const detailBizFields = ref<BizTypeItem['fields']>([]);
const detailFieldsLoading = ref(false);
const detailTablesStructure = ref<TemplateDetailTableItem[]>([]);
const detailTabKey = ref('');
/** 每个模板明细表键对应选中的业务明细属性名 */
const detailSlotByTable = reactive<Record<string, string | undefined>>({});
const detailBizFieldsMap = reactive<Record<string, BizTypeItem['fields']>>({});
const detailFieldsLoadingMap = reactive<Record<string, boolean>>({});
const isEditMode = ref(false);
const modalTitle = computed(() => (unref(isEditMode) ? '编辑业务打印绑定' : '新增业务打印绑定'));
@@ -372,19 +381,125 @@
return [...head, ...rest];
});
/** 主表 + 明细前缀字段(用于明细/表格占位) */
const bizFieldOptions = computed(() => {
/** 某一模板明细表标签页:主表字段 + 该表选中的业务明细前缀字段 */
function bizFieldOptionsForTable(tableKey: string) {
const head = [{ label: '— 空占位符(不参与业务 JSON—', value: EMPTY_BIZ_FIELD_SENTINEL }];
const main = unref(bizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
}));
const detail = unref(detailBizFields).map((f) => ({
const detail = (detailBizFieldsMap[tableKey] || []).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
}));
return [...head, ...main, ...detail];
});
}
function hasAnyDetailBizFields(): boolean {
for (const k in detailBizFieldsMap) {
if ((detailBizFieldsMap[k] || []).length) {
return true;
}
}
return false;
}
function detailTabTitle(dt: TemplateDetailTableItem) {
const hint = dt.label || '';
return hint ? `${hint}${dt.tableKey}` : dt.tableKey;
}
/** 属于某一模板明细表的映射行(与后端分组一致) */
function mappingRowsForDetailTable(dt: TemplateDetailTableItem): MappingRow[] {
const keys = new Set((dt.fields || []).map((f) => (f.bindField || '').trim()).filter(Boolean));
return unref(mappingRows).filter((r) => keys.has((r.templateField || '').trim()));
}
/** 根据已保存映射推断业务明细属性(编辑时用) */
function inferDetailSlotForTable(tableKey: string): string | undefined {
const dt = unref(detailTablesStructure).find((d) => d.tableKey === tableKey);
if (!dt?.fields?.length) {
return undefined;
}
const keySet = new Set(dt.fields.map((f) => (f.bindField || '').trim()).filter(Boolean));
const counts = new Map<string, number>();
for (const r of unref(mappingRows)) {
if (!keySet.has((r.templateField || '').trim())) {
continue;
}
const bf = r.bizField;
if (!bf || bf === EMPTY_BIZ_FIELD_SENTINEL) {
continue;
}
const s = String(bf);
const dot = s.indexOf('.');
if (dot <= 0) {
continue;
}
const head = s.slice(0, dot);
counts.set(head, (counts.get(head) || 0) + 1);
}
let best: string | undefined;
let bestN = 0;
counts.forEach((n, h) => {
if (n > bestN) {
bestN = n;
best = h;
}
});
return best;
}
async function loadBizFieldsForTableSlot(tableKey: string, propertyName: string | undefined) {
if (!propertyName || !form.value.bizCode) {
detailBizFieldsMap[tableKey] = [];
return;
}
const slot = unref(detailSlots).find((s) => s.propertyName === propertyName);
const kind = slot?.slotKind || 'LIST';
detailFieldsLoadingMap[tableKey] = true;
try {
const list = await Api.bizFieldsForDetailSlot({
bizCode: form.value.bizCode,
detailProperty: propertyName,
slotKind: kind,
});
detailBizFieldsMap[tableKey] = (list || []) as BizTypeItem['fields'];
} catch {
detailBizFieldsMap[tableKey] = [];
} finally {
detailFieldsLoadingMap[tableKey] = false;
}
}
async function onDetailSlotChangeForTable(tableKey: string, propertyName: string | undefined) {
detailSlotByTable[tableKey] = propertyName;
await loadBizFieldsForTableSlot(tableKey, propertyName);
}
async function restoreDetailSlotsFromMapping() {
for (const dt of unref(detailTablesStructure)) {
const tk = dt.tableKey;
const existing = detailSlotByTable[tk];
if (existing) {
await loadBizFieldsForTableSlot(tk, existing);
continue;
}
const inferred = inferDetailSlotForTable(tk);
if (inferred && unref(detailSlots).some((s) => s.propertyName === inferred)) {
detailSlotByTable[tk] = inferred;
await loadBizFieldsForTableSlot(tk, inferred);
}
}
}
function resetDetailTableUiState() {
detailTablesStructure.value = [];
detailTabKey.value = '';
Object.keys(detailSlotByTable).forEach((k) => delete detailSlotByTable[k]);
Object.keys(detailBizFieldsMap).forEach((k) => delete detailBizFieldsMap[k]);
Object.keys(detailFieldsLoadingMap).forEach((k) => delete detailFieldsLoadingMap[k]);
}
/** 已保存的空字符串映射为下拉哨兵,便于展示「空占位符」项 */
function normalizeBizFieldForUi(raw?: string) {
@@ -407,11 +522,6 @@
unref(mappingRows).filter((r) => (r.elementType || '') === 'param'),
);
/** 非参数占位(明细字段、表格列、其它画布元素) */
const mappingRowsDetail = computed(() =>
unref(mappingRows).filter((r) => (r.elementType || '') !== 'param'),
);
function templateFieldKindLabel(t?: string) {
const m: Record<string, string> = {
param: '主表·参数',
@@ -545,8 +655,6 @@
async function refreshDetailSlots(code: string | undefined) {
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
if (!code) {
return;
}
@@ -557,37 +665,20 @@
}
}
async function onDetailSlotChange(propertyName: string | undefined) {
selectedDetailProperty.value = propertyName;
if (!propertyName || !form.value.bizCode) {
detailBizFields.value = [];
return;
}
const slot = unref(detailSlots).find((s) => s.propertyName === propertyName);
const kind = slot?.slotKind || 'LIST';
detailFieldsLoading.value = true;
try {
const list = await Api.bizFieldsForDetailSlot({
bizCode: form.value.bizCode,
detailProperty: propertyName,
slotKind: kind,
});
detailBizFields.value = (list || []) as BizTypeItem['fields'];
} catch {
detailBizFields.value = [];
} finally {
detailFieldsLoading.value = false;
}
}
async function onBizCodeChange(code: string) {
const hit = unref(bizTypesRef).find((b) => b.bizCode === code);
bizFields.value = hit?.fields ?? [];
form.value.bizName = hit?.bizName;
Object.keys(detailSlotByTable).forEach((k) => delete detailSlotByTable[k]);
Object.keys(detailBizFieldsMap).forEach((k) => delete detailBizFieldsMap[k]);
await refreshDetailSlots(code);
if (form.value.templateId && unref(detailTablesStructure).length) {
await restoreDetailSlotsFromMapping();
}
}
async function onTemplateChange() {
resetDetailTableUiState();
tplFields.value = [];
mappingRows.value = [];
await reloadTemplateFields();
@@ -598,13 +689,20 @@
if (!tid) {
tplFields.value = [];
mappingRows.value = [];
detailTablesStructure.value = [];
detailTabKey.value = '';
return;
}
parseLoading.value = true;
try {
const list = (await Api.parseTemplateFields(tid)) as TplFieldItem[];
tplFields.value = list || [];
const structure = await Api.parseTemplateStructure(tid);
detailTablesStructure.value = structure?.detailTables ?? [];
const params = structure?.params ?? [];
const flatDetail = (structure?.detailTables ?? []).flatMap((d) => d.fields ?? []);
tplFields.value = [...params, ...flatDetail];
detailTabKey.value = detailTablesStructure.value[0]?.tableKey ?? '';
rebuildMappingRows();
await restoreDetailSlotsFromMapping();
} finally {
parseLoading.value = false;
}
@@ -648,7 +746,10 @@
const savedMappingRef = ref<{ templateField: string; bizField?: string }[]>([]);
function autoMatchFields() {
const merged = [...unref(bizFields), ...unref(detailBizFields)];
const merged = [...unref(bizFields)];
for (const k in detailBizFieldsMap) {
merged.push(...(detailBizFieldsMap[k] || []));
}
const set = new Map(merged.map((f) => [f.fieldKey, f.fieldKey]));
for (const row of unref(mappingRows)) {
if (set.has(row.templateField)) {
@@ -676,8 +777,7 @@
bizFields.value = [];
mappingRows.value = [];
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
resetDetailTableUiState();
previewBizJson.value = '';
previewResult.value = '';
openModal(true);
@@ -709,8 +809,7 @@
mappingRows.value = [];
bizFields.value = [];
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
resetDetailTableUiState();
openModal(true);
modalDataLoading.value = true;
try {
@@ -853,6 +952,14 @@
border-top: 1px dashed #f0f0f0;
}
.bind-detail-tabs {
margin-top: 4px;
}
.bind-detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 10px;
}
.bind-section-bar {
display: flex;
flex-wrap: wrap;