feat(admin): 收银台支付项配置优化——图标对齐渠道编码、商户去重、能力批量加载、真实图标展示

- 图标选项对齐 PayProviderEnum 7 项编码,新增/迁移 union→union_pay、aggregate→aggregate_pay
- DIRECT 模式商户列表扁平去重,支付能力改用 batch API 合并该商户全部 method 组合
- 图标联动从商户选择移到支付能力选择(从 batch key 反推 provider)
- 图标下拉与已选回显均显示真实 SVG(optionRender + labelRender)
- 列表图标列显示 SVG 图标 + 名称
- 弹窗改为抽屉(:size=800),表单字段重排,图标必填
- 新增支付能力 29 项 i18n(zh-CN / en-US)
This commit is contained in:
DaxPay Dev
2026-07-12 11:55:08 +08:00
parent 80aa261f3c
commit d073c6036c
5 changed files with 303 additions and 138 deletions

View File

@@ -27,6 +27,7 @@
"capabilityPlaceholder": "Select capability",
"methodFilterHint": "Used to load channel merchant candidates; not stored in DIRECT mode",
"validationName": "Please enter name",
"validationIcon": "Please select an icon",
"validationResolveMode": "Please select resolve mode",
"validationMethod": "Please select payment method",
"validationChannelMch": "Please select channel merchant",
@@ -39,11 +40,43 @@
"douyin": "Douyin"
},
"icons": {
"aggregate_pay": "Aggregate Pay",
"wechat": "WeChat",
"alipay": "Alipay",
"union": "UnionPay",
"douyin": "Douyin",
"aggregate": "Aggregate"
"union_pay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Douyin"
},
"capabilities": {
"aggregate_pay_qrcode": "Aggregate QR Code",
"aggregate_pay_barcode": "Aggregate Barcode",
"wechat_cashier": "WeChat Mini Cashier",
"wechat_jsapi": "WeChat JSAPI",
"wechat_app": "WeChat App",
"wechat_h5": "WeChat H5",
"wechat_qr": "WeChat QR",
"wechat_mini": "WeChat Mini",
"wechat_barcode": "WeChat Barcode",
"alipay_barcode": "Alipay Barcode",
"alipay_qr": "Alipay QR",
"alipay_app": "Alipay App",
"alipay_h5": "Alipay H5",
"alipay_pc": "Alipay PC",
"alipay_jsapi": "Alipay JSAPI",
"alipay_mini": "Alipay Mini",
"union_pay_qr": "UnionPay QR",
"union_pay_barcode": "UnionPay Barcode",
"union_pay_h5": "UnionPay H5",
"union_pay_jsapi": "UnionPay JSAPI",
"douyin_qr": "Douyin QR",
"douyin_jsapi": "Douyin JSAPI",
"douyin_h5": "Douyin H5",
"douyin_app": "Douyin App",
"visa_card_gateway": "Visa Gateway",
"visa_card_present": "Visa Present",
"mastercard_card_gateway": "Mastercard Gateway",
"mastercard_card_present": "Mastercard Present"
}
}
}

View File

@@ -27,6 +27,7 @@
"capabilityPlaceholder": "请选择支付能力",
"methodFilterHint": "用于加载通道商户候选, 保存时不落库",
"validationName": "请输入名称",
"validationIcon": "请选择图标",
"validationResolveMode": "请选择解析模式",
"validationMethod": "请选择支付方式",
"validationChannelMch": "请选择通道商户",
@@ -39,11 +40,43 @@
"douyin": "抖音"
},
"icons": {
"aggregate_pay": "聚合支付",
"wechat": "微信",
"alipay": "支付宝",
"union": "银联",
"douyin": "抖音",
"aggregate": "聚合"
"union_pay": "银联",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "抖音"
},
"capabilities": {
"aggregate_pay_qrcode": "聚合扫码",
"aggregate_pay_barcode": "聚合付款码",
"wechat_cashier": "微信小程序收银台",
"wechat_jsapi": "微信 JSAPI",
"wechat_app": "微信 APP",
"wechat_h5": "微信 H5",
"wechat_qr": "微信扫码",
"wechat_mini": "微信小程序",
"wechat_barcode": "微信付款码",
"alipay_barcode": "支付宝付款码",
"alipay_qr": "支付宝扫码",
"alipay_app": "支付宝 APP",
"alipay_h5": "支付宝 H5",
"alipay_pc": "支付宝电脑",
"alipay_jsapi": "支付宝 JSAPI",
"alipay_mini": "支付宝小程序",
"union_pay_qr": "银联扫码",
"union_pay_barcode": "银联付款码",
"union_pay_h5": "银联 H5",
"union_pay_jsapi": "银联 JSAPI",
"douyin_qr": "抖音扫码",
"douyin_jsapi": "抖音 JSAPI",
"douyin_h5": "抖音 H5",
"douyin_app": "抖音 APP",
"visa_card_gateway": "Visa 网关",
"visa_card_present": "Visa 线下",
"mastercard_card_gateway": "万事达网关",
"mastercard_card_present": "万事达线下"
}
}
}

View File

@@ -17,6 +17,7 @@
import { useMessage } from '#/hooks/useMessage';
import { normalizeRouteQueryValue, useRequiredRouteQuery } from '#/hooks/useRequiredRouteQuery';
import { usePermission } from '#/hooks/usePermission';
import { getProviderSvgUrl } from '#/views/payment/shared/payProviderDisplay';
import CashierItemEdit from './CashierItemEdit.vue';
import {
@@ -58,6 +59,8 @@
const appInfo = ref<MchAppInfoResult>({});
const tableData = ref<CashierItemResult[]>([]);
const methodLabelMap = ref<Record<string, string>>({});
// DIRECT 模式列表展示用:通道商户号 → 名称
const channelMchLabelMap = ref<Record<string, string>>({});
// 一级 / 二级 Tab
const activeType = ref<CashierType>(CASHIER_TYPE.H5);
@@ -89,7 +92,12 @@
/** 支付解析摘要 */
function paySummary(row: CashierItemResult) {
if (row.resolveMode === RESOLVE_MODE.DIRECT) {
const parts = [row.channelMchNo, row.capability].filter(Boolean);
const mchLabel = channelMchLabelMap.value[row.channelMchNo || ''] || row.channelMchNo || '';
// 支付能力为固定枚举(PayCapabilityEnum),直接用 i18n 静态翻译
const capLabel = row.capability
? $t(`payment.merchant.cashier.cashier.capabilities.${row.capability}`)
: '';
const parts = [mchLabel, capLabel].filter(Boolean);
return parts.join(' / ') || '-';
}
return methodLabelMap.value[row.method || ''] || row.method || '-';
@@ -113,6 +121,26 @@
methodLabelMap.value = map;
}
/** 加载通道商户标签(每应用仅加载一次) */
async function loadChannelMchLabels() {
if (Object.keys(channelMchLabelMap.value).length > 0) {
return;
}
const { data } = await PayRouteApi.listSceneChannelMchCandidatesBatch({
appId: appId.value,
});
if (!data) {
return;
}
const labelMap: Record<string, string> = {};
for (const list of Object.values(data)) {
for (const item of list || []) {
labelMap[item.value] = item.label;
}
}
channelMchLabelMap.value = labelMap;
}
/** 加载当前桶列表 */
async function loadList() {
if (!appId.value) return;
@@ -181,7 +209,7 @@
onMounted(async () => {
if (!routeContext.isValid.value) return;
await Promise.all([loadAppInfo(), loadMethodLabels()]);
await Promise.all([loadAppInfo(), loadMethodLabels(), loadChannelMchLabels()]);
await loadList();
});
</script>
@@ -239,10 +267,18 @@
<a-spin :spinning="loading">
<vxe-table :data="tableData" :row-config="{ keyField: 'id' }" min-height="200">
<vxe-column type="seq" :title="$t('common.seq')" width="60" align="center" />
<vxe-column field="name" :title="$t('payment.merchant.cashier.cashier.name')" min-width="140" />
<vxe-column field="icon" :title="$t('payment.merchant.cashier.cashier.icon')" width="100" align="center">
<vxe-column field="name" :title="$t('payment.merchant.cashier.cashier.name')" min-width="70" />
<vxe-column field="icon" :title="$t('payment.merchant.cashier.cashier.icon')" width="120" align="center">
<template #default="{ row }">
{{ iconLabel(row.icon) }}
<div class="flex items-center justify-center gap-1">
<img
v-if="getProviderSvgUrl(row.icon)"
:src="getProviderSvgUrl(row.icon)"
class="h-5 w-5 object-contain"
:alt="row.icon"
/>
<span>{{ iconLabel(row.icon) }}</span>
</div>
</template>
</vxe-column>
<vxe-column

View File

@@ -11,9 +11,10 @@
type CashierItemResult,
} from '#/api/payment/merchant/cashier.api';
import { PayRouteApi } from '#/api/payment/route/pay-route.api';
import { FormEditType } from '#/enums/formEditType';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
import { FormEditType } from '#/enums/formEditType';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
import { getProviderSvgUrl } from '#/views/payment/shared/payProviderDisplay';
import {
CASHIER_ICON_OPTIONS,
@@ -40,8 +41,10 @@
cashierType: CASHIER_TYPE.H5,
});
// DIRECT 模式用:目录 method 仅作候选过滤,不落库
const filterMethod = ref('');
// DIRECT 模式:通道商户扁平选项(去重)+ 商户→method 组合映射 + 能力→provider 映射(图标联动)
const channelMchOptions = ref<LabelValue[]>([]);
const channelMchCombosMap = ref<Record<string, Array<{ method: string; provider: string }>>>({});
const capabilityProviderMap = ref<Record<string, string>>({});
const formState = ref<CashierItemParam>({
mchNo: '',
@@ -54,9 +57,8 @@
});
const methodOptions = ref<LabelValue[]>([]);
// method value → provider加载通道商户用)
// method value → providerMETHOD 模式联动图标用)
const methodProviderMap = ref<Record<string, string>>({});
const channelMchOptions = ref<LabelValue[]>([]);
const capabilityOptions = ref<LabelValue[]>([]);
const iconOptions = computed(() =>
@@ -69,6 +71,7 @@
const formRules = computed(() => {
const rules: Record<string, any[]> = {
name: [{ required: true, message: $t('payment.merchant.cashier.cashier.validationName') }],
icon: [{ required: true, message: $t('payment.merchant.cashier.cashier.validationIcon') }],
resolveMode: [{ required: true, message: $t('payment.merchant.cashier.cashier.validationResolveMode') }],
};
if (formState.value.resolveMode === RESOLVE_MODE.METHOD) {
@@ -100,8 +103,9 @@
channelMchNo: undefined,
capability: undefined,
};
filterMethod.value = '';
channelMchOptions.value = [];
channelMchCombosMap.value = {};
capabilityProviderMap.value = {};
capabilityOptions.value = [];
formRef.value?.resetFields();
}
@@ -113,9 +117,9 @@
const providerMap: Record<string, string> = {};
for (const item of data || []) {
const label = item.methodLabel || item.method;
const providerLabel = item.provider || '';
// 只展示方式名称,不显示 provider 编码前缀
options.push({
label: providerLabel ? `${providerLabel} / ${label}` : label,
label,
value: item.method,
});
providerMap[item.method] = item.provider;
@@ -124,42 +128,90 @@
methodProviderMap.value = providerMap;
}
/** DIRECT按 filterMethod 加载通道商户 */
async function loadChannelMchOptions(method: string) {
/** DIRECT批量加载全部通道商户(扁平去重,记录每商户的全部 provider|method 组合) */
async function loadAllChannelMchOptions() {
channelMchOptions.value = [];
capabilityOptions.value = [];
if (!method || !context.value.appId) {
channelMchCombosMap.value = {};
if (!context.value.appId) {
return;
}
const provider = methodProviderMap.value[method];
if (!provider) {
return;
}
const { data } = await PayRouteApi.listSceneChannelMchCandidates({
const { data } = await PayRouteApi.listSceneChannelMchCandidatesBatch({
appId: context.value.appId,
provider,
method,
});
channelMchOptions.value = data || [];
if (!data) {
return;
}
// 按 channelMchNo 去重,同时收集每商户的全部 provider|method 组合
const labelMap = new Map<string, string>();
const combosMap: Record<string, Array<{ method: string; provider: string }>> = {};
for (const [key, list] of Object.entries(data)) {
const parts = key.split('|');
const provider = parts[0] || '';
const method = parts.length >= 2 ? parts[1]! : '';
if (!method) {
continue;
}
for (const item of list || []) {
labelMap.set(item.value, item.label);
if (!combosMap[item.value]) {
combosMap[item.value] = [];
}
combosMap[item.value]!.push({ method, provider });
}
}
channelMchOptions.value = [...labelMap.entries()].map(([value, label]) => ({
label,
value,
}));
channelMchCombosMap.value = combosMap;
}
/** DIRECT加载支付能力 */
async function loadCapabilityOptions(method: string, channelMchNo: string) {
/** DIRECT按通道商户加载支付能力候选batch API 合并该商户全部 method 组合) */
async function loadCapabilityByChannelMch(channelMchNo: string) {
capabilityOptions.value = [];
if (!method || !channelMchNo || !context.value.appId) {
capabilityProviderMap.value = {};
if (!channelMchNo || !context.value.appId) {
return;
}
const provider = methodProviderMap.value[method];
if (!provider) {
const combos = channelMchCombosMap.value[channelMchNo];
if (!combos?.length) {
return;
}
const { data } = await PayRouteApi.listSceneCapabilityCandidates({
const { data } = await PayRouteApi.listSceneCapabilityCandidatesBatch({
appId: context.value.appId,
provider,
method,
channelMchNo,
items: combos.map((c) => ({
provider: c.provider,
method: c.method,
channelMchNo,
})),
});
capabilityOptions.value = data || [];
if (!data) {
return;
}
// 合并去重,同时建立 capability → provider 映射(用于图标联动)
const capLabelMap = new Map<string, string>();
const capProviderMap: Record<string, string> = {};
for (const [key, list] of Object.entries(data)) {
const provider = key.split('|')[0] || '';
for (const item of list || []) {
capLabelMap.set(item.value, item.label);
capProviderMap[item.value] = provider;
}
}
capabilityProviderMap.value = capProviderMap;
// label 统一用 i18n 翻译
const toLabel = (code: string) =>
$t(`payment.merchant.cashier.cashier.capabilities.${code}`) || code;
let options = [...capLabelMap.keys()].map((value) => ({
label: toLabel(value),
value,
}));
// 编辑回显兜底:确保已选值在选项中
const currentCap = formState.value.capability;
if (currentCap && !options.some((o) => o.value === currentCap)) {
options = [...options, { label: toLabel(currentCap), value: currentCap }];
}
capabilityOptions.value = options;
}
/** 解析模式切换 */
@@ -167,27 +219,37 @@
formState.value.method = undefined;
formState.value.channelMchNo = undefined;
formState.value.capability = undefined;
filterMethod.value = '';
channelMchOptions.value = [];
capabilityOptions.value = [];
capabilityProviderMap.value = {};
}
/** DIRECT 过滤 method 变更 */
async function onFilterMethodChange(method: any) {
filterMethod.value = method || '';
formState.value.channelMchNo = undefined;
formState.value.capability = undefined;
await loadChannelMchOptions(filterMethod.value);
/** METHOD 模式支付方式变更 — 联动图标icon = provider */
function onMethodChange(method: any) {
const provider = method ? methodProviderMap.value[method as string] : undefined;
if (provider) {
formState.value.icon = provider;
}
}
/** 通道商户变更 */
/** DIRECT 通道商户变更 — 加载该商户全部支付能力 */
async function onChannelMchChange(channelMchNo: any) {
formState.value.channelMchNo = channelMchNo || undefined;
formState.value.capability = undefined;
if (channelMchNo && filterMethod.value) {
await loadCapabilityOptions(filterMethod.value, channelMchNo);
} else {
if (!channelMchNo) {
capabilityOptions.value = [];
return;
}
await loadCapabilityByChannelMch(channelMchNo as string);
}
/** DIRECT 支付能力变更 — 联动图标icon = provider从 batch key 反推) */
function onCapabilityChange(capability: any) {
if (!capability) {
return;
}
const provider = capabilityProviderMap.value[capability as string];
if (provider) {
formState.value.icon = provider;
}
}
@@ -202,6 +264,7 @@
initFormEditType(FormEditType.Add);
resetForm();
await loadMethodDirectory();
await loadAllChannelMchOptions();
}
/** 编辑 */
@@ -221,6 +284,7 @@
initFormEditType(FormEditType.Edit);
resetForm();
await loadMethodDirectory();
await loadAllChannelMchOptions();
confirmLoading.value = true;
try {
const { data } = await CashierConfigApi.getById(opts.record.id!);
@@ -240,46 +304,15 @@
channelMchNo: row.channelMchNo || undefined,
capability: row.capability || undefined,
};
// DIRECT 编辑时无法还原 filterMethod需用户重选目录方式加载候选若已有 channelMch 则尝试 batch 匹配
// DIRECT 编辑时还原支付能力候选
if (formState.value.resolveMode === RESOLVE_MODE.DIRECT && row.channelMchNo) {
await tryRestoreDirectCandidates(row.channelMchNo, row.capability);
await loadCapabilityByChannelMch(row.channelMchNo);
}
} finally {
confirmLoading.value = false;
}
}
/**
* 编辑 DIRECT 项时尝试用 batch 候选还原 filterMethod / 选项
*/
async function tryRestoreDirectCandidates(channelMchNo: string, capability?: string) {
const { data } = await PayRouteApi.listSceneChannelMchCandidatesBatch({
appId: context.value.appId,
});
if (!data) {
return;
}
for (const [key, list] of Object.entries(data)) {
const hit = (list || []).some((item) => item.value === channelMchNo);
if (!hit) {
continue;
}
// key = provider|method
const parts = key.split('|');
const method = parts.length >= 2 ? parts[1]! : '';
if (method) {
filterMethod.value = method;
await loadChannelMchOptions(method);
if (channelMchNo) {
formState.value.channelMchNo = channelMchNo;
await loadCapabilityOptions(method, channelMchNo);
formState.value.capability = capability;
}
return;
}
}
}
/** 保存 */
async function handleOk() {
try {
@@ -322,15 +355,13 @@
</script>
<template>
<a-modal
<a-drawer
v-model:open="visible"
:title="title"
:width="720"
:confirm-loading="confirmLoading"
:size="800"
:destroy-on-hidden="true"
:mask-closable="showable"
destroy-on-close
@ok="handleOk"
@cancel="handleCancel"
@close="handleCancel"
>
<a-spin :spinning="confirmLoading">
<a-form
@@ -360,29 +391,6 @@
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('payment.merchant.cashier.cashier.recommend')" name="recommend">
<a-radio-group v-model:value="formState.recommend" button-style="solid" :disabled="showable">
<a-radio-button :value="false">{{
$t('payment.merchant.cashier.cashier.recommendNo')
}}</a-radio-button>
<a-radio-button :value="true">{{
$t('payment.merchant.cashier.cashier.recommendYes')
}}</a-radio-button>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('payment.merchant.cashier.cashier.icon')" name="icon">
<a-select
v-model:value="formState.icon"
:disabled="showable"
allow-clear
:options="iconOptions"
:placeholder="$t('payment.merchant.cashier.cashier.iconPlaceholder')"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item :label="$t('payment.merchant.cashier.cashier.resolveMode')" name="resolveMode">
<a-radio-group
@@ -411,28 +419,13 @@
option-filter-prop="label"
:options="methodOptions"
:placeholder="$t('payment.merchant.cashier.cashier.methodPlaceholder')"
@change="onMethodChange"
/>
</a-form-item>
</a-col>
</template>
<template v-else>
<a-col :span="24">
<a-form-item :label="$t('payment.merchant.cashier.cashier.method')">
<a-select
v-model:value="filterMethod"
:disabled="showable"
show-search
option-filter-prop="label"
:options="methodOptions"
:placeholder="$t('payment.merchant.cashier.cashier.methodPlaceholder')"
@change="onFilterMethodChange"
/>
<div class="mt-1 text-xs text-muted-foreground">
{{ $t('payment.merchant.cashier.cashier.methodFilterHint') }}
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="$t('payment.merchant.cashier.cashier.channelMerchant')"
@@ -440,7 +433,7 @@
>
<a-select
v-model:value="formState.channelMchNo"
:disabled="showable || !filterMethod"
:disabled="showable"
show-search
option-filter-prop="label"
:options="channelMchOptions"
@@ -458,12 +451,77 @@
option-filter-prop="label"
:options="capabilityOptions"
:placeholder="$t('payment.merchant.cashier.cashier.capabilityPlaceholder')"
@change="onCapabilityChange"
/>
</a-form-item>
</a-col>
</template>
<a-col :span="12">
<a-form-item :label="$t('payment.merchant.cashier.cashier.recommend')" name="recommend">
<a-radio-group v-model:value="formState.recommend" button-style="solid" :disabled="showable">
<a-radio-button :value="false">{{
$t('payment.merchant.cashier.cashier.recommendNo')
}}</a-radio-button>
<a-radio-button :value="true">{{
$t('payment.merchant.cashier.cashier.recommendYes')
}}</a-radio-button>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('payment.merchant.cashier.cashier.icon')" name="icon">
<a-select
v-model:value="formState.icon"
:disabled="showable"
show-search
option-filter-prop="label"
:options="iconOptions"
:placeholder="$t('payment.merchant.cashier.cashier.iconPlaceholder')"
>
<template #optionRender="{ option }">
<img
v-if="getProviderSvgUrl(option.data.value)"
:src="getProviderSvgUrl(option.data.value)"
class="icon-inline"
:alt="option.data.label"
/>
<span>{{ option.data.label }}</span>
</template>
<template #labelRender="{ label, value }">
<img
v-if="getProviderSvgUrl(value)"
:src="getProviderSvgUrl(value)"
class="icon-inline"
:alt="label"
/>
<span>{{ label }}</span>
</template>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-spin>
</a-modal>
<template #footer>
<a-space>
<a-button @click="handleCancel">{{ showable ? $t('common.close') : $t('common.cancel') }}</a-button>
<a-button v-if="!showable" type="primary" :loading="confirmLoading" @click="handleOk">
{{ $t('common.save') }}
</a-button>
</a-space>
</template>
</a-drawer>
</template>
<style scoped>
.icon-inline {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: -3px;
object-fit: contain;
}
</style>

View File

@@ -33,11 +33,16 @@ export const CASHIER_H5_SCENES: CashierSceneConfig[] = [
{ scene: 'douyin', provider: 'douyin' },
];
/** 图标选项 */
/**
* 图标选项(值与 PayProviderEnum.code 完全对齐)
* 选支付方式/通道商户时 icon = provider 直接赋值,实现联动
*/
export const CASHIER_ICON_OPTIONS = [
{ value: 'aggregate_pay', labelKey: 'payment.merchant.cashier.cashier.icons.aggregate_pay' },
{ value: 'wechat', labelKey: 'payment.merchant.cashier.cashier.icons.wechat' },
{ value: 'alipay', labelKey: 'payment.merchant.cashier.cashier.icons.alipay' },
{ value: 'union', labelKey: 'payment.merchant.cashier.cashier.icons.union' },
{ value: 'union_pay', labelKey: 'payment.merchant.cashier.cashier.icons.union_pay' },
{ value: 'visa', labelKey: 'payment.merchant.cashier.cashier.icons.visa' },
{ value: 'mastercard', labelKey: 'payment.merchant.cashier.cashier.icons.mastercard' },
{ value: 'douyin', labelKey: 'payment.merchant.cashier.cashier.icons.douyin' },
{ value: 'aggregate', labelKey: 'payment.merchant.cashier.cashier.icons.aggregate' },
] as const;