feat(admin): 应用级聚合扫码配置页面

新增聚合扫码配置 API/页面与权限码, 应用卡片入口联动。
This commit is contained in:
DaxPay Dev
2026-07-11 10:21:28 +08:00
parent 27b21bfccb
commit d488e6f6f9
9 changed files with 645 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import type { BaseEntity, LabelValue, Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 聚合扫码配置 API
* 对接后端 GatewayAggregateConfigAdminController
*/
export const AggregateConfigApi = {
/** 按应用查询聚合扫码配置 */
getByAppId(appId: string): Promise<Result<AggregateConfigResult>> {
return defHttp.get({
url: '/admin/gateway/aggregate-config/get-by-app-id',
params: { appId },
});
},
/** 保存或更新聚合扫码配置 */
saveOrUpdate(data: AggregateConfigParam): Promise<Result<void>> {
return defHttp.post({
url: '/admin/gateway/aggregate-config/save-or-update',
data,
});
},
};
/** 聚合扫码配置结果 */
export interface AggregateConfigResult extends BaseEntity {
appId?: string;
mchNo?: string;
/** 配置深度: auto/method/direct */
level?: string;
autoLaunch?: boolean;
scenes?: AggregateSceneResult[];
}
/** 场景配置结果 */
export interface AggregateSceneResult {
/** 场景编码: wechat_pay/alipay/union_pay/douyin */
scene?: string;
/** 支付方式(METHOD 模式) */
method?: string;
/** 通道商户号(DIRECT 模式) */
channelMchNo?: string;
/** 支付能力(DIRECT 模式) */
capability?: string;
}
/** 聚合扫码配置参数 */
export interface AggregateConfigParam {
mchNo: string;
appId: string;
level: string;
autoLaunch?: boolean;
scenes?: AggregateSceneParam[];
}
/** 场景配置参数 */
export interface AggregateSceneParam {
scene?: string;
method?: string;
channelMchNo?: string;
capability?: string;
}
// re-export LabelValue 供消费方就近引用
export type { LabelValue };

View File

@@ -69,6 +69,11 @@ export const PermCodes = {
VIEW: 'merchant:app:route:view',
MANAGE: 'merchant:app:route:manage',
},
/** 聚合扫码配置 menuCode=merchant:gateway-aggregate */
GatewayAggregate: {
VIEW: 'merchant:gateway-aggregate:view',
MANAGE: 'merchant:gateway-aggregate:update',
},
/** 门店 menuCode=merchant:store */
Store: {
VIEW: 'merchant:store:view',

View File

@@ -0,0 +1,32 @@
{
"aggregate": {
"title": "Aggregate QR Pay Config",
"editModeLabel": "Config Level",
"autoLaunch": "Auto Launch",
"autoLaunchHint": "Automatically initiate payment after scanning, no manual click needed",
"autoModeHint": "System auto-derives payment method by scan environment, resolved via basic routing. No manual config needed.",
"methodModeHint": "Manually select payment method per scene, resolved via scene routing.",
"directModeHint": "Directly specify channel merchant and capability per scene, bypassing routing.",
"scene": "Scene",
"method": "Payment Method",
"methodPlaceholder": "Select payment method",
"channelMerchant": "Channel Merchant",
"channelMerchantPlaceholder": "Select channel merchant",
"capability": "Capability",
"capabilityPlaceholder": "Select capability",
"autoDerived": "Auto-derived",
"saveConfirm": "Are you sure to save the aggregate QR pay config?",
"missingAppContext": "Please access aggregate QR config from app management",
"notSelected": "Not selected",
"levelActive": "Active",
"scenes": {
"wechat_pay": "WeChat",
"alipay": "Alipay",
"union_pay": "UnionPay",
"douyin": "Douyin"
},
"modeAuto": "Auto",
"modeMethod": "By Method",
"modeDirect": "Direct"
}
}

View File

@@ -25,6 +25,7 @@
"validationAppName": "Please enter app name",
"validationStatus": "Please select status",
"payRoute": "Channel Routing",
"aggregateScan": "Aggregate QR",
"notifyConfig": "Notification Config"
}
}

View File

@@ -0,0 +1,32 @@
{
"aggregate": {
"title": "聚合扫码配置",
"editModeLabel": "配置模式",
"autoLaunch": "自动拉起支付",
"autoLaunchHint": "扫码后自动发起支付,无需用户手动点击",
"autoModeHint": "系统按扫码环境自动推导支付方式,走路由基础模式解析,无需手动配置",
"methodModeHint": "每个场景手动选择支付方式,走路由场景模式解析",
"directModeHint": "每个场景直接指定通道商户号和支付能力,跳过路由直定通道",
"scene": "场景",
"method": "支付方式",
"methodPlaceholder": "请选择支付方式",
"channelMerchant": "通道商户号",
"channelMerchantPlaceholder": "请选择通道商户",
"capability": "支付能力",
"capabilityPlaceholder": "请选择支付能力",
"autoDerived": "系统自动推导",
"saveConfirm": "确定要保存聚合扫码配置吗?",
"missingAppContext": "请从应用管理进入聚合扫码配置",
"notSelected": "未选择",
"levelActive": "生效中",
"scenes": {
"wechat_pay": "微信环境",
"alipay": "支付宝环境",
"union_pay": "云闪付环境",
"douyin": "抖音环境"
},
"modeAuto": "自动模式",
"modeMethod": "方式模式",
"modeDirect": "精确模式"
}
}

View File

@@ -25,6 +25,7 @@
"validationAppName": "请输入应用名称",
"validationStatus": "请选择状态",
"payRoute": "通道路由",
"aggregateScan": "聚合扫码",
"notifyConfig": "通知配置"
}
}

View File

@@ -0,0 +1,450 @@
<script lang="ts" setup>
import type { LabelValue } from '#/types/web';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import { MchAppInfoApi, type MchAppInfoResult } from '#/api/payment/merchant/mch-app-info.api';
import {
AggregateConfigApi,
type AggregateConfigResult,
type AggregateSceneParam,
} from '#/api/payment/merchant/aggregate.api';
import { PayRouteApi } from '#/api/payment/route/pay-route.api';
import RouteQueryMissingState from '#/components/route/RouteQueryMissingState.vue';
import { useMessage } from '#/hooks/useMessage';
import { normalizeRouteQueryValue, useRequiredRouteQuery } from '#/hooks/useRequiredRouteQuery';
import { AGGREGATE_LEVEL, AGGREGATE_SCENES, type AggregateLevel } from './shared/constants';
defineOptions({ name: 'AggregateScanConfig' });
const route = useRoute();
const router = useRouter();
const { confirm, message } = useMessage();
// 路由参数校验: mchNo + appId 必传
const routeContext = useRequiredRouteQuery({
keys: ['mchNo', 'appId'],
messageKey: computed(() =>
normalizeRouteQueryValue(route.query.mchNo)
? 'payment.merchant.aggregate.aggregate.missingAppContext'
: 'payment.common.route.missingMchNo',
),
fallbackPath: computed(() => {
const no = normalizeRouteQueryValue(route.query.mchNo);
return no ? { path: '/payment/merchant/app', query: { mchNo: no } } : '/payment/merchant';
}),
});
const mchNo = computed(() => routeContext.query.value.mchNo);
const appId = computed(() => routeContext.query.value.appId);
// 页面状态
const loading = ref(false);
const editing = ref(false);
const appInfo = ref<MchAppInfoResult>({});
const config = ref<AggregateConfigResult>({});
// 编辑态
const editLevel = ref<AggregateLevel>(AGGREGATE_LEVEL.AUTO);
const autoLaunch = ref(false);
// 每场景的编辑数据: scene → { method, channelMchNo, capability }
const sceneForm = ref<Record<string, AggregateSceneParam>>({});
// 候选数据
const methodDirectory = ref<Record<string, LabelValue[]>>({}); // provider → 方式列表
const channelMchMap = ref<Record<string, LabelValue[]>>({}); // scene → 通道商户列表
const capabilityMap = ref<Record<string, LabelValue[]>>({}); // scene → 能力列表
// 生效模式(服务端)
const effectiveLevel = computed(() => config.value.level || AGGREGATE_LEVEL.AUTO);
const isLevelActive = computed(() => editLevel.value === effectiveLevel.value);
// 模式提示
const modeHint = computed(() => {
const key = editLevel.value;
if (key === AGGREGATE_LEVEL.AUTO) {
return $t('payment.merchant.aggregate.aggregate.autoModeHint');
}
if (key === AGGREGATE_LEVEL.METHOD) {
return $t('payment.merchant.aggregate.aggregate.methodModeHint');
}
return $t('payment.merchant.aggregate.aggregate.directModeHint');
});
/** 场景国际化名 */
function sceneLabel(scene: string) {
return $t(`payment.merchant.aggregate.aggregate.scenes.${scene}`);
}
/** 初始化每场景编辑数据 */
function initSceneForm() {
const form: Record<string, AggregateSceneParam> = {};
for (const sc of AGGREGATE_SCENES) {
const serverScene = config.value.scenes?.find((s) => s.scene === sc.scene);
form[sc.scene] = {
scene: sc.scene,
method: serverScene?.method || '',
channelMchNo: serverScene?.channelMchNo || '',
capability: serverScene?.capability || '',
};
}
sceneForm.value = form;
}
/** 获取某场景编辑数据 */
function getSceneData(scene: string) {
if (!sceneForm.value[scene]) {
sceneForm.value[scene] = { scene, method: '', channelMchNo: '', capability: '' };
}
return sceneForm.value[scene]!;
}
/** METHOD 模式: 某渠道下可选支付方式 */
function methodOptions(provider: string) {
return methodDirectory.value[provider] || [];
}
/** DIRECT 模式: 某场景可选通道商户 */
function channelMchOptions(scene: string) {
return channelMchMap.value[scene] || [];
}
/** DIRECT 模式: 某场景可选能力 */
function capabilityOptions(scene: string) {
return capabilityMap.value[scene] || [];
}
/** 加载支付方式目录(METHOD 模式用) */
async function loadMethodDirectory() {
const { data } = await PayRouteApi.listMethodDirectoryFlat();
const map: Record<string, LabelValue[]> = {};
for (const item of data || []) {
if (!map[item.provider]) {
map[item.provider] = [];
}
map[item.provider]!.push({ label: item.methodLabel || item.method, value: item.method });
}
methodDirectory.value = map;
}
/** 加载通道商户候选(DIRECT 模式用) */
async function loadChannelMchCandidates() {
const map: Record<string, LabelValue[]> = {};
await Promise.all(
AGGREGATE_SCENES.map(async (sc) => {
const { data } = await PayRouteApi.listSceneChannelMchCandidates({
appId: appId.value,
provider: sc.provider,
});
map[sc.scene] = data || [];
}),
);
channelMchMap.value = map;
}
/** 某场景选中通道商户后加载能力候选 */
async function loadCapabilityForScene(scene: string, channelMchNo: string) {
const sc = AGGREGATE_SCENES.find((s) => s.scene === scene);
if (!sc || !channelMchNo) {
capabilityMap.value = { ...capabilityMap.value, [scene]: [] };
return;
}
const { data } = await PayRouteApi.listSceneCapabilityCandidates({
appId: appId.value,
provider: sc.provider,
method: sc.defaultMethod,
channelMchNo,
});
capabilityMap.value = { ...capabilityMap.value, [scene]: data || [] };
}
/** 加载应用信息 */
async function loadAppInfo() {
if (!mchNo.value || !appId.value) return;
const { data } = await MchAppInfoApi.page({ mchNo: mchNo.value, current: 1, size: 200 });
const app = data?.records?.find((a) => a.appId === appId.value);
appInfo.value = app || {};
}
/** 加载配置 */
async function loadConfig() {
if (!appId.value) return;
loading.value = true;
const { data } = await AggregateConfigApi.getByAppId(appId.value);
config.value = data || {};
editLevel.value = (effectiveLevel.value as AggregateLevel) || AGGREGATE_LEVEL.AUTO;
autoLaunch.value = config.value.autoLaunch || false;
initSceneForm();
loading.value = false;
}
/** 返回应用列表 */
function handleBack() {
router.push({ path: '/payment/merchant/app', query: { mchNo: mchNo.value } });
}
/** 进入编辑 */
async function startEdit() {
editing.value = true;
initSceneForm();
// 按模式加载候选
if (editLevel.value === AGGREGATE_LEVEL.METHOD) {
await loadMethodDirectory();
} else if (editLevel.value === AGGREGATE_LEVEL.DIRECT) {
await loadChannelMchCandidates();
// 对已选通道商户的场景加载能力候选
for (const sc of AGGREGATE_SCENES) {
const sd = getSceneData(sc.scene);
if (sd.channelMchNo) {
await loadCapabilityForScene(sc.scene, sd.channelMchNo);
}
}
}
}
/** 保存 */
function save() {
// 按模式校验
if (editLevel.value === AGGREGATE_LEVEL.METHOD) {
for (const sc of AGGREGATE_SCENES) {
if (!getSceneData(sc.scene).method) {
message.error(
$t('payment.merchant.aggregate.aggregate.methodPlaceholder') +
': ' +
sceneLabel(sc.scene),
);
return;
}
}
} else if (editLevel.value === AGGREGATE_LEVEL.DIRECT) {
for (const sc of AGGREGATE_SCENES) {
const sd = getSceneData(sc.scene);
if (!sd.channelMchNo) {
message.error(
$t('payment.merchant.aggregate.aggregate.channelMerchantPlaceholder') +
': ' +
sceneLabel(sc.scene),
);
return;
}
}
}
confirm({
content: $t('payment.merchant.aggregate.aggregate.saveConfirm'),
async onOk() {
const scenes =
editLevel.value === AGGREGATE_LEVEL.AUTO
? []
: Object.values(sceneForm.value);
await AggregateConfigApi.saveOrUpdate({
mchNo: mchNo.value,
appId: appId.value,
level: editLevel.value,
autoLaunch: autoLaunch.value,
scenes,
});
message.success($t('common.operationSuccess'));
editing.value = false;
await loadConfig();
},
});
}
/** 取消编辑 */
function cancel() {
editing.value = false;
initSceneForm();
autoLaunch.value = config.value.autoLaunch || false;
editLevel.value = effectiveLevel.value as AggregateLevel;
}
// 编辑模式下切换模式时加载候选
watch(editLevel, async (level, prev) => {
if (!editing.value || level === prev) return;
if (level === AGGREGATE_LEVEL.METHOD) {
await loadMethodDirectory();
} else if (level === AGGREGATE_LEVEL.DIRECT) {
await loadChannelMchCandidates();
}
});
/** DIRECT 模式: 通道商户变更时加载能力候选并清空旧值 */
async function onChannelMchChange(scene: string, channelMchNo: any) {
const sd = getSceneData(scene);
sd.channelMchNo = channelMchNo || '';
sd.capability = '';
if (channelMchNo) {
await loadCapabilityForScene(scene, channelMchNo);
} else {
capabilityMap.value = { ...capabilityMap.value, [scene]: [] };
}
}
onMounted(async () => {
if (!routeContext.isValid.value) return;
await loadAppInfo();
await loadConfig();
});
</script>
<template>
<RouteQueryMissingState
v-if="!routeContext.isValid"
:description="$t('payment.merchant.aggregate.aggregate.missingAppContext')"
:back-text="$t('payment.merchant.workbench.workbench.backToList')"
@back="routeContext.goFallback"
/>
<div v-else class="m-4">
<a-card variant="borderless" class="rounded-xl shadow-sm">
<template #title>
<div class="flex items-center gap-2">
<a-button type="text" @click="handleBack">
<template #icon>
<IconifyIcon icon="ant-design:arrow-left-outlined" />
</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>
</div>
</template>
<template #extra>
<div class="flex gap-2">
<a-button v-if="!editing" type="primary" @click="startEdit">
{{ $t('common.edit') }}
</a-button>
<template v-else>
<a-button type="primary" @click="save">{{ $t('common.save') }}</a-button>
<a-button @click="cancel">{{ $t('common.cancel') }}</a-button>
</template>
</div>
</template>
<a-spin :spinning="loading">
<!-- 模式切换 -->
<div class="mb-4 flex items-center gap-3">
<span class="text-sm font-medium">{{ $t('payment.merchant.aggregate.aggregate.editModeLabel') }}</span>
<a-radio-group v-model:value="editLevel" button-style="solid" :disabled="!editing">
<a-radio-button :value="AGGREGATE_LEVEL.AUTO">
{{ $t('payment.merchant.aggregate.aggregate.modeAuto') }}
</a-radio-button>
<a-radio-button :value="AGGREGATE_LEVEL.METHOD">
{{ $t('payment.merchant.aggregate.aggregate.modeMethod') }}
</a-radio-button>
<a-radio-button :value="AGGREGATE_LEVEL.DIRECT">
{{ $t('payment.merchant.aggregate.aggregate.modeDirect') }}
</a-radio-button>
</a-radio-group>
<a-tag v-if="isLevelActive && !editing" color="green">
{{ $t('payment.merchant.aggregate.aggregate.levelActive') }}
</a-tag>
</div>
<!-- 模式提示 -->
<a-alert :message="modeHint" type="info" show-icon class="mb-4" />
<!-- 自动拉起 -->
<div class="mb-4">
<a-checkbox v-model:checked="autoLaunch" :disabled="!editing">
{{ $t('payment.merchant.aggregate.aggregate.autoLaunch') }}
</a-checkbox>
<span class="ml-2 text-xs text-muted-foreground">
{{ $t('payment.merchant.aggregate.aggregate.autoLaunchHint') }}
</span>
</div>
<!-- 场景配置 -->
<div class="scene-list">
<div v-for="sc in AGGREGATE_SCENES" :key="sc.scene" class="scene-row">
<div class="scene-label">
<span class="font-medium">{{ sceneLabel(sc.scene) }}</span>
</div>
<!-- AUTO 模式: 只读提示 -->
<div v-if="editLevel === AGGREGATE_LEVEL.AUTO" class="scene-content">
<a-tag color="blue">
{{ $t('payment.merchant.aggregate.aggregate.autoDerived') }}: {{ sc.defaultMethod }}
</a-tag>
</div>
<!-- METHOD 模式: 选支付方式 -->
<div v-else-if="editLevel === AGGREGATE_LEVEL.METHOD" class="scene-content">
<a-select
:value="getSceneData(sc.scene).method"
:options="methodOptions(sc.provider)"
:placeholder="$t('payment.merchant.aggregate.aggregate.methodPlaceholder')"
:disabled="!editing"
allow-clear
style="width: 240px"
@change="(val: any) => (getSceneData(sc.scene).method = val)"
/>
</div>
<!-- DIRECT 模式: 选通道商户 + 能力 -->
<div v-else class="scene-content direct-content">
<a-select
:value="getSceneData(sc.scene).channelMchNo"
:options="channelMchOptions(sc.scene)"
:placeholder="$t('payment.merchant.aggregate.aggregate.channelMerchantPlaceholder')"
:disabled="!editing"
allow-clear
style="width: 240px"
@change="(val: any) => onChannelMchChange(sc.scene, val)"
/>
<a-select
:value="getSceneData(sc.scene).capability"
:options="capabilityOptions(sc.scene)"
:placeholder="$t('payment.merchant.aggregate.aggregate.capabilityPlaceholder')"
:disabled="!editing || !getSceneData(sc.scene).channelMchNo"
allow-clear
style="width: 240px"
@change="(val: any) => (getSceneData(sc.scene).capability = val)"
/>
</div>
</div>
</div>
</a-spin>
</a-card>
</div>
</template>
<style scoped>
.scene-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
background: hsl(var(--muted) / 0.3);
border-radius: 12px;
}
.scene-row {
display: flex;
align-items: center;
gap: 16px;
}
.scene-label {
flex-shrink: 0;
width: 100px;
}
.scene-content {
display: flex;
align-items: center;
gap: 12px;
}
.direct-content {
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,34 @@
// 聚合扫码配置深度(与后端 AggregateConfigLevelEnum 对齐)
export const AGGREGATE_LEVEL = {
AUTO: 'auto',
METHOD: 'method',
DIRECT: 'direct',
} as const;
export type AggregateLevel = (typeof AGGREGATE_LEVEL)[keyof typeof AGGREGATE_LEVEL];
/**
* 聚合扫码场景定义
* scene: 后端 CashierSceneEnum 编码
* provider: 对应的支付渠道PayProviderEnum用于过滤支付方式候选
* defaultMethod: L1 自动推导的默认支付方式(与后端 SceneMethodDefaultResolver 对齐)
*/
export interface AggregateSceneConfig {
scene: string;
provider: string;
defaultMethod: string;
}
export const AGGREGATE_SCENES: AggregateSceneConfig[] = [
{ scene: 'wechat_pay', provider: 'wechat', defaultMethod: 'wechat_jsapi' },
{ scene: 'alipay', provider: 'alipay', defaultMethod: 'alipay_jsapi' },
{ scene: 'union_pay', provider: 'union_pay', defaultMethod: 'union_jsapi' },
{ scene: 'douyin', provider: 'douyin', defaultMethod: 'douyin_jsapi' },
];
// 模式显示名
export const LEVEL_DISPLAY: Record<AggregateLevel, string> = {
auto: '自动模式',
method: '方式模式',
direct: '精确模式',
};

View File

@@ -40,6 +40,14 @@
});
}
/** 进入聚合扫码配置 */
function goAggregateScan() {
router.push({
path: '/payment/merchant/aggregate',
query: { mchNo: props.mchNo, appId: props.record.appId },
});
}
/**
* 更多操作菜单
*/
@@ -117,6 +125,21 @@
</template>
</a-button>
</a-tooltip>
<a-tooltip
v-if="hasPermission(PermCodes.Merchant.GatewayAggregate.VIEW)"
:title="$t('payment.merchant.app.app.aggregateScan')"
>
<a-button
type="text"
size="small"
class="!h-6 !w-6 !min-w-6 !p-0 !text-muted-foreground hover:!text-primary"
@click="goAggregateScan"
>
<template #icon>
<IconifyIcon icon="ant-design:qrcode-outlined" class="text-sm" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="hasPermission(PermCodes.Merchant.App.MANAGE)" :title="$t('payment.merchant.app.app.edit')">
<a-button
type="text"