Files
qhmes/jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue

1000 lines
34 KiB
Vue
Raw Normal View History

<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。明细列占位多为「模板明细键.列」(如 List2.Field1业务侧选「明细属性.列」(如 lineList.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 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 && !hasAnyDetailBizFields()) || !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">
按模板明细表tableKey分页配置每个标签页先选业务明细集合再映射列多表明细互不影响
</span>
</div>
<template v-if="detailTablesStructure.length">
<a-tabs v-model:activeKey="detailTabKey" type="card" size="small" class="bind-detail-tabs">
<a-tab-pane v-for="dt in detailTablesStructure" :key="dt.tableKey" :tab="detailTabTitle(dt)">
<p class="bind-card-desc">
模板明细键 <code>{{ dt.tableKey }}</code>
对应画布表格等组件的数据源请选择主实体上要绑定到该明细表的业务集合或嵌套对象
</p>
<a-select
v-model:value="detailSlotByTable[dt.tableKey]"
allow-clear
show-search
option-filter-prop="label"
placeholder="选择业务明细属性(如 lineList"
:options="detailSlotSelectOptions"
:loading="!!detailFieldsLoadingMap[dt.tableKey]"
style="width: 100%; margin-bottom: 12px"
@change="(v) => onDetailSlotChangeForTable(dt.tableKey, v as string | undefined)"
/>
<a-table
v-if="mappingRowsForDetailTable(dt).length"
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumnsDetail"
:data-source="mappingRowsForDetailTable(dt)"
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="bizFieldOptionsForTable(dt.tableKey)"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择业务字段"
/>
</template>
</template>
</a-table>
<a-empty v-else class="bind-empty" description="该模板明细表下暂无占位字段(可在设计器中维护 dataBinding.detailTables" />
</a-tab-pane>
</a-tabs>
</template>
<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, reactive, 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;
}
interface TemplateDetailTableItem {
tableKey: string;
label?: string;
fields: TplFieldItem[];
}
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 detailTablesStructure = ref<TemplateDetailTableItem[]>([]);
const detailTabKey = ref('');
/** 每个模板明细表键对应选中的业务明细属性名 */
const detailSlotByTable = reactive<Record<string, string | undefined>>({});
const detailBizFieldsMap = reactive<Record<string, BizTypeItem['fields']>>({});
const detailFieldsLoadingMap = reactive<Record<string, boolean>>({});
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];
});
/** 某一模板明细表标签页:主表字段 + 该表选中的业务明细前缀字段 */
function bizFieldOptionsForTable(tableKey: string) {
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 = (detailBizFieldsMap[tableKey] || []).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
}));
return [...head, ...main, ...detail];
}
function hasAnyDetailBizFields(): boolean {
for (const k in detailBizFieldsMap) {
if ((detailBizFieldsMap[k] || []).length) {
return true;
}
}
return false;
}
function detailTabTitle(dt: TemplateDetailTableItem) {
const hint = dt.label || '';
return hint ? `${hint}${dt.tableKey}` : dt.tableKey;
}
/** 属于某一模板明细表的映射行(与后端分组一致) */
function mappingRowsForDetailTable(dt: TemplateDetailTableItem): MappingRow[] {
const keys = new Set((dt.fields || []).map((f) => (f.bindField || '').trim()).filter(Boolean));
return unref(mappingRows).filter((r) => keys.has((r.templateField || '').trim()));
}
/** 根据已保存映射推断业务明细属性(编辑时用) */
function inferDetailSlotForTable(tableKey: string): string | undefined {
const dt = unref(detailTablesStructure).find((d) => d.tableKey === tableKey);
if (!dt?.fields?.length) {
return undefined;
}
const keySet = new Set(dt.fields.map((f) => (f.bindField || '').trim()).filter(Boolean));
const counts = new Map<string, number>();
for (const r of unref(mappingRows)) {
if (!keySet.has((r.templateField || '').trim())) {
continue;
}
const bf = r.bizField;
if (!bf || bf === EMPTY_BIZ_FIELD_SENTINEL) {
continue;
}
const s = String(bf);
const dot = s.indexOf('.');
if (dot <= 0) {
continue;
}
const head = s.slice(0, dot);
counts.set(head, (counts.get(head) || 0) + 1);
}
let best: string | undefined;
let bestN = 0;
counts.forEach((n, h) => {
if (n > bestN) {
bestN = n;
best = h;
}
});
return best;
}
async function loadBizFieldsForTableSlot(tableKey: string, propertyName: string | undefined) {
if (!propertyName || !form.value.bizCode) {
detailBizFieldsMap[tableKey] = [];
return;
}
const slot = unref(detailSlots).find((s) => s.propertyName === propertyName);
const kind = slot?.slotKind || 'LIST';
detailFieldsLoadingMap[tableKey] = true;
try {
const list = await Api.bizFieldsForDetailSlot({
bizCode: form.value.bizCode,
detailProperty: propertyName,
slotKind: kind,
});
detailBizFieldsMap[tableKey] = (list || []) as BizTypeItem['fields'];
} catch {
detailBizFieldsMap[tableKey] = [];
} finally {
detailFieldsLoadingMap[tableKey] = false;
}
}
async function onDetailSlotChangeForTable(tableKey: string, propertyName: string | undefined) {
detailSlotByTable[tableKey] = propertyName;
await loadBizFieldsForTableSlot(tableKey, propertyName);
}
async function restoreDetailSlotsFromMapping() {
for (const dt of unref(detailTablesStructure)) {
const tk = dt.tableKey;
const existing = detailSlotByTable[tk];
if (existing) {
await loadBizFieldsForTableSlot(tk, existing);
continue;
}
const inferred = inferDetailSlotForTable(tk);
if (inferred && unref(detailSlots).some((s) => s.propertyName === inferred)) {
detailSlotByTable[tk] = inferred;
await loadBizFieldsForTableSlot(tk, inferred);
}
}
}
function resetDetailTableUiState() {
detailTablesStructure.value = [];
detailTabKey.value = '';
Object.keys(detailSlotByTable).forEach((k) => delete detailSlotByTable[k]);
Object.keys(detailBizFieldsMap).forEach((k) => delete detailBizFieldsMap[k]);
Object.keys(detailFieldsLoadingMap).forEach((k) => delete detailFieldsLoadingMap[k]);
}
/** 已保存的空字符串映射为下拉哨兵,便于展示「空占位符」项 */
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'),
);
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 = [];
if (!code) {
return;
}
try {
detailSlots.value = (await Api.detailSlots(code)) || [];
} catch {
detailSlots.value = [];
}
}
async function onBizCodeChange(code: string) {
const hit = unref(bizTypesRef).find((b) => b.bizCode === code);
bizFields.value = hit?.fields ?? [];
form.value.bizName = hit?.bizName;
Object.keys(detailSlotByTable).forEach((k) => delete detailSlotByTable[k]);
Object.keys(detailBizFieldsMap).forEach((k) => delete detailBizFieldsMap[k]);
await refreshDetailSlots(code);
if (form.value.templateId && unref(detailTablesStructure).length) {
await restoreDetailSlotsFromMapping();
}
}
async function onTemplateChange() {
resetDetailTableUiState();
tplFields.value = [];
mappingRows.value = [];
await reloadTemplateFields();
}
async function reloadTemplateFields() {
const tid = form.value.templateId;
if (!tid) {
tplFields.value = [];
mappingRows.value = [];
detailTablesStructure.value = [];
detailTabKey.value = '';
return;
}
parseLoading.value = true;
try {
const structure = await Api.parseTemplateStructure(tid);
detailTablesStructure.value = structure?.detailTables ?? [];
const params = structure?.params ?? [];
const flatDetail = (structure?.detailTables ?? []).flatMap((d) => d.fields ?? []);
tplFields.value = [...params, ...flatDetail];
detailTabKey.value = detailTablesStructure.value[0]?.tableKey ?? '';
rebuildMappingRows();
await restoreDetailSlotsFromMapping();
} 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)];
for (const k in detailBizFieldsMap) {
merged.push(...(detailBizFieldsMap[k] || []));
}
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 = [];
resetDetailTableUiState();
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 = [];
resetDetailTableUiState();
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-detail-tabs {
margin-top: 4px;
}
.bind-detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 10px;
}
.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>