原材料入库结存
This commit is contained in:
@@ -12,6 +12,10 @@ enum Api {
|
||||
importExcel = '/xslmes/mesXslRawMaterialCard/importExcel',
|
||||
exportXls = '/xslmes/mesXslRawMaterialCard/exportXls',
|
||||
updatePriority = '/xslmes/mesXslRawMaterialCard/updatePriority',
|
||||
/** 与打印模板页 queryPrinters 返回结构一致 */
|
||||
queryPrinters = '/xslmes/mesXslRawMaterialCard/queryPrinters',
|
||||
prepareNativePrint = '/xslmes/mesXslRawMaterialCard/prepareNativePrint',
|
||||
printPdf = '/xslmes/mesXslRawMaterialCard/printPdf',
|
||||
}
|
||||
|
||||
export const getExportUrl = Api.exportXls;
|
||||
@@ -47,3 +51,15 @@ export const saveOrUpdate = (params, isUpdate) => {
|
||||
|
||||
export const updatePriority = (id: string, priorityPickup: string) =>
|
||||
defHttp.put({ url: Api.updatePriority, params: { id, priorityPickup } }, { joinParamsToUrl: true });
|
||||
|
||||
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 });
|
||||
|
||||
@@ -7,6 +7,51 @@
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
|
||||
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
|
||||
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
|
||||
PrintDot 桥接
|
||||
</a-checkbox>
|
||||
<a-input
|
||||
v-model:value="printDotWsUrl"
|
||||
style="width: 220px; margin-left: 8px"
|
||||
placeholder="ws://127.0.0.1:1122/ws"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-input-password
|
||||
v-model:value="printDotKey"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="密钥(可选)"
|
||||
autocomplete="new-password"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-button @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 @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
|
||||
<a-input
|
||||
v-model:value="manualPrinterName"
|
||||
style="width: 150px; margin-left: 8px"
|
||||
placeholder="手动输入打印机名"
|
||||
@press-enter="addManualPrinter"
|
||||
/>
|
||||
<a-button @click="addManualPrinter">添加</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
v-auth="'xslmes:mes_xsl_raw_material_card:edit'"
|
||||
:loading="printLoading"
|
||||
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
|
||||
@click="handlePrintSelected"
|
||||
>
|
||||
<Icon icon="ant-design:printer-outlined" />
|
||||
打印选中
|
||||
</a-button>
|
||||
<a-dropdown v-if="selectedRowKeys.length > 0">
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
@@ -42,20 +87,73 @@
|
||||
</BasicTable>
|
||||
<!-- 表单区域 -->
|
||||
<MesXslRawMaterialCardModal @register="registerModal" @success="handleSuccess" />
|
||||
<RawMaterialCardPrintPreviewModal
|
||||
v-model:open="printPreviewOpen"
|
||||
:card-id="printPreviewCardId"
|
||||
:barcode="printPreviewBarcode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslRawMaterialCard" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { onMounted, ref, reactive, watch } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { BasicTable, useTable, TableAction } from '/@/components/Table';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useListPage } from '/@/hooks/system/useListPage';
|
||||
import MesXslRawMaterialCardModal from './components/MesXslRawMaterialCardModal.vue';
|
||||
import RawMaterialCardPrintPreviewModal from './components/RawMaterialCardPrintPreviewModal.vue';
|
||||
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialCard.data';
|
||||
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, updatePriority } from './MesXslRawMaterialCard.api';
|
||||
import {
|
||||
list,
|
||||
deleteOne,
|
||||
batchDelete,
|
||||
getImportUrl,
|
||||
getExportUrl,
|
||||
updatePriority,
|
||||
prepareNativePrint,
|
||||
} from './MesXslRawMaterialCard.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 LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
|
||||
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
|
||||
const printDotCfg = getPrintDotBridgeConfig();
|
||||
const printDotWsUrl = ref(printDotCfg.wsUrl);
|
||||
const printDotKey = ref(printDotCfg.key);
|
||||
|
||||
function persistPrintDotConfig() {
|
||||
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
|
||||
void refreshPrinterOptions(false);
|
||||
}
|
||||
|
||||
function onPrintDotEnabledChange() {
|
||||
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
|
||||
}
|
||||
|
||||
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 queryParam = reactive<any>({});
|
||||
const checkedKeys = ref<Array<string | number>>([]);
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
@@ -74,8 +172,11 @@
|
||||
],
|
||||
},
|
||||
actionColumn: {
|
||||
width: 120,
|
||||
width: 248,
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
slots: { customRender: 'action' },
|
||||
},
|
||||
beforeFetch: (params) => {
|
||||
return Object.assign(params, queryParam);
|
||||
@@ -92,9 +193,207 @@
|
||||
},
|
||||
});
|
||||
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
|
||||
const superQueryConfig = reactive(superQuerySchema);
|
||||
|
||||
/** 打印预览弹窗 */
|
||||
const printPreviewOpen = ref(false);
|
||||
const printPreviewCardId = ref<string | null>(null);
|
||||
const printPreviewBarcode = ref<string | undefined>(undefined);
|
||||
|
||||
function handlePrintPreview(record: Recordable) {
|
||||
printPreviewCardId.value = record.id as string;
|
||||
printPreviewBarcode.value = record.barcode as string | undefined;
|
||||
printPreviewOpen.value = true;
|
||||
}
|
||||
|
||||
/** 与打印模板列表共用 localStorage 键,打印机选择保持一致 */
|
||||
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const selectedPrinterName = ref<string>('__system_default__');
|
||||
const manualPrinterName = ref('');
|
||||
const printLoading = ref(false);
|
||||
|
||||
/** 与打印模板列表启用 PrintDot 时一致:仅本机桥接打印机 */
|
||||
const printerSelectPlaceholder = '选择打印机(PrintDot 桥接)';
|
||||
|
||||
watch(selectedPrinterName, (v) => {
|
||||
if (v) {
|
||||
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
|
||||
}
|
||||
});
|
||||
|
||||
/** 与打印模板列表「PrintDot 桥接」勾选时相同:仅从本机 WebSocket 获取打印机 */
|
||||
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();
|
||||
dotList.forEach((p) => {
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) return;
|
||||
const defMark = p.isDefault ? '(默认)' : '';
|
||||
optionMap.set(name, { label: `${name}${defMark}`, value: name });
|
||||
});
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
if (dotList.length) {
|
||||
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
|
||||
} else {
|
||||
createMessage.warning('PrintDot 已连接但未返回打印机列表');
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
createMessage.warning(`PrintDot:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addManualPrinter() {
|
||||
const name = String(manualPrinterName.value || '').trim();
|
||||
if (!name) return;
|
||||
const exists = printerOptions.value.some((item) => item.value === name);
|
||||
if (!exists) {
|
||||
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
|
||||
}
|
||||
selectedPrinterName.value = name;
|
||||
manualPrinterName.value = '';
|
||||
createMessage.success('已添加打印机');
|
||||
}
|
||||
|
||||
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);
|
||||
// 以模板主表的纸张配置为准,避免 schema 页面尺寸与模板设置不同步导致方向错误/内容缩小。
|
||||
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;
|
||||
}
|
||||
/** 与打印模板原生打印一致:render → PDF → PrintDot WebSocket */
|
||||
await printNativeSchemaViaPrintDot({
|
||||
schema,
|
||||
data: printData as Record<string, unknown>,
|
||||
jobName: `原材料卡片-${(record.barcode as string) || 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() {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
const rows = selectedRows.value || [];
|
||||
if (!rows.length) {
|
||||
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
|
||||
(async () => {
|
||||
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}` : ''}`);
|
||||
}
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
})();
|
||||
}
|
||||
|
||||
function handlePrintRow(record: Recordable) {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
|
||||
executePrint(record)
|
||||
.then(() => {
|
||||
createMessage.success('已通过 PrintDot 提交打印');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
createMessage.error(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
|
||||
if (saved) {
|
||||
selectedPrinterName.value = saved;
|
||||
}
|
||||
refreshPrinterOptions(false);
|
||||
});
|
||||
|
||||
function handleSuperQuery(params) {
|
||||
Object.keys(params).map((k) => {
|
||||
queryParam[k] = params[k];
|
||||
@@ -145,6 +444,16 @@
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_card:edit',
|
||||
},
|
||||
{
|
||||
label: '打印预览',
|
||||
onClick: handlePrintPreview.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_card:edit',
|
||||
},
|
||||
{
|
||||
label: '打印',
|
||||
onClick: handlePrintRow.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_card:edit',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="innerOpen"
|
||||
:title="modalTitle"
|
||||
width="960px"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
wrap-class-name="raw-material-card-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"
|
||||
ref="previewIframeRef"
|
||||
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 '../MesXslRawMaterialCard.api';
|
||||
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
|
||||
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 卡片主键,有值时拉取模板与业务数据并渲染 */
|
||||
cardId: string | null;
|
||||
/** 展示在标题上的条码/说明 */
|
||||
barcode?: 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.barcode || '').trim();
|
||||
return b ? `原材料卡片打印预览(条码:${b})` : '原材料卡片打印预览';
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorText = ref('');
|
||||
const previewHtml = ref('');
|
||||
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Modal 内对 iframe 直接 print() 时,打印对话框易被遮罩层级挡住或焦点异常,表现为「点了没反应」。
|
||||
* 改为在 body 下挂临时 iframe、写入同一套 HTML 再打印,与模板预览常用做法一致。
|
||||
*/
|
||||
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();
|
||||
/** 关闭打印对话框后移除临时 iframe(部分浏览器支持 afterprint) */
|
||||
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.cardId] 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>
|
||||
@@ -12,6 +12,9 @@ enum Api {
|
||||
batchStockIn = '/xslmes/mesXslRawMaterialEntry/batchStockIn',
|
||||
importExcel = '/xslmes/mesXslRawMaterialEntry/importExcel',
|
||||
exportXls = '/xslmes/mesXslRawMaterialEntry/exportXls',
|
||||
queryPrinters = '/xslmes/mesXslRawMaterialEntry/queryPrinters',
|
||||
prepareNativePrint = '/xslmes/mesXslRawMaterialEntry/prepareNativePrint',
|
||||
printPdf = '/xslmes/mesXslRawMaterialEntry/printPdf',
|
||||
}
|
||||
|
||||
export const getExportUrl = Api.exportXls;
|
||||
@@ -47,3 +50,15 @@ export const saveOrUpdate = (params, isUpdate) => {
|
||||
let url = isUpdate ? Api.edit : Api.save;
|
||||
return defHttp.post({ url: url, 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 });
|
||||
|
||||
@@ -6,6 +6,51 @@
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
|
||||
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:stockIn'" preIcon="ant-design:check-circle-outlined" @click="handleStockIn"> 结存入库</a-button>
|
||||
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
|
||||
PrintDot 桥接
|
||||
</a-checkbox>
|
||||
<a-input
|
||||
v-model:value="printDotWsUrl"
|
||||
style="width: 220px; margin-left: 8px"
|
||||
placeholder="ws://127.0.0.1:1122/ws"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-input-password
|
||||
v-model:value="printDotKey"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="密钥(可选)"
|
||||
autocomplete="new-password"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-button @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 @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
|
||||
<a-input
|
||||
v-model:value="manualPrinterName"
|
||||
style="width: 150px; margin-left: 8px"
|
||||
placeholder="手动输入打印机名"
|
||||
@press-enter="addManualPrinter"
|
||||
/>
|
||||
<a-button @click="addManualPrinter">添加</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
v-auth="'xslmes:mes_xsl_raw_material_entry:edit'"
|
||||
:loading="printLoading"
|
||||
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
|
||||
@click="handlePrintSelected"
|
||||
>
|
||||
<Icon icon="ant-design:printer-outlined" />
|
||||
打印选中
|
||||
</a-button>
|
||||
<a-dropdown v-if="selectedRowKeys.length > 0">
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
@@ -26,24 +71,77 @@
|
||||
</template>
|
||||
</BasicTable>
|
||||
<MesXslRawMaterialEntryModal @register="registerModal" @success="handleSuccess" />
|
||||
<RawMaterialEntryPrintPreviewModal
|
||||
v-model:open="printPreviewOpen"
|
||||
:entry-id="printPreviewEntryId"
|
||||
:barcode="printPreviewBarcode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslRawMaterialEntry" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { BasicTable, useTable, TableAction } from '/@/components/Table';
|
||||
import { onMounted, ref, reactive, 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 MesXslRawMaterialEntryModal from './components/MesXslRawMaterialEntryModal.vue';
|
||||
import RawMaterialEntryPrintPreviewModal from './components/RawMaterialEntryPrintPreviewModal.vue';
|
||||
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialEntry.data';
|
||||
import { list, deleteOne, batchDelete, batchStockIn, getImportUrl, getExportUrl } from './MesXslRawMaterialEntry.api';
|
||||
import {
|
||||
list,
|
||||
deleteOne,
|
||||
batchDelete,
|
||||
batchStockIn,
|
||||
getImportUrl,
|
||||
getExportUrl,
|
||||
prepareNativePrint,
|
||||
} from './MesXslRawMaterialEntry.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 { createConfirm, createMessage } = useMessage();
|
||||
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
|
||||
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
|
||||
const printDotCfg = getPrintDotBridgeConfig();
|
||||
const printDotWsUrl = ref(printDotCfg.wsUrl);
|
||||
const printDotKey = ref(printDotCfg.key);
|
||||
|
||||
function persistPrintDotConfig() {
|
||||
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
|
||||
void refreshPrinterOptions(false);
|
||||
}
|
||||
|
||||
function onPrintDotEnabledChange() {
|
||||
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
|
||||
}
|
||||
|
||||
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 queryParam = reactive<any>({});
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
|
||||
const { prefixCls, tableContext, onExportXls, onImportXls } = useListPage({
|
||||
const { tableContext, onExportXls, onImportXls } = useListPage({
|
||||
tableProps: {
|
||||
title: '原料入场记录',
|
||||
api: list,
|
||||
@@ -58,8 +156,11 @@
|
||||
],
|
||||
},
|
||||
actionColumn: {
|
||||
width: 180,
|
||||
width: 320,
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
slots: { customRender: 'action' },
|
||||
},
|
||||
beforeFetch: (params) => {
|
||||
return Object.assign(params, queryParam);
|
||||
@@ -76,9 +177,201 @@
|
||||
},
|
||||
});
|
||||
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
|
||||
const superQueryConfig = reactive(superQuerySchema);
|
||||
|
||||
/** 打印预览弹窗 */
|
||||
const printPreviewOpen = ref(false);
|
||||
const printPreviewEntryId = ref<string | null>(null);
|
||||
const printPreviewBarcode = ref<string | undefined>(undefined);
|
||||
|
||||
function handlePrintPreview(record: Recordable) {
|
||||
printPreviewEntryId.value = record.id as string;
|
||||
printPreviewBarcode.value = record.barcode as string | undefined;
|
||||
printPreviewOpen.value = true;
|
||||
}
|
||||
|
||||
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const selectedPrinterName = ref<string>('__system_default__');
|
||||
const manualPrinterName = ref('');
|
||||
const printLoading = ref(false);
|
||||
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();
|
||||
dotList.forEach((p) => {
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) return;
|
||||
const defMark = p.isDefault ? '(默认)' : '';
|
||||
optionMap.set(name, { label: `${name}${defMark}`, value: name });
|
||||
});
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
if (dotList.length) {
|
||||
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
|
||||
} else {
|
||||
createMessage.warning('PrintDot 已连接但未返回打印机列表');
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
createMessage.warning(`PrintDot:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addManualPrinter() {
|
||||
const name = String(manualPrinterName.value || '').trim();
|
||||
if (!name) return;
|
||||
const exists = printerOptions.value.some((item) => item.value === name);
|
||||
if (!exists) {
|
||||
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
|
||||
}
|
||||
selectedPrinterName.value = name;
|
||||
manualPrinterName.value = '';
|
||||
createMessage.success('已添加打印机');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await printNativeSchemaViaPrintDot({
|
||||
schema,
|
||||
data: printData as Record<string, unknown>,
|
||||
jobName: `原料入场记录-${(record.barcode as string) || 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() {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
const rows = selectedRows.value || [];
|
||||
if (!rows.length) {
|
||||
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
|
||||
(async () => {
|
||||
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}` : ''}`);
|
||||
}
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
})();
|
||||
}
|
||||
|
||||
function handlePrintRow(record: Recordable) {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
|
||||
executePrint(record)
|
||||
.then(() => {
|
||||
createMessage.success('已通过 PrintDot 提交打印');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
createMessage.error(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
|
||||
if (saved) {
|
||||
selectedPrinterName.value = saved;
|
||||
}
|
||||
refreshPrinterOptions(false);
|
||||
});
|
||||
|
||||
function handleSuperQuery(params) {
|
||||
Object.keys(params).map((k) => {
|
||||
queryParam[k] = params[k];
|
||||
@@ -136,6 +429,16 @@
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
|
||||
},
|
||||
{
|
||||
label: '打印预览',
|
||||
onClick: handlePrintPreview.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
|
||||
},
|
||||
{
|
||||
label: '打印',
|
||||
onClick: handlePrintRow.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="innerOpen"
|
||||
:title="modalTitle"
|
||||
width="960px"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
wrap-class-name="raw-material-entry-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"
|
||||
ref="previewIframeRef"
|
||||
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 '../MesXslRawMaterialEntry.api';
|
||||
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
|
||||
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 入场记录主键 */
|
||||
entryId: string | null;
|
||||
/** 展示在标题上的条码/说明 */
|
||||
barcode?: 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.barcode || '').trim();
|
||||
return b ? `原料入场记录打印预览(条码:${b})` : '原料入场记录打印预览';
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorText = ref('');
|
||||
const previewHtml = ref('');
|
||||
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Modal 内对 iframe 直接 print() 时,打印对话框易被遮罩层级挡住或焦点异常,表现为「点了没反应」。
|
||||
* 改为在 body 下挂临时 iframe、写入同一套 HTML 再打印,与模板预览常用做法一致。
|
||||
*/
|
||||
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();
|
||||
/** 关闭打印对话框后移除临时 iframe(部分浏览器支持 afterprint) */
|
||||
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.entryId] 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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
board = '/xslmes/mesXslRawMaterialWarehouseBoard/board',
|
||||
}
|
||||
|
||||
export const fetchBoard = (params: { warehouseId?: string; keyword?: string; measureType?: string }) =>
|
||||
defHttp.get({ url: Api.board, params });
|
||||
@@ -0,0 +1,725 @@
|
||||
<template>
|
||||
<div class="rmb-page">
|
||||
<div class="rmb-toolbar card-surface">
|
||||
<div class="rmb-toolbar-row">
|
||||
<div class="rmb-title">
|
||||
<span class="rmb-title-icon" />
|
||||
<div>
|
||||
<div class="rmb-title-text">原材料库区看板</div>
|
||||
<div class="rmb-title-sub">按库区聚合条码卡片,点击查看明细</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rmb-actions">
|
||||
<a-button type="primary" ghost @click="loadBoard">
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rmb-filter" role="search">
|
||||
<div class="rmb-filter-row">
|
||||
<div class="rmb-inline-field">
|
||||
<span class="rmb-inline-label">所属仓库</span>
|
||||
<a-select
|
||||
v-model:value="warehouseId"
|
||||
allow-clear
|
||||
placeholder="全部仓库"
|
||||
class="rmb-filter-control"
|
||||
:loading="warehouseLoading"
|
||||
:options="warehouseOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="rmb-inline-field">
|
||||
<span class="rmb-inline-label">物料 / 条码 / 批次</span>
|
||||
<a-input
|
||||
v-model:value="keyword"
|
||||
placeholder="关键字模糊筛选"
|
||||
class="rmb-filter-control"
|
||||
allow-clear
|
||||
@press-enter="loadBoard"
|
||||
/>
|
||||
</div>
|
||||
<div class="rmb-inline-field">
|
||||
<span class="rmb-inline-label">占用率口径</span>
|
||||
<a-radio-group v-model:value="measureType" button-style="solid" @change="loadBoard">
|
||||
<a-radio-button value="quantity">剩余数量</a-radio-button>
|
||||
<a-radio-button value="weight">剩余重量</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="rmb-inline-field rmb-inline-actions">
|
||||
<a-button type="primary" @click="loadBoard">查询</a-button>
|
||||
<a-button @click="resetFilter">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!bands.length && !loading" class="rmb-empty card-surface">
|
||||
<a-empty description="暂无启用库区或无匹配数据" />
|
||||
</div>
|
||||
|
||||
<div v-for="band in bands" :key="band.bandKey" class="rmb-band card-surface">
|
||||
<div class="rmb-band-head">
|
||||
<span class="rmb-band-label">{{ band.bandLabel }}</span>
|
||||
<span class="rmb-band-count">{{ band.areas?.length || 0 }} 个库区</span>
|
||||
</div>
|
||||
<div class="rmb-card-row">
|
||||
<div
|
||||
v-for="area in band.areas"
|
||||
:key="area.areaId"
|
||||
class="rmb-card"
|
||||
:class="'rmb-card--' + (area.alertLevel || 'unknown')"
|
||||
@click="openDetail(area)"
|
||||
>
|
||||
<div class="rmb-card-head">
|
||||
<span class="rmb-card-code">{{ area.areaCode }}</span>
|
||||
<a-tag v-if="area.warehouseName" color="processing" class="rmb-card-wh">{{ area.warehouseName }}</a-tag>
|
||||
</div>
|
||||
<div class="rmb-card-name">{{ area.areaName || area.areaCode }}</div>
|
||||
<div class="rmb-card-stats">
|
||||
<div>
|
||||
<div class="rmb-stat-label">当前(数量)</div>
|
||||
<div class="rmb-stat-value">{{ area.currentQuantity ?? 0 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rmb-stat-label">当前(重量)</div>
|
||||
<div class="rmb-stat-value">{{ formatWeight(area.currentWeight) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rmb-stat-label">上限</div>
|
||||
<div class="rmb-stat-value">{{ area.maxCapacity ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="progressPercent(area)"
|
||||
:show-info="true"
|
||||
:stroke-color="progressStroke(area)"
|
||||
:trail-color="'rgba(0,0,0,0.06)'"
|
||||
size="small"
|
||||
/>
|
||||
<div class="rmb-card-foot">
|
||||
<span class="rmb-meta">卡片 {{ area.cardCount ?? 0 }} 张 · 物料 {{ area.materialKindCount ?? 0 }} 种</span>
|
||||
</div>
|
||||
<div class="rmb-tags">
|
||||
<template v-for="(m, idx) in (area.topMaterialNames || []).slice(0, 4)" :key="idx">
|
||||
<a-tooltip :title="m">
|
||||
<a-tag class="rmb-tag">{{ truncate(m, 10) }}</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-tag v-if="(area.topMaterialNames?.length || 0) > 4" class="rmb-tag rmb-tag-more">+{{ (area.topMaterialNames?.length || 0) - 4 }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="detailOpen"
|
||||
:title="detailTitle"
|
||||
placement="right"
|
||||
width="min(96vw, 1080px)"
|
||||
destroy-on-close
|
||||
@close="onDetailClose"
|
||||
@after-open-change="onDrawerAfterOpenChange"
|
||||
>
|
||||
<BasicTable @register="registerDetailTable" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslRawMaterialWarehouseBoard" setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { BasicTable, useTable } from '/@/components/Table';
|
||||
import type { BasicColumn } from '/@/components/Table';
|
||||
import type { FormSchema } from '/@/components/Form';
|
||||
import Icon from '/@/components/Icon';
|
||||
import { fetchBoard } from './MesXslRawMaterialWarehouseBoard.api';
|
||||
import { list as cardList } from '/@/views/xslmes/mesXslRawMaterialCard/MesXslRawMaterialCard.api';
|
||||
import { list as warehouseList } from '/@/views/xslmes/mesXslWarehouse/MesXslWarehouse.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getTenantId } from '/@/utils/auth';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
/** 上次选择的「所属仓库」持久化键(区分租户) */
|
||||
const LS_RM_BOARD_WAREHOUSE = 'MES_XSL_RM_BOARD_WAREHOUSE_ID';
|
||||
|
||||
function warehouseBoardStorageKey() {
|
||||
const tid = getTenantId();
|
||||
if (tid === undefined || tid === null || tid === '') {
|
||||
return LS_RM_BOARD_WAREHOUSE;
|
||||
}
|
||||
return `${LS_RM_BOARD_WAREHOUSE}_${tid}`;
|
||||
}
|
||||
|
||||
/** 写入 localStorage:空串/undefined 则清除 */
|
||||
function persistWarehouseId(id: string | undefined | null) {
|
||||
const k = warehouseBoardStorageKey();
|
||||
if (id === undefined || id === null || String(id).trim() === '') {
|
||||
localStorage.removeItem(k);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(k, String(id).trim());
|
||||
}
|
||||
|
||||
/** 读取上次选择的仓库 id(若无或非法由调用方忽略) */
|
||||
function readPersistedWarehouseId(): string | undefined {
|
||||
const v = localStorage.getItem(warehouseBoardStorageKey());
|
||||
const t = v?.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
interface BoardArea {
|
||||
areaId: string;
|
||||
areaCode: string;
|
||||
areaName?: string;
|
||||
warehouseId?: string;
|
||||
warehouseName?: string;
|
||||
maxCapacity?: number | null;
|
||||
actualCapacity?: number | null;
|
||||
cardCount?: number;
|
||||
materialKindCount?: number;
|
||||
currentQuantity?: number;
|
||||
currentWeight?: number | string | null;
|
||||
topMaterialNames?: string[];
|
||||
usagePercent?: number | null;
|
||||
alertLevel?: string;
|
||||
}
|
||||
|
||||
interface BoardBand {
|
||||
bandKey: string;
|
||||
bandLabel: string;
|
||||
bandSort?: number;
|
||||
areas: BoardArea[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const warehouseLoading = ref(false);
|
||||
const measureType = ref<'quantity' | 'weight'>('quantity');
|
||||
const warehouseId = ref<string | undefined>(undefined);
|
||||
const keyword = ref('');
|
||||
const bands = ref<BoardBand[]>([]);
|
||||
|
||||
const warehouseOptions = ref<{ label: string; value: string }[]>([]);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailAreaCode = ref('');
|
||||
const detailTitle = computed(() => (detailAreaCode.value ? `库区明细 · ${detailAreaCode.value}` : '库区明细'));
|
||||
|
||||
const detailQuery = reactive({ warehouseArea: '' });
|
||||
|
||||
/** 库区明细抽屉:条码/批次/物料 合并关键字 + 剩余数量筛选(单行紧凑布局) */
|
||||
const detailFormColResponsive = {
|
||||
xs: 24,
|
||||
sm: 24,
|
||||
md: 9,
|
||||
lg: 9,
|
||||
xl: 9,
|
||||
xxl: 9,
|
||||
span: 9,
|
||||
} as const;
|
||||
const detailQtyColResponsive = {
|
||||
xs: 24,
|
||||
sm: 24,
|
||||
md: 7,
|
||||
lg: 7,
|
||||
xl: 7,
|
||||
xxl: 7,
|
||||
span: 7,
|
||||
} as const;
|
||||
|
||||
const detailSearchFormSchema: FormSchema[] = [
|
||||
{
|
||||
label: '条码/批次/物料',
|
||||
field: 'mixKeyword',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '条码/批次/物料模糊',
|
||||
allowClear: true,
|
||||
},
|
||||
colProps: { ...detailFormColResponsive },
|
||||
},
|
||||
{
|
||||
label: '剩余数量',
|
||||
field: 'remainQtyFilter',
|
||||
component: 'Select',
|
||||
defaultValue: '',
|
||||
componentProps: {
|
||||
placeholder: '全部',
|
||||
allowClear: true,
|
||||
style: { width: '100%', maxWidth: 200 },
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '有剩余', value: 'has' },
|
||||
{ label: '无剩余', value: 'none' },
|
||||
],
|
||||
},
|
||||
colProps: { ...detailQtyColResponsive },
|
||||
},
|
||||
];
|
||||
|
||||
/** 查询、重置与本行输入框并排:前两列占 16 栅格,操作列占 8,合计 24 */
|
||||
const detailFormActionCol = { xs: 24, sm: 24, md: 8, lg: 8, xl: 8, xxl: 8, span: 8 };
|
||||
|
||||
const detailColumns: BasicColumn[] = [
|
||||
{ title: '条码', dataIndex: 'barcode', width: 190 },
|
||||
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
|
||||
{ title: '入场日期', dataIndex: 'entryDate', width: 110 },
|
||||
{ title: '物料名称', dataIndex: 'materialName', width: 140 },
|
||||
{ title: '剩余数量', dataIndex: 'remainingQuantity', width: 90 },
|
||||
{ title: '剩余重量', dataIndex: 'remainingWeight', width: 90 },
|
||||
{ title: '检测结果', dataIndex: 'testResult_dictText', width: 90 },
|
||||
{ title: '库区', dataIndex: 'warehouseArea', width: 100 },
|
||||
];
|
||||
|
||||
const [registerDetailTable, { reload }] = useTable({
|
||||
title: '原材料卡片明细',
|
||||
api: cardList,
|
||||
columns: detailColumns,
|
||||
useSearchForm: true,
|
||||
formConfig: {
|
||||
labelWidth: 108,
|
||||
layout: 'horizontal',
|
||||
compact: true,
|
||||
schemas: detailSearchFormSchema,
|
||||
autoSubmitOnEnter: true,
|
||||
showAdvancedButton: false,
|
||||
submitButtonOptions: { text: '查询' },
|
||||
resetButtonOptions: { text: '重置' },
|
||||
actionColOptions: {
|
||||
...detailFormActionCol,
|
||||
style: { textAlign: 'left', whiteSpace: 'nowrap', paddingLeft: '4px' },
|
||||
},
|
||||
},
|
||||
showTableSetting: true,
|
||||
canResize: true,
|
||||
immediate: false,
|
||||
pagination: { pageSize: 20 },
|
||||
beforeFetch: (params) => {
|
||||
const merged = Object.assign({}, params, {
|
||||
warehouseArea: (detailQuery.warehouseArea || '').trim(),
|
||||
});
|
||||
const v = merged.remainQtyFilter;
|
||||
// 后端只认 remainQtyFilter=has/none;空串不传避免误筛
|
||||
if (v === '' || v === undefined || v === null) {
|
||||
delete merged.remainQtyFilter;
|
||||
}
|
||||
const kw = merged.mixKeyword;
|
||||
if (kw === '' || kw === undefined || kw === null || String(kw).trim() === '') {
|
||||
delete merged.mixKeyword;
|
||||
} else if (typeof kw === 'string') {
|
||||
merged.mixKeyword = kw.trim();
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
});
|
||||
|
||||
async function loadWarehouses() {
|
||||
warehouseLoading.value = true;
|
||||
try {
|
||||
const res = await warehouseList({ pageNo: 1, pageSize: 500 });
|
||||
const records = res?.records ?? [];
|
||||
warehouseOptions.value = records.map((r: Record<string, string>) => ({
|
||||
label: r.warehouseName || r.warehouseCode || r.id,
|
||||
value: r.id,
|
||||
}));
|
||||
// 恢复上次选择的仓库(仅当仍在列表中)
|
||||
const savedId = readPersistedWarehouseId();
|
||||
if (savedId) {
|
||||
const ok = warehouseOptions.value.some((o) => o.value === savedId);
|
||||
if (ok) {
|
||||
warehouseId.value = savedId;
|
||||
} else {
|
||||
localStorage.removeItem(warehouseBoardStorageKey());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
createMessage.warning('加载仓库列表失败');
|
||||
} finally {
|
||||
warehouseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchBoard({
|
||||
warehouseId: warehouseId.value,
|
||||
keyword: keyword.value?.trim(),
|
||||
measureType: measureType.value,
|
||||
});
|
||||
bands.value = (data?.bands as BoardBand[]) || [];
|
||||
if (!(data?.bands?.length ?? 0)) {
|
||||
bands.value = [];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
createMessage.error(e instanceof Error ? e.message : '加载看板失败');
|
||||
bands.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
warehouseId.value = undefined;
|
||||
persistWarehouseId(undefined);
|
||||
keyword.value = '';
|
||||
measureType.value = 'quantity';
|
||||
loadBoard();
|
||||
}
|
||||
|
||||
function progressPercent(area: BoardArea): number {
|
||||
const p = area.usagePercent;
|
||||
if (p == null || Number.isNaN(p)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.max(0, Math.round(p)));
|
||||
}
|
||||
|
||||
function progressStroke(area: BoardArea) {
|
||||
const level = area.alertLevel;
|
||||
const map: Record<string, string> = {
|
||||
empty: '#bfbfbf',
|
||||
low: '#597ef7',
|
||||
normal: 'var(--j-global-primary-color, #1677ff)',
|
||||
high: '#fa8c16',
|
||||
full: '#f5222d',
|
||||
unknown: '#8c8c8c',
|
||||
};
|
||||
return map[level || 'unknown'] || map.unknown;
|
||||
}
|
||||
|
||||
function formatWeight(w: BoardArea['currentWeight']) {
|
||||
if (w == null || w === '') {
|
||||
return '—';
|
||||
}
|
||||
const n = Number(w);
|
||||
if (Number.isFinite(n)) {
|
||||
return n.toFixed(3);
|
||||
}
|
||||
return String(w);
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number) {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
function openDetail(area: BoardArea) {
|
||||
const code = String(area.areaCode ?? '').trim();
|
||||
detailQuery.warehouseArea = code;
|
||||
detailAreaCode.value = code;
|
||||
detailOpen.value = true;
|
||||
// 不在此处 reload:抽屉 destroy-on-close 时子表格可能尚未挂载,会导致请求未带上条件或无实例
|
||||
}
|
||||
|
||||
/** 抽屉打开动画完成后再拉取明细,确保 BasicTable 已 register */
|
||||
async function onDrawerAfterOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const code = String(detailQuery.warehouseArea || '').trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
await reload();
|
||||
}
|
||||
|
||||
function onDetailClose() {
|
||||
detailQuery.warehouseArea = '';
|
||||
}
|
||||
|
||||
// 用户切换「所属仓库」时持久化(无 immediate,避免挂载前误清空缓存)
|
||||
watch(warehouseId, (v) => {
|
||||
persistWarehouseId(v);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadWarehouses();
|
||||
await loadBoard();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.rmb-page {
|
||||
padding: 0 8px 16px;
|
||||
min-height: calc(100vh - 120px);
|
||||
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, transparent 480px),
|
||||
radial-gradient(1200px 400px at 10% -10%, rgba(82, 196, 26, 0.08), transparent);
|
||||
}
|
||||
|
||||
.card-surface {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.rmb-toolbar {
|
||||
padding: 16px 20px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rmb-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rmb-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rmb-title-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--j-global-primary-color, #1677ff), #722ed1);
|
||||
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rmb-title-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.rmb-title-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rmb-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
.rmb-inline-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* 标签右对齐 + 固定宽度,与控件纵向居中对齐 */
|
||||
.rmb-inline-label {
|
||||
flex: 0 0 148px;
|
||||
width: 148px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
line-height: 32px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.rmb-filter-control {
|
||||
width: 220px !important;
|
||||
}
|
||||
|
||||
/* 按钮组与其它字段同一中线,不靠虚构标签占位 */
|
||||
.rmb-inline-actions {
|
||||
gap: 8px;
|
||||
padding-left: 4px;
|
||||
margin-left: 4px;
|
||||
border-left: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rmb-inline-label {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rmb-inline-field {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rmb-filter-control {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.rmb-inline-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rmb-empty {
|
||||
padding: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rmb-band {
|
||||
padding: 16px 16px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rmb-band-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.rmb-band-label {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.rmb-band-count {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rmb-card-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.rmb-card {
|
||||
flex: 0 0 280px;
|
||||
scroll-snap-align: start;
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
border: 1px solid rgba(22, 119, 255, 0.12);
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f6faff 100%);
|
||||
}
|
||||
|
||||
.rmb-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--j-global-primary-color, #1677ff);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rmb-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.rmb-card--empty::before {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
.rmb-card--low::before {
|
||||
background: #597ef7;
|
||||
}
|
||||
.rmb-card--normal::before {
|
||||
background: var(--j-global-primary-color, #1677ff);
|
||||
}
|
||||
.rmb-card--high::before {
|
||||
background: #fa8c16;
|
||||
}
|
||||
.rmb-card--full::before {
|
||||
background: #f5222d;
|
||||
}
|
||||
.rmb-card--unknown::before {
|
||||
background: #8c8c8c;
|
||||
}
|
||||
|
||||
.rmb-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rmb-card-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.rmb-card-wh {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rmb-card-name {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.rmb-card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rmb-stat-label {
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rmb-stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.rmb-card-foot {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rmb-meta {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rmb-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rmb-tag {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rmb-tag-more {
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
@@ -271,9 +271,9 @@
|
||||
async function loadCategoryTree() {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const root = await fetchUnitCategoryRoot();
|
||||
// 根节点查询与整棵树接口并行,减少串行等待;后端已改为按层批量查子节点,降低 DB 往返
|
||||
const [root, res] = await Promise.all([fetchUnitCategoryRoot(), loadUnitCategoryTreeRoot({ async: false, pcode: 'XSLMES_UNIT' })]);
|
||||
unitCategoryRootId.value = root?.id != null ? String(root.id) : '';
|
||||
const res = await loadUnitCategoryTreeRoot({ async: false, pcode: 'XSLMES_UNIT' });
|
||||
rawUnitCategoryTree.value = Array.isArray(res) ? res : [];
|
||||
if (!unitCategoryRootId.value || !rawUnitCategoryTree.value.length) {
|
||||
createMessage.warning('未加载到单位分类树,请确认已执行库脚本且分类字典根编码为 XSLMES_UNIT。');
|
||||
@@ -295,6 +295,8 @@
|
||||
title: '单位管理',
|
||||
api: list,
|
||||
columns,
|
||||
// 避免:表格默认 immediate 请求一次 + onMounted 末尾 reload 再请求一次(进入页列表闪两次)
|
||||
immediate: false,
|
||||
canResize: true,
|
||||
formConfig: {
|
||||
schemas: searchFormSchema,
|
||||
|
||||
@@ -294,9 +294,8 @@
|
||||
async function loadCategoryTree() {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const root = await fetchWarehouseCategoryRoot();
|
||||
const [root, res] = await Promise.all([fetchWarehouseCategoryRoot(), loadCategoryTreeRoot({ async: false, pcode: 'XSLMES_WH' })]);
|
||||
warehouseCategoryRootId.value = root?.id != null ? String(root.id) : '';
|
||||
const res = await loadCategoryTreeRoot({ async: false, pcode: 'XSLMES_WH' });
|
||||
rawWarehouseCategoryTree.value = Array.isArray(res) ? res : [];
|
||||
if (!warehouseCategoryRootId.value || !rawWarehouseCategoryTree.value.length) {
|
||||
createMessage.warning('未加载到仓库分类树,请确认已执行库脚本并已在「分类字典」中维护根节点 XSLMES_WH。');
|
||||
@@ -385,6 +384,8 @@
|
||||
title: '仓库管理',
|
||||
api: list,
|
||||
columns,
|
||||
// 避免:表格默认 immediate 请求一次 + onMounted 末尾 reload 再请求一次(进入页列表闪两次)
|
||||
immediate: false,
|
||||
canResize: true,
|
||||
formConfig: {
|
||||
schemas: searchFormSchema,
|
||||
|
||||
Reference in New Issue
Block a user