新增打印业务绑定功能,整合原材料卡片和入场记录的打印模板配置,优化打印数据准备逻辑。新增打印机查询接口,提升打印服务的灵活性和用户体验。同时,重构相关控制器以支持新的打印常量定义,增强系统的可维护性和扩展性。

This commit is contained in:
geht
2026-05-13 17:25:13 +08:00
parent c3f8190537
commit 642cecb04d
29 changed files with 2265 additions and 217 deletions

View File

@@ -6,8 +6,12 @@ enum Api {
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 });
@@ -18,6 +22,13 @@ 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 });
/** 白名单:已勾选菜单 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,
@@ -27,3 +38,21 @@ export const parseTemplateFields = (templateId: string) =>
/** 预览映射后的打印数据 */
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

@@ -2,7 +2,12 @@
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" @click="openCreate" v-auth="'print:bizBind:add'">新增绑定</a-button>
<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
@@ -29,99 +34,235 @@
<BasicModal
@register="registerModal"
:title="modalTitle"
width="920px"
width="1000px"
@ok="submitModal"
:confirm-loading="modalSubmitLoading"
destroy-on-close
wrap-class-name="biz-bind-modal-wrap"
>
<a-spin :spinning="tplLoading || parseLoading">
<a-space direction="vertical" style="width: 100%" size="middle">
<a-spin :spinning="modalDataLoading || parseLoading">
<div class="biz-bind-form">
<a-alert
type="info"
show-icon
message="配置步骤"
description="1选择业务类型2选择已发布的打印模板3为模板每个占位字段bindField指定对应的业务 JSON 字段;可点击「同名匹配」快速对齐。"
class="bind-alert"
message="配置说明"
description="按卡片顺序操作:先选业务与模板 → 若模板含明细占位,在「明细数据来源」中选择主实体上的集合/嵌套对象 → 点击「解析模板占位字段」→ 在下方「主表参数」「明细与表格」中分别为每个占位选择业务字段。主表参数一般映射主实体字段;明细占位可选带「明细前缀」的路径(如 lines.qty。支持 lines.qty首行或 lines.0.qty。"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="业务" required>
<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>
<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 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 或菜单 component 推断主实体并反射主表字段。"
>
<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-space wrap>
<a-button type="primary" ghost @click="reloadTemplateFields" :loading="parseLoading">
解析模板占位字段
</a-button>
<a-button @click="autoMatchFields" :disabled="!bizFields.length || !tplFields.length">
同名自动匹配
</a-button>
</a-space>
<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>
<div v-if="tplFields.length">
<div style="margin-bottom: 8px; font-weight: 500">字段映射</div>
<a-table
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumns"
:data-source="mappingRows"
bordered
>
<template #bodyCell="{ column, record }">
<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>
</div>
<a-empty v-else-if="form.templateId && !parseLoading" description="请点击「解析模板占位字段」或切换模板" />
<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>
<a-divider />
<div style="font-weight: 500">映射预览可选</div>
<a-textarea
v-model:value="previewBizJson"
placeholder='粘贴业务 JSON例如{"barcode":"TEST001","materialName":"胶料A"}'
:rows="4"
/>
<a-button type="dashed" @click="runPreview" :loading="previewLoading">生成打印数据预览</a-button>
<pre v-if="previewResult" class="preview-pre">{{ previewResult }}</pre>
<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>
@@ -131,12 +272,17 @@
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();
interface BizTypeItem {
bizCode: string;
@@ -157,9 +303,17 @@
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 }[]>([]);
const tplLoading = ref(false);
/** 弹窗内:业务列表 + 模板下拉并行加载中(不再阻塞 openModal避免点按钮好几秒才出框 */
const modalDataLoading = ref(false);
const parseLoading = ref(false);
const modalSubmitLoading = ref(false);
const previewLoading = ref(false);
@@ -177,6 +331,10 @@
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) ? '编辑业务打印绑定' : '新增业务打印绑定'));
@@ -195,16 +353,61 @@
})),
);
const bizFieldOptions = computed(() =>
const detailSlotSelectOptions = computed(() =>
unref(detailSlots).map((s) => ({
label: `${s.label}${s.propertyName} · ${s.slotKind}`,
value: s.propertyName,
})),
);
const bizFieldOptionsMain = computed(() =>
unref(bizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
})),
);
const mapTableColumns = [
{ title: '模板占位bindField', dataIndex: 'templateField', width: 220 },
{ title: '类型', dataIndex: 'elementType', width: 100 },
/** 主表 + 明细前缀字段(用于明细/表格占位) */
const bizFieldOptions = computed(() => {
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 [...main, ...detail];
});
/** 主表参数行:模板 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 },
];
@@ -228,26 +431,138 @@
});
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.bizTypes();
const res = await Api.bizTypesForBinding();
bizTypesRef.value = res || [];
}
async function loadAllTemplates() {
tplLoading.value = true;
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 {
const res = await tplList({ pageNo: 1, pageSize: 500 });
tplListRef.value = res?.records ?? [];
} finally {
tplLoading.value = false;
detailSlots.value = (await Api.detailSlots(code)) || [];
} catch {
detailSlots.value = [];
}
}
function onBizCodeChange(code: string) {
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() {
@@ -290,7 +605,8 @@
const savedMappingRef = ref<{ templateField: string; bizField?: string }[]>([]);
function autoMatchFields() {
const set = new Map(unref(bizFields).map((f) => [f.fieldKey, f.fieldKey]));
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;
@@ -313,17 +629,27 @@
tplFields.value = [];
bizFields.value = [];
mappingRows.value = [];
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
previewBizJson.value = '';
previewResult.value = '';
await loadBizTypes();
await loadAllTemplates();
openModal(true);
modalDataLoading.value = true;
try {
await Promise.all([loadBizTypes(), loadAllTemplates()]);
} finally {
modalDataLoading.value = false;
}
}
async function openEdit(record: Recordable) {
isEditMode.value = true;
await loadBizTypes();
await loadAllTemplates();
try {
savedMappingRef.value = JSON.parse(record.fieldMappingJson || '[]');
} catch {
savedMappingRef.value = [];
}
form.value = {
id: record.id,
bizCode: record.bizCode,
@@ -331,16 +657,23 @@
templateId: record.templateId,
remark: record.remark,
};
onBizCodeChange(record.bizCode);
try {
savedMappingRef.value = JSON.parse(record.fieldMappingJson || '[]');
} catch {
savedMappingRef.value = [];
}
previewBizJson.value = '';
previewResult.value = '';
tplFields.value = [];
mappingRows.value = [];
bizFields.value = [];
detailSlots.value = [];
selectedDetailProperty.value = undefined;
detailBizFields.value = [];
openModal(true);
await reloadTemplateFields();
modalDataLoading.value = true;
try {
await Promise.all([loadBizTypes(), loadAllTemplates()]);
await onBizCodeChange(record.bizCode as string);
await reloadTemplateFields();
} finally {
modalDataLoading.value = false;
}
}
async function submitModal() {
@@ -403,6 +736,104 @@
</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;
@@ -410,5 +841,6 @@
max-height: 240px;
overflow: auto;
font-size: 12px;
margin-top: 10px;
}
</style>

View File

@@ -12,6 +12,9 @@ enum Api {
batchStockIn = '/xslmes/mesXslRawMaterialEntry/batchStockIn',
importExcel = '/xslmes/mesXslRawMaterialEntry/importExcel',
exportXls = '/xslmes/mesXslRawMaterialEntry/exportXls',
queryPrinters = '/xslmes/mesXslRawMaterialEntry/queryPrinters',
prepareNativePrint = '/xslmes/mesXslRawMaterialEntry/prepareNativePrint',
printPdf = '/xslmes/mesXslRawMaterialEntry/printPdf',
}
export const getExportUrl = Api.exportXls;
@@ -47,3 +50,15 @@ export const saveOrUpdate = (params, isUpdate) => {
let url = isUpdate ? Api.edit : Api.save;
return defHttp.post({ url: url, params });
};
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
export const prepareNativePrint = (id: string) =>
defHttp.get({
url: Api.prepareNativePrint,
params: { id, _t: Date.now() },
});
/** id + 前端生成的 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,51 @@
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:stockIn'" preIcon="ant-design:check-circle-outlined" @click="handleStockIn"> 结存入库</a-button>
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
PrintDot 桥接
</a-checkbox>
<a-input
v-model:value="printDotWsUrl"
style="width: 220px; margin-left: 8px"
placeholder="ws://127.0.0.1:1122/ws"
@blur="persistPrintDotConfig"
/>
<a-input-password
v-model:value="printDotKey"
style="width: 130px; margin-left: 8px"
placeholder="密钥(可选)"
autocomplete="new-password"
@blur="persistPrintDotConfig"
/>
<a-button @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 220px; margin-left: 8px"
allow-clear
show-search
option-filter-prop="label"
:placeholder="printerSelectPlaceholder"
/>
<a-button @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-input
v-model:value="manualPrinterName"
style="width: 150px; margin-left: 8px"
placeholder="手动输入打印机名"
@press-enter="addManualPrinter"
/>
<a-button @click="addManualPrinter">添加</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_entry:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
打印选中
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -26,24 +71,77 @@
</template>
</BasicTable>
<MesXslRawMaterialEntryModal @register="registerModal" @success="handleSuccess" />
<RawMaterialEntryPrintPreviewModal
v-model:open="printPreviewOpen"
:entry-id="printPreviewEntryId"
:barcode="printPreviewBarcode"
/>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialEntry" setup>
import { ref, reactive } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { onMounted, ref, reactive, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslRawMaterialEntryModal from './components/MesXslRawMaterialEntryModal.vue';
import RawMaterialEntryPrintPreviewModal from './components/RawMaterialEntryPrintPreviewModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialEntry.data';
import { list, deleteOne, batchDelete, batchStockIn, getImportUrl, getExportUrl } from './MesXslRawMaterialEntry.api';
import {
list,
deleteOne,
batchDelete,
batchStockIn,
getImportUrl,
getExportUrl,
prepareNativePrint,
} from './MesXslRawMaterialEntry.api';
import { useMessage } from '/@/hooks/web/useMessage';
import {
PRINT_TEMPLATE_SELECTED_PRINTER_KEY,
printNativeSchemaViaPrintDot,
} from '/@/views/print/template/utils/printNativeViaPrintDot';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
import {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
setPrintDotBridgeConfig,
} from '/@/views/print/template/utils/printDotBridge';
const { createConfirm, createMessage } = useMessage();
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
const printDotCfg = getPrintDotBridgeConfig();
const printDotWsUrl = ref(printDotCfg.wsUrl);
const printDotKey = ref(printDotCfg.key);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
void refreshPrinterOptions(false);
}
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
}
function downloadPrintPlugin() {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'XSL-PrintDot.exe');
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
const queryParam = reactive<any>({});
const [registerModal, { openModal }] = useModal();
const { prefixCls, tableContext, onExportXls, onImportXls } = useListPage({
const { tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '原料入场记录',
api: list,
@@ -58,8 +156,11 @@
],
},
actionColumn: {
width: 180,
width: 320,
fixed: 'right',
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
},
beforeFetch: (params) => {
return Object.assign(params, queryParam);
@@ -76,9 +177,201 @@
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
/** 打印预览弹窗 */
const printPreviewOpen = ref(false);
const printPreviewEntryId = ref<string | null>(null);
const printPreviewBarcode = ref<string | undefined>(undefined);
function handlePrintPreview(record: Recordable) {
printPreviewEntryId.value = record.id as string;
printPreviewBarcode.value = record.barcode as string | undefined;
printPreviewOpen.value = true;
}
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedPrinterName = ref<string>('__system_default__');
const manualPrinterName = ref('');
const printLoading = ref(false);
const printerSelectPlaceholder = '选择打印机PrintDot 桥接)';
watch(selectedPrinterName, (v) => {
if (v) {
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
}
});
async function refreshPrinterOptions(showMessage = true) {
const optionMap = new Map<string, { label: string; value: string }>();
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
try {
const dotList = await fetchPrintDotPrinters();
dotList.forEach((p) => {
const name = String(p.name || '').trim();
if (!name) return;
const defMark = p.isDefault ? '(默认)' : '';
optionMap.set(name, { label: `${name}${defMark}`, value: name });
});
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
if (dotList.length) {
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
} else {
createMessage.warning('PrintDot 已连接但未返回打印机列表');
}
}
} catch (e: unknown) {
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
createMessage.warning(`PrintDot${e instanceof Error ? e.message : String(e)}`);
}
}
}
function addManualPrinter() {
const name = String(manualPrinterName.value || '').trim();
if (!name) return;
const exists = printerOptions.value.some((item) => item.value === name);
if (!exists) {
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
}
selectedPrinterName.value = name;
manualPrinterName.value = '';
createMessage.success('已添加打印机');
}
async function executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
try {
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
if (paperWidthMm > 0 && paperHeightMm > 0) {
const orient = paperOrientation === 'landscape' ? 'landscape' : paperOrientation === 'portrait' ? 'portrait' : '';
const normalized =
orient === 'landscape'
? {
width: Math.max(paperWidthMm, paperHeightMm),
height: Math.min(paperWidthMm, paperHeightMm),
}
: orient === 'portrait'
? {
width: Math.min(paperWidthMm, paperHeightMm),
height: Math.max(paperWidthMm, paperHeightMm),
}
: {
width: paperWidthMm,
height: paperHeightMm,
};
schema.page.width = normalized.width;
schema.page.height = normalized.height;
}
await printNativeSchemaViaPrintDot({
schema,
data: printData as Record<string, unknown>,
jobName: `原料入场记录-${(record.barcode as string) || record.id}.pdf`,
printerSelection:
selectedPrinterName.value ||
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
'__system_default__',
});
if (!options?.silentSuccess) {
createMessage.success('已通过 PrintDot 提交打印');
}
} catch (e: unknown) {
throw new Error(e instanceof Error ? e.message : String(e));
}
}
function handlePrintSelected() {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
return;
}
const rows = selectedRows.value || [];
if (!rows.length) {
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
return;
}
printLoading.value = true;
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
(async () => {
let ok = 0;
let firstError = '';
for (const row of rows) {
try {
await executePrint(row, { silentSuccess: true });
ok += 1;
} catch (e: unknown) {
if (!firstError) {
firstError = e instanceof Error ? e.message : String(e);
}
}
}
if (ok === rows.length) {
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
} else {
createMessage.warning(`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`);
}
hideLoadingMsg();
printLoading.value = false;
})();
}
function handlePrintRow(record: Recordable) {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
return;
}
printLoading.value = true;
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
executePrint(record)
.then(() => {
createMessage.success('已通过 PrintDot 提交打印');
})
.catch((e: unknown) => {
createMessage.error(e instanceof Error ? e.message : String(e));
})
.finally(() => {
hideLoadingMsg();
printLoading.value = false;
});
}
onMounted(() => {
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (saved) {
selectedPrinterName.value = saved;
}
refreshPrinterOptions(false);
});
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
@@ -136,6 +429,16 @@
onClick: handleEdit.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
},
{
label: '打印预览',
onClick: handlePrintPreview.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
},
{
label: '打印',
onClick: handlePrintRow.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
},
];
}

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>