This commit is contained in:
2026-05-15 15:10:34 +08:00
34 changed files with 1693 additions and 215 deletions

View File

@@ -150,9 +150,7 @@ export const itemFormSchema: FormSchema[] = [
if (!value) {
return Promise.reject('请输入数据值');
}
if (new RegExp("[`~!@#$^&*()=|{}'.<>《》/?!¥()—【】‘;:”“。,、?]").test(value)) {
return Promise.reject('数据值不能包含特殊字符!');
}
// 允许 URL 等场景(如 ws://host:port/path重复性由 dictItemCheck 校验
return new Promise<void>((resolve, reject) => {
let params = {
dictId: values.dictId,

View File

@@ -155,7 +155,8 @@ export const searchFormSchema: FormSchema[] = [
},
];
export const formSchema: FormSchema[] = [
/** 新增:完整字段 */
export const formSchemaAdd: FormSchema[] = [
{
label: '',
field: 'id',
@@ -288,6 +289,62 @@ export const formSchema: FormSchema[] = [
},
];
/**
* 编辑/详情:仅展示条码、批次号、物料、剩余数量、剩余重量、库区;
* 条码/批次号/物料只读,仅剩余数量、剩余重量、库区可编辑(详情时由表单全局禁用)。
*/
export const formSchemaEdit: FormSchema[] = [
{
label: '',
field: 'id',
component: 'Input',
show: false,
},
{
label: '条码',
field: 'barcode',
component: 'Input',
componentProps: { disabled: true },
colProps: { span: 24 },
},
{
label: '批次号',
field: 'batchNo',
component: 'Input',
componentProps: { disabled: true },
colProps: { span: 24 },
},
{
label: '物料名称',
field: 'materialName',
component: 'Input',
componentProps: { disabled: true },
colProps: { span: 24 },
},
{
label: '剩余数量',
field: 'remainingQuantity',
component: 'InputNumber',
componentProps: { placeholder: '请输入剩余数量', min: 0, precision: 0 },
colProps: { span: 24 },
},
{
label: '剩余重量',
field: 'remainingWeight',
component: 'InputNumber',
componentProps: { placeholder: '请输入剩余重量', min: 0, precision: 3 },
colProps: { span: 24 },
},
{
label: '库区',
field: 'warehouseArea',
component: 'Input',
slot: 'warehouseAreaPickSlot',
helpMessage: '编辑时点击输入框,在右侧列表中选择库区(保存库区编码)',
colProps: { span: 24 },
},
];
export const superQuerySchema = {
barcode: { title: '条码', order: 0, view: 'text' },
batchNo: { title: '批次号', order: 1, view: 'text' },

View File

@@ -7,23 +7,15 @@
<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
<JDictSelectTag
v-model:value="printDotWsUrl"
style="width: 220px; margin-left: 8px"
placeholder="ws://127.0.0.1:1122/ws"
@blur="persistPrintDotConfig"
dictCode="xslmes_print_dot_ws"
:showChooseOption="false"
style="width: 280px; margin-left: 8px"
placeholder="选择 PrintDot 地址"
@change="onPrintDotWsUrlChange"
/>
<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-button v-if="!printDotConnected" style="margin-left: 8px" @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
@@ -33,20 +25,13 @@
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 style="margin-left: 8px" @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_card:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
:disabled="selectedRowKeys.length === 0"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
@@ -101,6 +86,8 @@
import { BasicTable, useTable, 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 MesXslRawMaterialCardModal from './components/MesXslRawMaterialCardModal.vue';
import RawMaterialCardPrintPreviewModal from './components/RawMaterialCardPrintPreviewModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialCard.data';
@@ -126,19 +113,20 @@
} 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);
/** 与 Flyway 中 sys_dict.dict_code 一致 */
const PRINT_DOT_WS_DICT = 'xslmes_print_dot_ws';
const printDotWsUrl = ref('');
/** 是否已成功连接桥并拿到响应(用于隐藏「下载打印插件」) */
const printDotConnected = ref(false);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
setPrintDotBridgeConfig(String(printDotWsUrl.value || '').trim(), '');
void refreshPrinterOptions(false);
}
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
function onPrintDotWsUrlChange() {
printDotConnected.value = false;
persistPrintDotConfig();
}
function downloadPrintPlugin() {
@@ -210,9 +198,12 @@
/** 与打印模板列表共用 localStorage 键,打印机选择保持一致 */
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedPrinterName = ref<string>('__system_default__');
const manualPrinterName = ref('');
const printLoading = ref(false);
/** 固定 key便于关闭 loading避免返回值在某些场景下未能 removeNotice */
const PRINT_ROW_LOADING_KEY = 'mesXslRawMaterialCard-print-row';
const PRINT_BATCH_LOADING_KEY = 'mesXslRawMaterialCard-print-batch';
/** 与打印模板列表启用 PrintDot 时一致:仅本机桥接打印机 */
const printerSelectPlaceholder = '选择打印机PrintDot 桥接)';
@@ -228,18 +219,13 @@
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 });
});
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) {
@@ -249,12 +235,7 @@
}
}
} catch (e: unknown) {
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printDotConnected.value = false;
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
createMessage.warning(`PrintDot${e instanceof Error ? e.message : String(e)}`);
@@ -262,18 +243,6 @@
}
}
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>;
@@ -323,6 +292,7 @@
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
'__system_default__',
});
/** 单行/批量外层会统一 toast此处避免重复 success */
if (!options?.silentSuccess) {
createMessage.success('已通过 PrintDot 提交打印');
}
@@ -332,66 +302,94 @@
}
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);
createMessage.destroy(PRINT_BATCH_LOADING_KEY);
createMessage.loading({
content: `正在打印 ${rows.length} 条记录,请稍候…`,
key: PRINT_BATCH_LOADING_KEY,
duration: 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);
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;
}
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;
}
async function handlePrintRow(record: Recordable) {
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;
});
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(() => {
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (saved) {
selectedPrinterName.value = saved;
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 */
}
refreshPrinterOptions(false);
const savedPrinter = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (savedPrinter) {
selectedPrinterName.value = savedPrinter;
}
await refreshPrinterOptions(false);
});
function handleSuperQuery(params) {

View File

@@ -1,6 +1,66 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="1000" @ok="handleSubmit">
<BasicForm @register="registerForm" name="MesXslRawMaterialCardForm" />
<BasicModal
v-bind="$attrs"
wrap-class-name="mes-raw-card-edit-modal"
@register="registerModal"
destroyOnClose
:title="title"
:width="modalWidth"
:bodyStyle="modalBodyStyle"
@ok="handleSubmit"
>
<div :class="['raw-card-modal__layout', layoutClass]">
<div class="raw-card-modal__form">
<BasicForm @register="registerForm" name="MesXslRawMaterialCardForm">
<template #warehouseAreaPickSlot="{ model, field }">
<a-input
:value="warehouseAreaTriggerText(model[field])"
readonly
:disabled="!formEditable"
placeholder="点击后在右侧选择库区"
class="raw-card-modal__area-trigger"
@click="onWarehouseAreaTriggerClick"
>
<template #suffix>
<Icon icon="ant-design:unordered-list-outlined" />
</template>
</a-input>
</template>
</BasicForm>
</div>
<aside v-if="showSidePane" class="raw-card-modal__area-pane">
<div class="raw-card-modal__area-pane-head">
<span class="raw-card-modal__area-pane-title">库区列表</span>
<a-button type="link" size="small" @click="areaPanelVisible = false">收起</a-button>
</div>
<a-input
v-model:value="areaSearch"
allow-clear
placeholder="筛选仓库 / 库区名称 / 编码"
class="raw-card-modal__area-search"
/>
<a-spin :spinning="areaLoading" class="raw-card-modal__area-spin">
<div class="raw-card-modal__area-list">
<div
v-for="row in filteredAreaRows"
:key="row.id || row.areaCode"
:class="[
'raw-card-modal__area-item',
{ 'is-active': (row.areaCode || '').trim() === (pickedAreaCode || '').trim() },
]"
@click="onPickWarehouseArea(row)"
>
<div class="raw-card-modal__area-item-name">{{ row.areaName || row.areaCode || '—' }}</div>
<div class="raw-card-modal__area-item-meta">
<span v-if="row.warehouseName" class="raw-card-modal__area-item-meta-item">仓库{{ row.warehouseName }}</span>
<span class="raw-card-modal__area-item-meta-item">编码{{ row.areaCode }}</span>
</div>
</div>
<a-empty v-if="!filteredAreaRows.length && !areaLoading" description="无启用库区" />
</div>
</a-spin>
</aside>
</div>
</BasicModal>
</template>
@@ -8,36 +68,169 @@
import { ref, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from '../MesXslRawMaterialCard.data';
import { Icon } from '/@/components/Icon';
import { formSchemaAdd, formSchemaEdit } from '../MesXslRawMaterialCard.data';
import { saveOrUpdate } from '../MesXslRawMaterialCard.api';
import { list as warehouseAreaList } from '/@/views/xslmes/mesXslWarehouseArea/MesXslWarehouseArea.api';
/** 库区行(列表接口 records */
interface WarehouseAreaRow {
id?: string;
areaCode: string;
areaName?: string;
/** 所属仓库名称(列表接口冗余字段) */
warehouseName?: string;
}
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(true);
/** 与标题逻辑一致showFooter 为 true 表示可编辑编辑false 为详情 */
const isDetail = ref(false);
const [registerForm, { setProps, resetFields, setFieldsValue, validate, scrollToField }] = useForm({
const formEditable = ref(true);
const areaPanelVisible = ref(false);
const areaSearch = ref('');
const areaLoading = ref(false);
const areaRows = ref<WarehouseAreaRow[]>([]);
const pickedAreaCode = ref<string>('');
const [registerForm, { setProps, resetFields, setFieldsValue, validate, scrollToField, resetSchema }] = useForm({
labelWidth: 120,
schemas: formSchema,
schemas: formSchemaAdd,
showActionButtonGroup: false,
baseColProps: { span: 12 },
});
const showSidePane = computed(() => unref(isUpdate) && unref(formEditable) && unref(areaPanelVisible));
const modalWidth = computed(() => {
if (!unref(isUpdate)) {
return 1000;
}
return unref(showSidePane) ? 980 : 640;
});
const modalBodyStyle = computed(() =>
unref(isUpdate) ? { paddingTop: '12px', paddingBottom: '8px' } : undefined
);
const layoutClass = computed(() => ({
'raw-card-modal__layout--split': unref(showSidePane),
}));
const filteredAreaRows = computed(() => {
const q = unref(areaSearch).trim().toLowerCase();
const rows = unref(areaRows);
if (!q) {
return rows;
}
return rows.filter(
(r) =>
(r.areaCode || '').toLowerCase().includes(q) ||
(r.areaName || '').toLowerCase().includes(q) ||
(r.warehouseName || '').toLowerCase().includes(q)
);
});
function warehouseAreaTriggerText(code: string | undefined | null) {
const c = (code || '').trim();
if (!c) {
return '';
}
const row = unref(areaRows).find((r) => (r.areaCode || '').trim() === c);
if (!row) {
return c;
}
const wh = (row.warehouseName || '').trim();
const an = (row.areaName || '').trim();
if (wh && an) {
return `${wh} · ${an}${row.areaCode}`;
}
if (an) {
return `${an}${row.areaCode}`;
}
return row.areaCode || c;
}
let warehouseAreasLoadPromise: Promise<void> | null = null;
async function loadEnabledWarehouseAreas() {
if (warehouseAreasLoadPromise) {
return warehouseAreasLoadPromise;
}
warehouseAreasLoadPromise = (async () => {
areaLoading.value = true;
try {
const page = await warehouseAreaList({
pageNo: 1,
pageSize: 5000,
status: '0',
});
const records = (page?.records || []) as WarehouseAreaRow[];
areaRows.value = records;
} catch {
areaRows.value = [];
} finally {
areaLoading.value = false;
}
})();
try {
await warehouseAreasLoadPromise;
} finally {
warehouseAreasLoadPromise = null;
}
}
async function onWarehouseAreaTriggerClick() {
if (!unref(formEditable)) {
return;
}
areaPanelVisible.value = true;
areaSearch.value = '';
if (!unref(areaRows).length) {
await loadEnabledWarehouseAreas();
}
}
function onPickWarehouseArea(row: WarehouseAreaRow) {
if (!row?.areaCode || !unref(formEditable)) {
return;
}
const code = String(row.areaCode).trim();
pickedAreaCode.value = code;
void setFieldsValue({ warehouseArea: code });
}
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
areaPanelVisible.value = false;
areaSearch.value = '';
await resetFields();
const isAdd = !data?.isUpdate;
await resetSchema(isAdd ? formSchemaAdd : formSchemaEdit);
await setProps({
disabled: !data?.showFooter,
baseColProps: { span: isAdd ? 12 : 24 },
});
setModalProps({ confirmLoading: false, showCancelBtn: !!data?.showFooter, showOkBtn: !!data?.showFooter });
isUpdate.value = !!data?.isUpdate;
isDetail.value = !!data?.showFooter;
formEditable.value = !!data?.showFooter;
if (unref(isUpdate)) {
// 先写入行数据,避免等待库区列表接口导致整表单长时间空白
pickedAreaCode.value = String(data.record?.warehouseArea || '').trim();
await setFieldsValue({ ...data.record });
void loadEnabledWarehouseAreas();
} else {
areaRows.value = [];
pickedAreaCode.value = '';
}
setProps({ disabled: !data?.showFooter });
});
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(isDetail) ? '详情' : '编辑'));
async function handleSubmit(v) {
async function handleSubmit() {
try {
let values = await validate();
const values = await validate();
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, isUpdate.value);
closeModal();
@@ -57,6 +250,158 @@
</script>
<style lang="less" scoped>
.raw-card-modal__layout {
display: flex;
align-items: flex-start;
gap: 16px;
min-height: 200px;
&--split {
.raw-card-modal__form {
flex: 1;
min-width: 0;
}
}
}
.raw-card-modal__form {
width: 100%;
flex: 1;
min-width: 0;
}
.raw-card-modal__area-pane {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
/* 固定可视高度:仅列表区域滚动,避免整块侧栏把 Modal body 撑出滚动条 */
height: min(440px, calc(100vh - 240px));
max-height: calc(100vh - 240px);
min-height: 240px;
padding: 0 4px 8px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.raw-card-modal__area-pane-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px 6px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.raw-card-modal__area-pane-title {
font-weight: 600;
font-size: 14px;
}
.raw-card-modal__area-search {
flex-shrink: 0;
margin: 10px 0 8px;
}
.raw-card-modal__area-spin {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.ant-spin-nested-loading) {
flex: 1;
min-height: 0;
display: flex !important;
flex-direction: column;
overflow: hidden;
}
:deep(.ant-spin-container) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.ant-spin) {
max-height: 100%;
}
}
.raw-card-modal__area-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
/* 明确上限,避免父级未约束高度时无法触发 overflow */
max-height: 320px;
overflow-y: auto;
padding-right: 2px;
-webkit-overflow-scrolling: touch;
}
.raw-card-modal__area-item {
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid #f0f0f0;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: #1677ff;
background: rgba(22, 119, 255, 0.06);
}
&.is-active {
border-color: #1677ff;
background: rgba(22, 119, 255, 0.1);
}
}
.raw-card-modal__area-item-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.4;
font-weight: 500;
}
.raw-card-modal__area-item-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.35;
}
.raw-card-modal__area-item-meta-item {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.raw-card-modal__area-item-meta-item:first-of-type:last-of-type {
/* 仅编码、无仓库名时占满一行 */
flex-basis: 100%;
}
.raw-card-modal__area-trigger {
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
:deep(.ant-input-number) {
width: 100%;
}
@@ -64,3 +409,25 @@
width: 100%;
}
</style>
<!-- 挂载在 body Modal 无法被 scoped 穿透单独取消本弹窗外层 ScrollContainer 纵向滚动由右侧库区列表自滚动 -->
<style lang="less">
.mes-raw-card-edit-modal {
.ant-modal-body {
.scroll-container {
height: auto !important;
}
.scroll-container .scrollbar__wrap {
max-height: none !important;
overflow-y: visible !important;
}
/* ModalWrapper 内层包裹 slot 的容器,内联了 maxHeight */
.scrollbar__view > div {
max-height: none !important;
height: auto !important;
}
}
}
</style>

View File

@@ -0,0 +1,10 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslRawMaterialCardEditLog/list',
exportXls = '/xslmes/mesXslRawMaterialCardEditLog/exportXls',
}
export const getExportUrl = Api.exportXls;
export const list = (params) => defHttp.get({ url: Api.list, params });

View File

@@ -0,0 +1,45 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '条码', align: 'center', dataIndex: 'barcode', width: 240 },
{ title: '批次号', align: 'center', dataIndex: 'batchNo', width: 220 },
{ title: '物料名称', align: 'center', dataIndex: 'materialName', width: 320 },
{ title: '修改前重量', align: 'center', dataIndex: 'beforeRemainingWeight', width: 110 },
{ title: '修改前数量', align: 'center', dataIndex: 'beforeRemainingQty', width: 100 },
{ title: '修改前库区', align: 'center', dataIndex: 'beforeWarehouseArea', width: 120 },
{ title: '修改后重量', align: 'center', dataIndex: 'afterRemainingWeight', width: 110 },
{ title: '修改后数量', align: 'center', dataIndex: 'afterRemainingQty', width: 100 },
{ title: '修改后库区', align: 'center', dataIndex: 'afterWarehouseArea', width: 120 },
{
title: '修改时间',
align: 'center',
dataIndex: 'modifyTime',
width: 165,
},
{ title: '修改人姓名', align: 'center', dataIndex: 'modifyByName', width: 120 },
{ title: '数值来源', align: 'center', dataIndex: 'dataSource', width: 160, ellipsis: true },
];
export const searchFormSchema: FormSchema[] = [
{ label: '条码', field: 'barcode', component: 'JInput', colProps: { span: 6 } },
{ label: '批次号', field: 'batchNo', component: 'JInput', colProps: { span: 6 } },
{ label: '物料名称', field: 'materialName', component: 'Input', colProps: { span: 6 } },
{ label: '修改人', field: 'modifyByName', component: 'Input', colProps: { span: 6 } },
{
label: '数值来源',
field: 'dataSource',
component: 'Input',
colProps: { span: 6 },
componentProps: { placeholder: '如 Web端原材料卡片' },
},
{
label: '修改时间',
field: 'modifyTime',
component: 'RangePicker',
colProps: { span: 8 },
componentProps: {
showTime: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];

View File

@@ -0,0 +1,44 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card_edit_log:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls">
导出
</a-button>
</template>
</BasicTable>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialCardEditLog" setup>
import { reactive } from 'vue';
import { BasicTable } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { columns, searchFormSchema } from './MesXslRawMaterialCardEditLog.data';
import { list, getExportUrl } from './MesXslRawMaterialCardEditLog.api';
const queryParam = reactive<any>({});
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '原材料卡片修改日志',
api: list,
columns,
canResize: true,
showIndexColumn: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
fieldMapToTime: [['modifyTime', ['modifyTime_begin', 'modifyTime_end'], 'YYYY-MM-DD HH:mm:ss']],
},
beforeFetch: (params) => Object.assign(params, queryParam),
},
exportConfig: {
name: '原材料卡片修改日志',
url: getExportUrl,
params: queryParam,
},
});
const [registerTable] = tableContext;
</script>

View File

@@ -1,14 +1,11 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslRawMaterialEntry/list',
enum Api { list = '/xslmes/mesXslRawMaterialEntry/list',
save = '/xslmes/mesXslRawMaterialEntry/add',
edit = '/xslmes/mesXslRawMaterialEntry/edit',
deleteOne = '/xslmes/mesXslRawMaterialEntry/delete',
deleteBatch = '/xslmes/mesXslRawMaterialEntry/deleteBatch',
linkedRawMaterialCards = '/xslmes/mesXslRawMaterialEntry/linkedRawMaterialCards',
batchStockIn = '/xslmes/mesXslRawMaterialEntry/batchStockIn',
importExcel = '/xslmes/mesXslRawMaterialEntry/importExcel',
exportXls = '/xslmes/mesXslRawMaterialEntry/exportXls',
@@ -20,26 +17,29 @@ enum Api {
export const getExportUrl = Api.exportXls;
export const getImportUrl = Api.importExcel;
/** 删除前:查询入场记录关联的已生成原材料卡片 */
export const getLinkedRawMaterialCards = (ids: string) =>
defHttp.get<MesXslRawMaterialCardBrief[]>({ url: Api.linkedRawMaterialCards, params: { ids } });
export interface MesXslRawMaterialCardBrief {
id?: string;
barcode?: string;
batchNo?: string;
materialName?: string;
splitDetailId?: string;
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const deleteOne = (params, handleSuccess) => {
export const deleteOne = (params: { id: string; cascadeDeleteCards?: boolean }, handleSuccess?: () => void) => {
return defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => {
handleSuccess();
handleSuccess?.();
});
};
export const batchDelete = (params, handleSuccess) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否删除选中数据',
okText: '确认',
cancelText: '取消',
onOk: () => {
return defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => {
handleSuccess();
});
},
export const batchDelete = (params: { ids: string; cascadeDeleteCards?: boolean }, handleSuccess?: () => void) => {
return defHttp.delete({ url: Api.deleteBatch, params }, { joinParamsToUrl: true }).then(() => {
handleSuccess?.();
});
};

View File

@@ -6,23 +6,16 @@
<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
<!-- 与原材料卡片一致字典选桥接地址 + 打印机 + 刷新 -->
<JDictSelectTag
v-model:value="printDotWsUrl"
style="width: 220px; margin-left: 8px"
placeholder="ws://127.0.0.1:1122/ws"
@blur="persistPrintDotConfig"
dictCode="xslmes_print_dot_ws"
:showChooseOption="false"
style="width: 280px; margin-left: 8px"
placeholder="选择打印桥接"
@change="onPrintDotWsUrlChange"
/>
<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-button v-if="!printDotConnected" style="margin-left: 8px" @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
@@ -32,20 +25,13 @@
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 style="margin-left: 8px" @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_entry:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
:disabled="selectedRowKeys.length === 0"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
@@ -80,11 +66,13 @@
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialEntry" setup>
import { onMounted, ref, reactive, watch } from 'vue';
import { onMounted, ref, reactive, watch, h } 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 MesXslRawMaterialEntryModal from './components/MesXslRawMaterialEntryModal.vue';
import RawMaterialEntryPrintPreviewModal from './components/RawMaterialEntryPrintPreviewModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialEntry.data';
@@ -95,7 +83,9 @@
batchStockIn,
getImportUrl,
getExportUrl,
getLinkedRawMaterialCards,
prepareNativePrint,
type MesXslRawMaterialCardBrief,
} from './MesXslRawMaterialEntry.api';
import { useMessage } from '/@/hooks/web/useMessage';
import {
@@ -110,19 +100,21 @@
} 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);
/** 与 Flyway 中 sys_dict.dict_code 一致(与原材料卡片列表相同) */
const PRINT_DOT_WS_DICT = 'xslmes_print_dot_ws';
const printDotWsUrl = ref('');
/** 是否已成功连接桥并拿到响应(用于显示「下载打印插件」) */
const printDotConnected = ref(false);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
// 与原材料卡片一致:桥接密钥固定为空,由本地 PrintDot 与字典地址对接
setPrintDotBridgeConfig(String(printDotWsUrl.value || '').trim(), '');
void refreshPrinterOptions(false);
}
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
function onPrintDotWsUrlChange() {
printDotConnected.value = false;
persistPrintDotConfig();
}
function downloadPrintPlugin() {
@@ -193,7 +185,6 @@
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedPrinterName = ref<string>('__system_default__');
const manualPrinterName = ref('');
const printLoading = ref(false);
const printerSelectPlaceholder = '选择打印机PrintDot 桥接)';
@@ -208,6 +199,7 @@
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;
@@ -229,6 +221,7 @@
}
}
} catch (e: unknown) {
printDotConnected.value = false;
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
@@ -242,18 +235,6 @@
}
}
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>;
@@ -310,8 +291,8 @@
}
function handlePrintSelected() {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
if (!String(printDotWsUrl.value || '').trim()) {
createMessage.warning('请先选择打印桥接(PrintDot 地址)');
return;
}
const rows = selectedRows.value || [];
@@ -345,8 +326,8 @@
}
function handlePrintRow(record: Recordable) {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
if (!String(printDotWsUrl.value || '').trim()) {
createMessage.warning('请先选择打印桥接(PrintDot 地址)');
return;
}
printLoading.value = true;
@@ -364,12 +345,35 @@
});
}
onMounted(() => {
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 && !String(printDotWsUrl.value || '').trim()) {
printDotWsUrl.value = values[0];
setPrintDotBridgeConfig(printDotWsUrl.value, '');
}
} catch {
/* 字典未配置时沿用 localStorage */
}
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (saved) {
selectedPrinterName.value = saved;
}
refreshPrinterOptions(false);
await refreshPrinterOptions(false);
});
function handleSuperQuery(params) {
@@ -391,12 +395,107 @@
openModal(true, { record, isUpdate: true, showFooter: false });
}
async function handleDelete(record) {
await deleteOne({ id: record.id }, handleSuccess);
/** 删除确认框:关联卡片以列表形式展示 */
function renderLinkedCardsConfirmContent(linked: MesXslRawMaterialCardBrief[], summaryText: string) {
const labelStyle = { color: 'rgba(0,0,0,0.45)', display: 'inline-block', minWidth: '40px' as const };
return h('div', { class: 'linked-raw-material-cards-confirm' }, [
h('p', { style: { margin: '0 0 12px', color: 'rgba(0,0,0,0.88)' } }, summaryText),
h(
'ul',
{
style: {
listStyle: 'none',
padding: 0,
margin: 0,
maxHeight: '360px',
overflowY: 'auto' as const,
border: '1px solid #f0f0f0',
borderRadius: '6px',
background: '#fafafa',
},
},
linked.map((c, idx) =>
h(
'li',
{
key: c.id ?? `${c.barcode ?? ''}-${c.batchNo ?? ''}-${idx}`,
style: {
padding: '10px 12px',
borderBottom: idx < linked.length - 1 ? '1px solid #f0f0f0' : 'none',
background: '#fff',
},
},
[
h('div', { style: { fontSize: '13px', lineHeight: '22px', color: 'rgba(0,0,0,0.88)', wordBreak: 'break-all' as const } }, [
h('div', null, [h('span', { style: labelStyle }, '条码'), '', c.barcode || '-']),
h('div', null, [h('span', { style: labelStyle }, '批次'), '', c.batchNo || '-']),
h('div', null, [h('span', { style: labelStyle }, '物料'), '', c.materialName || '-']),
]),
],
),
),
),
]);
}
async function handleDelete(record: Recordable) {
try {
const linked = await getLinkedRawMaterialCards(record.id as string);
if (linked && linked.length > 0) {
createConfirm({
iconType: 'warning',
title: '已生成原材料卡片',
width: 600,
content: renderLinkedCardsConfirmContent(linked, `以下 ${linked.length} 张卡片将随入场记录一并删除:`),
okText: '仍要删除',
cancelText: '取消',
onOk: async () => {
await deleteOne({ id: record.id as string, cascadeDeleteCards: true }, handleSuccess);
},
});
} else {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否确认删除该条原料入场记录?',
okText: '确认',
cancelText: '取消',
onOk: async () => {
await deleteOne({ id: record.id as string, cascadeDeleteCards: false }, handleSuccess);
},
});
}
} catch (e: unknown) {
createMessage.error(e instanceof Error ? e.message : String(e));
}
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
const ids = (selectedRowKeys.value || []).join(',');
if (!ids) {
createMessage.warning('请先勾选要删除的记录');
return;
}
try {
const linked = await getLinkedRawMaterialCards(ids);
const cardList: MesXslRawMaterialCardBrief[] = linked && linked.length > 0 ? linked : [];
const hasCards = cardList.length > 0;
createConfirm({
iconType: 'warning',
title: hasCards ? '已生成原材料卡片' : '确认删除',
width: hasCards ? 600 : 416,
content: hasCards
? renderLinkedCardsConfirmContent(cardList, `以下 ${cardList.length} 张卡片将随选中入场记录一并删除:`)
: `是否删除选中的 ${selectedRowKeys.value.length} 条数据?`,
okText: '确认删除',
cancelText: '取消',
onOk: async () => {
await batchDelete({ ids, cascadeDeleteCards: !!hasCards }, handleSuccess);
},
});
} catch (e: unknown) {
createMessage.error(e instanceof Error ? e.message : String(e));
}
}
function handleStockIn() {
@@ -450,11 +549,7 @@
},
{
label: '删除',
popConfirm: {
title: '是否确认删除',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
onClick: handleDelete.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_entry:delete',
},
];

View File

@@ -0,0 +1,7 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslRawMaterialEntryDeleteLog/list',
}
export const list = (params) => defHttp.get({ url: Api.list, params });

View File

@@ -0,0 +1,31 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '条码', align: 'center', dataIndex: 'barcode', width: 240 },
{ title: '批次', align: 'center', dataIndex: 'batchNo', width: 220 },
{ title: '创建人', align: 'center', dataIndex: 'createBy', width: 120 },
{
title: '创建时间',
align: 'center',
dataIndex: 'createTime',
width: 170,
},
{ title: '物料名称', align: 'center', dataIndex: 'materialName', width: 320 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '条码', field: 'barcode', component: 'JInput', colProps: { span: 6 } },
{ label: '批次', field: 'batchNo', component: 'JInput', colProps: { span: 6 } },
{ label: '物料名称', field: 'materialName', component: 'Input', colProps: { span: 6 } },
{ label: '创建人', field: 'createBy', component: 'Input', colProps: { span: 6 } },
{
label: '创建时间',
field: 'createTime',
component: 'RangePicker',
colProps: { span: 8 },
componentProps: {
showTime: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];

View File

@@ -0,0 +1,33 @@
<template>
<div>
<BasicTable @register="registerTable" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialEntryDeleteLog" setup>
import { reactive } from 'vue';
import { BasicTable } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { columns, searchFormSchema } from './MesXslRawMaterialEntryDeleteLog.data';
import { list } from './MesXslRawMaterialEntryDeleteLog.api';
const queryParam = reactive<any>({});
const { tableContext } = useListPage({
tableProps: {
title: '原料入场删除日志',
api: list,
columns,
canResize: true,
showIndexColumn: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
fieldMapToTime: [['createTime', ['createTime_begin', 'createTime_end'], 'YYYY-MM-DD HH:mm:ss']],
},
beforeFetch: (params) => Object.assign(params, queryParam),
},
});
const [registerTable] = tableContext;
</script>