Merge remote-tracking branch 'origin/main' into cxversion
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 20 KiB |
@@ -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 + 前端生成的 pdfBase64;printerName 空则用默认队列(与原材料卡片一致) */
|
||||
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
|
||||
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });
|
||||
|
||||
@@ -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) },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<明细实体>)或嵌套对象;系统将明细类字段并入下方「明细与表格」中的业务字段下拉。
|
||||
</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;
|
||||
|
||||