原材料入库结存
This commit is contained in:
@@ -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 已存在。');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 },
|
||||
];
|
||||
892
jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue
Normal file
892
jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue
Normal 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<明细实体>)或嵌套对象;系统将明细类字段并入下方「明细与表格」中的业务字段下拉。
|
||||
</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>
|
||||
@@ -1059,6 +1059,13 @@
|
||||
}
|
||||
if (type === 'qrcode' || type === 'barcode') {
|
||||
base.value = payload.value || '';
|
||||
if (type === 'barcode') {
|
||||
// 默认 Code128(自动),与 jsbarcode 默认一致;displayValue 默认显示底部文字
|
||||
base.format = payload.format || 'CODE128';
|
||||
base.displayValue = payload.displayValue !== false;
|
||||
// 条码下文字对齐:center / left / right / justify(两端对齐),默认居中
|
||||
base.textAlign = payload.textAlign || 'center';
|
||||
}
|
||||
return base as NativeElement;
|
||||
}
|
||||
if (type === 'reportHeader' || type === 'reportFooter') {
|
||||
|
||||
@@ -86,6 +86,47 @@
|
||||
<template v-if="isText(selectedElement.type)">
|
||||
<a-divider />
|
||||
<a-input :value="(selectedElement as any).text" addon-before="内容" @update:value="updateField('text', $event)" />
|
||||
<template v-if="supportsTextBorderDesign(selectedElement.type)">
|
||||
<a-space direction="vertical" style="width: 100%; margin-top: 8px">
|
||||
<a-switch
|
||||
:checked="Number(selectedElement.style?.borderWidth || 0) > 0"
|
||||
checked-children="显示边框"
|
||||
un-checked-children="隐藏边框"
|
||||
@update:checked="updateTextBorderEnabled($event)"
|
||||
/>
|
||||
<template v-if="Number(selectedElement.style?.borderWidth || 0) > 0">
|
||||
<a-input-number
|
||||
:value="Math.max(1, Number(selectedElement.style?.borderWidth || 1))"
|
||||
addon-before="边框宽(px)"
|
||||
:min="1"
|
||||
:max="8"
|
||||
style="width: 100%"
|
||||
@update:value="updateStyle('borderWidth', Math.max(1, Number($event || 1)))"
|
||||
/>
|
||||
<div class="color-input-row">
|
||||
<a-input
|
||||
:value="selectedElement.style?.borderColor || '#222222'"
|
||||
addon-before="边框色"
|
||||
placeholder="#222222 / rgb(...)"
|
||||
@update:value="updateStyle('borderColor', $event)"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
class="native-color-picker-trigger"
|
||||
title="选择边框颜色"
|
||||
:value="toColorHex(String(selectedElement.style?.borderColor || '#222222'))"
|
||||
@input="handleStyleColorInput('borderColor', $event, '#222222')"
|
||||
/>
|
||||
</div>
|
||||
<a-space wrap size="small">
|
||||
<a-checkbox :checked="selectedElement.style?.hideBorderTop !== true" @update:checked="updateTextBorderSide('top', $event)">上边</a-checkbox>
|
||||
<a-checkbox :checked="selectedElement.style?.hideBorderRight !== true" @update:checked="updateTextBorderSide('right', $event)">右边</a-checkbox>
|
||||
<a-checkbox :checked="selectedElement.style?.hideBorderBottom !== true" @update:checked="updateTextBorderSide('bottom', $event)">下边</a-checkbox>
|
||||
<a-checkbox :checked="selectedElement.style?.hideBorderLeft !== true" @update:checked="updateTextBorderSide('left', $event)">左边</a-checkbox>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="isReportBand(selectedElement.type)">
|
||||
<a-divider />
|
||||
@@ -165,6 +206,46 @@
|
||||
<template v-if="selectedElement.type === 'qrcode' || selectedElement.type === 'barcode'">
|
||||
<a-divider />
|
||||
<a-input :value="(selectedElement as any).value" addon-before="编码值" @update:value="updateField('value', $event)" />
|
||||
<template v-if="selectedElement.type === 'barcode'">
|
||||
<div class="bind-param-compact" style="margin-top: 8px">
|
||||
<span class="bind-param-compact__addon">条码类型</span>
|
||||
<a-select
|
||||
:value="(selectedElement as any).format || 'CODE128'"
|
||||
:options="BARCODE_FORMAT_OPTIONS"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
class="bind-param-compact__select"
|
||||
@update:value="updateField('format', $event)"
|
||||
/>
|
||||
</div>
|
||||
<a-switch
|
||||
style="margin-top: 8px"
|
||||
:checked="(selectedElement as any).displayValue !== false"
|
||||
checked-children="显示条码下文字"
|
||||
un-checked-children="隐藏条码下文字"
|
||||
@update:checked="updateField('displayValue', $event)"
|
||||
/>
|
||||
<a-switch
|
||||
style="margin-top: 8px"
|
||||
:checked="(selectedElement as any).fillCell === true"
|
||||
checked-children="条码填充整格"
|
||||
un-checked-children="条码保持比例"
|
||||
@update:checked="updateField('fillCell', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="(selectedElement as any).displayValue !== false"
|
||||
class="bind-param-compact"
|
||||
style="margin-top: 8px"
|
||||
>
|
||||
<span class="bind-param-compact__addon">文字对齐</span>
|
||||
<a-select
|
||||
:value="(selectedElement as any).textAlign || 'center'"
|
||||
:options="BARCODE_TEXT_ALIGN_OPTIONS"
|
||||
class="bind-param-compact__select"
|
||||
@update:value="updateField('textAlign', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="selectedElement.type === 'table' || selectedElement.type === 'detailTable'">
|
||||
<a-divider />
|
||||
@@ -586,7 +667,7 @@
|
||||
:min="1"
|
||||
:max="100"
|
||||
size="small"
|
||||
disabled
|
||||
@update:value="setFreeTableRowCount(Number($event || 1))"
|
||||
/>
|
||||
</div>
|
||||
<div class="free-table-dim-item">
|
||||
@@ -596,16 +677,23 @@
|
||||
:min="1"
|
||||
:max="50"
|
||||
size="small"
|
||||
disabled
|
||||
@update:value="setFreeTableColCount(Number($event || 1))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-space wrap>
|
||||
<a-button size="small" @click="addFreeTableRow">新增行</a-button>
|
||||
<a-button size="small" @click="removeFreeTableRow">删除行</a-button>
|
||||
<a-button size="small" @click="addFreeTableCol">新增列</a-button>
|
||||
<a-button size="small" @click="removeFreeTableCol">删除列</a-button>
|
||||
</a-space>
|
||||
<a-space wrap>
|
||||
<a-button size="small" @click="resetFreeTableEvenGrid">重置为均分网格</a-button>
|
||||
<a-button size="small" type="primary" ghost @click="applyFreeTableKeyValueTemplate">套用「键值对」模板</a-button>
|
||||
</a-space>
|
||||
<div class="free-table-merge-tip">
|
||||
「键值对」模板:把当前表改成 N 行 × 2 列,左列预填"标题1、标题2…"占位字符,方便快速制作图二那种标签卡片;右列由你填字段或绑定字段值。
|
||||
</div>
|
||||
<div class="free-table-track-head">单元格合并</div>
|
||||
<a-button type="primary" size="small" block :disabled="!canMergeFreeTableSelection" @click="mergeFreeTableSelection">合并选中区域</a-button>
|
||||
<a-button size="small" block :disabled="!canSplitFreeTableMerged" @click="splitFreeTableMerged">拆分当前合并</a-button>
|
||||
@@ -1321,14 +1409,67 @@
|
||||
{ label: 'JPEG', value: 'image/jpeg' },
|
||||
{ label: 'WEBP', value: 'image/webp' },
|
||||
];
|
||||
// jsbarcode 全量支持的码制清单(按官方分组)。a-select 通过嵌套 options 自动渲染为
|
||||
// OptGroup(antdv 4+)。桌面端 ZXing.Net 已对所有这些 value 做了等价/最接近的映射,
|
||||
// 未覆盖的(EAN5/EAN2/MSI 变体/pharmacode)会回退到 CODE_128 渲染,避免渲染失败。
|
||||
const BARCODE_FORMAT_OPTIONS = [
|
||||
{ label: 'CODE128', value: 'CODE128' },
|
||||
{ label: 'CODE39', value: 'CODE39' },
|
||||
{ label: 'EAN13', value: 'EAN13' },
|
||||
{ label: 'EAN8', value: 'EAN8' },
|
||||
{ label: 'ITF14', value: 'ITF14' },
|
||||
{ label: 'MSI', value: 'MSI' },
|
||||
{ label: 'pharmacode', value: 'pharmacode' },
|
||||
{
|
||||
label: 'CODE 128 系列',
|
||||
options: [
|
||||
{ label: 'CODE128(自动)', value: 'CODE128' },
|
||||
{ label: 'CODE128 A', value: 'CODE128A' },
|
||||
{ label: 'CODE128 B', value: 'CODE128B' },
|
||||
{ label: 'CODE128 C', value: 'CODE128C' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'EAN / UPC',
|
||||
options: [
|
||||
{ label: 'EAN-13', value: 'EAN13' },
|
||||
{ label: 'EAN-8', value: 'EAN8' },
|
||||
{ label: 'EAN-5', value: 'EAN5' },
|
||||
{ label: 'EAN-2', value: 'EAN2' },
|
||||
{ label: 'UPC-A', value: 'UPC' },
|
||||
{ label: 'UPC-E', value: 'UPCE' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CODE 39',
|
||||
options: [{ label: 'CODE39', value: 'CODE39' }],
|
||||
},
|
||||
{
|
||||
label: 'ITF (Interleaved 2 of 5)',
|
||||
options: [
|
||||
{ label: 'ITF', value: 'ITF' },
|
||||
{ label: 'ITF-14', value: 'ITF14' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MSI',
|
||||
options: [
|
||||
{ label: 'MSI(无校验)', value: 'MSI' },
|
||||
{ label: 'MSI10 (Mod10)', value: 'MSI10' },
|
||||
{ label: 'MSI11 (Mod11)', value: 'MSI11' },
|
||||
{ label: 'MSI1010 (Mod10+Mod10)', value: 'MSI1010' },
|
||||
{ label: 'MSI1110 (Mod11+Mod10)', value: 'MSI1110' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '其它',
|
||||
options: [
|
||||
{ label: 'Pharmacode', value: 'pharmacode' },
|
||||
{ label: 'Codabar', value: 'codabar' },
|
||||
],
|
||||
},
|
||||
];
|
||||
// 条码下文字对齐方式:jsbarcode 原生支持 left/center/right;"两端对齐" 由我们在
|
||||
// SVG 后处理时给 <text> 加上 textLength=条码宽 + lengthAdjust=spacing 实现,浏览器
|
||||
// 会自动拉伸字符间距让文字横向占满条码宽度,桌面端在 ZXing 输出 SVG 上同款处理。
|
||||
const BARCODE_TEXT_ALIGN_OPTIONS = [
|
||||
{ label: '居中', value: 'center' },
|
||||
{ label: '靠左', value: 'left' },
|
||||
{ label: '靠右', value: 'right' },
|
||||
{ label: '两端对齐', value: 'justify' },
|
||||
];
|
||||
const REFRESH_PAGE_OPTIONS = [
|
||||
{ label: '刷新页:不应用', value: 'none' },
|
||||
@@ -1364,6 +1505,46 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅标题/副标题/正文支持自由边框控制(日期/页码不展示该项,避免误操作)。 */
|
||||
function supportsTextBorderDesign(type: string) {
|
||||
return type === 'title' || type === 'subtitle' || type === 'text';
|
||||
}
|
||||
|
||||
/** 文本边框总开关:开=默认四边显示,关=边框宽设为 0。 */
|
||||
function updateTextBorderEnabled(checked: boolean) {
|
||||
if (!props.selectedElement) return;
|
||||
if (!supportsTextBorderDesign(props.selectedElement.type)) return;
|
||||
if (checked) {
|
||||
emit('update-element', {
|
||||
id: props.selectedElement.id,
|
||||
patch: {
|
||||
style: {
|
||||
...(props.selectedElement.style || {}),
|
||||
borderWidth: Math.max(1, Number(props.selectedElement.style?.borderWidth || 1)),
|
||||
borderColor: props.selectedElement.style?.borderColor || '#222222',
|
||||
hideBorderTop: false,
|
||||
hideBorderRight: false,
|
||||
hideBorderBottom: false,
|
||||
hideBorderLeft: false,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateStyle('borderWidth', 0);
|
||||
}
|
||||
|
||||
/** 单边显示控制:勾选=显示,取消=隐藏。内部存储为 hideBorderX boolean。 */
|
||||
function updateTextBorderSide(side: 'top' | 'right' | 'bottom' | 'left', visible: boolean) {
|
||||
const map = {
|
||||
top: 'hideBorderTop',
|
||||
right: 'hideBorderRight',
|
||||
bottom: 'hideBorderBottom',
|
||||
left: 'hideBorderLeft',
|
||||
} as const;
|
||||
updateStyle(map[side], visible !== true);
|
||||
}
|
||||
|
||||
function updateField(key: string, value: any) {
|
||||
if (!props.selectedElement) return;
|
||||
emit('update-element', { id: props.selectedElement.id, patch: { [key]: value } as any });
|
||||
@@ -1706,6 +1887,115 @@
|
||||
updateFreeTableElement({ rowCount, cells, rowHeights: nextRh });
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接设置 freeTable 的行数:保留已有 cells,按新 rowCount 均分行高(总高度不变)。
|
||||
* 与"逐一点击新增/删除行"等价,但允许用户一次性把 2 改成 9 等批量调整。
|
||||
*/
|
||||
function setFreeTableRowCount(nextRowCount: number) {
|
||||
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
||||
const el = props.selectedElement as any;
|
||||
const target = Math.max(1, Math.min(100, Math.round(Number(nextRowCount) || 1)));
|
||||
if (target === Number(el.rowCount || 1)) return;
|
||||
const colCount = Math.max(1, Number(el.colCount || 1));
|
||||
const cells = rebuildFreeTableCells(target, colCount, el.cells || []);
|
||||
const totalH = Math.max(0.01, Number(el.h) || 0.01);
|
||||
updateFreeTableElement({ rowCount: target, cells, rowHeights: buildEvenRowHeights(target, totalH) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接设置 freeTable 的列数:保留已有 cells,按新 colCount 均分列宽(总宽度不变)。
|
||||
*/
|
||||
function setFreeTableColCount(nextColCount: number) {
|
||||
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
||||
const el = props.selectedElement as any;
|
||||
const target = Math.max(1, Math.min(50, Math.round(Number(nextColCount) || 1)));
|
||||
if (target === Number(el.colCount || 1)) return;
|
||||
const rowCount = Math.max(1, Number(el.rowCount || 1));
|
||||
const cells = rebuildFreeTableCells(rowCount, target, el.cells || []);
|
||||
const totalW = Math.max(0.01, Number(el.w) || 0.01);
|
||||
updateFreeTableElement({ colCount: target, cells, colWidths: buildEvenColWidths(target, totalW) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为均分网格:清空所有 cells(包括用户填写的文字、绑定、合并),按当前
|
||||
* rowCount × colCount 重新均分行高/列宽。用于一键洗牌网格、从零开始设计。
|
||||
*/
|
||||
function resetFreeTableEvenGrid() {
|
||||
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
||||
const el = props.selectedElement as any;
|
||||
const rowCount = Math.max(1, Number(el.rowCount || 1));
|
||||
const colCount = Math.max(1, Number(el.colCount || 1));
|
||||
const totalW = Math.max(0.01, Number(el.w) || 0.01);
|
||||
const totalH = Math.max(0.01, Number(el.h) || 0.01);
|
||||
const cells = normalizeFreeTableAnchors(rowCount, colCount, []);
|
||||
updateFreeTableElement({
|
||||
cells,
|
||||
colWidths: buildEvenColWidths(colCount, totalW),
|
||||
rowHeights: buildEvenRowHeights(rowCount, totalH),
|
||||
});
|
||||
createMessage.success('已重置为均分网格');
|
||||
}
|
||||
|
||||
/**
|
||||
* 套用"键值对"模板:把当前 freeTable 整为 N 行 × 2 列,左列预填"标题i"占位、
|
||||
* 右列留空(用户后续填值或绑定字段)。N 取当前 rowCount,列数强制为 2,
|
||||
* 列宽按 1:2 比例(标题列窄、值列宽,常见标签布局)。
|
||||
*/
|
||||
function applyFreeTableKeyValueTemplate() {
|
||||
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
||||
const el = props.selectedElement as any;
|
||||
const rowCount = Math.max(1, Number(el.rowCount || 1));
|
||||
const colCount = 2;
|
||||
const totalW = Math.max(0.01, Number(el.w) || 0.01);
|
||||
const totalH = Math.max(0.01, Number(el.h) || 0.01);
|
||||
// 列宽 1:2,标题列居中加粗、值列靠左
|
||||
const labelColW = Math.max(MIN_FREE_TABLE_TRACK_MM, Math.round((totalW / 3) * 100) / 100);
|
||||
const valueColW = Math.max(MIN_FREE_TABLE_TRACK_MM, Math.round((totalW - labelColW) * 100) / 100);
|
||||
|
||||
const cells: any[] = [];
|
||||
for (let r = 0; r < rowCount; r += 1) {
|
||||
cells.push({
|
||||
row: r,
|
||||
col: 0,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
text: `标题${r + 1}`,
|
||||
bindField: '',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
fontSize: 12,
|
||||
color: '#111111',
|
||||
backgroundColor: '#f7f7f7',
|
||||
contentType: 'text',
|
||||
autoWrap: true,
|
||||
});
|
||||
cells.push({
|
||||
row: r,
|
||||
col: 1,
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
text: '',
|
||||
bindField: '',
|
||||
align: 'left',
|
||||
verticalAlign: 'middle',
|
||||
fontSize: 12,
|
||||
color: '#111111',
|
||||
backgroundColor: '#ffffff',
|
||||
contentType: 'text',
|
||||
autoWrap: true,
|
||||
});
|
||||
}
|
||||
|
||||
updateFreeTableElement({
|
||||
colCount,
|
||||
rowCount,
|
||||
cells: normalizeFreeTableAnchors(rowCount, colCount, cells),
|
||||
colWidths: [labelColW, valueColW],
|
||||
rowHeights: buildEvenRowHeights(rowCount, totalH),
|
||||
});
|
||||
createMessage.success('已套用「键值对」模板');
|
||||
}
|
||||
|
||||
function removeFreeTableRow() {
|
||||
if (!props.selectedElement || props.selectedElement.type !== 'freeTable') return;
|
||||
const el = props.selectedElement as any;
|
||||
|
||||
@@ -1,50 +1,63 @@
|
||||
<template>
|
||||
<div class="barcode-element">
|
||||
<canvas ref="canvasRef"></canvas>
|
||||
<svg ref="svgRef" class="barcode-svg" :aria-label="`barcode: ${displayValue}`"></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type { NativeCodeElement } from '../../core/types';
|
||||
import { renderNativeBarcodeIntoSvg } from '../../core/barcodeRenderer';
|
||||
|
||||
const props = defineProps<{
|
||||
element: NativeCodeElement;
|
||||
previewData?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
const svgRef = ref<SVGSVGElement>();
|
||||
|
||||
function resolveFieldValue(field?: string) {
|
||||
if (!field) return undefined;
|
||||
return field.split('.').reduce((acc: any, key) => acc?.[key], props.previewData || {});
|
||||
}
|
||||
|
||||
/** 实际用于渲染的字符串:优先取 previewData 里绑定字段值,否则 element.value,再否则示例 */
|
||||
const displayValue = computed(() => {
|
||||
const bind = resolveFieldValue(props.element.bindField);
|
||||
if (bind !== undefined && bind !== null && String(bind).trim() !== '') {
|
||||
return String(bind);
|
||||
}
|
||||
return String(props.element.value || '0000000000');
|
||||
});
|
||||
|
||||
async function renderBarcode() {
|
||||
if (!canvasRef.value) return;
|
||||
const module: any = await import('jsbarcode');
|
||||
const JsBarcode = module.default || module;
|
||||
const bindValue = resolveFieldValue(props.element.bindField);
|
||||
const value = bindValue !== undefined && bindValue !== null ? String(bindValue) : props.element.value || '0000000000';
|
||||
JsBarcode(canvasRef.value, value, {
|
||||
format: 'CODE128',
|
||||
displayValue: true,
|
||||
margin: 0,
|
||||
width: 1.5,
|
||||
height: 40,
|
||||
fontSize: 12,
|
||||
if (!svgRef.value) return;
|
||||
await renderNativeBarcodeIntoSvg(svgRef.value, displayValue.value, {
|
||||
format: (props.element as any).format,
|
||||
displayValue: (props.element as any).displayValue !== false,
|
||||
fontSize: (props.element as any).fontSize,
|
||||
lineWidth: (props.element as any).lineWidth,
|
||||
barHeight: (props.element as any).barHeight,
|
||||
textAlign: (props.element as any).textAlign,
|
||||
fillCell: (props.element as any).fillCell === true,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderBarcode();
|
||||
});
|
||||
onMounted(renderBarcode);
|
||||
|
||||
watch(
|
||||
() => [props.element.value, props.element.bindField, props.previewData],
|
||||
() => {
|
||||
renderBarcode();
|
||||
},
|
||||
() => [
|
||||
displayValue.value,
|
||||
props.element.bindField,
|
||||
(props.element as any).format,
|
||||
(props.element as any).displayValue,
|
||||
(props.element as any).fontSize,
|
||||
(props.element as any).lineWidth,
|
||||
(props.element as any).barHeight,
|
||||
(props.element as any).textAlign,
|
||||
(props.element as any).fillCell,
|
||||
],
|
||||
renderBarcode,
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -57,10 +70,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* 关键:SVG 自带 preserveAspectRatio="xMidYMid meet",
|
||||
这里宽高 100% 后浏览器会自动按 viewBox 等比缩放并居中,
|
||||
不再像之前的 canvas 直接被 CSS 拉伸成扁平条码。 */
|
||||
.barcode-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="free-table-element" :data-free-table-id="element.id">
|
||||
<div
|
||||
class="free-table-element"
|
||||
:data-free-table-id="element.id"
|
||||
:style="{
|
||||
'--ft-edit-line-w': `${Math.max(1, Number(element?.borderWidth || 1))}px`,
|
||||
}"
|
||||
>
|
||||
<!-- 选中时左上角四向箭头:与画布整体拖动一致,不在此阻止 pointerdown,事件冒泡到 ElementWrapper -->
|
||||
<button
|
||||
v-if="isElementSelected"
|
||||
@@ -30,13 +36,13 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="free-table-surface" @pointerdown.stop>
|
||||
<div ref="surfaceRef" class="free-table-surface" @pointerdown.stop>
|
||||
<table>
|
||||
<colgroup>
|
||||
<col v-for="(cw, ci) in colWidthsMm" :key="`col_${ci}`" :style="{ width: `${cw}mm` }" />
|
||||
<col v-for="(cw, ci) in renderColWidthsMm" :key="`col_${ci}`" :style="{ width: `${cw}mm` }" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr v-for="r in rowCount" :key="`tr_${r - 1}`" :style="{ height: `${rowHeightsMm[r - 1] ?? 6}mm` }">
|
||||
<tr v-for="r in rowCount" :key="`tr_${r - 1}`" :style="{ height: `${renderRowHeightsMm[r - 1] ?? 6}mm` }">
|
||||
<td
|
||||
v-for="cell in anchorsForRow(r - 1)"
|
||||
:key="`td_${cell.row}_${cell.col}`"
|
||||
@@ -115,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import QRCode from 'qrcode';
|
||||
import { getValueByPath } from '../../core/tableBuilder';
|
||||
import { normalizeFreeTableAnchors } from '../../core/freeTableGrid';
|
||||
@@ -128,6 +134,7 @@
|
||||
|
||||
const qrCodeCache = ref<Record<string, string>>({});
|
||||
const barcodeCache = ref<Record<string, string>>({});
|
||||
const surfaceRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -157,8 +164,33 @@
|
||||
|
||||
const colWidthsMm = computed(() => resolveFreeTableColWidthsMm(props.element));
|
||||
const rowHeightsMm = computed(() => resolveFreeTableRowHeightsMm(props.element));
|
||||
const renderColWidthsMm = computed(() => compensateFreeTableTracksByBorder(colWidthsMm.value, Number(props.element?.w || 0.01), colCount.value, Number(props.element?.borderWidth || 1)));
|
||||
const renderRowHeightsMm = computed(() => compensateFreeTableTracksByBorder(rowHeightsMm.value, Number(props.element?.h || 0.01), rowCount.value, Number(props.element?.borderWidth || 1)));
|
||||
|
||||
const colGripPositionsPct = computed(() => {
|
||||
/**
|
||||
* 在显示层把“边框占用预算”从轨道尺寸里扣掉,避免 row/col 总和 + 网格线宽度 > 元素外框,
|
||||
* 导致新增行/列在底部(或右侧)被裁切。
|
||||
*/
|
||||
function compensateFreeTableTracksByBorder(tracks: number[], totalMm: number, count: number, borderWidthPx: number): number[] {
|
||||
const n = Math.max(1, Number(count || tracks.length || 1));
|
||||
const safeTracks = (tracks || []).slice(0, n).map((v) => Math.max(0.01, Number(v) || 0.01));
|
||||
while (safeTracks.length < n) safeTracks.push(0.01);
|
||||
|
||||
const total = Math.max(0.01, Number(totalMm) || 0.01);
|
||||
const bwMm = Math.max(0, Number(borderWidthPx) || 1) / PX_PER_MM;
|
||||
// n 条轨道对应 n+1 条网格线(含外框上下/左右)
|
||||
const lineBudget = (n + 1) * bwMm;
|
||||
const targetInner = Math.max(0.01, total - lineBudget);
|
||||
const sourceInner = safeTracks.reduce((s, v) => s + v, 0);
|
||||
const scale = sourceInner > 0 ? targetInner / sourceInner : 1;
|
||||
const out = safeTracks.map((v) => Math.max(0.01, Math.round(v * scale * 1000) / 1000));
|
||||
// 尾项兜底补差,保证总和严格贴合 targetInner
|
||||
const sum = out.reduce((s, v) => s + v, 0);
|
||||
out[out.length - 1] = Math.max(0.01, Math.round((out[out.length - 1] + (targetInner - sum)) * 1000) / 1000);
|
||||
return out;
|
||||
}
|
||||
|
||||
const modelColGripPositionsPct = computed(() => {
|
||||
const w = Math.max(0.01, Number(props.element?.w) || 0.01);
|
||||
let x = 0;
|
||||
const arr = colWidthsMm.value;
|
||||
@@ -170,7 +202,7 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
const rowGripPositionsPct = computed(() => {
|
||||
const modelRowGripPositionsPct = computed(() => {
|
||||
const h = Math.max(0.01, Number(props.element?.h) || 0.01);
|
||||
let y = 0;
|
||||
const arr = rowHeightsMm.value;
|
||||
@@ -182,6 +214,62 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
const domColGripPositionsPct = ref<number[] | null>(null);
|
||||
const domRowGripPositionsPct = ref<number[] | null>(null);
|
||||
|
||||
/**
|
||||
* 基于真实 DOM 渲染结果计算行/列分隔线位置,彻底消除“理论 mm 百分比”与浏览器
|
||||
* 表格布局(border-collapse、像素取整、缩放)之间的偏差。
|
||||
*/
|
||||
function syncTrackGripPositionsFromDom() {
|
||||
const surface = surfaceRef.value;
|
||||
const table = surface?.querySelector?.('table') as HTMLTableElement | null;
|
||||
if (!surface || !table) return;
|
||||
|
||||
const sRect = surface.getBoundingClientRect();
|
||||
if (sRect.width <= 0 || sRect.height <= 0) return;
|
||||
|
||||
// 列边界:优先读取 colgroup 的真实宽度(支持 table-layout:fixed)
|
||||
const colEls = Array.from(table.querySelectorAll('colgroup col')) as HTMLTableColElement[];
|
||||
if (colEls.length > 1) {
|
||||
let x = 0;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < colEls.length - 1; i += 1) {
|
||||
x += colEls[i].getBoundingClientRect().width;
|
||||
out.push((x / sRect.width) * 100);
|
||||
}
|
||||
domColGripPositionsPct.value = out;
|
||||
} else {
|
||||
domColGripPositionsPct.value = null;
|
||||
}
|
||||
|
||||
// 行边界:读取每个 tr 的真实渲染高度
|
||||
const rows = Array.from(table.querySelectorAll('tbody > tr')) as HTMLTableRowElement[];
|
||||
if (rows.length > 1) {
|
||||
let y = 0;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < rows.length - 1; i += 1) {
|
||||
y += rows[i].getBoundingClientRect().height;
|
||||
out.push((y / sRect.height) * 100);
|
||||
}
|
||||
domRowGripPositionsPct.value = out;
|
||||
} else {
|
||||
domRowGripPositionsPct.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const colGripPositionsPct = computed(() => {
|
||||
const dom = domColGripPositionsPct.value;
|
||||
if (dom && dom.length === Math.max(0, colCount.value - 1)) return dom;
|
||||
return modelColGripPositionsPct.value;
|
||||
});
|
||||
|
||||
const rowGripPositionsPct = computed(() => {
|
||||
const dom = domRowGripPositionsPct.value;
|
||||
if (dom && dom.length === Math.max(0, rowCount.value - 1)) return dom;
|
||||
return modelRowGripPositionsPct.value;
|
||||
});
|
||||
|
||||
const anchorsNormalized = computed(() =>
|
||||
normalizeFreeTableAnchors(rowCount.value, colCount.value, props.element?.cells || []),
|
||||
);
|
||||
@@ -350,16 +438,33 @@
|
||||
rowCount.value,
|
||||
colCount.value,
|
||||
);
|
||||
// 行高较小时(如 4~5mm),固定大内边距会把行撑爆导致下方行被裁切;
|
||||
// 这里按当前(含合并)单元格高度动态给一个较小 padding,保证能展示全部行线。
|
||||
const spanHmm = renderRowHeightsMm.value
|
||||
.slice(cell.row, Math.min(rowCount.value, cell.row + rs))
|
||||
.reduce((sum, v) => sum + (Number(v) || 0), 0);
|
||||
const spanHpx = Math.max(8, spanHmm * PX_PER_MM);
|
||||
const vPadPx = Math.max(1, Math.min(4, Math.round(spanHpx * 0.08)));
|
||||
const hPadPx = Math.max(2, Math.min(6, Math.round(vPadPx * 1.5)));
|
||||
const baseFontSize = Math.max(1, Number(cell?.fontSize || 12));
|
||||
// 高密行场景(例如 26mm 内 9~10 行)优先保证行线完整展示:
|
||||
// 按可用高度自动收缩画布字体,最小降到 1px,确保“行越多字体越小,直到全部行可见”。
|
||||
const innerHpx = Math.max(1, spanHpx - vPadPx * 2);
|
||||
const fitFontSize = Math.max(1, Math.min(baseFontSize, Math.floor(innerHpx * 0.82)));
|
||||
const nowrap = (cell as any).autoWrap === false;
|
||||
return {
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '0',
|
||||
textAlign: cell?.align || 'left',
|
||||
verticalAlign: cell?.verticalAlign || 'middle',
|
||||
fontSize: `${Number(cell?.fontSize || 12)}px`,
|
||||
fontSize: `${fitFontSize}px`,
|
||||
color: cell?.color || '#111111',
|
||||
backgroundColor: cell?.backgroundColor || '#ffffff',
|
||||
padding: `${vPadPx}px ${hPadPx}px`,
|
||||
whiteSpace: nowrap ? 'nowrap' : 'pre-wrap',
|
||||
wordBreak: nowrap ? 'normal' : 'break-all',
|
||||
lineHeight: nowrap ? `${Math.max(1, innerHpx)}px` : '1.15',
|
||||
overflow: 'hidden',
|
||||
borderTop: sides.top ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.top)} ${bc}` : 'none',
|
||||
borderRight: sides.right ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.right)} ${bc}` : 'none',
|
||||
borderBottom: sides.bottom ? `${bw}px ${lineStyleKeyToCssBorderStyle(lineKeys.bottom)} ${bc}` : 'none',
|
||||
@@ -416,6 +521,8 @@
|
||||
const next = redistributeColEdge(base, edge, deltaMm, totalW);
|
||||
if (next) {
|
||||
emit('update-tracks', { colWidths: next });
|
||||
// 拖拽中实时重算,保证蓝线始终贴着真实线
|
||||
nextTick(syncTrackGripPositionsFromDom);
|
||||
}
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
@@ -453,6 +560,8 @@
|
||||
const next = redistributeRowEdge(base, edge, deltaMm, totalH);
|
||||
if (next) {
|
||||
emit('update-tracks', { rowHeights: next });
|
||||
// 拖拽中实时重算,保证蓝线始终贴着真实线
|
||||
nextTick(syncTrackGripPositionsFromDom);
|
||||
}
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
@@ -493,10 +602,31 @@
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(syncTrackGripPositionsFromDom);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
rowCount.value,
|
||||
colCount.value,
|
||||
colWidthsMm.value.join(','),
|
||||
rowHeightsMm.value.join(','),
|
||||
Number(props.element?.borderWidth || 1),
|
||||
Number(props.element?.w || 0),
|
||||
Number(props.element?.h || 0),
|
||||
Number(props.scale || 1),
|
||||
],
|
||||
() => {
|
||||
nextTick(syncTrackGripPositionsFromDom);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.free-table-element {
|
||||
--ft-edit-line-w: 1px;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -571,25 +701,45 @@
|
||||
.track-grip--col {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
margin-left: -5px;
|
||||
width: var(--ft-edit-line-w);
|
||||
cursor: col-resize;
|
||||
background: rgba(22, 119, 255, 0.14);
|
||||
border-radius: 2px;
|
||||
background: rgba(22, 119, 255, 0.95);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.track-grip--row {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
margin-top: -5px;
|
||||
height: var(--ft-edit-line-w);
|
||||
cursor: row-resize;
|
||||
background: rgba(22, 119, 255, 0.14);
|
||||
border-radius: 2px;
|
||||
background: rgba(22, 119, 255, 0.95);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* 线保持细,拖拽热区单独加大,不影响视觉粗细 */
|
||||
.track-grip--col::before,
|
||||
.track-grip--row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.track-grip--col::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.track-grip--row::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
.track-grip:hover {
|
||||
background: rgba(22, 119, 255, 0.28);
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.free-table-surface table {
|
||||
@@ -610,8 +760,8 @@
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
padding: 10px 4px 4px;
|
||||
min-height: 0;
|
||||
padding: 1px 2px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
user-select: none;
|
||||
@@ -626,6 +776,7 @@
|
||||
|
||||
.cell-body {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-media-free {
|
||||
@@ -641,13 +792,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 12px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
min-width: 18px;
|
||||
height: 10px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -673,7 +824,7 @@
|
||||
}
|
||||
|
||||
.free-table-cell.is-selected {
|
||||
box-shadow: inset 0 0 0 2px #1677ff;
|
||||
box-shadow: inset 0 0 0 var(--ft-edit-line-w) #1677ff;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,20 +36,32 @@
|
||||
return props.element.text || '';
|
||||
});
|
||||
|
||||
const styleObject = computed(() => ({
|
||||
fontSize: `${props.element.style?.fontSize || 12}px`,
|
||||
fontWeight: String(props.element.style?.fontWeight || 400),
|
||||
color: props.element.style?.color || '#111',
|
||||
textAlign: props.element.style?.textAlign || 'left',
|
||||
lineHeight: String(props.element.style?.lineHeight || 1.4),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: props.element.style?.backgroundColor || 'transparent',
|
||||
display: (props.element as any)?.visible === false ? 'none' : 'block',
|
||||
borderTop: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
|
||||
borderBottom: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? '1px dashed rgba(22,119,255,0.5)' : 'none',
|
||||
background: props.element.type === 'reportHeader' || props.element.type === 'reportFooter' ? 'rgba(22,119,255,0.06)' : props.element.style?.backgroundColor || 'transparent',
|
||||
}));
|
||||
const styleObject = computed(() => {
|
||||
const isBand = props.element.type === 'reportHeader' || props.element.type === 'reportFooter';
|
||||
const bw = Math.max(0, Number(props.element.style?.borderWidth || 0));
|
||||
const bc = props.element.style?.borderColor || '#222';
|
||||
const normalBorderTop = bw > 0 && props.element.style?.hideBorderTop !== true ? `${bw}px solid ${bc}` : 'none';
|
||||
const normalBorderRight = bw > 0 && props.element.style?.hideBorderRight !== true ? `${bw}px solid ${bc}` : 'none';
|
||||
const normalBorderBottom = bw > 0 && props.element.style?.hideBorderBottom !== true ? `${bw}px solid ${bc}` : 'none';
|
||||
const normalBorderLeft = bw > 0 && props.element.style?.hideBorderLeft !== true ? `${bw}px solid ${bc}` : 'none';
|
||||
return {
|
||||
fontSize: `${props.element.style?.fontSize || 12}px`,
|
||||
fontWeight: String(props.element.style?.fontWeight || 400),
|
||||
color: props.element.style?.color || '#111',
|
||||
textAlign: props.element.style?.textAlign || 'left',
|
||||
lineHeight: String(props.element.style?.lineHeight || 1.4),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
display: (props.element as any)?.visible === false ? 'none' : 'block',
|
||||
// 报表头/尾保留原蓝色虚线辅助;标题/副标题/正文走用户配置四边边框
|
||||
borderTop: isBand ? '1px dashed rgba(22,119,255,0.5)' : normalBorderTop,
|
||||
borderRight: isBand ? 'none' : normalBorderRight,
|
||||
borderBottom: isBand ? '1px dashed rgba(22,119,255,0.5)' : normalBorderBottom,
|
||||
borderLeft: isBand ? 'none' : normalBorderLeft,
|
||||
background: isBand ? 'rgba(22,119,255,0.06)' : props.element.style?.backgroundColor || 'transparent',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 共享的 1D 条码渲染工具:用 jsbarcode 生成 SVG 字符串/元素。
|
||||
*
|
||||
* 关键设计:
|
||||
* - 保比例:返回的 SVG 设置 preserveAspectRatio="xMidYMid meet",CSS 100% 拉伸时
|
||||
* 会保持原始条宽/条高比,避免被容器横向拉长后变形。
|
||||
* - 容器铺满:内部 viewBox 由 jsbarcode 按 width/height 参数自动写入,外层只需
|
||||
* width:100%;height:100% 即可让 SVG 在容器内最大化保比例显示。
|
||||
* - 兼容设计器画布与打印路径(printRenderer.ts)两种使用场景:画布走 renderInto,
|
||||
* 打印走 buildString 拼到 HTML 字符串中。
|
||||
*/
|
||||
|
||||
export interface NativeBarcodeOptions {
|
||||
/** Code128 / EAN13 / EAN8 / UPC / CODE39 等,默认 CODE128 */
|
||||
format?: string;
|
||||
/** 是否在底部显示文本,默认 true */
|
||||
displayValue?: boolean;
|
||||
/** 单根细线宽度(像素),默认 2 */
|
||||
lineWidth?: number;
|
||||
/** 条码主体高度(像素,不含文字),默认 60 */
|
||||
barHeight?: number;
|
||||
/** 底部文本字号(像素),默认 14 */
|
||||
fontSize?: number;
|
||||
/** 上下左右安静区(像素),默认 0 */
|
||||
margin?: number;
|
||||
/** 前景色(条),默认黑 */
|
||||
lineColor?: string;
|
||||
/** 背景色,默认白 */
|
||||
background?: string;
|
||||
/**
|
||||
* 条码下文字对齐:center / left / right 直接交由 jsbarcode 原生 textAlign;
|
||||
* justify(两端对齐)在 jsbarcode center 输出基础上后处理,给 <text> 加 textLength
|
||||
* + lengthAdjust=spacing,由浏览器拉伸字符间距让文字横向铺满条码宽度。
|
||||
*/
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
/**
|
||||
* 是否无损填满外层容器:
|
||||
* - false(默认):preserveAspectRatio="xMidYMid meet",保比例居中。
|
||||
* - true:preserveAspectRatio="none",SVG 按容器宽高 1:1 拉伸(矢量缩放无损)。
|
||||
*/
|
||||
fillCell?: boolean;
|
||||
}
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function resolveOptions(value: string, options: NativeBarcodeOptions | undefined) {
|
||||
const text = String(value ?? '').trim() || '0000000000';
|
||||
const rawAlign = (options?.textAlign || 'center').toLowerCase();
|
||||
const isJustify = rawAlign === 'justify';
|
||||
// jsbarcode 不直接支持 justify,先按 center 出 SVG,再后处理拉伸字符间距
|
||||
const jsAlign = (['left', 'center', 'right'] as const).includes(rawAlign as any) ? (rawAlign as 'left' | 'center' | 'right') : 'center';
|
||||
return {
|
||||
text,
|
||||
format: options?.format || 'CODE128',
|
||||
displayValue: options?.displayValue !== false,
|
||||
width: Math.max(0.5, Number(options?.lineWidth ?? 2)),
|
||||
height: Math.max(10, Number(options?.barHeight ?? 60)),
|
||||
fontSize: Math.max(8, Number(options?.fontSize ?? 14)),
|
||||
margin: Math.max(0, Number(options?.margin ?? 0)),
|
||||
lineColor: options?.lineColor || '#000000',
|
||||
background: options?.background || '#ffffff',
|
||||
textAlign: jsAlign,
|
||||
justifyText: isJustify,
|
||||
fillCell: !!options?.fillCell,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把条码渲染到一个已存在的 SVG 元素中(用于 Vue 组件内的 svg ref)。
|
||||
* 渲染完成后会清除 svg 自身的 width/height 属性,并设置 preserveAspectRatio
|
||||
* 以便外层 CSS 100% 高宽时仍保持原比例。
|
||||
*/
|
||||
export async function renderNativeBarcodeIntoSvg(
|
||||
svg: SVGSVGElement,
|
||||
value: string,
|
||||
options?: NativeBarcodeOptions,
|
||||
): Promise<boolean> {
|
||||
const module: any = await import('jsbarcode');
|
||||
const JsBarcode = module.default || module;
|
||||
const opts = resolveOptions(value, options);
|
||||
try {
|
||||
JsBarcode(svg, opts.text, {
|
||||
format: opts.format,
|
||||
displayValue: opts.displayValue,
|
||||
margin: opts.margin,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
fontSize: opts.fontSize,
|
||||
lineColor: opts.lineColor,
|
||||
background: opts.background,
|
||||
textAlign: opts.textAlign,
|
||||
});
|
||||
// 关键修复:jsbarcode 默认只设 width/height 属性,没有 viewBox。我们若直接移除
|
||||
// width/height,SVG 会失去几何坐标系,preserveAspectRatio 不生效——
|
||||
// 在设计画布(外层 flex+overflow:hidden)里浏览器会按 user units 裁切显示成"满铺",
|
||||
// 在打印输出 div 里却按缩水的 user units 居中,造成两边视觉差异。
|
||||
// 修复:用 jsbarcode 设置的 width/height 派生 viewBox,再交给 CSS 控制最终尺寸,
|
||||
// preserveAspectRatio="xMidYMid meet" 才能保比例统一缩放。
|
||||
const rawWidth = parseFloat(svg.getAttribute('width') || '0');
|
||||
const rawHeight = parseFloat(svg.getAttribute('height') || '0');
|
||||
if (rawWidth > 0 && rawHeight > 0 && !svg.getAttribute('viewBox')) {
|
||||
svg.setAttribute('viewBox', `0 0 ${rawWidth} ${rawHeight}`);
|
||||
}
|
||||
svg.removeAttribute('width');
|
||||
svg.removeAttribute('height');
|
||||
// fillCell=true 时:preserveAspectRatio="none",SVG 矢量按容器宽高 1:1 拉伸铺满
|
||||
//(条宽/条高比会跟着容器变,但因为是矢量,缩放本身不会失真);
|
||||
// fillCell=false 时:默认 "xMidYMid meet" 保比例居中,扫码兼容性最佳。
|
||||
svg.setAttribute('preserveAspectRatio', opts.fillCell ? 'none' : 'xMidYMid meet');
|
||||
// 两端对齐:找到底部文字 <text>,让浏览器把字符间距自动拉伸到条码宽度
|
||||
if (opts.displayValue && opts.justifyText) {
|
||||
applyJustifyTextAlign(svg);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 jsbarcode 输出的底部文字(<text>)改成两端对齐:保留原 y/字号,重置 x=0、
|
||||
* text-anchor=start,并加 textLength=条码宽 + lengthAdjust=spacing,
|
||||
* 浏览器会自动拉伸字符间距让文字横向铺满条码宽度。
|
||||
*/
|
||||
function applyJustifyTextAlign(svg: SVGSVGElement) {
|
||||
const texts = svg.querySelectorAll('text');
|
||||
if (!texts.length) return;
|
||||
// jsbarcode 输出的 SVG 第一个有 viewBox/width,取其宽度作为目标 textLength
|
||||
const viewBox = (svg.getAttribute('viewBox') || '').split(/\s+/).map((n) => Number(n) || 0);
|
||||
const targetWidth = viewBox.length === 4 && viewBox[2] > 0 ? viewBox[2] : Number(svg.getAttribute('width')) || 0;
|
||||
if (!targetWidth) return;
|
||||
texts.forEach((t) => {
|
||||
t.setAttribute('x', '0');
|
||||
t.setAttribute('text-anchor', 'start');
|
||||
t.setAttribute('textLength', String(targetWidth));
|
||||
t.setAttribute('lengthAdjust', 'spacing');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可直接拼入 HTML 字符串的 `<svg>...</svg>` 文本(供打印 HTML 渲染使用)。
|
||||
* 失败时回退到带条码内容的占位 div 字符串,便于使用方区分。
|
||||
*/
|
||||
export async function buildNativeBarcodeSvgString(
|
||||
value: string,
|
||||
options?: NativeBarcodeOptions,
|
||||
): Promise<string> {
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement;
|
||||
const ok = await renderNativeBarcodeIntoSvg(svg, value, options);
|
||||
if (!ok) return '';
|
||||
// 让外层布局可以通过 CSS 控制宽高(width/height attribute 已在 renderInto 中清除)
|
||||
return new XMLSerializer().serializeToString(svg);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { normalizeFreeTableAnchors } from './freeTableGrid';
|
||||
import { borderSidesToCssFragment, resolveFreeTableCellBorderSides } from './freeTableBorders';
|
||||
import { resolveFreeTableCellLineStyleKeys } from './freeTableLineStyles';
|
||||
import { resolveFreeTableColWidthsMm, resolveFreeTableRowHeightsMm } from './freeTableTracks';
|
||||
import { buildNativeBarcodeSvgString } from './barcodeRenderer';
|
||||
|
||||
function resolveBoundValue(element: NativeElement, data: Record<string, any>) {
|
||||
const bindField = (element as any).bindField;
|
||||
@@ -48,10 +49,10 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
|
||||
const colCount = Math.max(1, Number((element as any)?.colCount || 1));
|
||||
const wMm = Math.max(0.01, Number((element as any)?.w) || 0.01);
|
||||
const hMm = Math.max(0.01, Number((element as any)?.h) || 0.01);
|
||||
const colWidthsMm = resolveFreeTableColWidthsMm(element as any);
|
||||
const rowHeightsMm = resolveFreeTableRowHeightsMm(element as any);
|
||||
const borderColor = String((element as any)?.borderColor || '#d9d9d9');
|
||||
const borderWidth = Math.max(1, Number((element as any)?.borderWidth || 1));
|
||||
const colWidthsMm = compensateFreeTableTracksByBorder(resolveFreeTableColWidthsMm(element as any), wMm, colCount, borderWidth);
|
||||
const rowHeightsMm = compensateFreeTableTracksByBorder(resolveFreeTableRowHeightsMm(element as any), hMm, rowCount, borderWidth);
|
||||
const colgroup = `<colgroup>${colWidthsMm.map((cw) => `<col style="width:${cw}mm;box-sizing:border-box" />`).join('')}</colgroup>`;
|
||||
const anchors = normalizeFreeTableAnchors(rowCount, colCount, (element as any)?.cells || []);
|
||||
const body = (
|
||||
@@ -79,12 +80,21 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
|
||||
const bodyInnerHtml = await resolvePrintCellInnerHtml(contentType, innerArg, cell as any);
|
||||
const align = String((cell as any)?.align || 'left');
|
||||
const verticalAlign = String((cell as any)?.verticalAlign || 'middle');
|
||||
const fontSize = Math.max(8, Number((cell as any)?.fontSize || element.style?.fontSize || 12));
|
||||
const baseFontSize = Math.max(1, Number((cell as any)?.fontSize || element.style?.fontSize || 12));
|
||||
const color = String((cell as any)?.color || '#111111');
|
||||
const backgroundColor = String((cell as any)?.backgroundColor || '#ffffff');
|
||||
const rowspanAttr = rs > 1 ? ` rowspan="${rs}"` : '';
|
||||
const colspanAttr = cs > 1 ? ` colspan="${cs}"` : '';
|
||||
const spanW = colWidthsMm.slice(cell.col, cell.col + cs).reduce((a, b) => a + b, 0);
|
||||
const spanH = rowHeightsMm.slice(cell.row, cell.row + rs).reduce((a, b) => a + b, 0);
|
||||
// 行高很小(4~5mm)时固定 2mm padding 会把行撑爆,导致后续行被容器裁切。
|
||||
// 这里改为按单元格高度自适应,保证高密度标签场景仍能完整显示所有行线。
|
||||
const vPadMm = Math.max(0.15, Math.min(0.8, spanH * 0.08));
|
||||
const hPadMm = Math.max(0.3, Math.min(1.2, vPadMm * 1.6));
|
||||
// 随行密度自动收缩字体:优先保证全部行可见(与画布规则保持一致)
|
||||
const innerHmm = Math.max(0.1, spanH - vPadMm * 2);
|
||||
const innerHpx = innerHmm * (96 / 25.4);
|
||||
const fitFontSize = Math.max(1, Math.min(baseFontSize, Math.floor(innerHpx * 0.82)));
|
||||
const colWidthStyle = `width:${spanW}mm;`;
|
||||
const sides = resolveFreeTableCellBorderSides(element, anchors, cell, cell.row, cell.col, rs, cs, rowCount, colCount);
|
||||
const lineKeys = resolveFreeTableCellLineStyleKeys(element, cell.row, cell.col, rs, cs, rowCount, colCount);
|
||||
@@ -93,8 +103,8 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
|
||||
const ws = nowrap ? 'nowrap' : 'normal';
|
||||
const wb = nowrap ? 'normal' : 'break-all';
|
||||
const ow = nowrap ? 'normal' : 'anywhere';
|
||||
return `<td${rowspanAttr}${colspanAttr} style="box-sizing:border-box;${borderCss}${colWidthStyle}padding:2mm;text-align:${align};vertical-align:${verticalAlign};font-size:${fontSize}px;color:${color};background:${backgroundColor};white-space:${ws};word-break:${wb};overflow-wrap:${ow};line-height:${
|
||||
nowrap ? `${rh}mm` : '1.3'
|
||||
return `<td${rowspanAttr}${colspanAttr} style="box-sizing:border-box;${borderCss}${colWidthStyle}padding:${vPadMm.toFixed(3)}mm ${hPadMm.toFixed(3)}mm;text-align:${align};vertical-align:${verticalAlign};font-size:${fitFontSize}px;color:${color};background:${backgroundColor};white-space:${ws};word-break:${wb};overflow-wrap:${ow};overflow:hidden;line-height:${
|
||||
nowrap ? `${innerHmm.toFixed(3)}mm` : '1.15'
|
||||
};">${bodyInnerHtml}</td>`;
|
||||
}),
|
||||
)
|
||||
@@ -107,6 +117,28 @@ async function renderFreeTable(element: NativeFreeTableElement, data: Record<str
|
||||
return `<table style="width:${wMm}mm;border-collapse:collapse;border-spacing:0;table-layout:fixed;box-sizing:border-box;">${colgroup}<tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在打印渲染层按边框宽度扣减轨道预算,避免“轨道总高 + 网格线宽度”超过元素高度,
|
||||
* 从而导致底部行被裁切(特别是高行数小高度,如 9 行 / 26mm)。
|
||||
*/
|
||||
function compensateFreeTableTracksByBorder(tracks: number[], totalMm: number, count: number, borderWidthPx: number): number[] {
|
||||
const n = Math.max(1, Number(count || tracks.length || 1));
|
||||
const safeTracks = (tracks || []).slice(0, n).map((v) => Math.max(0.01, Number(v) || 0.01));
|
||||
while (safeTracks.length < n) safeTracks.push(0.01);
|
||||
|
||||
const total = Math.max(0.01, Number(totalMm) || 0.01);
|
||||
const mmPerPx = 25.4 / 96;
|
||||
const bwMm = Math.max(0, Number(borderWidthPx) || 1) * mmPerPx;
|
||||
const lineBudget = (n + 1) * bwMm;
|
||||
const targetInner = Math.max(0.01, total - lineBudget);
|
||||
const sourceInner = safeTracks.reduce((s, v) => s + v, 0);
|
||||
const scale = sourceInner > 0 ? targetInner / sourceInner : 1;
|
||||
const out = safeTracks.map((v) => Math.max(0.01, Math.round(v * scale * 1000) / 1000));
|
||||
const sum = out.reduce((s, v) => s + v, 0);
|
||||
out[out.length - 1] = Math.max(0.01, Math.round((out[out.length - 1] + (targetInner - sum)) * 1000) / 1000);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function renderFixedRowsTablePages(element: NativeTableElement, data: Record<string, any>) {
|
||||
const sourceRows = resolveTableRows(element, data);
|
||||
const columns = normalizeTableWidths(element);
|
||||
@@ -302,9 +334,20 @@ async function resolvePrintCellInnerHtml(contentType: string, value: string, col
|
||||
}
|
||||
}
|
||||
if (contentType === 'barcode') {
|
||||
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${fillCell ? '100%' : `${scale}%`};height:${
|
||||
fillCell ? '100%' : `${Math.max(20, scale * 0.6)}%`
|
||||
};margin:0 auto;">BAR:${safeValue}</div>`;
|
||||
const svgStr = await buildNativeBarcodeSvgString(safeValue, {
|
||||
format: column?.barcodeFormat,
|
||||
displayValue: column?.displayValue !== false,
|
||||
fontSize: column?.barcodeFontSize,
|
||||
});
|
||||
const w = fillCell ? '100%' : `${scale}%`;
|
||||
const h = fillCell ? '100%' : `${Math.max(20, scale * 0.6)}%`;
|
||||
if (svgStr) {
|
||||
// SVG 已设 preserveAspectRatio="xMidYMid meet",下面套层 div 限定宽高、居中显示,
|
||||
// 浏览器会保持原始条宽比例自动缩放,不再被单元格拉宽变形。
|
||||
const sized = svgStr.replace(/<svg /i, '<svg width="100%" height="100%" ');
|
||||
return `<div style="display:flex;align-items:center;justify-content:center;width:${w};height:${h};margin:0 auto;overflow:hidden;">${sized}</div>`;
|
||||
}
|
||||
return `<div style="display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:${w};height:${h};margin:0 auto;">${safeValue}</div>`;
|
||||
}
|
||||
return safeValue;
|
||||
}
|
||||
@@ -397,6 +440,12 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
||||
const renderX = isReportHeader || isReportFooter ? 0 : item.x;
|
||||
const renderY = isReportHeader ? 0 : isReportFooter && (item as any).printAtPageBottom === true ? Math.max(0, schema.page.height - item.h) : item.y;
|
||||
const renderW = isReportHeader || isReportFooter ? schema.page.width : item.w;
|
||||
const borderWidth = Math.max(0, Number(item.style?.borderWidth || 0));
|
||||
const borderColor = item.style?.borderColor || '#222';
|
||||
const borderTopCss = borderWidth > 0 && item.style?.hideBorderTop !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
|
||||
const borderRightCss = borderWidth > 0 && item.style?.hideBorderRight !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
|
||||
const borderBottomCss = borderWidth > 0 && item.style?.hideBorderBottom !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
|
||||
const borderLeftCss = borderWidth > 0 && item.style?.hideBorderLeft !== true ? `${borderWidth}px solid ${borderColor}` : 'none';
|
||||
const styleParts = [
|
||||
`position:absolute`,
|
||||
`width:${renderW}mm`,
|
||||
@@ -407,7 +456,10 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
||||
`line-height:${item.style?.lineHeight || 1.4}`,
|
||||
`text-align:${item.style?.textAlign || 'left'}`,
|
||||
`background:${item.style?.backgroundColor || 'transparent'}`,
|
||||
item.style?.borderWidth ? `border:${item.style.borderWidth}px solid ${item.style.borderColor || '#222'}` : '',
|
||||
`border-top:${borderTopCss}`,
|
||||
`border-right:${borderRightCss}`,
|
||||
`border-bottom:${borderBottomCss}`,
|
||||
`border-left:${borderLeftCss}`,
|
||||
'overflow:hidden',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -481,7 +533,9 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
||||
return ftParts.join('');
|
||||
}
|
||||
|
||||
const shouldRepeat = (repeatReportHeader || repeatHeaderElement) && pageCount > 1;
|
||||
// pageNo 元素默认按页重复:与桌面端 NativePrintRenderService 行为对齐,
|
||||
// 用户放置页码就一定每页都画一次,无需再单独勾选 printRepeated。
|
||||
const shouldRepeat = (repeatReportHeader || repeatHeaderElement || item.type === 'pageNo') && pageCount > 1;
|
||||
const pages = shouldRepeat ? Array.from({ length: pageCount }, (_v, i) => i + 1) : [1];
|
||||
const htmlByPage = await Promise.all(
|
||||
pages.map(async (pageNo) => {
|
||||
@@ -497,6 +551,21 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
|
||||
}
|
||||
if (item.type === 'barcode') {
|
||||
const value = resolveBoundValue(item, data) ?? (item as any).value;
|
||||
const svgStr = await buildNativeBarcodeSvgString(String(value ?? ''), {
|
||||
format: (item as any).format,
|
||||
displayValue: (item as any).displayValue !== false,
|
||||
fontSize: (item as any).fontSize,
|
||||
lineWidth: (item as any).lineWidth,
|
||||
barHeight: (item as any).barHeight,
|
||||
textAlign: (item as any).textAlign,
|
||||
fillCell: (item as any).fillCell === true,
|
||||
});
|
||||
if (svgStr) {
|
||||
// 让 SVG 100% 充满定位 div;preserveAspectRatio 在 SVG 内部已设为 xMidYMid meet,
|
||||
// 因此条码在元素框内保持原比例居中,不会被拉宽/压扁。
|
||||
const sized = svgStr.replace(/<svg /i, '<svg width="100%" height="100%" ');
|
||||
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;overflow:hidden;">${sized}</div>`;
|
||||
}
|
||||
return `<div style="${style(top)};display:flex;align-items:center;justify-content:center;border:1px dashed #999;">条形码:${value ?? ''}</div>`;
|
||||
}
|
||||
if (item.type === 'image') {
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface NativeElementBase {
|
||||
lineHeight?: number;
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
hideBorderTop?: boolean;
|
||||
hideBorderRight?: boolean;
|
||||
hideBorderBottom?: boolean;
|
||||
hideBorderLeft?: boolean;
|
||||
backgroundColor?: string;
|
||||
};
|
||||
}
|
||||
@@ -69,6 +73,15 @@ export interface NativeImageElement extends NativeElementBase {
|
||||
export interface NativeCodeElement extends NativeElementBase {
|
||||
type: 'qrcode' | 'barcode';
|
||||
value: string;
|
||||
/**
|
||||
* 是否无损铺满整个编辑框(条码):
|
||||
* - false / undefined(默认):preserveAspectRatio="xMidYMid meet",保比例居中,
|
||||
* 扁容器中上下会留白,但条宽比例严格 1:1,扫码兼容性最佳。
|
||||
* - true:preserveAspectRatio="none",SVG 矢量按容器宽高拉伸铺满;
|
||||
* 放大缩小不会失真(仍是矢量),但条宽/条高比会随容器变化,
|
||||
* 适合需要"卡片整格塞满"的小票/标签场景。
|
||||
*/
|
||||
fillCell?: boolean;
|
||||
}
|
||||
|
||||
export interface NativeTableColumn {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,10 @@ enum Api {
|
||||
importExcel = '/xslmes/mesXslRawMaterialCard/importExcel',
|
||||
exportXls = '/xslmes/mesXslRawMaterialCard/exportXls',
|
||||
updatePriority = '/xslmes/mesXslRawMaterialCard/updatePriority',
|
||||
/** 与打印模板页 queryPrinters 返回结构一致 */
|
||||
queryPrinters = '/xslmes/mesXslRawMaterialCard/queryPrinters',
|
||||
prepareNativePrint = '/xslmes/mesXslRawMaterialCard/prepareNativePrint',
|
||||
printPdf = '/xslmes/mesXslRawMaterialCard/printPdf',
|
||||
}
|
||||
|
||||
export const getExportUrl = Api.exportXls;
|
||||
@@ -47,3 +51,15 @@ export const saveOrUpdate = (params, isUpdate) => {
|
||||
|
||||
export const updatePriority = (id: string, priorityPickup: string) =>
|
||||
defHttp.put({ url: Api.updatePriority, params: { id, priorityPickup } }, { joinParamsToUrl: true });
|
||||
|
||||
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
|
||||
|
||||
export const prepareNativePrint = (id: string) =>
|
||||
defHttp.get({
|
||||
url: Api.prepareNativePrint,
|
||||
params: { id, _t: Date.now() },
|
||||
});
|
||||
|
||||
/** id + 前端生成的 pdfBase64;printerName 空则用默认队列 */
|
||||
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
|
||||
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });
|
||||
|
||||
@@ -7,6 +7,51 @@
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
|
||||
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
|
||||
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
|
||||
PrintDot 桥接
|
||||
</a-checkbox>
|
||||
<a-input
|
||||
v-model:value="printDotWsUrl"
|
||||
style="width: 220px; margin-left: 8px"
|
||||
placeholder="ws://127.0.0.1:1122/ws"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-input-password
|
||||
v-model:value="printDotKey"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="密钥(可选)"
|
||||
autocomplete="new-password"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-button @click="downloadPrintPlugin">下载打印插件</a-button>
|
||||
<a-select
|
||||
v-model:value="selectedPrinterName"
|
||||
:options="printerOptions"
|
||||
style="width: 220px; margin-left: 8px"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:placeholder="printerSelectPlaceholder"
|
||||
/>
|
||||
<a-button @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
|
||||
<a-input
|
||||
v-model:value="manualPrinterName"
|
||||
style="width: 150px; margin-left: 8px"
|
||||
placeholder="手动输入打印机名"
|
||||
@press-enter="addManualPrinter"
|
||||
/>
|
||||
<a-button @click="addManualPrinter">添加</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
v-auth="'xslmes:mes_xsl_raw_material_card:edit'"
|
||||
:loading="printLoading"
|
||||
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
|
||||
@click="handlePrintSelected"
|
||||
>
|
||||
<Icon icon="ant-design:printer-outlined" />
|
||||
打印选中
|
||||
</a-button>
|
||||
<a-dropdown v-if="selectedRowKeys.length > 0">
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
@@ -42,20 +87,73 @@
|
||||
</BasicTable>
|
||||
<!-- 表单区域 -->
|
||||
<MesXslRawMaterialCardModal @register="registerModal" @success="handleSuccess" />
|
||||
<RawMaterialCardPrintPreviewModal
|
||||
v-model:open="printPreviewOpen"
|
||||
:card-id="printPreviewCardId"
|
||||
:barcode="printPreviewBarcode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslRawMaterialCard" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { onMounted, ref, reactive, watch } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { BasicTable, useTable, TableAction } from '/@/components/Table';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useListPage } from '/@/hooks/system/useListPage';
|
||||
import MesXslRawMaterialCardModal from './components/MesXslRawMaterialCardModal.vue';
|
||||
import RawMaterialCardPrintPreviewModal from './components/RawMaterialCardPrintPreviewModal.vue';
|
||||
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialCard.data';
|
||||
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, updatePriority } from './MesXslRawMaterialCard.api';
|
||||
import {
|
||||
list,
|
||||
deleteOne,
|
||||
batchDelete,
|
||||
getImportUrl,
|
||||
getExportUrl,
|
||||
updatePriority,
|
||||
prepareNativePrint,
|
||||
} from './MesXslRawMaterialCard.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import {
|
||||
PRINT_TEMPLATE_SELECTED_PRINTER_KEY,
|
||||
printNativeSchemaViaPrintDot,
|
||||
} from '/@/views/print/template/utils/printNativeViaPrintDot';
|
||||
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
|
||||
import {
|
||||
fetchPrintDotPrinters,
|
||||
getPrintDotBridgeConfig,
|
||||
setPrintDotBridgeConfig,
|
||||
} from '/@/views/print/template/utils/printDotBridge';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
|
||||
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
|
||||
const printDotCfg = getPrintDotBridgeConfig();
|
||||
const printDotWsUrl = ref(printDotCfg.wsUrl);
|
||||
const printDotKey = ref(printDotCfg.key);
|
||||
|
||||
function persistPrintDotConfig() {
|
||||
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
|
||||
void refreshPrinterOptions(false);
|
||||
}
|
||||
|
||||
function onPrintDotEnabledChange() {
|
||||
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
|
||||
}
|
||||
|
||||
function downloadPrintPlugin() {
|
||||
const base = import.meta.env.BASE_URL || '/';
|
||||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'XSL-PrintDot.exe');
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
const queryParam = reactive<any>({});
|
||||
const checkedKeys = ref<Array<string | number>>([]);
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
@@ -74,8 +172,11 @@
|
||||
],
|
||||
},
|
||||
actionColumn: {
|
||||
width: 120,
|
||||
width: 248,
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
slots: { customRender: 'action' },
|
||||
},
|
||||
beforeFetch: (params) => {
|
||||
return Object.assign(params, queryParam);
|
||||
@@ -92,9 +193,207 @@
|
||||
},
|
||||
});
|
||||
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
|
||||
const superQueryConfig = reactive(superQuerySchema);
|
||||
|
||||
/** 打印预览弹窗 */
|
||||
const printPreviewOpen = ref(false);
|
||||
const printPreviewCardId = ref<string | null>(null);
|
||||
const printPreviewBarcode = ref<string | undefined>(undefined);
|
||||
|
||||
function handlePrintPreview(record: Recordable) {
|
||||
printPreviewCardId.value = record.id as string;
|
||||
printPreviewBarcode.value = record.barcode as string | undefined;
|
||||
printPreviewOpen.value = true;
|
||||
}
|
||||
|
||||
/** 与打印模板列表共用 localStorage 键,打印机选择保持一致 */
|
||||
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const selectedPrinterName = ref<string>('__system_default__');
|
||||
const manualPrinterName = ref('');
|
||||
const printLoading = ref(false);
|
||||
|
||||
/** 与打印模板列表启用 PrintDot 时一致:仅本机桥接打印机 */
|
||||
const printerSelectPlaceholder = '选择打印机(PrintDot 桥接)';
|
||||
|
||||
watch(selectedPrinterName, (v) => {
|
||||
if (v) {
|
||||
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
|
||||
}
|
||||
});
|
||||
|
||||
/** 与打印模板列表「PrintDot 桥接」勾选时相同:仅从本机 WebSocket 获取打印机 */
|
||||
async function refreshPrinterOptions(showMessage = true) {
|
||||
const optionMap = new Map<string, { label: string; value: string }>();
|
||||
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
|
||||
try {
|
||||
const dotList = await fetchPrintDotPrinters();
|
||||
dotList.forEach((p) => {
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) return;
|
||||
const defMark = p.isDefault ? '(默认)' : '';
|
||||
optionMap.set(name, { label: `${name}${defMark}`, value: name });
|
||||
});
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
if (dotList.length) {
|
||||
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
|
||||
} else {
|
||||
createMessage.warning('PrintDot 已连接但未返回打印机列表');
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
createMessage.warning(`PrintDot:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addManualPrinter() {
|
||||
const name = String(manualPrinterName.value || '').trim();
|
||||
if (!name) return;
|
||||
const exists = printerOptions.value.some((item) => item.value === name);
|
||||
if (!exists) {
|
||||
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
|
||||
}
|
||||
selectedPrinterName.value = name;
|
||||
manualPrinterName.value = '';
|
||||
createMessage.success('已添加打印机');
|
||||
}
|
||||
|
||||
async function executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
|
||||
try {
|
||||
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
|
||||
const templateJsonRaw = prep.templateJson as string;
|
||||
const printData = prep.printData as Record<string, unknown>;
|
||||
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
|
||||
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
|
||||
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
|
||||
if (!templateJsonRaw) {
|
||||
throw new Error('模板 JSON 为空');
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
|
||||
} catch {
|
||||
throw new Error('模板 JSON 格式错误');
|
||||
}
|
||||
const schema = normalizeImportedNativeSchema(raw);
|
||||
// 以模板主表的纸张配置为准,避免 schema 页面尺寸与模板设置不同步导致方向错误/内容缩小。
|
||||
if (paperWidthMm > 0 && paperHeightMm > 0) {
|
||||
const orient = paperOrientation === 'landscape' ? 'landscape' : paperOrientation === 'portrait' ? 'portrait' : '';
|
||||
const normalized =
|
||||
orient === 'landscape'
|
||||
? {
|
||||
width: Math.max(paperWidthMm, paperHeightMm),
|
||||
height: Math.min(paperWidthMm, paperHeightMm),
|
||||
}
|
||||
: orient === 'portrait'
|
||||
? {
|
||||
width: Math.min(paperWidthMm, paperHeightMm),
|
||||
height: Math.max(paperWidthMm, paperHeightMm),
|
||||
}
|
||||
: {
|
||||
width: paperWidthMm,
|
||||
height: paperHeightMm,
|
||||
};
|
||||
schema.page.width = normalized.width;
|
||||
schema.page.height = normalized.height;
|
||||
}
|
||||
/** 与打印模板原生打印一致:render → PDF → PrintDot WebSocket */
|
||||
await printNativeSchemaViaPrintDot({
|
||||
schema,
|
||||
data: printData as Record<string, unknown>,
|
||||
jobName: `原材料卡片-${(record.barcode as string) || record.id}.pdf`,
|
||||
printerSelection:
|
||||
selectedPrinterName.value ||
|
||||
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
|
||||
'__system_default__',
|
||||
});
|
||||
if (!options?.silentSuccess) {
|
||||
createMessage.success('已通过 PrintDot 提交打印');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
throw new Error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrintSelected() {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
const rows = selectedRows.value || [];
|
||||
if (!rows.length) {
|
||||
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
|
||||
(async () => {
|
||||
let ok = 0;
|
||||
let firstError = '';
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await executePrint(row, { silentSuccess: true });
|
||||
ok += 1;
|
||||
} catch (e: unknown) {
|
||||
if (!firstError) {
|
||||
firstError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok === rows.length) {
|
||||
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
|
||||
} else {
|
||||
createMessage.warning(`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`);
|
||||
}
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
})();
|
||||
}
|
||||
|
||||
function handlePrintRow(record: Recordable) {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
|
||||
executePrint(record)
|
||||
.then(() => {
|
||||
createMessage.success('已通过 PrintDot 提交打印');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
createMessage.error(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
|
||||
if (saved) {
|
||||
selectedPrinterName.value = saved;
|
||||
}
|
||||
refreshPrinterOptions(false);
|
||||
});
|
||||
|
||||
function handleSuperQuery(params) {
|
||||
Object.keys(params).map((k) => {
|
||||
queryParam[k] = params[k];
|
||||
@@ -145,6 +444,16 @@
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_card:edit',
|
||||
},
|
||||
{
|
||||
label: '打印预览',
|
||||
onClick: handlePrintPreview.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_card:edit',
|
||||
},
|
||||
{
|
||||
label: '打印',
|
||||
onClick: handlePrintRow.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_card:edit',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="innerOpen"
|
||||
:title="modalTitle"
|
||||
width="960px"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
wrap-class-name="raw-material-card-print-preview-modal"
|
||||
@cancel="onClose"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="errorText" class="preview-error">{{ errorText }}</div>
|
||||
<div v-else class="preview-body">
|
||||
<iframe
|
||||
v-if="previewHtml"
|
||||
ref="previewIframeRef"
|
||||
class="preview-iframe"
|
||||
title="原材料卡片打印预览"
|
||||
:srcdoc="previewHtml"
|
||||
/>
|
||||
<a-empty v-else-if="!loading" description="暂无预览内容" />
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="preview-footer">
|
||||
<a-space>
|
||||
<a-button @click="innerOpen = false">关闭</a-button>
|
||||
<a-button type="primary" :disabled="!previewHtml || !!errorText" @click="handleBrowserPrint">
|
||||
<Icon icon="ant-design:printer-outlined" />
|
||||
浏览器打印
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { prepareNativePrint } from '../MesXslRawMaterialCard.api';
|
||||
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
|
||||
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 卡片主键,有值时拉取模板与业务数据并渲染 */
|
||||
cardId: string | null;
|
||||
/** 展示在标题上的条码/说明 */
|
||||
barcode?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', v: boolean): void;
|
||||
}>();
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const innerOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit('update:open', v),
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const b = String(props.barcode || '').trim();
|
||||
return b ? `原材料卡片打印预览(条码:${b})` : '原材料卡片打印预览';
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorText = ref('');
|
||||
const previewHtml = ref('');
|
||||
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
async function loadPreview(id: string) {
|
||||
loading.value = true;
|
||||
errorText.value = '';
|
||||
previewHtml.value = '';
|
||||
try {
|
||||
const prep = (await prepareNativePrint(id)) as Record<string, unknown>;
|
||||
const templateJsonRaw = prep.templateJson as string;
|
||||
const printData = prep.printData as Record<string, unknown>;
|
||||
if (!templateJsonRaw) {
|
||||
throw new Error('模板 JSON 为空,请检查「业务打印绑定」是否已配置');
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
|
||||
} catch {
|
||||
throw new Error('模板 JSON 格式错误');
|
||||
}
|
||||
const schema = normalizeImportedNativeSchema(raw);
|
||||
previewHtml.value = await renderNativePrintHtml(schema, printData as Record<string, unknown>);
|
||||
} catch (e: unknown) {
|
||||
errorText.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Modal 内对 iframe 直接 print() 时,打印对话框易被遮罩层级挡住或焦点异常,表现为「点了没反应」。
|
||||
* 改为在 body 下挂临时 iframe、写入同一套 HTML 再打印,与模板预览常用做法一致。
|
||||
*/
|
||||
function handleBrowserPrint() {
|
||||
const html = previewHtml.value;
|
||||
if (!html?.trim()) {
|
||||
createMessage.warning('预览未就绪,请稍后再试');
|
||||
return;
|
||||
}
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute(
|
||||
'style',
|
||||
'position:fixed;left:0;top:0;width:0;height:0;border:0;opacity:0;pointer-events:none;',
|
||||
);
|
||||
document.body.appendChild(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) {
|
||||
document.body.removeChild(iframe);
|
||||
createMessage.error('无法创建打印文档');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
} catch {
|
||||
document.body.removeChild(iframe);
|
||||
createMessage.error('写入打印内容失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (iframe.parentNode) {
|
||||
document.body.removeChild(iframe);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const runPrint = () => {
|
||||
try {
|
||||
const w = iframe.contentWindow;
|
||||
if (!w) {
|
||||
createMessage.error('无法唤起打印窗口');
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
w.focus();
|
||||
w.print();
|
||||
/** 关闭打印对话框后移除临时 iframe(部分浏览器支持 afterprint) */
|
||||
w.addEventListener('afterprint', cleanup, { once: true });
|
||||
window.setTimeout(cleanup, 120000);
|
||||
} catch {
|
||||
createMessage.error('无法唤起打印,请检查浏览器弹窗/打印权限');
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
/** 等待排版与字体后再打印,减少空白页 */
|
||||
window.setTimeout(runPrint, 100);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
errorText.value = '';
|
||||
previewHtml.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.cardId] as const,
|
||||
([isOpen, id]) => {
|
||||
if (isOpen && id) {
|
||||
void loadPreview(id);
|
||||
}
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.preview-error {
|
||||
color: #cf1322;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
min-height: 420px;
|
||||
max-height: 72vh;
|
||||
overflow: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@ enum Api {
|
||||
batchStockIn = '/xslmes/mesXslRawMaterialEntry/batchStockIn',
|
||||
importExcel = '/xslmes/mesXslRawMaterialEntry/importExcel',
|
||||
exportXls = '/xslmes/mesXslRawMaterialEntry/exportXls',
|
||||
queryPrinters = '/xslmes/mesXslRawMaterialEntry/queryPrinters',
|
||||
prepareNativePrint = '/xslmes/mesXslRawMaterialEntry/prepareNativePrint',
|
||||
printPdf = '/xslmes/mesXslRawMaterialEntry/printPdf',
|
||||
}
|
||||
|
||||
export const getExportUrl = Api.exportXls;
|
||||
@@ -47,3 +50,15 @@ export const saveOrUpdate = (params, isUpdate) => {
|
||||
let url = isUpdate ? Api.edit : Api.save;
|
||||
return defHttp.post({ url: url, params });
|
||||
};
|
||||
|
||||
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
|
||||
|
||||
export const prepareNativePrint = (id: string) =>
|
||||
defHttp.get({
|
||||
url: Api.prepareNativePrint,
|
||||
params: { id, _t: Date.now() },
|
||||
});
|
||||
|
||||
/** id + 前端生成的 pdfBase64;printerName 空则用默认队列 */
|
||||
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
|
||||
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });
|
||||
|
||||
@@ -6,6 +6,51 @@
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
|
||||
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_entry:stockIn'" preIcon="ant-design:check-circle-outlined" @click="handleStockIn"> 结存入库</a-button>
|
||||
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
|
||||
PrintDot 桥接
|
||||
</a-checkbox>
|
||||
<a-input
|
||||
v-model:value="printDotWsUrl"
|
||||
style="width: 220px; margin-left: 8px"
|
||||
placeholder="ws://127.0.0.1:1122/ws"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-input-password
|
||||
v-model:value="printDotKey"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="密钥(可选)"
|
||||
autocomplete="new-password"
|
||||
@blur="persistPrintDotConfig"
|
||||
/>
|
||||
<a-button @click="downloadPrintPlugin">下载打印插件</a-button>
|
||||
<a-select
|
||||
v-model:value="selectedPrinterName"
|
||||
:options="printerOptions"
|
||||
style="width: 220px; margin-left: 8px"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:placeholder="printerSelectPlaceholder"
|
||||
/>
|
||||
<a-button @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
|
||||
<a-input
|
||||
v-model:value="manualPrinterName"
|
||||
style="width: 150px; margin-left: 8px"
|
||||
placeholder="手动输入打印机名"
|
||||
@press-enter="addManualPrinter"
|
||||
/>
|
||||
<a-button @click="addManualPrinter">添加</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
v-auth="'xslmes:mes_xsl_raw_material_entry:edit'"
|
||||
:loading="printLoading"
|
||||
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
|
||||
@click="handlePrintSelected"
|
||||
>
|
||||
<Icon icon="ant-design:printer-outlined" />
|
||||
打印选中
|
||||
</a-button>
|
||||
<a-dropdown v-if="selectedRowKeys.length > 0">
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
@@ -26,24 +71,77 @@
|
||||
</template>
|
||||
</BasicTable>
|
||||
<MesXslRawMaterialEntryModal @register="registerModal" @success="handleSuccess" />
|
||||
<RawMaterialEntryPrintPreviewModal
|
||||
v-model:open="printPreviewOpen"
|
||||
:entry-id="printPreviewEntryId"
|
||||
:barcode="printPreviewBarcode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslRawMaterialEntry" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { BasicTable, useTable, TableAction } from '/@/components/Table';
|
||||
import { onMounted, ref, reactive, watch } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { BasicTable, TableAction } from '/@/components/Table';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useListPage } from '/@/hooks/system/useListPage';
|
||||
import MesXslRawMaterialEntryModal from './components/MesXslRawMaterialEntryModal.vue';
|
||||
import RawMaterialEntryPrintPreviewModal from './components/RawMaterialEntryPrintPreviewModal.vue';
|
||||
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialEntry.data';
|
||||
import { list, deleteOne, batchDelete, batchStockIn, getImportUrl, getExportUrl } from './MesXslRawMaterialEntry.api';
|
||||
import {
|
||||
list,
|
||||
deleteOne,
|
||||
batchDelete,
|
||||
batchStockIn,
|
||||
getImportUrl,
|
||||
getExportUrl,
|
||||
prepareNativePrint,
|
||||
} from './MesXslRawMaterialEntry.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import {
|
||||
PRINT_TEMPLATE_SELECTED_PRINTER_KEY,
|
||||
printNativeSchemaViaPrintDot,
|
||||
} from '/@/views/print/template/utils/printNativeViaPrintDot';
|
||||
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
|
||||
import {
|
||||
fetchPrintDotPrinters,
|
||||
getPrintDotBridgeConfig,
|
||||
setPrintDotBridgeConfig,
|
||||
} from '/@/views/print/template/utils/printDotBridge';
|
||||
|
||||
const { createConfirm, createMessage } = useMessage();
|
||||
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
|
||||
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
|
||||
const printDotCfg = getPrintDotBridgeConfig();
|
||||
const printDotWsUrl = ref(printDotCfg.wsUrl);
|
||||
const printDotKey = ref(printDotCfg.key);
|
||||
|
||||
function persistPrintDotConfig() {
|
||||
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
|
||||
void refreshPrinterOptions(false);
|
||||
}
|
||||
|
||||
function onPrintDotEnabledChange() {
|
||||
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
|
||||
}
|
||||
|
||||
function downloadPrintPlugin() {
|
||||
const base = import.meta.env.BASE_URL || '/';
|
||||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'XSL-PrintDot.exe');
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
const queryParam = reactive<any>({});
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
|
||||
const { prefixCls, tableContext, onExportXls, onImportXls } = useListPage({
|
||||
const { tableContext, onExportXls, onImportXls } = useListPage({
|
||||
tableProps: {
|
||||
title: '原料入场记录',
|
||||
api: list,
|
||||
@@ -58,8 +156,11 @@
|
||||
],
|
||||
},
|
||||
actionColumn: {
|
||||
width: 180,
|
||||
width: 320,
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
slots: { customRender: 'action' },
|
||||
},
|
||||
beforeFetch: (params) => {
|
||||
return Object.assign(params, queryParam);
|
||||
@@ -76,9 +177,201 @@
|
||||
},
|
||||
});
|
||||
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
|
||||
const superQueryConfig = reactive(superQuerySchema);
|
||||
|
||||
/** 打印预览弹窗 */
|
||||
const printPreviewOpen = ref(false);
|
||||
const printPreviewEntryId = ref<string | null>(null);
|
||||
const printPreviewBarcode = ref<string | undefined>(undefined);
|
||||
|
||||
function handlePrintPreview(record: Recordable) {
|
||||
printPreviewEntryId.value = record.id as string;
|
||||
printPreviewBarcode.value = record.barcode as string | undefined;
|
||||
printPreviewOpen.value = true;
|
||||
}
|
||||
|
||||
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const selectedPrinterName = ref<string>('__system_default__');
|
||||
const manualPrinterName = ref('');
|
||||
const printLoading = ref(false);
|
||||
const printerSelectPlaceholder = '选择打印机(PrintDot 桥接)';
|
||||
|
||||
watch(selectedPrinterName, (v) => {
|
||||
if (v) {
|
||||
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshPrinterOptions(showMessage = true) {
|
||||
const optionMap = new Map<string, { label: string; value: string }>();
|
||||
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
|
||||
try {
|
||||
const dotList = await fetchPrintDotPrinters();
|
||||
dotList.forEach((p) => {
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) return;
|
||||
const defMark = p.isDefault ? '(默认)' : '';
|
||||
optionMap.set(name, { label: `${name}${defMark}`, value: name });
|
||||
});
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
if (dotList.length) {
|
||||
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
|
||||
} else {
|
||||
createMessage.warning('PrintDot 已连接但未返回打印机列表');
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||||
optionMap.set(selectedPrinterName.value, {
|
||||
label: `${selectedPrinterName.value}(手动)`,
|
||||
value: selectedPrinterName.value,
|
||||
});
|
||||
}
|
||||
printerOptions.value = Array.from(optionMap.values());
|
||||
if (showMessage) {
|
||||
createMessage.warning(`PrintDot:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addManualPrinter() {
|
||||
const name = String(manualPrinterName.value || '').trim();
|
||||
if (!name) return;
|
||||
const exists = printerOptions.value.some((item) => item.value === name);
|
||||
if (!exists) {
|
||||
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
|
||||
}
|
||||
selectedPrinterName.value = name;
|
||||
manualPrinterName.value = '';
|
||||
createMessage.success('已添加打印机');
|
||||
}
|
||||
|
||||
async function executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
|
||||
try {
|
||||
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
|
||||
const templateJsonRaw = prep.templateJson as string;
|
||||
const printData = prep.printData as Record<string, unknown>;
|
||||
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
|
||||
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
|
||||
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
|
||||
if (!templateJsonRaw) {
|
||||
throw new Error('模板 JSON 为空');
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
|
||||
} catch {
|
||||
throw new Error('模板 JSON 格式错误');
|
||||
}
|
||||
const schema = normalizeImportedNativeSchema(raw);
|
||||
if (paperWidthMm > 0 && paperHeightMm > 0) {
|
||||
const orient = paperOrientation === 'landscape' ? 'landscape' : paperOrientation === 'portrait' ? 'portrait' : '';
|
||||
const normalized =
|
||||
orient === 'landscape'
|
||||
? {
|
||||
width: Math.max(paperWidthMm, paperHeightMm),
|
||||
height: Math.min(paperWidthMm, paperHeightMm),
|
||||
}
|
||||
: orient === 'portrait'
|
||||
? {
|
||||
width: Math.min(paperWidthMm, paperHeightMm),
|
||||
height: Math.max(paperWidthMm, paperHeightMm),
|
||||
}
|
||||
: {
|
||||
width: paperWidthMm,
|
||||
height: paperHeightMm,
|
||||
};
|
||||
schema.page.width = normalized.width;
|
||||
schema.page.height = normalized.height;
|
||||
}
|
||||
await printNativeSchemaViaPrintDot({
|
||||
schema,
|
||||
data: printData as Record<string, unknown>,
|
||||
jobName: `原料入场记录-${(record.barcode as string) || record.id}.pdf`,
|
||||
printerSelection:
|
||||
selectedPrinterName.value ||
|
||||
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
|
||||
'__system_default__',
|
||||
});
|
||||
if (!options?.silentSuccess) {
|
||||
createMessage.success('已通过 PrintDot 提交打印');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
throw new Error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrintSelected() {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
const rows = selectedRows.value || [];
|
||||
if (!rows.length) {
|
||||
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
|
||||
(async () => {
|
||||
let ok = 0;
|
||||
let firstError = '';
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await executePrint(row, { silentSuccess: true });
|
||||
ok += 1;
|
||||
} catch (e: unknown) {
|
||||
if (!firstError) {
|
||||
firstError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok === rows.length) {
|
||||
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
|
||||
} else {
|
||||
createMessage.warning(`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`);
|
||||
}
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
})();
|
||||
}
|
||||
|
||||
function handlePrintRow(record: Recordable) {
|
||||
if (!printDotEnabled.value) {
|
||||
createMessage.warning('请先开启 PrintDot 桥接');
|
||||
return;
|
||||
}
|
||||
printLoading.value = true;
|
||||
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
|
||||
executePrint(record)
|
||||
.then(() => {
|
||||
createMessage.success('已通过 PrintDot 提交打印');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
createMessage.error(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoadingMsg();
|
||||
printLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
|
||||
if (saved) {
|
||||
selectedPrinterName.value = saved;
|
||||
}
|
||||
refreshPrinterOptions(false);
|
||||
});
|
||||
|
||||
function handleSuperQuery(params) {
|
||||
Object.keys(params).map((k) => {
|
||||
queryParam[k] = params[k];
|
||||
@@ -136,6 +429,16 @@
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
|
||||
},
|
||||
{
|
||||
label: '打印预览',
|
||||
onClick: handlePrintPreview.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
|
||||
},
|
||||
{
|
||||
label: '打印',
|
||||
onClick: handlePrintRow.bind(null, record),
|
||||
auth: 'xslmes:mes_xsl_raw_material_entry:edit',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="innerOpen"
|
||||
:title="modalTitle"
|
||||
width="960px"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
wrap-class-name="raw-material-entry-print-preview-modal"
|
||||
@cancel="onClose"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="errorText" class="preview-error">{{ errorText }}</div>
|
||||
<div v-else class="preview-body">
|
||||
<iframe
|
||||
v-if="previewHtml"
|
||||
ref="previewIframeRef"
|
||||
class="preview-iframe"
|
||||
title="原料入场记录打印预览"
|
||||
:srcdoc="previewHtml"
|
||||
/>
|
||||
<a-empty v-else-if="!loading" description="暂无预览内容" />
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="preview-footer">
|
||||
<a-space>
|
||||
<a-button @click="innerOpen = false">关闭</a-button>
|
||||
<a-button type="primary" :disabled="!previewHtml || !!errorText" @click="handleBrowserPrint">
|
||||
<Icon icon="ant-design:printer-outlined" />
|
||||
浏览器打印
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { prepareNativePrint } from '../MesXslRawMaterialEntry.api';
|
||||
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
|
||||
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 入场记录主键 */
|
||||
entryId: string | null;
|
||||
/** 展示在标题上的条码/说明 */
|
||||
barcode?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', v: boolean): void;
|
||||
}>();
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const innerOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit('update:open', v),
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const b = String(props.barcode || '').trim();
|
||||
return b ? `原料入场记录打印预览(条码:${b})` : '原料入场记录打印预览';
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorText = ref('');
|
||||
const previewHtml = ref('');
|
||||
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
async function loadPreview(id: string) {
|
||||
loading.value = true;
|
||||
errorText.value = '';
|
||||
previewHtml.value = '';
|
||||
try {
|
||||
const prep = (await prepareNativePrint(id)) as Record<string, unknown>;
|
||||
const templateJsonRaw = prep.templateJson as string;
|
||||
const printData = prep.printData as Record<string, unknown>;
|
||||
if (!templateJsonRaw) {
|
||||
throw new Error('模板 JSON 为空,请检查「业务打印绑定」是否已配置');
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
|
||||
} catch {
|
||||
throw new Error('模板 JSON 格式错误');
|
||||
}
|
||||
const schema = normalizeImportedNativeSchema(raw);
|
||||
previewHtml.value = await renderNativePrintHtml(schema, printData as Record<string, unknown>);
|
||||
} catch (e: unknown) {
|
||||
errorText.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Modal 内对 iframe 直接 print() 时,打印对话框易被遮罩层级挡住或焦点异常,表现为「点了没反应」。
|
||||
* 改为在 body 下挂临时 iframe、写入同一套 HTML 再打印,与模板预览常用做法一致。
|
||||
*/
|
||||
function handleBrowserPrint() {
|
||||
const html = previewHtml.value;
|
||||
if (!html?.trim()) {
|
||||
createMessage.warning('预览未就绪,请稍后再试');
|
||||
return;
|
||||
}
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute(
|
||||
'style',
|
||||
'position:fixed;left:0;top:0;width:0;height:0;border:0;opacity:0;pointer-events:none;',
|
||||
);
|
||||
document.body.appendChild(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) {
|
||||
document.body.removeChild(iframe);
|
||||
createMessage.error('无法创建打印文档');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
} catch {
|
||||
document.body.removeChild(iframe);
|
||||
createMessage.error('写入打印内容失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (iframe.parentNode) {
|
||||
document.body.removeChild(iframe);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const runPrint = () => {
|
||||
try {
|
||||
const w = iframe.contentWindow;
|
||||
if (!w) {
|
||||
createMessage.error('无法唤起打印窗口');
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
w.focus();
|
||||
w.print();
|
||||
/** 关闭打印对话框后移除临时 iframe(部分浏览器支持 afterprint) */
|
||||
w.addEventListener('afterprint', cleanup, { once: true });
|
||||
window.setTimeout(cleanup, 120000);
|
||||
} catch {
|
||||
createMessage.error('无法唤起打印,请检查浏览器弹窗/打印权限');
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
/** 等待排版与字体后再打印,减少空白页 */
|
||||
window.setTimeout(runPrint, 100);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
errorText.value = '';
|
||||
previewHtml.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.entryId] as const,
|
||||
([isOpen, id]) => {
|
||||
if (isOpen && id) {
|
||||
void loadPreview(id);
|
||||
}
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.preview-error {
|
||||
color: #cf1322;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
min-height: 420px;
|
||||
max-height: 72vh;
|
||||
overflow: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
board = '/xslmes/mesXslRawMaterialWarehouseBoard/board',
|
||||
}
|
||||
|
||||
export const fetchBoard = (params: { warehouseId?: string; keyword?: string; measureType?: string }) =>
|
||||
defHttp.get({ url: Api.board, params });
|
||||
@@ -0,0 +1,725 @@
|
||||
<template>
|
||||
<div class="rmb-page">
|
||||
<div class="rmb-toolbar card-surface">
|
||||
<div class="rmb-toolbar-row">
|
||||
<div class="rmb-title">
|
||||
<span class="rmb-title-icon" />
|
||||
<div>
|
||||
<div class="rmb-title-text">原材料库区看板</div>
|
||||
<div class="rmb-title-sub">按库区聚合条码卡片,点击查看明细</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rmb-actions">
|
||||
<a-button type="primary" ghost @click="loadBoard">
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rmb-filter" role="search">
|
||||
<div class="rmb-filter-row">
|
||||
<div class="rmb-inline-field">
|
||||
<span class="rmb-inline-label">所属仓库</span>
|
||||
<a-select
|
||||
v-model:value="warehouseId"
|
||||
allow-clear
|
||||
placeholder="全部仓库"
|
||||
class="rmb-filter-control"
|
||||
:loading="warehouseLoading"
|
||||
:options="warehouseOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="rmb-inline-field">
|
||||
<span class="rmb-inline-label">物料 / 条码 / 批次</span>
|
||||
<a-input
|
||||
v-model:value="keyword"
|
||||
placeholder="关键字模糊筛选"
|
||||
class="rmb-filter-control"
|
||||
allow-clear
|
||||
@press-enter="loadBoard"
|
||||
/>
|
||||
</div>
|
||||
<div class="rmb-inline-field">
|
||||
<span class="rmb-inline-label">占用率口径</span>
|
||||
<a-radio-group v-model:value="measureType" button-style="solid" @change="loadBoard">
|
||||
<a-radio-button value="quantity">剩余数量</a-radio-button>
|
||||
<a-radio-button value="weight">剩余重量</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="rmb-inline-field rmb-inline-actions">
|
||||
<a-button type="primary" @click="loadBoard">查询</a-button>
|
||||
<a-button @click="resetFilter">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!bands.length && !loading" class="rmb-empty card-surface">
|
||||
<a-empty description="暂无启用库区或无匹配数据" />
|
||||
</div>
|
||||
|
||||
<div v-for="band in bands" :key="band.bandKey" class="rmb-band card-surface">
|
||||
<div class="rmb-band-head">
|
||||
<span class="rmb-band-label">{{ band.bandLabel }}</span>
|
||||
<span class="rmb-band-count">{{ band.areas?.length || 0 }} 个库区</span>
|
||||
</div>
|
||||
<div class="rmb-card-row">
|
||||
<div
|
||||
v-for="area in band.areas"
|
||||
:key="area.areaId"
|
||||
class="rmb-card"
|
||||
:class="'rmb-card--' + (area.alertLevel || 'unknown')"
|
||||
@click="openDetail(area)"
|
||||
>
|
||||
<div class="rmb-card-head">
|
||||
<span class="rmb-card-code">{{ area.areaCode }}</span>
|
||||
<a-tag v-if="area.warehouseName" color="processing" class="rmb-card-wh">{{ area.warehouseName }}</a-tag>
|
||||
</div>
|
||||
<div class="rmb-card-name">{{ area.areaName || area.areaCode }}</div>
|
||||
<div class="rmb-card-stats">
|
||||
<div>
|
||||
<div class="rmb-stat-label">当前(数量)</div>
|
||||
<div class="rmb-stat-value">{{ area.currentQuantity ?? 0 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rmb-stat-label">当前(重量)</div>
|
||||
<div class="rmb-stat-value">{{ formatWeight(area.currentWeight) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rmb-stat-label">上限</div>
|
||||
<div class="rmb-stat-value">{{ area.maxCapacity ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="progressPercent(area)"
|
||||
:show-info="true"
|
||||
:stroke-color="progressStroke(area)"
|
||||
:trail-color="'rgba(0,0,0,0.06)'"
|
||||
size="small"
|
||||
/>
|
||||
<div class="rmb-card-foot">
|
||||
<span class="rmb-meta">卡片 {{ area.cardCount ?? 0 }} 张 · 物料 {{ area.materialKindCount ?? 0 }} 种</span>
|
||||
</div>
|
||||
<div class="rmb-tags">
|
||||
<template v-for="(m, idx) in (area.topMaterialNames || []).slice(0, 4)" :key="idx">
|
||||
<a-tooltip :title="m">
|
||||
<a-tag class="rmb-tag">{{ truncate(m, 10) }}</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-tag v-if="(area.topMaterialNames?.length || 0) > 4" class="rmb-tag rmb-tag-more">+{{ (area.topMaterialNames?.length || 0) - 4 }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="detailOpen"
|
||||
:title="detailTitle"
|
||||
placement="right"
|
||||
width="min(96vw, 1080px)"
|
||||
destroy-on-close
|
||||
@close="onDetailClose"
|
||||
@after-open-change="onDrawerAfterOpenChange"
|
||||
>
|
||||
<BasicTable @register="registerDetailTable" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslRawMaterialWarehouseBoard" setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { BasicTable, useTable } from '/@/components/Table';
|
||||
import type { BasicColumn } from '/@/components/Table';
|
||||
import type { FormSchema } from '/@/components/Form';
|
||||
import Icon from '/@/components/Icon';
|
||||
import { fetchBoard } from './MesXslRawMaterialWarehouseBoard.api';
|
||||
import { list as cardList } from '/@/views/xslmes/mesXslRawMaterialCard/MesXslRawMaterialCard.api';
|
||||
import { list as warehouseList } from '/@/views/xslmes/mesXslWarehouse/MesXslWarehouse.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getTenantId } from '/@/utils/auth';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
/** 上次选择的「所属仓库」持久化键(区分租户) */
|
||||
const LS_RM_BOARD_WAREHOUSE = 'MES_XSL_RM_BOARD_WAREHOUSE_ID';
|
||||
|
||||
function warehouseBoardStorageKey() {
|
||||
const tid = getTenantId();
|
||||
if (tid === undefined || tid === null || tid === '') {
|
||||
return LS_RM_BOARD_WAREHOUSE;
|
||||
}
|
||||
return `${LS_RM_BOARD_WAREHOUSE}_${tid}`;
|
||||
}
|
||||
|
||||
/** 写入 localStorage:空串/undefined 则清除 */
|
||||
function persistWarehouseId(id: string | undefined | null) {
|
||||
const k = warehouseBoardStorageKey();
|
||||
if (id === undefined || id === null || String(id).trim() === '') {
|
||||
localStorage.removeItem(k);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(k, String(id).trim());
|
||||
}
|
||||
|
||||
/** 读取上次选择的仓库 id(若无或非法由调用方忽略) */
|
||||
function readPersistedWarehouseId(): string | undefined {
|
||||
const v = localStorage.getItem(warehouseBoardStorageKey());
|
||||
const t = v?.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
interface BoardArea {
|
||||
areaId: string;
|
||||
areaCode: string;
|
||||
areaName?: string;
|
||||
warehouseId?: string;
|
||||
warehouseName?: string;
|
||||
maxCapacity?: number | null;
|
||||
actualCapacity?: number | null;
|
||||
cardCount?: number;
|
||||
materialKindCount?: number;
|
||||
currentQuantity?: number;
|
||||
currentWeight?: number | string | null;
|
||||
topMaterialNames?: string[];
|
||||
usagePercent?: number | null;
|
||||
alertLevel?: string;
|
||||
}
|
||||
|
||||
interface BoardBand {
|
||||
bandKey: string;
|
||||
bandLabel: string;
|
||||
bandSort?: number;
|
||||
areas: BoardArea[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const warehouseLoading = ref(false);
|
||||
const measureType = ref<'quantity' | 'weight'>('quantity');
|
||||
const warehouseId = ref<string | undefined>(undefined);
|
||||
const keyword = ref('');
|
||||
const bands = ref<BoardBand[]>([]);
|
||||
|
||||
const warehouseOptions = ref<{ label: string; value: string }[]>([]);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailAreaCode = ref('');
|
||||
const detailTitle = computed(() => (detailAreaCode.value ? `库区明细 · ${detailAreaCode.value}` : '库区明细'));
|
||||
|
||||
const detailQuery = reactive({ warehouseArea: '' });
|
||||
|
||||
/** 库区明细抽屉:条码/批次/物料 合并关键字 + 剩余数量筛选(单行紧凑布局) */
|
||||
const detailFormColResponsive = {
|
||||
xs: 24,
|
||||
sm: 24,
|
||||
md: 9,
|
||||
lg: 9,
|
||||
xl: 9,
|
||||
xxl: 9,
|
||||
span: 9,
|
||||
} as const;
|
||||
const detailQtyColResponsive = {
|
||||
xs: 24,
|
||||
sm: 24,
|
||||
md: 7,
|
||||
lg: 7,
|
||||
xl: 7,
|
||||
xxl: 7,
|
||||
span: 7,
|
||||
} as const;
|
||||
|
||||
const detailSearchFormSchema: FormSchema[] = [
|
||||
{
|
||||
label: '条码/批次/物料',
|
||||
field: 'mixKeyword',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '条码/批次/物料模糊',
|
||||
allowClear: true,
|
||||
},
|
||||
colProps: { ...detailFormColResponsive },
|
||||
},
|
||||
{
|
||||
label: '剩余数量',
|
||||
field: 'remainQtyFilter',
|
||||
component: 'Select',
|
||||
defaultValue: '',
|
||||
componentProps: {
|
||||
placeholder: '全部',
|
||||
allowClear: true,
|
||||
style: { width: '100%', maxWidth: 200 },
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '有剩余', value: 'has' },
|
||||
{ label: '无剩余', value: 'none' },
|
||||
],
|
||||
},
|
||||
colProps: { ...detailQtyColResponsive },
|
||||
},
|
||||
];
|
||||
|
||||
/** 查询、重置与本行输入框并排:前两列占 16 栅格,操作列占 8,合计 24 */
|
||||
const detailFormActionCol = { xs: 24, sm: 24, md: 8, lg: 8, xl: 8, xxl: 8, span: 8 };
|
||||
|
||||
const detailColumns: BasicColumn[] = [
|
||||
{ title: '条码', dataIndex: 'barcode', width: 190 },
|
||||
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
|
||||
{ title: '入场日期', dataIndex: 'entryDate', width: 110 },
|
||||
{ title: '物料名称', dataIndex: 'materialName', width: 140 },
|
||||
{ title: '剩余数量', dataIndex: 'remainingQuantity', width: 90 },
|
||||
{ title: '剩余重量', dataIndex: 'remainingWeight', width: 90 },
|
||||
{ title: '检测结果', dataIndex: 'testResult_dictText', width: 90 },
|
||||
{ title: '库区', dataIndex: 'warehouseArea', width: 100 },
|
||||
];
|
||||
|
||||
const [registerDetailTable, { reload }] = useTable({
|
||||
title: '原材料卡片明细',
|
||||
api: cardList,
|
||||
columns: detailColumns,
|
||||
useSearchForm: true,
|
||||
formConfig: {
|
||||
labelWidth: 108,
|
||||
layout: 'horizontal',
|
||||
compact: true,
|
||||
schemas: detailSearchFormSchema,
|
||||
autoSubmitOnEnter: true,
|
||||
showAdvancedButton: false,
|
||||
submitButtonOptions: { text: '查询' },
|
||||
resetButtonOptions: { text: '重置' },
|
||||
actionColOptions: {
|
||||
...detailFormActionCol,
|
||||
style: { textAlign: 'left', whiteSpace: 'nowrap', paddingLeft: '4px' },
|
||||
},
|
||||
},
|
||||
showTableSetting: true,
|
||||
canResize: true,
|
||||
immediate: false,
|
||||
pagination: { pageSize: 20 },
|
||||
beforeFetch: (params) => {
|
||||
const merged = Object.assign({}, params, {
|
||||
warehouseArea: (detailQuery.warehouseArea || '').trim(),
|
||||
});
|
||||
const v = merged.remainQtyFilter;
|
||||
// 后端只认 remainQtyFilter=has/none;空串不传避免误筛
|
||||
if (v === '' || v === undefined || v === null) {
|
||||
delete merged.remainQtyFilter;
|
||||
}
|
||||
const kw = merged.mixKeyword;
|
||||
if (kw === '' || kw === undefined || kw === null || String(kw).trim() === '') {
|
||||
delete merged.mixKeyword;
|
||||
} else if (typeof kw === 'string') {
|
||||
merged.mixKeyword = kw.trim();
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
});
|
||||
|
||||
async function loadWarehouses() {
|
||||
warehouseLoading.value = true;
|
||||
try {
|
||||
const res = await warehouseList({ pageNo: 1, pageSize: 500 });
|
||||
const records = res?.records ?? [];
|
||||
warehouseOptions.value = records.map((r: Record<string, string>) => ({
|
||||
label: r.warehouseName || r.warehouseCode || r.id,
|
||||
value: r.id,
|
||||
}));
|
||||
// 恢复上次选择的仓库(仅当仍在列表中)
|
||||
const savedId = readPersistedWarehouseId();
|
||||
if (savedId) {
|
||||
const ok = warehouseOptions.value.some((o) => o.value === savedId);
|
||||
if (ok) {
|
||||
warehouseId.value = savedId;
|
||||
} else {
|
||||
localStorage.removeItem(warehouseBoardStorageKey());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
createMessage.warning('加载仓库列表失败');
|
||||
} finally {
|
||||
warehouseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchBoard({
|
||||
warehouseId: warehouseId.value,
|
||||
keyword: keyword.value?.trim(),
|
||||
measureType: measureType.value,
|
||||
});
|
||||
bands.value = (data?.bands as BoardBand[]) || [];
|
||||
if (!(data?.bands?.length ?? 0)) {
|
||||
bands.value = [];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
createMessage.error(e instanceof Error ? e.message : '加载看板失败');
|
||||
bands.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
warehouseId.value = undefined;
|
||||
persistWarehouseId(undefined);
|
||||
keyword.value = '';
|
||||
measureType.value = 'quantity';
|
||||
loadBoard();
|
||||
}
|
||||
|
||||
function progressPercent(area: BoardArea): number {
|
||||
const p = area.usagePercent;
|
||||
if (p == null || Number.isNaN(p)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.max(0, Math.round(p)));
|
||||
}
|
||||
|
||||
function progressStroke(area: BoardArea) {
|
||||
const level = area.alertLevel;
|
||||
const map: Record<string, string> = {
|
||||
empty: '#bfbfbf',
|
||||
low: '#597ef7',
|
||||
normal: 'var(--j-global-primary-color, #1677ff)',
|
||||
high: '#fa8c16',
|
||||
full: '#f5222d',
|
||||
unknown: '#8c8c8c',
|
||||
};
|
||||
return map[level || 'unknown'] || map.unknown;
|
||||
}
|
||||
|
||||
function formatWeight(w: BoardArea['currentWeight']) {
|
||||
if (w == null || w === '') {
|
||||
return '—';
|
||||
}
|
||||
const n = Number(w);
|
||||
if (Number.isFinite(n)) {
|
||||
return n.toFixed(3);
|
||||
}
|
||||
return String(w);
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number) {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
function openDetail(area: BoardArea) {
|
||||
const code = String(area.areaCode ?? '').trim();
|
||||
detailQuery.warehouseArea = code;
|
||||
detailAreaCode.value = code;
|
||||
detailOpen.value = true;
|
||||
// 不在此处 reload:抽屉 destroy-on-close 时子表格可能尚未挂载,会导致请求未带上条件或无实例
|
||||
}
|
||||
|
||||
/** 抽屉打开动画完成后再拉取明细,确保 BasicTable 已 register */
|
||||
async function onDrawerAfterOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const code = String(detailQuery.warehouseArea || '').trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
await reload();
|
||||
}
|
||||
|
||||
function onDetailClose() {
|
||||
detailQuery.warehouseArea = '';
|
||||
}
|
||||
|
||||
// 用户切换「所属仓库」时持久化(无 immediate,避免挂载前误清空缓存)
|
||||
watch(warehouseId, (v) => {
|
||||
persistWarehouseId(v);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadWarehouses();
|
||||
await loadBoard();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.rmb-page {
|
||||
padding: 0 8px 16px;
|
||||
min-height: calc(100vh - 120px);
|
||||
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, transparent 480px),
|
||||
radial-gradient(1200px 400px at 10% -10%, rgba(82, 196, 26, 0.08), transparent);
|
||||
}
|
||||
|
||||
.card-surface {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.rmb-toolbar {
|
||||
padding: 16px 20px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rmb-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rmb-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rmb-title-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--j-global-primary-color, #1677ff), #722ed1);
|
||||
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rmb-title-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.rmb-title-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rmb-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
.rmb-inline-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* 标签右对齐 + 固定宽度,与控件纵向居中对齐 */
|
||||
.rmb-inline-label {
|
||||
flex: 0 0 148px;
|
||||
width: 148px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
line-height: 32px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.rmb-filter-control {
|
||||
width: 220px !important;
|
||||
}
|
||||
|
||||
/* 按钮组与其它字段同一中线,不靠虚构标签占位 */
|
||||
.rmb-inline-actions {
|
||||
gap: 8px;
|
||||
padding-left: 4px;
|
||||
margin-left: 4px;
|
||||
border-left: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rmb-inline-label {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rmb-inline-field {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rmb-filter-control {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.rmb-inline-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rmb-empty {
|
||||
padding: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rmb-band {
|
||||
padding: 16px 16px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rmb-band-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.rmb-band-label {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.rmb-band-count {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rmb-card-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.rmb-card {
|
||||
flex: 0 0 280px;
|
||||
scroll-snap-align: start;
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
border: 1px solid rgba(22, 119, 255, 0.12);
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f6faff 100%);
|
||||
}
|
||||
|
||||
.rmb-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--j-global-primary-color, #1677ff);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rmb-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.rmb-card--empty::before {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
.rmb-card--low::before {
|
||||
background: #597ef7;
|
||||
}
|
||||
.rmb-card--normal::before {
|
||||
background: var(--j-global-primary-color, #1677ff);
|
||||
}
|
||||
.rmb-card--high::before {
|
||||
background: #fa8c16;
|
||||
}
|
||||
.rmb-card--full::before {
|
||||
background: #f5222d;
|
||||
}
|
||||
.rmb-card--unknown::before {
|
||||
background: #8c8c8c;
|
||||
}
|
||||
|
||||
.rmb-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rmb-card-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.rmb-card-wh {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rmb-card-name {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.rmb-card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rmb-stat-label {
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rmb-stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.rmb-card-foot {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rmb-meta {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rmb-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rmb-tag {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rmb-tag-more {
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
@@ -271,9 +271,9 @@
|
||||
async function loadCategoryTree() {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const root = await fetchUnitCategoryRoot();
|
||||
// 根节点查询与整棵树接口并行,减少串行等待;后端已改为按层批量查子节点,降低 DB 往返
|
||||
const [root, res] = await Promise.all([fetchUnitCategoryRoot(), loadUnitCategoryTreeRoot({ async: false, pcode: 'XSLMES_UNIT' })]);
|
||||
unitCategoryRootId.value = root?.id != null ? String(root.id) : '';
|
||||
const res = await loadUnitCategoryTreeRoot({ async: false, pcode: 'XSLMES_UNIT' });
|
||||
rawUnitCategoryTree.value = Array.isArray(res) ? res : [];
|
||||
if (!unitCategoryRootId.value || !rawUnitCategoryTree.value.length) {
|
||||
createMessage.warning('未加载到单位分类树,请确认已执行库脚本且分类字典根编码为 XSLMES_UNIT。');
|
||||
@@ -295,6 +295,8 @@
|
||||
title: '单位管理',
|
||||
api: list,
|
||||
columns,
|
||||
// 避免:表格默认 immediate 请求一次 + onMounted 末尾 reload 再请求一次(进入页列表闪两次)
|
||||
immediate: false,
|
||||
canResize: true,
|
||||
formConfig: {
|
||||
schemas: searchFormSchema,
|
||||
|
||||
@@ -294,9 +294,8 @@
|
||||
async function loadCategoryTree() {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const root = await fetchWarehouseCategoryRoot();
|
||||
const [root, res] = await Promise.all([fetchWarehouseCategoryRoot(), loadCategoryTreeRoot({ async: false, pcode: 'XSLMES_WH' })]);
|
||||
warehouseCategoryRootId.value = root?.id != null ? String(root.id) : '';
|
||||
const res = await loadCategoryTreeRoot({ async: false, pcode: 'XSLMES_WH' });
|
||||
rawWarehouseCategoryTree.value = Array.isArray(res) ? res : [];
|
||||
if (!warehouseCategoryRootId.value || !rawWarehouseCategoryTree.value.length) {
|
||||
createMessage.warning('未加载到仓库分类树,请确认已执行库脚本并已在「分类字典」中维护根节点 XSLMES_WH。');
|
||||
@@ -385,6 +384,8 @@
|
||||
title: '仓库管理',
|
||||
api: list,
|
||||
columns,
|
||||
// 避免:表格默认 immediate 请求一次 + onMounted 末尾 reload 再请求一次(进入页列表闪两次)
|
||||
immediate: false,
|
||||
canResize: true,
|
||||
formConfig: {
|
||||
schemas: searchFormSchema,
|
||||
|
||||
Reference in New Issue
Block a user