Merge branch 'main' into 生产基础资料

This commit is contained in:
2026-05-15 17:21:36 +08:00
2550 changed files with 63705 additions and 498 deletions

View File

@@ -22,7 +22,7 @@ export const columns: BasicColumn[] = [
{ title: '物料描述', align: 'center', width: 180, ellipsis: true, dataIndex: 'materialDesc' },
{ title: '物料别名', align: 'center', width: 120, dataIndex: 'aliasName' },
{
title: '投状态',
title: '投状态',
align: 'center',
width: 100,
dataIndex: 'feedManageStatus',
@@ -113,7 +113,7 @@ export const formSchema: FormSchema[] = [
{ label: '物料描述', field: 'materialDesc', component: 'InputTextArea' },
{ label: '物料别名', field: 'aliasName', component: 'Input' },
{
label: '投状态',
label: '投状态',
field: 'feedManageStatus',
component: 'Select',
defaultValue: 1,

View File

@@ -114,6 +114,8 @@ const { tableContext, onExportXls, onImportXls } = useListPage({
title: '密炼物料信息',
api: list,
columns,
// 避免:表格默认 immediate 请求一次 + onMounted 末尾 reload 再请求一次(进入页列表闪两次)
immediate: false,
canResize: true,
formConfig: { labelWidth: 120, schemas: searchFormSchema, autoSubmitOnEnter: true, showAdvancedButton: true },
actionColumn: { width: 120 },
@@ -227,9 +229,8 @@ function findNodeByKey(nodes: Recordable[], key: string): Recordable | null {
async function loadCategoryTree() {
treeLoading.value = true;
try {
const root = await fetchMaterialCategoryRoot();
const [root, res] = await Promise.all([fetchMaterialCategoryRoot(), loadCategoryTreeRoot({ async: false, pcode: 'XSLMES_MATERIAL' })]);
materialCategoryRootId.value = root?.id != null ? String(root.id) : '';
const res = await loadCategoryTreeRoot({ async: false, pcode: 'XSLMES_MATERIAL' });
rawCategoryTree.value = Array.isArray(res) ? res : [];
if (!materialCategoryRootId.value || !rawCategoryTree.value.length) {
createMessage.warning('未加载到物料分类树,请确认分类字典根编码 XSLMES_MATERIAL 已存在。');

View File

@@ -0,0 +1,59 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/print/bizTemplateBind/list',
add = '/print/bizTemplateBind/add',
edit = '/print/bizTemplateBind/edit',
deleteOne = '/print/bizTemplateBind/delete',
bizTypes = '/print/bizTemplateBind/bizTypes',
bizTypesForBinding = '/print/bizTemplateBind/bizTypesForBinding',
permWhitelist = '/print/bizTemplateBind/permWhitelist',
parseTemplateFields = '/print/bizTemplateBind/parseTemplateFields',
previewMappedData = '/print/bizTemplateBind/previewMappedData',
detailSlots = '/print/bizTemplateBind/detailSlots',
bizFieldsForDetailSlot = '/print/bizTemplateBind/bizFieldsForDetailSlot',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
// 与系统其它模块一致body 走 params 键
export const add = (params) => defHttp.post({ url: Api.add, params });
export const edit = (params) => defHttp.put({ url: Api.edit, params });
export const deleteOne = (params, handleSuccess?) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess?.());
export const bizTypes = () => defHttp.get({ url: Api.bizTypes });
/** 新增/编辑绑定时可选业务(受打印业务白名单过滤);后端批量查缓存与菜单,数据多时延长超时 */
export const bizTypesForBinding = () =>
defHttp.get({ url: Api.bizTypesForBinding, timeout: 120000 });
/** 白名单:已勾选菜单 id + 完整业务目录 */
export const getPermWhitelist = () => defHttp.get({ url: Api.permWhitelist });
/** 勾选菜单多时后端需批量 upsert默认 10s 易超时 */
export const savePermWhitelist = (data: { permIds: string[] }) =>
defHttp.post({ url: Api.permWhitelist, data, timeout: 3 * 60 * 1000 });
export const parseTemplateFields = (templateId: string) =>
defHttp.get({
url: Api.parseTemplateFields,
params: { templateId, _t: Date.now() },
});
/** 预览映射后的打印数据 */
export const previewMappedData = (data: { bizCode: string; bizDataJson: Record<string, unknown> }) =>
defHttp.post({ url: Api.previewMappedData, data });
/** 主实体上可作为明细的数据属性List/数组/嵌套对象) */
export const detailSlots = (bizCode: string) =>
defHttp.get<{ propertyName: string; itemEntityClassFqn: string; slotKind: string; label: string }[]>({
url: Api.detailSlots,
params: { bizCode },
});
/** 反射明细元素类字段fieldKey 已带「属性名.」前缀 */
export const bizFieldsForDetailSlot = (params: {
bizCode: string;
detailProperty: string;
slotKind?: string;
}) =>
defHttp.get<{ fieldKey: string; label: string; description?: string }[]>({
url: Api.bizFieldsForDetailSlot,
params,
});

View File

@@ -0,0 +1,8 @@
import type { BasicColumn } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务编码', dataIndex: 'bizCode', width: 200 },
{ title: '业务名称', dataIndex: 'bizName', width: 140 },
{ title: '模板编码', dataIndex: 'templateCode', width: 180 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
];

View File

@@ -0,0 +1,892 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-space>
<a-button type="primary" @click="openCreate" v-auth="'print:bizBind:add'">新增绑定</a-button>
<a-button @click="openPrintBizWhitelist" :loading="whitelistLoading" v-auth="'print:bizBind:whitelist'">
打印业务白名单
</a-button>
</a-space>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => openEdit(record),
auth: 'print:bizBind:edit',
},
{
label: '删除',
color: 'error',
popConfirm: {
title: '确认删除该绑定?',
confirm: () => handleDelete(record),
},
auth: 'print:bizBind:delete',
},
]"
/>
</template>
</BasicTable>
<BasicModal
@register="registerModal"
:title="modalTitle"
width="1000px"
@ok="submitModal"
:confirm-loading="modalSubmitLoading"
destroy-on-close
wrap-class-name="biz-bind-modal-wrap"
>
<a-spin :spinning="modalDataLoading || parseLoading">
<div class="biz-bind-form">
<a-alert
type="info"
show-icon
class="bind-alert"
message="配置说明"
description="按卡片顺序操作:先选业务与模板 → 若模板含明细占位,在「明细数据来源」中选择主实体上的集合/嵌套对象 → 点击「解析模板占位字段」→ 在下方「主表参数」「明细与表格」中分别为每个占位选择业务字段;业务字段下拉第一项为「空占位符」,表示不参与业务 JSON 取值(等同输出空)。主表参数一般映射主实体字段;明细占位可选带「明细前缀」的路径(如 lines.qty。支持 lines.qty首行或 lines.0.qty。"
/>
<a-card title="基础信息" size="small" :bordered="true" class="bind-card">
<a-form layout="vertical" class="bind-card-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="业务"
required
extra="业务编码为菜单 id业务字段优先从缓存表读取启动任务根据 print_biz_perm_entity 异步写入 mes_xsl_biz_entity_field_*),无缓存时再反射实体类。"
>
<a-select
v-model:value="form.bizCode"
:options="bizSelectOptions"
placeholder="选择业务"
show-search
option-filter-prop="label"
:disabled="isEditMode"
@change="onBizCodeChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="打印模板" required extra="请选择已发布的原生打印模板">
<a-select
v-model:value="form.templateId"
:options="tplSelectOptions"
placeholder="选择模板"
show-search
option-filter-prop="label"
@change="onTemplateChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注">
<a-input v-model:value="form.remark" placeholder="可选" />
</a-form-item>
</a-form>
</a-card>
<a-card size="small" :bordered="true" class="bind-card">
<template #title>
<span class="bind-card-head">明细数据来源</span>
<span class="bind-card-head-extra">可选</span>
</template>
<template #extra>
<span class="bind-card-sub">有明细/表格占位时需配置</span>
</template>
<p class="bind-card-desc">
选择主实体类上的明细集合属性 List&lt;明细实体&gt;或嵌套对象系统将明细类字段并入下方明细与表格中的业务字段下拉
</p>
<a-select
v-model:value="selectedDetailProperty"
allow-clear
show-search
option-filter-prop="label"
placeholder="无需明细请留空"
:options="detailSlotSelectOptions"
:loading="detailFieldsLoading"
style="width: 100%"
@change="onDetailSlotChange"
/>
</a-card>
<a-card size="small" :bordered="true" class="bind-card bind-card--mapping">
<template #title>
<span class="bind-card-head">字段映射</span>
</template>
<template #extra>
<a-space wrap>
<a-button type="primary" ghost size="small" @click="reloadTemplateFields" :loading="parseLoading">
解析模板占位字段
</a-button>
<a-button
size="small"
@click="autoMatchFields"
:disabled="(!bizFields.length && !detailBizFields.length) || !tplFields.length"
>
同名自动匹配
</a-button>
</a-space>
</template>
<div v-if="!form.templateId" class="bind-placeholder">请先在上方的基础信息中选择打印模板</div>
<template v-else-if="tplFields.length">
<div class="bind-map-section">
<div class="bind-section-bar">
<span class="bind-section-title"> 主表参数</span>
<span class="bind-section-hint">对应模板 dataBinding.params / 画布参数请选择主实体 JSON 字段</span>
</div>
<a-table
v-if="mappingRowsParam.length"
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumnsParam"
:data-source="mappingRowsParam"
bordered
class="bind-map-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'bizField'">
<a-select
v-model:value="record.bizField"
:options="bizFieldOptionsMain"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择主表业务字段"
/>
</template>
</template>
</a-table>
<a-empty v-else class="bind-empty" description="本模板未解析到「参数」类占位" />
</div>
<div class="bind-map-section bind-map-section--detail">
<div class="bind-section-bar">
<span class="bind-section-title"> 明细与表格列</span>
<span class="bind-section-hint">对应明细字段表格列等可选主表字段或上方明细来源生成的前缀字段</span>
</div>
<a-table
v-if="mappingRowsDetail.length"
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumnsDetail"
:data-source="mappingRowsDetail"
bordered
class="bind-map-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tplKind'">
{{ templateFieldKindLabel(record.elementType) }}
</template>
<template v-if="column.key === 'bizField'">
<a-select
v-model:value="record.bizField"
:options="bizFieldOptions"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择业务字段"
/>
</template>
</template>
</a-table>
<a-empty v-else class="bind-empty" description="本模板未解析到明细/表格列占位" />
</div>
</template>
<a-empty
v-else-if="form.templateId && !parseLoading"
class="bind-empty"
description="请点击右上角「解析模板占位字段」或切换模板"
/>
</a-card>
<a-card title="映射预览(可选)" size="small" :bordered="true" class="bind-card">
<a-textarea
v-model:value="previewBizJson"
placeholder='粘贴业务 JSON例如{"barcode":"TEST001","materialName":"胶料A"}'
:rows="4"
/>
<div style="margin-top: 10px">
<a-button type="dashed" @click="runPreview" :loading="previewLoading">生成打印数据预览</a-button>
</div>
<pre v-if="previewResult" class="preview-pre">{{ previewResult }}</pre>
</a-card>
</div>
</a-spin>
</BasicModal>
<BasicModal
@register="registerWhitelistModal"
title="打印业务白名单"
width="760px"
@ok="submitWhitelistModal"
:confirm-loading="whitelistSubmitLoading"
destroy-on-close
>
<a-spin :spinning="whitelistLoading">
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
message="说明"
description="勾选允许的菜单 id保存时会写入 print_biz_perm_entity能推断出实体类的菜单。打开弹窗已优化为不再加载全量业务目录。白名单为空时「新增绑定」下拉仅展示表里已有映射白名单非空时展示勾选中且能解析的菜单。"
/>
<a-space style="margin-bottom: 8px">
<a-button size="small" @click="expandWhitelistTree(true)">展开全部</a-button>
<a-button size="small" @click="expandWhitelistTree(false)">折叠全部</a-button>
<a-button size="small" @click="checkedKeysWhitelist = []">清空勾选</a-button>
</a-space>
<BasicTree
ref="whitelistTreeRef"
checkable
:treeData="whitelistTreeData"
:checkedKeys="checkedKeysWhitelist"
:expandedKeys="expandedKeysWhitelist"
:selectedKeys="selectedKeysWhitelist"
:clickRowToExpand="false"
:checkStrictly="true"
title="系统菜单(权限树)"
@check="onWhitelistCheck"
>
<template #title="{ slotTitle, ruleFlag }">
{{ slotTitle }}
<Icon v-if="ruleFlag" icon="ant-design:align-left-outlined" style="margin-left: 5px; color: red" />
</template>
</BasicTree>
</a-spin>
</BasicModal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, unref } from 'vue';
import { BasicTable, TableAction, useTable } from '/@/components/Table';
import { BasicModal, useModal } from '/@/components/Modal';
import { BasicTree, TreeItem } from '/@/components/Tree';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { useI18n } from '/@/hooks/web/useI18n';
import { columns } from './bizTemplateBind.data';
import * as Api from './bizTemplateBind.api';
import { list as tplList } from '../template/printTemplate.api';
import { queryTreeListForRole } from '/@/views/system/role/role.api';
const { createMessage } = useMessage();
const { t } = useI18n();
/** 下拉「空占位符」选项值(落库 fieldMappingJson 的 bizField 转为 '' */
const EMPTY_BIZ_FIELD_SENTINEL = '__PRINT_BIND_EMPTY_BIZ__';
interface BizTypeItem {
bizCode: string;
bizName: string;
fields: { fieldKey: string; label: string; description?: string }[];
}
interface TplFieldItem {
bindField: string;
elementType?: string;
titleHint?: string;
}
interface MappingRow {
templateField: string;
bizField?: string;
elementType?: string;
titleHint?: string;
}
interface DetailSlotItem {
propertyName: string;
itemEntityClassFqn: string;
slotKind: string;
label: string;
}
const bizTypesRef = ref<BizTypeItem[]>([]);
const tplListRef = ref<{ id: string; templateCode: string; templateName: string }[]>([]);
/** 弹窗内:业务列表 + 模板下拉并行加载中(不再阻塞 openModal避免点按钮好几秒才出框 */
const modalDataLoading = ref(false);
const parseLoading = ref(false);
const modalSubmitLoading = ref(false);
const previewLoading = ref(false);
const previewBizJson = ref('');
const previewResult = ref('');
const form = ref({
id: '' as string | undefined,
bizCode: undefined as string | undefined,
bizName: '' as string | undefined,
templateId: undefined as string | undefined,
remark: '' as string | undefined,
});
const tplFields = ref<TplFieldItem[]>([]);
const bizFields = ref<BizTypeItem['fields']>([]);
const mappingRows = ref<MappingRow[]>([]);
const detailSlots = ref<DetailSlotItem[]>([]);
const selectedDetailProperty = ref<string | undefined>(undefined);
const detailBizFields = ref<BizTypeItem['fields']>([]);
const detailFieldsLoading = ref(false);
const isEditMode = ref(false);
const modalTitle = computed(() => (unref(isEditMode) ? '编辑业务打印绑定' : '新增业务打印绑定'));
const bizSelectOptions = computed(() =>
unref(bizTypesRef).map((b) => ({
label: `${b.bizName}${b.bizCode}`,
value: b.bizCode,
})),
);
const tplSelectOptions = computed(() =>
unref(tplListRef).map((t) => ({
label: `${t.templateName}${t.templateCode}`,
value: t.id,
})),
);
const detailSlotSelectOptions = computed(() =>
unref(detailSlots).map((s) => ({
label: `${s.label}${s.propertyName} · ${s.slotKind}`,
value: s.propertyName,
})),
);
const bizFieldOptionsMain = computed(() => {
const head = [{ label: '— 空占位符(不参与业务 JSON—', value: EMPTY_BIZ_FIELD_SENTINEL }];
const rest = unref(bizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
}));
return [...head, ...rest];
});
/** 主表 + 明细前缀字段(用于明细/表格占位) */
const bizFieldOptions = computed(() => {
const head = [{ label: '— 空占位符(不参与业务 JSON—', value: EMPTY_BIZ_FIELD_SENTINEL }];
const main = unref(bizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
}));
const detail = unref(detailBizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
}));
return [...head, ...main, ...detail];
});
/** 已保存的空字符串映射为下拉哨兵,便于展示「空占位符」项 */
function normalizeBizFieldForUi(raw?: string) {
if (raw === undefined || raw === null || raw === '') {
return EMPTY_BIZ_FIELD_SENTINEL;
}
return raw;
}
/** 提交前哨兵还原为空字符串 */
function denormalizeBizFieldForSave(v?: string) {
if (v === EMPTY_BIZ_FIELD_SENTINEL || v === undefined || v === null || v === '') {
return '';
}
return v;
}
/** 主表参数行:模板 elementType 为 param */
const mappingRowsParam = computed(() =>
unref(mappingRows).filter((r) => (r.elementType || '') === 'param'),
);
/** 非参数占位(明细字段、表格列、其它画布元素) */
const mappingRowsDetail = computed(() =>
unref(mappingRows).filter((r) => (r.elementType || '') !== 'param'),
);
function templateFieldKindLabel(t?: string) {
const m: Record<string, string> = {
param: '主表·参数',
detailField: '模板·明细',
column: '表格列',
};
return m[t || ''] || (t || '—');
}
const mapTableColumnsParam = [
{ title: '模板参数占位bindField', dataIndex: 'templateField', width: 220 },
{ title: '标题/提示', dataIndex: 'titleHint', ellipsis: true },
{ title: '业务字段(主表)', key: 'bizField', width: 280 },
];
const mapTableColumnsDetail = [
{ title: '模板类型', key: 'tplKind', width: 104, ellipsis: true },
{ title: '模板占位bindField', dataIndex: 'templateField', width: 188 },
{ title: '标题/提示', dataIndex: 'titleHint', ellipsis: true },
{ title: '业务字段', key: 'bizField', width: 260 },
];
const [registerTable, { reload }] = useTable({
title: '业务打印绑定',
api: Api.list,
columns,
useSearchForm: false,
showTableSetting: true,
bordered: true,
showIndexColumn: true,
// 必须与模板插槽 #action 对应否则操作列不会渲染useTable 无 useListPage 的默认 slots
actionColumn: {
width: 160,
title: '操作',
fixed: 'right',
dataIndex: 'action',
slots: { customRender: 'action' },
},
});
const [registerModal, { openModal, closeModal }] = useModal();
const [registerWhitelistModal, { openModal: openWhitelistDlg, closeModal: closeWhitelistDlg }] = useModal();
const whitelistLoading = ref(false);
const whitelistSubmitLoading = ref(false);
const whitelistTreeRef = ref<InstanceType<typeof BasicTree> | null>(null);
const whitelistTreeData = ref<TreeItem[]>([]);
const allWhitelistTreeKeys = ref<string[]>([]);
const checkedKeysWhitelist = ref<any>([]);
const expandedKeysWhitelist = ref<any>([]);
const selectedKeysWhitelist = ref<any>([]);
/** 将菜单树标题中的 t('...') 表达式转为文案(与角色授权树一致) */
function translateWhitelistTitle(data: TreeItem[] | undefined): TreeItem[] {
if (data?.length) {
data.forEach((item) => {
if (item.slotTitle && typeof item.slotTitle === 'string' && item.slotTitle.includes("t('")) {
try {
item.slotTitle = new Function('t', `return ${item.slotTitle}`)(t) as string;
} catch {
/* 忽略解析失败 */
}
}
if (item.children?.length) {
translateWhitelistTitle(item.children as TreeItem[]);
}
});
}
return data ?? [];
}
function expandWhitelistTree(expand: boolean) {
expandedKeysWhitelist.value = expand ? [...unref(allWhitelistTreeKeys)] : [];
}
function onWhitelistCheck(o: { checked?: any } | any) {
checkedKeysWhitelist.value = o?.checked !== undefined ? o.checked : o;
}
/** 树勾选键转为字符串数组(兼容 checkStrictly */
function normalizeCheckedKeys(keys: unknown): string[] {
if (Array.isArray(keys)) {
return keys.map((k) => String(k));
}
if (keys && typeof keys === 'object' && 'checked' in (keys as object)) {
const c = (keys as { checked?: unknown }).checked;
if (Array.isArray(c)) {
return c.map((k) => String(k));
}
}
return [];
}
async function openPrintBizWhitelist() {
whitelistLoading.value = true;
try {
const [treeResult, wl] = await Promise.all([queryTreeListForRole(), Api.getPermWhitelist()]);
whitelistTreeData.value = translateWhitelistTitle(treeResult.treeList);
allWhitelistTreeKeys.value = treeResult.ids || [];
expandedKeysWhitelist.value = treeResult.ids || [];
checkedKeysWhitelist.value = wl?.permIds?.length ? [...wl.permIds] : [];
openWhitelistDlg(true);
} finally {
whitelistLoading.value = false;
}
}
async function submitWhitelistModal() {
whitelistSubmitLoading.value = true;
try {
const tree = unref(whitelistTreeRef) as any;
let raw = tree?.getCheckedKeys?.() ?? unref(checkedKeysWhitelist);
const permIds = normalizeCheckedKeys(raw);
await Api.savePermWhitelist({ permIds });
createMessage.success('保存成功');
closeWhitelistDlg();
} finally {
whitelistSubmitLoading.value = false;
}
}
async function loadBizTypes() {
const res = await Api.bizTypesForBinding();
bizTypesRef.value = res || [];
}
async function loadAllTemplates() {
const res = await tplList({ pageNo: 1, pageSize: 500 });
tplListRef.value = res?.records ?? [];
}
async function refreshDetailSlots(code: string | undefined) {
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
if (!code) {
return;
}
try {
detailSlots.value = (await Api.detailSlots(code)) || [];
} catch {
detailSlots.value = [];
}
}
async function onDetailSlotChange(propertyName: string | undefined) {
selectedDetailProperty.value = propertyName;
if (!propertyName || !form.value.bizCode) {
detailBizFields.value = [];
return;
}
const slot = unref(detailSlots).find((s) => s.propertyName === propertyName);
const kind = slot?.slotKind || 'LIST';
detailFieldsLoading.value = true;
try {
const list = await Api.bizFieldsForDetailSlot({
bizCode: form.value.bizCode,
detailProperty: propertyName,
slotKind: kind,
});
detailBizFields.value = (list || []) as BizTypeItem['fields'];
} catch {
detailBizFields.value = [];
} finally {
detailFieldsLoading.value = false;
}
}
async function onBizCodeChange(code: string) {
const hit = unref(bizTypesRef).find((b) => b.bizCode === code);
bizFields.value = hit?.fields ?? [];
form.value.bizName = hit?.bizName;
await refreshDetailSlots(code);
}
async function onTemplateChange() {
tplFields.value = [];
mappingRows.value = [];
await reloadTemplateFields();
}
async function reloadTemplateFields() {
const tid = form.value.templateId;
if (!tid) {
tplFields.value = [];
mappingRows.value = [];
return;
}
parseLoading.value = true;
try {
const list = (await Api.parseTemplateFields(tid)) as TplFieldItem[];
tplFields.value = list || [];
rebuildMappingRows();
} finally {
parseLoading.value = false;
}
}
function rebuildMappingRows() {
const saved = unref(savedMappingRef);
const tplList = unref(tplFields);
const tplByField = new Map(tplList.map((t) => [t.bindField, t]));
// 先按已保存 JSON 的顺序收录键(含「模板未再声明」的占位如 Parameter3 + 空 bizField避免仅依赖解析结果而丢行
const orderedKeys: string[] = [];
const seen = new Set<string>();
for (const s of saved) {
const k = (s.templateField || '').trim();
if (!k || seen.has(k)) continue;
seen.add(k);
orderedKeys.push(k);
}
for (const t of tplList) {
const k = (t.bindField || '').trim();
if (!k || seen.has(k)) continue;
seen.add(k);
orderedKeys.push(k);
}
mappingRows.value = orderedKeys.map((templateField) => {
const t = tplByField.get(templateField);
const hit = saved.find((x) => x.templateField === templateField);
return {
templateField,
bizField: hit !== undefined ? normalizeBizFieldForUi(hit.bizField) : undefined,
elementType: t?.elementType || 'param',
titleHint:
t?.titleHint ||
(!t ? '已保存映射(当前模板 JSON 未声明该占位,仍可按空业务字段输出)' : ''),
};
});
}
const savedMappingRef = ref<{ templateField: string; bizField?: string }[]>([]);
function autoMatchFields() {
const merged = [...unref(bizFields), ...unref(detailBizFields)];
const set = new Map(merged.map((f) => [f.fieldKey, f.fieldKey]));
for (const row of unref(mappingRows)) {
if (set.has(row.templateField)) {
row.bizField = row.templateField;
}
}
mappingRows.value = [...unref(mappingRows)];
}
function buildFieldMappingJson() {
const arr = unref(mappingRows)
.filter((r) => r.templateField)
.map((r) => ({
templateField: r.templateField,
bizField: denormalizeBizFieldForSave(r.bizField),
}));
return JSON.stringify(arr);
}
async function openCreate() {
isEditMode.value = false;
savedMappingRef.value = [];
form.value = { id: undefined, bizCode: undefined, bizName: undefined, templateId: undefined, remark: undefined };
tplFields.value = [];
bizFields.value = [];
mappingRows.value = [];
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
previewBizJson.value = '';
previewResult.value = '';
openModal(true);
modalDataLoading.value = true;
try {
await Promise.all([loadBizTypes(), loadAllTemplates()]);
} finally {
modalDataLoading.value = false;
}
}
async function openEdit(record: Recordable) {
isEditMode.value = true;
try {
savedMappingRef.value = JSON.parse(record.fieldMappingJson || '[]');
} catch {
savedMappingRef.value = [];
}
form.value = {
id: record.id,
bizCode: record.bizCode,
bizName: record.bizName,
templateId: record.templateId,
remark: record.remark,
};
previewBizJson.value = '';
previewResult.value = '';
tplFields.value = [];
mappingRows.value = [];
bizFields.value = [];
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
openModal(true);
modalDataLoading.value = true;
try {
await Promise.all([loadBizTypes(), loadAllTemplates()]);
await onBizCodeChange(record.bizCode as string);
await reloadTemplateFields();
} finally {
modalDataLoading.value = false;
}
}
async function submitModal() {
if (!form.value.bizCode) {
createMessage.warning('请选择业务');
return Promise.reject();
}
if (!form.value.templateId) {
createMessage.warning('请选择打印模板');
return Promise.reject();
}
modalSubmitLoading.value = true;
try {
const payload = {
id: form.value.id,
bizCode: form.value.bizCode,
bizName: form.value.bizName,
templateId: form.value.templateId,
remark: form.value.remark,
fieldMappingJson: buildFieldMappingJson(),
};
if (unref(isEditMode)) {
await Api.edit(payload);
} else {
await Api.add(payload);
}
closeModal();
reload();
} finally {
modalSubmitLoading.value = false;
}
}
async function handleDelete(record: Recordable) {
await Api.deleteOne({ id: record.id }, () => reload());
}
async function runPreview() {
if (!form.value.bizCode) {
createMessage.warning('请先选择业务;预览读取的是服务器已保存的绑定配置');
return;
}
let obj: Record<string, unknown> = {};
try {
obj = previewBizJson.value ? (JSON.parse(previewBizJson.value) as Record<string, unknown>) : {};
} catch {
previewResult.value = '业务 JSON 格式不正确';
return;
}
previewLoading.value = true;
try {
const res = await Api.previewMappedData({ bizCode: form.value.bizCode, bizDataJson: obj });
previewResult.value = JSON.stringify(res, null, 2);
} catch (e: unknown) {
previewResult.value = e instanceof Error ? e.message : String(e);
} finally {
previewLoading.value = false;
}
}
</script>
<style scoped>
.biz-bind-form {
max-height: calc(100vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.bind-alert {
margin-bottom: 12px;
}
.bind-card {
margin-bottom: 12px;
border-radius: 6px;
}
.bind-card :deep(.ant-card-head) {
min-height: 42px;
padding: 0 12px;
}
.bind-card :deep(.ant-card-body) {
padding: 12px 16px;
}
.bind-card-form :deep(.ant-form-item) {
margin-bottom: 12px;
}
.bind-card-head {
font-weight: 600;
font-size: 14px;
}
.bind-card-head-extra {
font-weight: 400;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
margin-left: 6px;
}
.bind-card-sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.bind-card-desc {
margin: 0 0 10px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.5;
}
.bind-placeholder {
padding: 16px;
text-align: center;
color: rgba(0, 0, 0, 0.45);
background: #fafafa;
border-radius: 4px;
margin-bottom: 8px;
}
.bind-map-section {
margin-bottom: 16px;
}
.bind-map-section--detail {
margin-bottom: 0;
padding-top: 8px;
border-top: 1px dashed #f0f0f0;
}
.bind-section-bar {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
margin-bottom: 8px;
}
.bind-section-title {
font-weight: 600;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
}
.bind-section-hint {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.bind-map-table {
margin-bottom: 0;
}
.bind-empty {
margin: 8px 0;
}
.preview-pre {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
max-height: 240px;
overflow: auto;
font-size: 12px;
margin-top: 10px;
}
</style>

View File

@@ -92,6 +92,20 @@ function enhancePrintDotErrorMessage(raw: string): string {
if (/SumatraPDF\.exe not found/i.test(m) || /SUMATRAPDF_PATH/i.test(m)) {
return `${m}。本地处理PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 Sumatra PDF 后任选其一:将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;或将 Sumatra 安装目录加入系统 PATH或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径,然后重启 PrintDot。`;
}
/** 桥接端在等待 Windows 打印队列接受作业(默认约 2 分钟)未果 */
if (/not queued/i.test(m) || /Printed\s+0\s*\/\s*\d+\s+copies/i.test(m)) {
return `${m}
【说明】PrintDot 已通过 SumatraPDF 发起静默打印,但在约定时间内未检测到作业进入系统打印队列。
【建议逐项排查】
1. 打印机是否开机、联网(网络打印机)、线缆/USB 是否正常。
2. Windows「设备和打印机」中该打印机是否就绪、无暂停打印队列里是否有卡住的任务可先清空队列
3. 下拉选择的打印机名称是否与系统完全一致(可在本页「刷新打印机」后重选)。
4. 重启「Print Spooler」打印后台服务或重启 PrintDot 客户端后再试。
5. 模板版面过大时生成的 PDF 体积大,可能导致 Sumatra 处理变慢——可先简化模板或缩小画布后再试。
6. 若频繁超时,需在 PrintDot 桌面端放宽「队列确认」超时(该 2 分钟由客户端决定,浏览器无法修改)。`;
}
return m;
}

View File

@@ -56,6 +56,11 @@ export type BuildPdfFromHtmlOptions = {
* false整张版面压成一页 PDF长图一页一般仅特殊场景使用
*/
paginate?: boolean;
/**
* 是否严格使用入参纸张尺寸(默认 false 保持历史行为)。
* 原生模板桥接打印建议开启,避免内容测量误差把小标签纸扩成 A4。
*/
exactPaperSize?: boolean;
};
/**
@@ -70,6 +75,7 @@ export async function buildPdfBase64FromHtmlFragment(
options: BuildPdfFromHtmlOptions = {},
): Promise<string> {
const paginate = options.paginate !== false;
const exactPaperSize = options.exactPaperSize === true;
const [{ jsPDF }, html2canvasModule] = await Promise.all([import('jspdf'), import('html2canvas')]);
const html2canvas = html2canvasModule.default;
const container = document.createElement('div');
@@ -152,10 +158,11 @@ export async function buildPdfBase64FromHtmlFragment(
const pad = 1;
if (paginate) {
const sheetW = Math.max(widthMm, pxToMm(sw) + pad);
const sheetW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, pxToMm(sw) + pad);
const sheetH = Math.max(1, heightMm);
const sliceH = Math.max(1, Math.round(mmToPx(sheetH) * scale));
const pdf = new jsPDF({ unit: 'mm', format: [sheetW, sheetH] });
const orientation = sheetW > sheetH ? 'landscape' : 'portrait';
const pdf = new jsPDF({ unit: 'mm', orientation, format: [sheetW, sheetH] });
let y = 0;
let first = true;
/** 余量不足一页高的 2% 时视为测量噪声,避免多出一页空白 */
@@ -193,7 +200,7 @@ export async function buildPdfBase64FromHtmlFragment(
// 单页长图模式paginate: false
const contentWidthMm = pxToMm(sw);
const contentHeightMm = pxToMm(sh);
const minW = Math.max(widthMm, contentWidthMm) + pad;
const minW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, contentWidthMm) + pad;
const minH = Math.max(heightMm, contentHeightMm) + pad;
const canvasRatio = cw / ch;
let pdfH = Math.max(minH, minW / canvasRatio);
@@ -202,7 +209,8 @@ export async function buildPdfBase64FromHtmlFragment(
pdfW = minW;
pdfH = pdfW / canvasRatio;
}
const pdf = new jsPDF({ unit: 'mm', format: [pdfW, pdfH] });
const orientation = pdfW > pdfH ? 'landscape' : 'portrait';
const pdf = new jsPDF({ unit: 'mm', orientation, format: [pdfW, pdfH] });
const imgData = canvas.toDataURL('image/jpeg', 0.92);
pdf.addImage(imgData, 'JPEG', 0, 0, pdfW, pdfH);
return arrayBufferToBase64(pdf.output('arraybuffer'));

View File

@@ -20,13 +20,14 @@ export async function printNativeSchemaViaPrintDot(params: {
const inner = extractBodyInnerHtmlFromFullDocument(fullHtml);
const pdfBase64 = await buildPdfBase64FromHtmlFragment(inner, params.schema.page.width, params.schema.page.height, {
paginate: true,
exactPaperSize: true,
});
const printers = await fetchPrintDotPrinters();
const fromStore =
params.printerSelection ?? localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ?? '__system_default__';
const resolved = resolvePrintDotPrinterName(fromStore, printers);
if (!resolved) {
throw new Error('未解析到可用打印机:请在模板列表选择打印机,或启动 PrintDot 后刷新打印机列表');
throw new Error('未解析到可用打印机:请在本页或打印模板页选择打印机,并确保本机 PrintDot 已启动后刷新打印机列表');
}
const result = await printDotSendPdf({
printer: resolved,

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

@@ -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 + 前端生成的 pdfBase64printerName 空则用默认队列 */
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });

View File

@@ -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,6 +7,36 @@
<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>
<JDictSelectTag
v-model:value="printDotWsUrl"
dictCode="xslmes_print_dot_ws"
:showChooseOption="false"
style="width: 280px; margin-left: 8px"
placeholder="选择 PrintDot 地址"
@change="onPrintDotWsUrlChange"
/>
<a-button v-if="!printDotConnected" style="margin-left: 8px" @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 220px; margin-left: 8px"
allow-clear
show-search
option-filter-prop="label"
:placeholder="printerSelectPlaceholder"
/>
<a-button style="margin-left: 8px" @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_card:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
打印选中
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -42,20 +72,76 @@
</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 { 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';
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();
/** 与 Flyway 中 sys_dict.dict_code 一致 */
const PRINT_DOT_WS_DICT = 'xslmes_print_dot_ws';
const printDotWsUrl = ref('');
/** 是否已成功连接桥并拿到响应(用于隐藏「下载打印插件」) */
const printDotConnected = ref(false);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(String(printDotWsUrl.value || '').trim(), '');
void refreshPrinterOptions(false);
}
function onPrintDotWsUrlChange() {
printDotConnected.value = false;
persistPrintDotConfig();
}
function downloadPrintPlugin() {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'XSL-PrintDot.exe');
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
const queryParam = reactive<any>({});
const checkedKeys = ref<Array<string | number>>([]);
const [registerModal, { openModal }] = useModal();
@@ -74,8 +160,11 @@
],
},
actionColumn: {
width: 120,
width: 248,
fixed: 'right',
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
},
beforeFetch: (params) => {
return Object.assign(params, queryParam);
@@ -92,9 +181,217 @@
},
});
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 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 桥接)';
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();
printDotConnected.value = true;
dotList.forEach((p) => {
const name = String(p.name || '').trim();
if (!name) return;
const defMark = p.isDefault ? '(默认)' : '';
optionMap.set(name, { label: `${name}${defMark}`, value: name });
});
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
if (dotList.length) {
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
} else {
createMessage.warning('PrintDot 已连接但未返回打印机列表');
}
}
} catch (e: unknown) {
printDotConnected.value = false;
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
createMessage.warning(`PrintDot${e instanceof Error ? e.message : String(e)}`);
}
}
}
async function executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
try {
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
// 以模板主表的纸张配置为准,避免 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__',
});
/** 单行/批量外层会统一 toast此处避免重复 success */
if (!options?.silentSuccess) {
createMessage.success('已通过 PrintDot 提交打印');
}
} catch (e: unknown) {
throw new Error(e instanceof Error ? e.message : String(e));
}
}
function handlePrintSelected() {
const rows = selectedRows.value || [];
if (!rows.length) {
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
return;
}
printLoading.value = true;
createMessage.destroy(PRINT_BATCH_LOADING_KEY);
createMessage.loading({
content: `正在打印 ${rows.length} 条记录,请稍候…`,
key: PRINT_BATCH_LOADING_KEY,
duration: 0,
});
(async () => {
try {
let ok = 0;
let firstError = '';
for (const row of rows) {
try {
await executePrint(row, { silentSuccess: true });
ok += 1;
} catch (e: unknown) {
if (!firstError) {
firstError = e instanceof Error ? e.message : String(e);
}
}
}
if (ok === rows.length) {
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
} else {
createMessage.warning(
`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`
);
}
} finally {
createMessage.destroy(PRINT_BATCH_LOADING_KEY);
printLoading.value = false;
}
})();
}
async function handlePrintRow(record: Recordable) {
printLoading.value = true;
createMessage.destroy(PRINT_ROW_LOADING_KEY);
createMessage.loading({
content: '正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…',
key: PRINT_ROW_LOADING_KEY,
duration: 0,
});
try {
await executePrint(record, { silentSuccess: true });
createMessage.success('已通过 PrintDot 提交打印');
} catch (e: unknown) {
createMessage.error(e instanceof Error ? e.message : String(e));
} finally {
createMessage.destroy(PRINT_ROW_LOADING_KEY);
printLoading.value = false;
}
}
onMounted(async () => {
const cfg = getPrintDotBridgeConfig();
// 密钥不再使用,写入空串
setPrintDotBridgeConfig(cfg.wsUrl, '');
printDotWsUrl.value = cfg.wsUrl || '';
try {
const raw = await initDictOptions(PRINT_DOT_WS_DICT);
const items = Array.isArray(raw) ? raw : [];
const values = items
.map((it: Recordable) => String(it.value ?? it.itemValue ?? '').trim())
.filter(Boolean);
const valueSet = new Set(values);
if (valueSet.size && printDotWsUrl.value && !valueSet.has(String(printDotWsUrl.value).trim())) {
printDotWsUrl.value = values[0];
setPrintDotBridgeConfig(printDotWsUrl.value, '');
} else if (valueSet.size && !printDotWsUrl.value.trim()) {
printDotWsUrl.value = values[0];
setPrintDotBridgeConfig(printDotWsUrl.value, '');
}
} catch {
/* 字典未配置时沿用 localStorage */
}
const savedPrinter = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (savedPrinter) {
selectedPrinterName.value = savedPrinter;
}
await refreshPrinterOptions(false);
});
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
@@ -145,6 +442,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',
},
];
}

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,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>

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,42 +1,45 @@
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',
queryPrinters = '/xslmes/mesXslRawMaterialEntry/queryPrinters',
prepareNativePrint = '/xslmes/mesXslRawMaterialEntry/prepareNativePrint',
printPdf = '/xslmes/mesXslRawMaterialEntry/printPdf',
}
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?.();
});
};
@@ -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 + 前端生成的 pdfBase64printerName 空则用默认队列 */
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });

View File

@@ -6,6 +6,37 @@
<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>
<!-- 与原材料卡片一致字典选桥接地址 + 打印机 + 刷新 -->
<JDictSelectTag
v-model:value="printDotWsUrl"
dictCode="xslmes_print_dot_ws"
:showChooseOption="false"
style="width: 280px; margin-left: 8px"
placeholder="选择打印桥接"
@change="onPrintDotWsUrlChange"
/>
<a-button v-if="!printDotConnected" style="margin-left: 8px" @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 220px; margin-left: 8px"
allow-clear
show-search
option-filter-prop="label"
:placeholder="printerSelectPlaceholder"
/>
<a-button style="margin-left: 8px" @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_entry:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
打印选中
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -26,24 +57,83 @@
</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, 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';
import { list, deleteOne, batchDelete, batchStockIn, getImportUrl, getExportUrl } from './MesXslRawMaterialEntry.api';
import {
list,
deleteOne,
batchDelete,
batchStockIn,
getImportUrl,
getExportUrl,
getLinkedRawMaterialCards,
prepareNativePrint,
type MesXslRawMaterialCardBrief,
} 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();
/** 与 Flyway 中 sys_dict.dict_code 一致(与原材料卡片列表相同) */
const PRINT_DOT_WS_DICT = 'xslmes_print_dot_ws';
const printDotWsUrl = ref('');
/** 是否已成功连接桥并拿到响应(用于显示「下载打印插件」) */
const printDotConnected = ref(false);
function persistPrintDotConfig() {
// 与原材料卡片一致:桥接密钥固定为空,由本地 PrintDot 与字典地址对接
setPrintDotBridgeConfig(String(printDotWsUrl.value || '').trim(), '');
void refreshPrinterOptions(false);
}
function onPrintDotWsUrlChange() {
printDotConnected.value = false;
persistPrintDotConfig();
}
function downloadPrintPlugin() {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'XSL-PrintDot.exe');
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
const queryParam = reactive<any>({});
const [registerModal, { openModal }] = useModal();
const { prefixCls, tableContext, onExportXls, onImportXls } = useListPage({
const { tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '原料入场记录',
api: list,
@@ -58,8 +148,11 @@
],
},
actionColumn: {
width: 180,
width: 320,
fixed: 'right',
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
},
beforeFetch: (params) => {
return Object.assign(params, queryParam);
@@ -76,9 +169,213 @@
},
});
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 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();
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) {
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
} else {
createMessage.warning('PrintDot 已连接但未返回打印机列表');
}
}
} catch (e: unknown) {
printDotConnected.value = false;
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)}`);
}
}
}
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 (!String(printDotWsUrl.value || '').trim()) {
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 (!String(printDotWsUrl.value || '').trim()) {
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(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;
}
await refreshPrinterOptions(false);
});
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
@@ -98,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() {
@@ -136,6 +528,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',
},
];
}
@@ -147,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,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>

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>

View File

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

View File

@@ -0,0 +1,39 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '所在仓库', align: 'center', dataIndex: 'warehouseName', width: 180 },
{ title: '物料名称', align: 'center', dataIndex: 'materialName', width: 160, ellipsis: true },
{ title: '物料编码', align: 'center', dataIndex: 'materialCode', width: 160 },
{ title: '状态检验', align: 'center', dataIndex: 'testResult_dictText', width: 100 },
{ title: '总包数', align: 'center', dataIndex: 'totalPackages', width: 120 },
{ title: '总重量', align: 'center', dataIndex: 'totalWeight', width: 120 },
{
title: '更新时间',
align: 'center',
dataIndex: 'updateTime',
width: 180,
customRender: ({ text }) => (!text ? '' : text.length > 19 ? text.substring(0, 19) : text),
},
];
export const searchFormSchema: FormSchema[] = [
{ label: '所在仓库', field: 'warehouseName', component: 'JInput', colProps: { span: 6 } },
{ label: '物料名称', field: 'materialName', component: 'JInput', colProps: { span: 6 } },
{ label: '物料编码', field: 'materialCode', component: 'JInput', colProps: { span: 6 } },
{
label: '状态检验',
field: 'testResult',
component: 'JDictSelectTag',
componentProps: { dictCode: 'xslmes_test_result' },
colProps: { span: 6 },
},
];
export const superQuerySchema = {
warehouseName: { title: '所在仓库', order: 0, view: 'text' },
materialName: { title: '物料名称', order: 1, view: 'text' },
materialCode: { title: '物料编码', order: 2, view: 'text' },
testResult: { title: '状态检验', order: 3, view: 'list', dictCode: 'xslmes_test_result' },
totalPackages: { title: '总包数', order: 4, view: 'number' },
totalWeight: { title: '总重量', order: 5, view: 'number' },
};

View File

@@ -0,0 +1,54 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_inventory:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls">
导出
</a-button>
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
</template>
</BasicTable>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialInventory" setup>
import { reactive } from 'vue';
import { BasicTable } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialInventory.data';
import { list, getExportUrl } from './MesXslRawMaterialInventory.api';
const queryParam = reactive<any>({});
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '原材料库存',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
beforeFetch: (params) => {
return Object.assign(params, queryParam);
},
},
exportConfig: {
name: '原材料库存',
url: getExportUrl,
params: queryParam,
},
});
const [registerTable, { reload }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
function handleSuperQuery(params) {
Object.keys(params).forEach((k) => {
queryParam[k] = params[k];
});
reload();
}
</script>

View File

@@ -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 });

View File

@@ -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>

View File

@@ -0,0 +1,20 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslRawMaterialWorkshopRemain/list',
exportXls = '/xslmes/mesXslRawMaterialWorkshopRemain/exportXls',
batchUpdatePriority = '/xslmes/mesXslRawMaterialWorkshopRemain/batchUpdatePriority',
}
export const getExportUrl = Api.exportXls;
export const list = (params) => defHttp.get({ url: Api.list, params });
export const batchUpdatePriority = (ids: string[], priorityPickup: '0' | '1') =>
defHttp.put(
{
url: Api.batchUpdatePriority,
params: { ids: ids.join(','), priorityPickup },
},
{ joinParamsToUrl: true },
);

View File

@@ -0,0 +1,72 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
import { render } from '/@/utils/common/renderUtils';
export const columns: BasicColumn[] = [
{ title: '条码', align: 'center', dataIndex: 'barcode', width: 180 },
{ title: '批次号', align: 'center', dataIndex: 'batchNo', width: 160 },
{
title: '入场日期',
align: 'center',
dataIndex: 'entryDate',
width: 120,
customRender: ({ text }) => (!text ? '' : text.length > 10 ? text.substring(0, 10) : text),
},
{ title: '物料名称', align: 'center', dataIndex: 'materialName', width: 160, ellipsis: true },
{ title: '供应商', align: 'center', dataIndex: 'supplierName', width: 160, ellipsis: true },
{ title: '保质期', align: 'center', dataIndex: 'shelfLife', width: 120 },
{ title: '总重', align: 'center', dataIndex: 'totalWeight', width: 110 },
{ title: '剩余重量', align: 'center', dataIndex: 'remainingWeight', width: 110 },
{ title: '剩余数量', align: 'center', dataIndex: 'remainingQuantity', width: 110 },
{ title: '检测结果', align: 'center', dataIndex: 'testResult_dictText', width: 110 },
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 100 },
{
title: '优先使用',
align: 'center',
dataIndex: 'priorityPickup',
width: 100,
customRender: ({ text }) => render.renderSwitch(text, [{ text: '是', value: '1' }, { text: '否', value: '0' }]),
},
];
export const searchFormSchema: FormSchema[] = [
{ label: '条码', field: 'barcode', component: 'JInput', colProps: { span: 6 } },
{ label: '批次号', field: 'batchNo', component: 'JInput', colProps: { span: 6 } },
{ label: '物料名称', field: 'materialName', component: 'JInput', colProps: { span: 6 } },
{ label: '供应商', field: 'supplierName', component: 'JInput', colProps: { span: 6 } },
{
label: '入场日期',
field: 'entryDate',
component: 'RangePicker',
componentProps: { showTime: false, valueFormat: 'YYYY-MM-DD' },
colProps: { span: 8 },
},
{
label: '检测结果',
field: 'testResult',
component: 'JDictSelectTag',
componentProps: { dictCode: 'xslmes_test_result' },
colProps: { span: 6 },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'xslmes_card_status' },
colProps: { span: 6 },
},
];
export const superQuerySchema = {
barcode: { title: '条码', order: 0, view: 'text' },
batchNo: { title: '批次号', order: 1, view: 'text' },
entryDate: { title: '入场日期', order: 2, view: 'date' },
materialName: { title: '物料名称', order: 3, view: 'text' },
supplierName: { title: '供应商', order: 4, view: 'text' },
shelfLife: { title: '保质期', order: 5, view: 'text' },
totalWeight: { title: '总重', order: 6, view: 'number' },
remainingWeight: { title: '剩余重量', order: 7, view: 'number' },
remainingQuantity: { title: '剩余数量', order: 8, view: 'number' },
testResult: { title: '检测结果', order: 9, view: 'list', dictCode: 'xslmes_test_result' },
status: { title: '状态', order: 10, view: 'list', dictCode: 'xslmes_card_status' },
priorityPickup: { title: '优先使用', order: 11, view: 'list', dictCode: 'yn' },
};

View File

@@ -0,0 +1,84 @@
<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_workshop_remain:edit'"
:disabled="selectedRowKeys.length === 0"
@click="() => handleBatchPriorityUpdate('1')"
>
设为优先使用
</a-button>
<a-button
danger
ghost
v-auth="'xslmes:mes_xsl_raw_material_workshop_remain:edit'"
:disabled="selectedRowKeys.length === 0"
@click="() => handleBatchPriorityUpdate('0')"
>
取消优先使用
</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_workshop_remain:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls">
导出
</a-button>
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
</template>
</BasicTable>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialWorkshopRemain" setup>
import { reactive } from 'vue';
import { BasicTable } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialWorkshopRemain.data';
import { batchUpdatePriority, list, getExportUrl } from './MesXslRawMaterialWorkshopRemain.api';
const { createMessage } = useMessage();
const queryParam = reactive<any>({});
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '原材料车间剩余量',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
fieldMapToTime: [['entryDate', ['entryDate_begin', 'entryDate_end'], 'YYYY-MM-DD']],
},
beforeFetch: (params) => Object.assign(params, queryParam),
},
exportConfig: {
name: '原材料车间剩余量',
url: getExportUrl,
params: queryParam,
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
function handleSuperQuery(params) {
Object.keys(params).forEach((k) => {
queryParam[k] = params[k];
});
reload();
}
async function handleBatchPriorityUpdate(priorityPickup: '0' | '1') {
if (!selectedRowKeys.value.length) {
createMessage.warning('请先勾选记录');
return;
}
await batchUpdatePriority(selectedRowKeys.value as string[], priorityPickup);
createMessage.success(priorityPickup === '1' ? '已设为优先使用' : '已取消优先使用');
selectedRowKeys.value = [];
reload();
}
</script>

View File

@@ -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,

View File

@@ -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,

View File

@@ -15,6 +15,7 @@ enum Api {
importExcel = '/xslmes/mesXslWarehouseArea/importExcel',
exportXls = '/xslmes/mesXslWarehouseArea/exportXls',
batchAdd = '/xslmes/mesXslWarehouseArea/batchAdd',
capacityMatchConfig = '/xslmes/mesXslWarehouseArea/capacityMatchConfig',
}
export const getExportUrl = Api.exportXls;
@@ -60,6 +61,12 @@ export const saveOrUpdate = (params, isUpdate) => {
return defHttp.post({ url, params });
};
/** 实际存放量:匹配仓库配置 */
export const getCapacityMatchConfig = () => defHttp.get({ url: Api.capacityMatchConfig });
export const saveCapacityMatchConfig = (params: Record<string, unknown>) =>
defHttp.post({ url: Api.capacityMatchConfig, params });
/** 批量添加库区(同一仓库下一次性创建多条) */
export const batchAddAreas = (params: any[]) => defHttp.post({ url: Api.batchAdd, params });

View File

@@ -2,6 +2,9 @@
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="default" v-auth="'xslmes:mes_xsl_warehouse_area:capacityMatch'" preIcon="ant-design:link-outlined" @click="openCapacityMatch">
匹配仓库
</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_warehouse_area:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_warehouse_area:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_warehouse_area:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
@@ -26,6 +29,7 @@
</template>
</BasicTable>
<MesXslWarehouseAreaModal @register="registerModal" @success="handleSuccess" />
<MesXslWarehouseAreaCapacityMatchModal @register="registerCapacityMatchModal" @success="handleSuccess" />
</div>
</template>
@@ -35,12 +39,14 @@
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslWarehouseAreaModal from './components/MesXslWarehouseAreaModal.vue';
import MesXslWarehouseAreaCapacityMatchModal from './components/MesXslWarehouseAreaCapacityMatchModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './MesXslWarehouseArea.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, updateStatus } from './MesXslWarehouseArea.api';
import Icon from '/@/components/Icon';
import type { Recordable } from '/@/types/global';
const [registerModal, { openModal }] = useModal();
const [registerCapacityMatchModal, { openModal: openCapacityMatchModal }] = useModal();
const { tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
@@ -71,6 +77,10 @@
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
function openCapacityMatch() {
openCapacityMatchModal(true, {});
}
function handleAdd() {
openModal(true, { isUpdate: false, showFooter: true, record: {} });
}

View File

@@ -0,0 +1,109 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroy-on-close title="匹配仓库(实际存放量)" width="720" @ok="handleSubmit">
<a-alert v-if="bootstrapHint" type="info" show-icon style="margin-bottom: 14px" :message="bootstrapHint" />
<BasicForm @register="registerForm">
<template #categoryIdsSlot="{ model, field }">
<JCategorySelect
v-model:value="model[field]"
pcode="XSLMES_WH"
placeholder="请选择仓库分类(可多选)"
:multiple="true"
/>
</template>
</BasicForm>
</BasicModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm, JCategorySelect } from '/@/components/Form';
import type { FormSchema } from '/@/components/Form';
import { useMessage } from '/@/hooks/web/useMessage';
import { getCapacityMatchConfig, saveCapacityMatchConfig } from '../MesXslWarehouseArea.api';
const emit = defineEmits(['register', 'success']);
const { createMessage } = useMessage();
const bootstrapHint = ref<string>('');
const capacityMatchSchemas: FormSchema[] = [
{
field: 'enabled',
label: '启用回填',
component: 'Switch',
defaultValue: true,
componentProps: {
checkedChildren: '开',
unCheckedChildren: '关',
},
},
{
field: 'warehouseCategoryIds',
label: '仓库分类',
component: 'Input',
slot: 'categoryIdsSlot',
helpMessage: '与列表中库区「仓库分类」一致时才按原材料卡片剩余数量汇总显示实际存放量',
},
{
field: 'warehouseCategoryCodes',
label: '补充分类编码',
component: 'InputTextArea',
componentProps: {
rows: 2,
placeholder: '可选,逗号分隔,例如 XSLMES_WH_F1_YCL会与上面所选分类一并生效',
},
},
{
field: 'remark',
label: '备注',
component: 'InputTextArea',
componentProps: { rows: 2, placeholder: '选填,仅作说明' },
},
];
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 128,
schemas: capacityMatchSchemas,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async () => {
bootstrapHint.value = '';
await resetFields();
setModalProps({ confirmLoading: true });
try {
const data = await getCapacityMatchConfig();
bootstrapHint.value = data?.configSource === 'yaml' && data?.bootstrapHint ? data.bootstrapHint : '';
await setFieldsValue({
enabled: data?.enabled !== false,
warehouseCategoryIds: data?.warehouseCategoryIds || '',
warehouseCategoryCodes: data?.warehouseCategoryCodes || '',
remark: data?.remark || '',
});
} catch {
createMessage.warning('加载配置失败');
} finally {
setModalProps({ confirmLoading: false });
}
});
async function handleSubmit() {
try {
const values = await validate();
setModalProps({ confirmLoading: true });
await saveCapacityMatchConfig({
enabled: values.enabled === true || values.enabled === 'true' || values.enabled === 1,
warehouseCategoryIds: values.warehouseCategoryIds,
warehouseCategoryCodes: values.warehouseCategoryCodes,
remark: values.remark,
});
createMessage.success('保存成功');
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>