feat(payment): 通道商户下拉显示支付产品图标

- 新增 ChannelMchOption 类型(继承 LabelValue, 扩展 product/channel/
  channelMchNo/channelMerchantName), 与后端 DTO 对齐
- 新建 ChannelMerchantSelect 共享组件, 下拉项与选中态均渲染
  ChannelLogo 产品图标, 解决多通道商户同名歧义
- optionRender 通过 option.data 读取业务字段(antdv-next 包装类型)
- 3 个 API 文件返回类型升级为 ChannelMchOption[] /
  Record<string, ChannelMchOption[]>
- 5 个页面替换为 ChannelMerchantSelect: CashierItemEdit /
  AggregateScanConfig / CodePayConfig / DevelopTrade / ChannelAuth
- PayRouteScenePanel 保持纯文本(表头已有通道图标且按 provider 分组)
This commit is contained in:
DaxPay Dev
2026-07-19 18:00:50 +08:00
parent f48574a97a
commit bb47efbebe
10 changed files with 228 additions and 191 deletions

View File

@@ -1,5 +1,5 @@
import type { PayProviderMethod } from '#/api/payment/masterdata/provider.api';
import type { LabelValue, Result } from '#/types/web';
import type { ChannelMchOption, LabelValue, Result } from '#/types/web';
import { defHttp } from '#/api/request';
@@ -26,7 +26,7 @@ export const DevelopTradeApi = {
/**
* 直接指定: 按商户号筛选通道商户候选, provider 非空时仅返回声明支持该支付渠道的通道商户
*/
channelMchCandidates(mchNo: string, provider?: string): Promise<Result<LabelValue[]>> {
channelMchCandidates(mchNo: string, provider?: string): Promise<Result<ChannelMchOption[]>> {
return defHttp.get({
url: '/admin/develop/trade/channel-mch-candidates',
params: { mchNo, provider },

View File

@@ -1,4 +1,4 @@
import type { BaseEntity, LabelValue, Result } from '#/types/web';
import type { BaseEntity, ChannelMchOption, LabelValue, Result } from '#/types/web';
import { defHttp } from '#/api/request';
@@ -26,10 +26,7 @@ export const CodeConfigApi = {
/**
* DIRECT: 按商户+支付渠道列通道商户候选(不绑默认 JSAPI method
*/
listDirectChannelMchCandidates(params: {
mchNo: string
provider: string
}): Promise<Result<LabelValue[]>> {
listDirectChannelMchCandidates(params: { mchNo: string; provider: string }): Promise<Result<ChannelMchOption[]>> {
return defHttp.get({
url: '/admin/gateway/code-config/direct-channel-mch-candidates',
params,

View File

@@ -1,5 +1,5 @@
import type { PayProviderMethod } from '#/api/payment/masterdata/provider.api';
import type { BaseEntity, LabelValue, Result } from '#/types/web';
import type { BaseEntity, ChannelMchOption, LabelValue, Result } from '#/types/web';
import { defHttp } from '#/api/request';
@@ -31,7 +31,7 @@ export const PayRouteApi = {
},
/** 通道路由白名单目录下全部 (provider|method) 通道商户候选 */
listSceneChannelMchCandidatesBatch(params: { appId: string }): Promise<Result<Record<string, LabelValue[]>>> {
listSceneChannelMchCandidatesBatch(params: { appId: string }): Promise<Result<Record<string, ChannelMchOption[]>>> {
return defHttp.get({
url: '/admin/merchant/pay-route/scene-config/channel-mch-candidates-batch',
params,
@@ -39,7 +39,7 @@ export const PayRouteApi = {
},
/** 目录项下商户已开通的通道商户候选 */
listSceneChannelMchCandidates(params: PayRouteSceneChannelMchCandidatesQuery): Promise<Result<LabelValue[]>> {
listSceneChannelMchCandidates(params: PayRouteSceneChannelMchCandidatesQuery): Promise<Result<ChannelMchOption[]>> {
return defHttp.get({
url: '/admin/merchant/pay-route/scene-config/channel-mch-candidates',
params,

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { ChannelMchOption } from '#/types/web';
import { computed } from 'vue';
import ChannelLogo from './ChannelLogo.vue';
/**
* 通道商户下拉选择
*
* - 选项数据由调用方通过 `options` 传入(类型 [ChannelMchOption], 含 product/channel 字段)
* - 下拉项与选中态都会渲染支付产品图标(通过 [ChannelLogo] 派生)
* - 当多个通道商户名重复时, 图标可辅助区分
*/
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
/** 通道商户候选, 携带 product/channel 用于显示图标 */
options?: ChannelMchOption[];
placeholder?: string;
/** 自定义下拉尺寸的 class */
rootClassName?: string;
/** 是否启用搜索(默认开启) */
showSearch?: boolean;
/** 当前选中的通道商户号 */
value?: string;
}>(),
{
value: '',
options: () => [],
placeholder: '',
disabled: false,
allowClear: true,
showSearch: true,
rootClassName: '',
},
);
const emit = defineEmits<{
(e: 'update:value', val: string | undefined): void;
(e: 'change', val: string | undefined, option?: ChannelMchOption): void;
}>();
const innerOptions = computed(() => props.options);
// 选中态根据 value 查回 option 拿 product/channel 渲染图标
const selectedOption = computed<ChannelMchOption | undefined>(() =>
innerOptions.value.find((o) => o.value === props.value),
);
function onChange(val: string | undefined, option: ChannelMchOption | undefined) {
emit('update:value', val);
emit('change', val, option);
}
</script>
<template>
<a-select
:value="value"
:options="innerOptions"
:placeholder="placeholder"
:disabled="disabled"
:allow-clear="allowClear"
:show-search="showSearch"
:class="rootClassName"
option-filter-prop="label"
option-label-prop="label"
@change="onChange"
>
<template #optionRender="{ option }">
<div class="flex items-center gap-2">
<!-- 支付产品图标(优先产品级, 回退到通道级) -->
<ChannelLogo
:product="(option.data as ChannelMchOption).product"
:channel="(option.data as ChannelMchOption).channel"
:size="18"
/>
<span>{{ option.data.label }}</span>
</div>
</template>
<template #labelRender>
<div class="inline-flex items-center gap-1">
<ChannelLogo
v-if="selectedOption"
:product="selectedOption.product"
:channel="selectedOption.channel"
:size="14"
/>
<span>{{ selectedOption?.label ?? value }}</span>
</div>
</template>
</a-select>
</template>

View File

@@ -83,6 +83,20 @@ export interface LabelValue {
value: string;
}
/**
* 通道商户下拉选项(扩展 LabelValue, 携带通道/产品编码用于展示支付产品图标)
*/
export interface ChannelMchOption extends LabelValue {
/** 通道商户号(等于 value) */
channelMchNo: string;
/** 通道商户名称(空时回退到 channelMchNo) */
channelMerchantName?: string;
/** 所属支付通道编码(如 wechat / alipay) */
channel?: string;
/** 所属支付产品编码(如 wechat_pay / lakala_pay), 优先用于匹配产品图标 */
product?: string;
}
/**
* 分页表格列表对象
*/

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AuthResult, AuthUrlResult, ChannelAuthUrlParam } from '#/api/payment/develop/developAuth.api';
import type { LabelValue } from '#/types/web';
import type { ChannelMchOption, LabelValue } from '#/types/web';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
@@ -13,6 +13,7 @@
import { DevelopAuthApi } from '#/api/payment/develop/developAuth.api';
import { DevelopTradeApi } from '#/api/payment/develop/developTrade.api';
import { MerchantApi } from '#/api/payment/merchant/merchant.api';
import ChannelMerchantSelect from '#/components/channel/ChannelMerchantSelect.vue';
import { QrCode } from '#/components/qrcode';
import { useMessage } from '#/hooks/useMessage';
@@ -26,7 +27,7 @@
} as const;
/** 认证类型 */
type AuthType = 'alipay' | 'alipayMini' | 'wechatChannel' | 'wechatMini' | 'wechatMp' | 'douyin';
type AuthType = 'alipay' | 'alipayMini' | 'douyin' | 'wechatChannel' | 'wechatMini' | 'wechatMp';
const authType = ref<AuthType>('alipay');
const { message } = useMessage();
@@ -55,7 +56,7 @@
/** 下拉选项 */
const mchNoOptions = ref<LabelValue[]>([]);
const channelMchNoOptions = ref<LabelValue[]>([]);
const channelMchNoOptions = ref<ChannelMchOption[]>([]);
const capabilityOptions = ref<LabelValue[]>([]);
/** 微信小程序端类型选项 */
@@ -207,12 +208,12 @@
: authType.value === 'douyin'
? DevelopAuthApi.generateDouyinAuthUrl()
: DevelopAuthApi.generateChannelAuthUrl({
channel: 'wechat',
authType: 'wechat',
mchNo: form.mchNo,
channelMchNo: form.channelMchNo,
capability: form.capability,
} as ChannelAuthUrlParam);
channel: 'wechat',
authType: 'wechat',
mchNo: form.mchNo,
channelMchNo: form.channelMchNo,
capability: form.capability,
} as ChannelAuthUrlParam);
const { data } = await promise;
authUrl.value = data ?? {};
if (data?.queryCode) {
@@ -248,7 +249,15 @@
<template #extra>
<a-space :size="8">
<a-tag color="blue">OAuth2.0</a-tag>
<a-tag :color="authType === 'alipay' || authType === 'alipayMini' ? 'processing' : authType === 'douyin' ? 'black' : 'green'">
<a-tag
:color="
authType === 'alipay' || authType === 'alipayMini'
? 'processing'
: authType === 'douyin'
? 'black'
: 'green'
"
>
{{ tagLabel }}
</a-tag>
</a-space>
@@ -303,13 +312,7 @@
<!-- 微信支付商户参数表单 -->
<div v-if="authType === 'wechatChannel'" class="form-panel">
<a-form
ref="formRef"
layout="vertical"
class="channel-auth-form"
:model="form"
:rules="formRules"
>
<a-form ref="formRef" layout="vertical" class="channel-auth-form" :model="form" :rules="formRules">
<a-form-item :label="$t('payment.develop.auth.form.mchNo')" name="mchNo">
<a-select
v-model:value="form.mchNo"
@@ -322,17 +325,18 @@
/>
</a-form-item>
<a-form-item :label="$t('payment.develop.auth.form.channelMchNo')" name="channelMchNo">
<a-select
<ChannelMerchantSelect
v-model:value="form.channelMchNo"
:options="channelMchNoOptions"
:placeholder="$t('payment.develop.auth.form.rule.channelMchNo')"
show-search
:filter-option="filterOption"
allow-clear
@change="channelMchNoChange"
/>
</a-form-item>
<a-form-item :label="$t('payment.develop.auth.form.capability')" name="capability" class="form-item-last">
<a-form-item
:label="$t('payment.develop.auth.form.capability')"
name="capability"
class="form-item-last"
>
<a-select
v-model:value="form.capability"
:options="capabilityOptions"

View File

@@ -3,7 +3,7 @@
import type { PayParam, PayResult } from '#/api/payment/develop/developTrade.api';
import type { DaxResult } from '#/api/payment/unipay/unipay-request';
import type { LabelValue } from '#/types/web';
import type { ChannelMchOption, LabelValue } from '#/types/web';
import { computed, onMounted, reactive, ref } from 'vue';
@@ -16,6 +16,7 @@
import { MchAppInfoApi } from '#/api/payment/merchant/mch-app-info.api';
import { MerchantApi } from '#/api/payment/merchant/merchant.api';
import { uniPay } from '#/api/payment/unipay/unipay-trade.api';
import ChannelMerchantSelect from '#/components/channel/ChannelMerchantSelect.vue';
import { QrCode } from '#/components/qrcode';
import { useMessage } from '#/hooks/useMessage';
@@ -51,7 +52,7 @@
{
validator: async () => {
if (!privateKey.value) {
return Promise.reject(new Error($t('payment.develop.trade.msg.inputPrivateKey')));
throw new Error($t('payment.develop.trade.msg.inputPrivateKey'));
}
},
},
@@ -86,7 +87,7 @@
const mchNoOptions = ref<LabelValue[]>([]);
const mchAppOptions = ref<LabelValue[]>([]);
const methodOptions = ref<LabelValue[]>([]);
const channelMchNoOptions = ref<LabelValue[]>([]);
const channelMchNoOptions = ref<ChannelMchOption[]>([]);
const capabilityOptions = ref<LabelValue[]>([]);
// ===== 调试结果(完整 unipay DaxResult) =====
@@ -552,13 +553,10 @@
<!-- 直接指定: 通道商户(必填) -->
<a-col v-if="routeMode === 'direct'" :span="8">
<a-form-item :label="$t('payment.develop.trade.field.channelMchNo')" name="channelMchNo">
<a-select
<ChannelMerchantSelect
v-model:value="form.channelMchNo"
show-search
:options="channelMchNoOptions"
:placeholder="$t('payment.develop.trade.field.channelMchNo')"
:filter-option="filterOption"
allow-clear
@change="channelMchNoChange"
/>
</a-form-item>
@@ -789,13 +787,9 @@
>
<!-- 响应摘要 -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<a-tag :color="resultData.code === 0 ? 'success' : 'error'">
code: {{ resultData.code }}
</a-tag>
<a-tag :color="resultData.code === 0 ? 'success' : 'error'"> code: {{ resultData.code }} </a-tag>
<span class="text-sm text-muted-foreground">{{ resultData.msg }}</span>
<span v-if="resultData.traceId" class="text-xs text-muted-foreground">
traceId: {{ resultData.traceId }}
</span>
<span v-if="resultData.traceId" class="text-xs text-muted-foreground"> traceId: {{ resultData.traceId }} </span>
</div>
<template v-if="hasPayBody">

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { LabelValue } from '#/types/web';
import type { ChannelMchOption, LabelValue } from '#/types/web';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -8,22 +8,23 @@
import { IconifyIcon } from '@vben-core/icons';
import { MchAppInfoApi, type MchAppInfoResult } from '#/api/payment/merchant/mch-app-info.api';
import {
type AggregateClientEnvParam,
AggregateConfigApi,
type AggregateConfigResult,
type AggregateClientEnvParam,
} from '#/api/payment/merchant/aggregate.api';
import { MchAppInfoApi, type MchAppInfoResult } from '#/api/payment/merchant/mch-app-info.api';
import { PayRouteApi } from '#/api/payment/route/pay-route.api';
import ChannelMerchantSelect from '#/components/channel/ChannelMerchantSelect.vue';
import RouteQueryMissingState from '#/components/route/RouteQueryMissingState.vue';
import { useMessage } from '#/hooks/useMessage';
import { normalizeRouteQueryValue, useRequiredRouteQuery } from '#/hooks/useRequiredRouteQuery';
import { PAY_ROUTE_MODE } from '#/views/payment/merchant/route/shared/payRoute.constants';
import { modeDisplayName } from '#/views/payment/merchant/route/shared/payRoute.labels';
import RouteHitPreviewBlock from '#/views/payment/merchant/shared/RouteHitPreviewBlock.vue';
import { useRouteHitPreview } from '#/views/payment/merchant/shared/useRouteHitPreview';
import { modeDisplayName } from '#/views/payment/merchant/route/shared/payRoute.labels';
import { PAY_ROUTE_MODE } from '#/views/payment/merchant/route/shared/payRoute.constants';
import { AGGREGATE_LEVEL, AGGREGATE_CLIENT_ENVS, type AggregateLevel } from './shared/constants';
import { AGGREGATE_CLIENT_ENVS, AGGREGATE_LEVEL, type AggregateLevel } from './shared/constants';
defineOptions({ name: 'AggregateScanConfig' });
@@ -66,7 +67,7 @@
// 候选数据
const methodDirectory = ref<Record<string, LabelValue[]>>({}); // provider → 方式列表
const channelMchMap = ref<Record<string, LabelValue[]>>({}); // clientEnv → 通道商户列表
const channelMchMap = ref<Record<string, ChannelMchOption[]>>({}); // clientEnv → 通道商户列表
const capabilityMap = ref<Record<string, LabelValue[]>>({}); // clientEnv → 能力列表
// 通道路由命中预览(与路由页同源;解构以便模板自动解包 ref
@@ -187,7 +188,7 @@
/** 加载通道商户候选(DIRECT 模式用) */
async function loadChannelMchCandidates() {
const map: Record<string, LabelValue[]> = {};
const map: Record<string, ChannelMchOption[]> = {};
await Promise.all(
AGGREGATE_CLIENT_ENVS.map(async (sc) => {
const { data } = await PayRouteApi.listSceneChannelMchCandidates({
@@ -298,9 +299,7 @@
}
if (!hasMch || !hasCap) {
message.error(
$t('payment.merchant.aggregate.aggregate.partialRowIncomplete') +
': ' +
clientEnvLabel(sc.clientEnv),
$t('payment.merchant.aggregate.aggregate.partialRowIncomplete') + ': ' + clientEnvLabel(sc.clientEnv),
);
return null;
}
@@ -391,9 +390,7 @@
</template>
</a-button>
<span class="text-lg font-bold">{{ $t('payment.merchant.aggregate.aggregate.title') }}</span>
<span v-if="appInfo.appName" class="text-sm text-muted-foreground">
({{ appInfo.appName }})
</span>
<span v-if="appInfo.appName" class="text-sm text-muted-foreground"> ({{ appInfo.appName }}) </span>
</div>
</template>
@@ -468,11 +465,7 @@
</div>
<!-- 数据行 -->
<div
v-for="sc in AGGREGATE_CLIENT_ENVS"
:key="sc.clientEnv"
class="env-grid-row"
>
<div v-for="sc in AGGREGATE_CLIENT_ENVS" :key="sc.clientEnv" class="env-grid-row">
<div class="cell-env font-medium">{{ clientEnvLabel(sc.clientEnv) }}</div>
<!-- AUTO只读支付方式 + 路由预览 -->
@@ -508,13 +501,12 @@
<!-- DIRECT通道商户 + 能力跳过路由 -->
<template v-else>
<div>
<a-select
<ChannelMerchantSelect
:value="getClientEnvData(sc.clientEnv).channelMchNo"
:options="channelMchOptions(sc.clientEnv)"
:placeholder="$t('payment.merchant.aggregate.aggregate.channelMerchantPlaceholder')"
:disabled="!editing"
allow-clear
class="w-full min-w-[160px]"
root-class-name="w-full min-w-[160px]"
@change="(val: any) => onChannelMchChange(sc.clientEnv, val)"
/>
</div>

View File

@@ -1,27 +1,24 @@
<script lang="ts" setup>
import type { LabelValue } from '#/types/web';
import type { ChannelMchOption, LabelValue } from '#/types/web';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import {
CashierConfigApi,
type CashierItemParam,
type CashierItemResult,
} from '#/api/payment/merchant/cashier.api';
import { CashierConfigApi, type CashierItemParam, 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 { getProviderSvgUrl } from '#/views/payment/shared/payProviderDisplay';
import ChannelMerchantSelect from '#/components/channel/ChannelMerchantSelect.vue';
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,
CASHIER_TYPE,
RESOLVE_MODE,
cashierTypeRequiresClientEnv,
type CashierType,
cashierTypeRequiresClientEnv,
RESOLVE_MODE,
} from './shared/constants';
const emit = defineEmits<{ ok: [] }>();
@@ -32,10 +29,10 @@
const { visible, confirmLoading, title, initFormEditType, handleCancel, formEditType, showable } = useFormEdit();
const context = ref<{
mchNo: string;
appId: string;
cashierType: CashierType;
clientEnv?: string;
mchNo: string;
}>({
mchNo: '',
appId: '',
@@ -43,7 +40,7 @@
});
// DIRECT 模式:通道商户扁平选项(去重)+ 商户→method 组合映射 + 能力→provider 映射(图标联动)
const channelMchOptions = ref<LabelValue[]>([]);
const channelMchOptions = ref<ChannelMchOption[]>([]);
const channelMchCombosMap = ref<Record<string, Array<{ method: string; provider: string }>>>({});
const capabilityProviderMap = ref<Record<string, string>>({});
@@ -78,12 +75,8 @@
if (formState.value.resolveMode === RESOLVE_MODE.METHOD) {
rules.method = [{ required: true, message: $t('payment.merchant.cashier.cashier.validationMethod') }];
} else {
rules.channelMchNo = [
{ required: true, message: $t('payment.merchant.cashier.cashier.validationChannelMch') },
];
rules.capability = [
{ required: true, message: $t('payment.merchant.cashier.cashier.validationCapability') },
];
rules.channelMchNo = [{ required: true, message: $t('payment.merchant.cashier.cashier.validationChannelMch') }];
rules.capability = [{ required: true, message: $t('payment.merchant.cashier.cashier.validationCapability') }];
}
return rules;
});
@@ -94,9 +87,7 @@
mchNo: context.value.mchNo,
appId: context.value.appId,
cashierType: context.value.cashierType,
clientEnv: cashierTypeRequiresClientEnv(context.value.cashierType)
? context.value.clientEnv
: undefined,
clientEnv: cashierTypeRequiresClientEnv(context.value.cashierType) ? context.value.clientEnv : undefined,
name: '',
icon: undefined,
recommend: false,
@@ -145,7 +136,7 @@
return;
}
// 按 channelMchNo 去重,同时收集每商户的全部 provider|method 组合
const labelMap = new Map<string, string>();
const optionMap = new Map<string, ChannelMchOption>();
const combosMap: Record<string, Array<{ method: string; provider: string }>> = {};
for (const [key, list] of Object.entries(data)) {
const parts = key.split('|');
@@ -155,17 +146,14 @@
continue;
}
for (const item of list || []) {
labelMap.set(item.value, item.label);
optionMap.set(item.value, item);
if (!combosMap[item.value]) {
combosMap[item.value] = [];
}
combosMap[item.value]!.push({ method, provider });
}
}
channelMchOptions.value = [...labelMap.entries()].map(([value, label]) => ({
label,
value,
}));
channelMchOptions.value = [...optionMap.values()];
channelMchCombosMap.value = combosMap;
}
@@ -203,8 +191,7 @@
}
capabilityProviderMap.value = capProviderMap;
// label 统一用 i18n 翻译
const toLabel = (code: string) =>
$t(`payment.merchant.cashier.cashier.capabilities.${code}`) || code;
const toLabel = (code: string) => $t(`payment.merchant.cashier.cashier.capabilities.${code}`) || code;
let options = [...capLabelMap.keys()].map((value) => ({
label: toLabel(value),
value,
@@ -257,12 +244,7 @@
}
/** 新增 */
async function show(opts: {
mchNo: string;
appId: string;
cashierType: CashierType;
clientEnv?: string;
}) {
async function show(opts: { appId: string; cashierType: CashierType; clientEnv?: string; mchNo: string }) {
context.value = { ...opts };
initFormEditType(FormEditType.Add);
resetForm();
@@ -272,10 +254,10 @@
/** 编辑 */
async function showEdit(opts: {
mchNo: string;
appId: string;
cashierType: CashierType;
clientEnv?: string;
mchNo: string;
record: CashierItemResult;
}) {
context.value = {
@@ -330,9 +312,7 @@
mchNo: context.value.mchNo,
appId: context.value.appId,
cashierType: context.value.cashierType,
clientEnv: cashierTypeRequiresClientEnv(context.value.cashierType)
? context.value.clientEnv
: undefined,
clientEnv: cashierTypeRequiresClientEnv(context.value.cashierType) ? context.value.clientEnv : undefined,
recommend: !!formState.value.recommend,
sortNo: formState.value.sortNo ?? 0,
};
@@ -343,11 +323,9 @@
payload.channelMchNo = undefined;
payload.capability = undefined;
}
if (formEditType.value === FormEditType.Edit) {
await CashierConfigApi.update(payload);
} else {
await CashierConfigApi.save(payload);
}
await (formEditType.value === FormEditType.Edit
? CashierConfigApi.update(payload)
: CashierConfigApi.save(payload));
message.success($t('common.operationSuccess'));
handleCancel();
emit('ok');
@@ -369,13 +347,7 @@
@close="handleCancel"
>
<a-spin :spinning="confirmLoading">
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
layout="vertical"
class="pt-2"
>
<a-form ref="formRef" :model="formState" :rules="formRules" layout="vertical" class="pt-2">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="$t('payment.merchant.cashier.cashier.name')" name="name">
@@ -432,16 +404,11 @@
<template v-else>
<a-col :span="12">
<a-form-item
:label="$t('payment.merchant.cashier.cashier.channelMerchant')"
name="channelMchNo"
>
<a-select
v-model:value="formState.channelMchNo"
:disabled="showable"
show-search
option-filter-prop="label"
<a-form-item :label="$t('payment.merchant.cashier.cashier.channelMerchant')" name="channelMchNo">
<ChannelMerchantSelect
:value="formState.channelMchNo"
:options="channelMchOptions"
:disabled="showable"
:placeholder="$t('payment.merchant.cashier.cashier.channelMerchantPlaceholder')"
@change="onChannelMchChange"
/>
@@ -465,12 +432,8 @@
<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-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>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { LabelValue } from '#/types/web';
import type { ChannelMchOption, LabelValue } from '#/types/web';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -8,26 +8,27 @@
import { IconifyIcon } from '@vben-core/icons';
import { MchAppInfoApi, type MchAppInfoResult } from '#/api/payment/merchant/mch-app-info.api';
import {
type CodeClientEnvParam,
CodeConfigApi,
type CodeConfigResult,
type CodeClientEnvParam,
} from '#/api/payment/merchant/code-config.api';
import { MchAppInfoApi, type MchAppInfoResult } from '#/api/payment/merchant/mch-app-info.api';
import { PayRouteApi } from '#/api/payment/route/pay-route.api';
import ChannelMerchantSelect from '#/components/channel/ChannelMerchantSelect.vue';
import RouteQueryMissingState from '#/components/route/RouteQueryMissingState.vue';
import { useMessage } from '#/hooks/useMessage';
import { normalizeRouteQueryValue, useRequiredRouteQuery } from '#/hooks/useRequiredRouteQuery';
import { PAY_ROUTE_MODE } from '#/views/payment/merchant/route/shared/payRoute.constants';
import { modeDisplayName } from '#/views/payment/merchant/route/shared/payRoute.labels';
import RouteHitPreviewBlock from '#/views/payment/merchant/shared/RouteHitPreviewBlock.vue';
import { useRouteHitPreview } from '#/views/payment/merchant/shared/useRouteHitPreview';
import { modeDisplayName } from '#/views/payment/merchant/route/shared/payRoute.labels';
import { PAY_ROUTE_MODE } from '#/views/payment/merchant/route/shared/payRoute.constants';
import {
CODE_LEVEL,
CODE_CLIENT_ENVS,
CODE_PAY_FORMS,
CODE_LEVEL,
CODE_PAY_FORM,
CODE_PAY_FORMS,
type CodeLevel,
type CodePayForm,
defaultMethodFor,
@@ -71,7 +72,7 @@
const clientEnvForm = ref<Record<string, CodeClientEnvParam>>({});
const methodDirectory = ref<Record<string, LabelValue[]>>({});
const channelMchMap = ref<Record<string, LabelValue[]>>({});
const channelMchMap = ref<Record<string, ChannelMchOption[]>>({});
const capabilityMap = ref<Record<string, LabelValue[]>>({});
// 通道路由命中预览(与路由页同源;解构以便模板自动解包 ref
@@ -86,9 +87,7 @@
const effectiveLevel = computed(() => config.value.level || CODE_LEVEL.AUTO);
const isLevelActive = computed(() => editLevel.value === effectiveLevel.value);
const showRoutePreview = computed(
() => editLevel.value === CODE_LEVEL.AUTO || editLevel.value === CODE_LEVEL.METHOD,
);
const showRoutePreview = computed(() => editLevel.value === CODE_LEVEL.AUTO || editLevel.value === CODE_LEVEL.METHOD);
const modeHint = computed(() => {
if (editLevel.value === CODE_LEVEL.AUTO) {
@@ -124,9 +123,7 @@
for (const sc of CODE_CLIENT_ENVS) {
for (const pf of CODE_PAY_FORMS) {
const key = rowKey(sc.clientEnv, pf);
const serverEnv = config.value.clientEnvs?.find(
(s) => s.clientEnv === sc.clientEnv && s.payForm === pf,
);
const serverEnv = config.value.clientEnvs?.find((s) => s.clientEnv === sc.clientEnv && s.payForm === pf);
form[key] = {
clientEnv: sc.clientEnv,
payForm: pf,
@@ -203,8 +200,8 @@
*/
type DisplayFormRow = {
key: string;
payForms: CodePayForm[];
merged: boolean;
payForms: CodePayForm[];
/** 预览/展示用主形态(合并时取 H5 */
primaryForm: CodePayForm;
};
@@ -233,11 +230,7 @@
}
/** 未配置时:同环境另一形态已配置则强调告警,否则灰色降噪 */
function emptyToneFor(
provider: string,
clientEnv: string,
payForm: CodePayForm,
): 'soft' | 'emphasize' {
function emptyToneFor(provider: string, clientEnv: string, payForm: CodePayForm): 'emphasize' | 'soft' {
const method = resolveMethodForRow(clientEnv, payForm);
if (!method) {
return 'soft';
@@ -279,11 +272,11 @@
const hit = previewRouteHit(sc.provider, method);
if (hit.status === 'ok') {
ok += 1;
} else if (hit.status === 'notConfigured' || hit.status === 'noStrategy') {
// 仅收集「同环境另一形态已配」的缺口,避免刷屏
if (emptyToneFor(sc.provider, sc.clientEnv, pf) === 'emphasize') {
gapLabels.push(`${clientEnvLabel(sc.clientEnv)} · ${payFormLabel(pf)}`);
}
} else if (
(hit.status === 'notConfigured' || hit.status === 'noStrategy') && // 仅收集「同环境另一形态已配」的缺口,避免刷屏
emptyToneFor(sc.provider, sc.clientEnv, pf) === 'emphasize'
) {
gapLabels.push(`${clientEnvLabel(sc.clientEnv)} · ${payFormLabel(pf)}`);
}
}
}
@@ -294,7 +287,7 @@
* DIRECT: 按商户+渠道列通道商户(不绑默认 JSAPI method与路由直接指定一致
*/
async function loadChannelMchCandidates() {
const map: Record<string, LabelValue[]> = {};
const map: Record<string, ChannelMchOption[]> = {};
await Promise.all(
CODE_CLIENT_ENVS.map(async (sc) => {
const { data } = await CodeConfigApi.listDirectChannelMchCandidates({
@@ -491,9 +484,7 @@
</template>
</a-button>
<span class="text-lg font-bold">{{ $t('payment.merchant.codeConfig.codeConfig.title') }}</span>
<span v-if="appInfo.appName" class="text-sm text-muted-foreground">
({{ appInfo.appName }})
</span>
<span v-if="appInfo.appName" class="text-sm text-muted-foreground"> ({{ appInfo.appName }}) </span>
</div>
</template>
@@ -533,27 +524,24 @@
</div>
<!-- openId 风控与码牌支付方式关系说明 -->
<div class="mb-4">
<a-alert
:message="$t('payment.merchant.codeConfig.codeConfig.openIdRiskHint')"
type="warning"
show-icon
/>
<a-alert :message="$t('payment.merchant.codeConfig.codeConfig.openIdRiskHint')" type="warning" show-icon />
</div>
<div v-if="showRoutePreview" class="mb-5 flex flex-wrap items-center gap-3 text-sm">
<span class="text-muted-foreground">{{ $t('payment.merchant.codeConfig.codeConfig.currentRouteMode') }}:</span>
<span class="text-muted-foreground"
>{{ $t('payment.merchant.codeConfig.codeConfig.currentRouteMode') }}:</span
>
<a-tag color="blue">{{ routeModeLabel }}</a-tag>
<template v-if="routeCoverage">
<span class="text-muted-foreground">
{{ $t('payment.merchant.codeConfig.codeConfig.routeCoverage', {
ok: routeCoverage.ok,
total: routeCoverage.total,
}) }}
{{
$t('payment.merchant.codeConfig.codeConfig.routeCoverage', {
ok: routeCoverage.ok,
total: routeCoverage.total,
})
}}
</span>
<span
v-if="routeCoverage.gapLabels.length"
class="text-xs text-orange-500"
>
<span v-if="routeCoverage.gapLabels.length > 0" class="text-xs text-orange-500">
{{ $t('payment.merchant.codeConfig.codeConfig.routeGaps') }}:
{{ routeCoverage.gapLabels.join(' · ') }}
</span>
@@ -577,11 +565,7 @@
<div>{{ $t('payment.merchant.route.route.payCapability') }}</div>
</div>
<div
v-for="drow in displayFormRows(sc.clientEnv)"
:key="drow.key"
class="env-grid-row"
>
<div v-for="drow in displayFormRows(sc.clientEnv)" :key="drow.key" class="env-grid-row">
<!-- 形态合并时展示 H5 + 小程序 -->
<div class="form-tags">
<template v-if="drow.merged">
@@ -589,11 +573,7 @@
<span class="text-muted-foreground text-xs">/</span>
<a-tag color="purple" class="!m-0">{{ payFormLabel(CODE_PAY_FORM.MINI) }}</a-tag>
</template>
<a-tag
v-else
:color="drow.primaryForm === CODE_PAY_FORM.MINI ? 'purple' : 'blue'"
class="!m-0"
>
<a-tag v-else :color="drow.primaryForm === CODE_PAY_FORM.MINI ? 'purple' : 'blue'" class="!m-0">
{{ payFormLabel(drow.primaryForm) }}
</a-tag>
</div>
@@ -636,13 +616,12 @@
<!-- DIRECT -->
<template v-else>
<div>
<a-select
<ChannelMerchantSelect
:value="getRow(sc.clientEnv, drow.primaryForm).channelMchNo"
:options="channelMchOptions(sc.clientEnv, drow.primaryForm)"
:placeholder="$t('payment.merchant.codeConfig.codeConfig.channelMerchantPlaceholder')"
:disabled="!editing"
allow-clear
class="w-full min-w-[160px]"
root-class-name="w-full min-w-[160px]"
@change="(val: any) => onChannelMchChange(sc.clientEnv, drow.primaryForm, val)"
/>
</div>