refactor(admin): 码牌列表优化并将表单手写校验统一为 form rules

- 码牌列表合并金额类型/固定金额列, 编码链接查看码牌
- 码牌编辑/批量创建固定金额绑定 formState 并用 rules 校验
- 退款/渠道授权/令牌/协议复制/交易调试私钥改为 form 校验
This commit is contained in:
DaxPay Dev
2026-07-10 22:43:00 +08:00
parent 9eb98d64f6
commit d573a03a12
8 changed files with 193 additions and 120 deletions

View File

@@ -25,8 +25,14 @@
const channelMchNo = ref('');
// 服务商通道商户配置(用于展示当前令牌 / 子商户号)
const isvConfig = ref<AlipayIsvChannelMerchantConfig>({});
// 新令牌输入
const newAuthToken = ref('');
// 手动设置表单
const formRef = ref();
const formState = ref({ appAuthToken: '' });
const formRules = {
appAuthToken: [
{ required: true, whitespace: true, message: $t('payment.merchant.channelMerchant.appAuthTokenRequired') },
],
};
// 当前 Tab: manual | agent
const activeTab = ref<'agent' | 'manual'>('manual');
// 代运营授权链接
@@ -67,7 +73,7 @@
/** 打开抽屉 */
function open(mchNo: string) {
channelMchNo.value = mchNo;
newAuthToken.value = '';
formState.value = { appAuthToken: '' };
authUrl.value = '';
callbackUrl.value = '';
activeTab.value = 'manual';
@@ -82,9 +88,11 @@
}
/** 保存新令牌(二次确认后提交) */
function handleSave() {
if (!newAuthToken.value.trim()) {
message.warning($t('payment.merchant.channelMerchant.appAuthTokenRequired'));
async function handleSave() {
try {
await formRef.value?.validate();
} catch {
// 校验失败: 表单已显示错误提示
return;
}
confirm({
@@ -96,7 +104,7 @@
saving.value = true;
return AlipayIsvChannelMerchantApi.updateAppAuthToken({
channelMchNo: channelMchNo.value,
appAuthToken: newAuthToken.value.trim(),
appAuthToken: formState.value.appAuthToken.trim(),
})
.then(() => {
message.success($t('payment.merchant.channelMerchant.appAuthTokenUpdateSuccess'));
@@ -177,7 +185,7 @@
<a-tabs v-model:active-key="activeTab" class="auth-token-tabs">
<!-- 国际化手动设置 -->
<a-tab-pane key="manual" :tab="$t('payment.merchant.channelMerchant.tabManualToken')">
<a-form layout="vertical" class="pt-2">
<a-form ref="formRef" layout="vertical" class="pt-2" :model="formState" :rules="formRules">
<!-- 国际化当前令牌 -->
<a-form-item :label="$t('payment.merchant.channelMerchant.appAuthTokenCurrent')">
<div class="current-token-value">
@@ -185,9 +193,9 @@
</div>
</a-form-item>
<!-- 国际化新令牌 -->
<a-form-item :label="$t('payment.merchant.channelMerchant.appAuthTokenNew')">
<a-form-item :label="$t('payment.merchant.channelMerchant.appAuthTokenNew')" name="appAuthToken">
<a-input
v-model:value="newAuthToken"
v-model:value="formState.appAuthToken"
:placeholder="$t('payment.merchant.channelMerchant.appAuthTokenNewPlaceholder')"
allow-clear
/>

View File

@@ -36,11 +36,17 @@
const authResult = ref<AuthResult>({});
/** 微信支付表单 */
const formRef = ref();
const form = reactive({
mchNo: '',
channelMchNo: '',
capability: '',
mchNo: undefined as string | undefined,
channelMchNo: undefined as string | undefined,
capability: undefined as string | undefined,
});
const formRules = {
mchNo: [{ required: true, message: $t('payment.develop.auth.form.rule.mchNo') }],
channelMchNo: [{ required: true, message: $t('payment.develop.auth.form.rule.channelMchNo') }],
capability: [{ required: true, message: $t('payment.develop.auth.form.rule.capability') }],
};
/** 微信小程序表单(端类型: merchant 商户端 / admin 运营端) */
const wechatMiniForm = reactive({
@@ -136,8 +142,8 @@
/** 商户变更: 刷新通道商户候选 */
function merchantChange() {
form.channelMchNo = '';
form.capability = '';
form.channelMchNo = undefined;
form.capability = undefined;
channelMchNoOptions.value = [];
capabilityOptions.value = [];
if (!form.mchNo) return;
@@ -147,7 +153,7 @@
/** 通道商户变更: 重置能力并重载能力候选 */
function channelMchNoChange() {
form.capability = '';
form.capability = undefined;
capabilityOptions.value = [];
if (form.channelMchNo) {
loadCapabilityCandidates(form.channelMchNo);
@@ -181,16 +187,10 @@
}
// 微信支付: 表单校验
if (authType.value === 'wechatChannel') {
if (!form.mchNo) {
message.warning($t('payment.develop.auth.form.rule.mchNo'));
return;
}
if (!form.channelMchNo) {
message.warning($t('payment.develop.auth.form.rule.channelMchNo'));
return;
}
if (!form.capability) {
message.warning($t('payment.develop.auth.form.rule.capability'));
try {
await formRef.value?.validate();
} catch {
// 校验失败: 表单已显示错误提示
return;
}
}
@@ -303,8 +303,14 @@
<!-- 微信支付商户参数表单 -->
<div v-if="authType === 'wechatChannel'" class="form-panel">
<a-form layout="vertical" class="channel-auth-form">
<a-form-item :label="$t('payment.develop.auth.form.mchNo')">
<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"
:options="mchNoOptions"
@@ -315,7 +321,7 @@
@change="merchantChange"
/>
</a-form-item>
<a-form-item :label="$t('payment.develop.auth.form.channelMchNo')">
<a-form-item :label="$t('payment.develop.auth.form.channelMchNo')" name="channelMchNo">
<a-select
v-model:value="form.channelMchNo"
:options="channelMchNoOptions"
@@ -326,7 +332,7 @@
@change="channelMchNoChange"
/>
</a-form-item>
<a-form-item :label="$t('payment.develop.auth.form.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

@@ -30,6 +30,12 @@
// 支付模式: route=路由模式(商户+方式+应用动态匹配) direct=直传模式(通道商户+能力直接决定)
const routeMode = ref<'direct' | 'route'>('route');
// 私钥独立存储(标签+弹窗交互, 校验通过 form 自定义规则挂接)
const privateKey = ref('');
const privateKeyVisible = ref(false);
const privateKeyInput = ref('');
const loading = ref(false);
// ===== 表单校验规则(按模式动态生成) =====
const formRules = computed<Record<string, any[]>>(() => {
// 通用必填字段
@@ -38,6 +44,16 @@
bizOrderNo: [{ required: true, message: $t('payment.develop.trade.rule.bizOrderNo') }],
amount: [{ required: true, message: $t('payment.develop.trade.rule.amount') }],
title: [{ required: true, message: $t('payment.develop.trade.rule.title') }],
// 私钥存独立 ref, 用自定义校验挂到 form 上
privateKey: [
{
validator: async () => {
if (!privateKey.value) {
return Promise.reject(new Error($t('payment.develop.trade.msg.inputPrivateKey')));
}
},
},
],
};
// 路由模式: 支付方式必填
if (routeMode.value === 'route') {
@@ -62,11 +78,6 @@
capability: '',
description: '',
});
const privateKey = ref('');
const privateKeyVisible = ref(false);
const privateKeyInput = ref('');
const loading = ref(false);
const signPreviewLoading = ref(false);
// ===== 下拉选项 =====
@@ -203,6 +214,8 @@
localStorage.removeItem(PRIVATE_KEY_STORAGE_KEY);
}
privateKeyVisible.value = false;
// 清除私钥字段校验态
formRef.value?.clearValidate?.(['privateKey']);
message.success($t('payment.develop.trade.privateKey.savedTip'));
}
@@ -214,6 +227,7 @@
onOk() {
privateKey.value = '';
localStorage.removeItem(PRIVATE_KEY_STORAGE_KEY);
formRef.value?.clearValidate?.(['privateKey']);
message.success($t('payment.develop.trade.privateKey.clearedTip'));
},
});
@@ -247,8 +261,10 @@
/** 生成签名预览(内联展示, 不弹结果) */
async function handleSignPreview() {
if (!privateKey.value) {
message.warning($t('payment.develop.trade.msg.inputPrivateKey'));
try {
await formRef.value?.validateFields(['privateKey']);
} catch {
// 校验失败: 表单已显示错误提示
return;
}
signPreviewLoading.value = true;
@@ -279,17 +295,13 @@
// ===== 提交 =====
/** 发起真实支付调试 */
async function handlePay() {
// 表单字段校验
// 表单字段校验(含私钥自定义规则)
try {
await formRef.value?.validate();
} catch {
// 校验未通过,字段错误已由表单自动展示
return;
}
if (!privateKey.value) {
message.warning($t('payment.develop.trade.msg.inputPrivateKey'));
return;
}
loading.value = true;
try {
const { data } = await DevelopTradeApi.pay({
@@ -380,8 +392,8 @@
</div>
</template>
<!-- 私钥状态行 -->
<a-form-item :label="$t('payment.develop.trade.field.privateKey')" required>
<!-- 私钥状态行(校验挂 form.privateKey 自定义规则, 实际值在 privateKey ref) -->
<a-form-item :label="$t('payment.develop.trade.field.privateKey')" name="privateKey">
<div class="flex items-center gap-2">
<a-tag v-if="privateKey" color="success">
<IconifyIcon icon="ant-design:check-circle-outlined" class="mr-0.5" />

View File

@@ -290,7 +290,18 @@
@checkbox-all="handleCheckboxChange"
>
<vxe-column type="checkbox" width="50" />
<vxe-column field="code" :title="$t('payment.device.qrcode.field.code')" :min-width="200" />
<!-- 编码可点, 打开查看码牌弹窗(替代操作列单独按钮) -->
<vxe-column field="code" :title="$t('payment.device.qrcode.field.code')" :min-width="200">
<template #default="{ row }">
<a
v-if="hasPermission(PermCodes.Device.QrCode.VIEW) && row.code"
class="vben-link"
@click="handleViewCode(row)"
>{{ row.code }}</a
>
<span v-else>{{ row.code || '-' }}</span>
</template>
</vxe-column>
<vxe-column field="name" :title="$t('payment.device.qrcode.field.name')" :min-width="140" />
<vxe-column field="batchNo" :title="$t('payment.device.qrcode.field.batchNo')" :min-width="140">
<template #default="{ row }">
@@ -307,27 +318,18 @@
<a-tag v-else color="default">{{ $t('payment.device.qrcode.unbound') }}</a-tag>
</template>
</vxe-column>
<!-- 金额类型 + 固定金额合并: 固定显示金额, 自定义显示类型文案 -->
<vxe-column
field="amountType"
:title="$t('payment.device.qrcode.field.amountType')"
:min-width="110"
align="center"
>
<template #default="{ row }">
{{ $t(`payment.device.qrcode.amountType.${row.amountType}`) }}
</template>
</vxe-column>
<vxe-column
field="fixedAmount"
:title="$t('payment.device.qrcode.field.fixedAmount')"
:title="$t('payment.device.qrcode.amountLabel')"
:min-width="120"
align="right"
align="center"
>
<template #default="{ row }">
<span v-if="row.amountType === 'fixed' && row.fixedAmount">
¥{{ (row.fixedAmount / 100).toFixed(2) }}
</span>
<span v-else style="color: var(--text-color-placeholder)">-</span>
<span v-else>{{ $t(`payment.device.qrcode.amountType.${row.amountType || 'random'}`) }}</span>
</template>
</vxe-column>
<vxe-column field="status" :title="$t('payment.device.qrcode.field.status')" :min-width="100" align="center">
@@ -346,19 +348,12 @@
:min-width="180"
formatter="formatDateTime"
/>
<vxe-column fixed="right" width="220" :show-overflow="false" :title="$t('common.operation')">
<vxe-column fixed="right" width="180" :show-overflow="false" :title="$t('common.operation')">
<template #default="{ row }">
<a-space :size="2">
<template #separator>
<a-divider type="vertical" />
</template>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.VIEW)"
type="link"
size="small"
@click="handleViewCode(row)"
>{{ $t('payment.device.qrcode.viewCode') }}</a-button
>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.MANAGE)"
type="link"

View File

@@ -18,9 +18,8 @@
const visible = ref(false);
const confirmLoading = ref(false);
const formRef = ref();
// 固定金额展示值(元)
const fixedAmountYuan = ref<number | undefined>(undefined);
// formState.fixedAmount 以「元」存储, 提交时再×100转分
const formState = ref<DeviceQrCodeBatchParam>({
batchNo: '',
count: 10,
@@ -30,12 +29,26 @@
const isFixedAmount = computed(() => formState.value.amountType === 'fixed');
const formRules = computed(() => ({
batchNo: [{ required: true, message: $t('payment.device.qrcode.validateBatchNo') }],
count: [{ required: true, message: $t('payment.device.qrcode.validateCount') }],
amountType: [{ required: true, message: $t('payment.device.qrcode.validateAmountType') }],
status: [{ required: true, message: $t('payment.device.qrcode.validateStatus') }],
}));
const formRules = computed(() => {
const rules: Record<string, any[]> = {
batchNo: [{ required: true, message: $t('payment.device.qrcode.validateBatchNo') }],
count: [{ required: true, message: $t('payment.device.qrcode.validateCount') }],
amountType: [{ required: true, message: $t('payment.device.qrcode.validateAmountType') }],
status: [{ required: true, message: $t('payment.device.qrcode.validateStatus') }],
};
// 固定金额类型时金额必填
if (isFixedAmount.value) {
rules.fixedAmount = [
{ required: true, message: $t('payment.device.qrcode.validateFixedAmount') },
{
type: 'number',
min: 0.01,
message: $t('payment.device.qrcode.validateFixedAmount'),
},
];
}
return rules;
});
/**
* 一键生成批次号: Q + YYMMDDHHmmss(对齐商业版)
@@ -56,8 +69,8 @@
count: 10,
amountType: 'random',
status: 'enabled',
fixedAmount: undefined,
};
fixedAmountYuan.value = undefined;
visible.value = true;
await nextTick();
formRef.value?.clearValidate?.();
@@ -95,18 +108,14 @@
} catch {
return;
}
if (isFixedAmount.value && (fixedAmountYuan.value === undefined || fixedAmountYuan.value <= 0)) {
message.error($t('payment.device.qrcode.validateFixedAmount'));
return;
}
confirmLoading.value = true;
try {
const payload: DeviceQrCodeBatchParam = {
...formState.value,
batchNo: formState.value.batchNo?.trim(),
fixedAmount:
isFixedAmount.value && fixedAmountYuan.value
? Math.round(fixedAmountYuan.value * 100)
isFixedAmount.value && formState.value.fixedAmount
? Math.round(formState.value.fixedAmount * 100)
: undefined,
};
// 再校验一次批次号
@@ -182,9 +191,10 @@
<a-radio-button value="fixed">{{ $t('payment.device.qrcode.amountType.fixed') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item v-if="isFixedAmount" :label="$t('payment.device.qrcode.field.fixedAmount')">
<!-- 固定金额(仅固定金额类型显示, 表单内以元存储) -->
<a-form-item v-if="isFixedAmount" :label="$t('payment.device.qrcode.field.fixedAmount')" name="fixedAmount">
<a-input-number
v-model:value="fixedAmountYuan"
v-model:value="formState.fixedAmount"
:min="0.01"
:step="0.01"
:precision="2"

View File

@@ -35,6 +35,10 @@
amountType: 'random',
});
// 金额类型为固定时, 固定金额必填
const isFixedAmount = computed(() => formState.value.amountType === 'fixed');
// formState.fixedAmount 以「元」存储, 提交时再×100转分
const formRules = computed(() => {
const rules: Record<string, any[]> = {
name: [{ required: true, message: $t('payment.device.qrcode.validateName') }],
@@ -44,15 +48,20 @@
if (!isEdit.value) {
rules.mchNo = [{ required: true, message: $t('payment.device.qrcode.validateMchNo') }];
}
// 固定金额类型时金额必填
if (isFixedAmount.value) {
rules.fixedAmount = [
{ required: true, message: $t('payment.device.qrcode.validateFixedAmount') },
{
type: 'number',
min: 0.01,
message: $t('payment.device.qrcode.validateFixedAmount'),
},
];
}
return rules;
});
// 金额类型为固定时, 固定金额必填
const isFixedAmount = computed(() => formState.value.amountType === 'fixed');
// 固定金额展示值(元), 提交时转回分
const fixedAmountYuan = ref<number | undefined>(undefined);
/**
* 加载商户下拉
*/
@@ -81,8 +90,8 @@
name: '',
mchNo: '',
amountType: 'random',
fixedAmount: undefined,
};
fixedAmountYuan.value = undefined;
appOptions.value = [];
formRef.value?.resetFields();
}
@@ -112,10 +121,10 @@
mchNo: row.mchNo,
appId: row.appId,
amountType: row.amountType,
fixedAmount: row.fixedAmount,
// 分转元展示
fixedAmount: row.fixedAmount ? row.fixedAmount / 100 : undefined,
remark: row.remark,
};
fixedAmountYuan.value = row.fixedAmount ? row.fixedAmount / 100 : undefined;
} finally {
confirmLoading.value = false;
}
@@ -131,17 +140,15 @@
// 校验失败: 表单已显示错误提示
return;
}
// 固定金额校验
if (isFixedAmount.value && (fixedAmountYuan.value === undefined || fixedAmountYuan.value <= 0)) {
message.error($t('payment.device.qrcode.validateFixedAmount'));
return;
}
confirmLoading.value = true;
try {
// 金额元转分, 在构造时一次性赋值避免对对象立即修改
const payload: DeviceQrCodeParam = {
...formState.value,
fixedAmount: isFixedAmount.value && fixedAmountYuan.value ? Math.round(fixedAmountYuan.value * 100) : undefined,
fixedAmount:
isFixedAmount.value && formState.value.fixedAmount
? Math.round(formState.value.fixedAmount * 100)
: undefined,
};
await (formEditType.value === FormEditType.Edit ? DeviceQrCodeApi.update(payload) : DeviceQrCodeApi.add(payload));
message.success($t('common.operationSuccess'));
@@ -218,10 +225,10 @@
<a-radio-button value="fixed">{{ $t('payment.device.qrcode.amountType.fixed') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<!-- 固定金额(仅固定金额类型显示) -->
<!-- 固定金额(仅固定金额类型显示, 表单内以元存储) -->
<a-form-item v-if="isFixedAmount" :label="$t('payment.device.qrcode.field.fixedAmount')" name="fixedAmount">
<a-input-number
v-model:value="fixedAmountYuan"
v-model:value="formState.fixedAmount"
:min="0.01"
:step="0.01"
:precision="2"

View File

@@ -45,11 +45,30 @@
const refundVisible = ref(false);
const refundLoading = ref(false);
const refundFetching = ref(false);
const refundFormRef = ref();
// refundForm.amount 以「元」存储, 提交时再×100转分
const refundForm = ref<{ amount: number; orderNo?: string; reason?: string }>({ amount: 0, reason: '' });
const refundForm = ref<{ amount?: number; orderNo?: string; reason?: string }>({ amount: undefined, reason: '' });
const refundRow = ref<NormalOrderResult | null>(null);
// 可退金额(元), 作为退款金额输入框上限
const refundableYuan = computed(() => (refundRow.value?.refundableBalance ?? 0) / 100);
// 退款表单校验(走 form rules, 不手写 message)
const refundRules = computed(() => ({
amount: [
{ required: true, message: $t('payment.order.action.refundAmountPlaceholder') },
{
type: 'number',
min: 0.01,
message: $t('payment.order.action.refundAmountPlaceholder'),
},
{
validator: async (_rule: unknown, value: number) => {
if (value != null && value > refundableYuan.value) {
return Promise.reject(new Error($t('payment.order.action.refundAmountExceed')));
}
},
},
],
}));
// 业务状态下拉
const statusOptions = computed(() =>
@@ -291,23 +310,27 @@
/**
* 提交退款
*/
function submitRefund() {
if (!refundRow.value) return;
// 校验退款金额(元)不能超过可退金额(元)
if (refundForm.value.amount > refundableYuan.value) {
message.error($t('payment.order.action.refundAmountExceed'));
async function submitRefund() {
if (!refundRow.value) {
return;
}
try {
await refundFormRef.value?.validate();
} catch {
// 校验失败: 表单已显示错误提示; 拒绝以阻止 modal 关闭
return Promise.reject();
}
// 元转分提交
const amountYuan = refundForm.value.amount ?? 0;
const param: PayRefundParam = {
orderNo: refundForm.value.orderNo,
bizOrderNo: refundRow.value.bizOrderNo,
amount: Math.round(refundForm.value.amount * 100),
amount: Math.round(amountYuan * 100),
reason: refundForm.value.reason,
};
confirm({
title: $t('payment.order.action.refundConfirmTitle'),
content: $t('payment.order.action.refundConfirmContent', { amount: refundForm.value.amount.toFixed(2) }),
content: $t('payment.order.action.refundConfirmContent', { amount: amountYuan.toFixed(2) }),
onOk() {
refundLoading.value = true;
return RefundOrderApi.refund(param)
@@ -564,8 +587,8 @@
@cancel="handleRefundClose"
>
<a-spin :spinning="refundFetching">
<a-form :label-col="{ span: 6 }">
<a-form-item :label="$t('payment.order.action.refundAmountLabel')">
<a-form ref="refundFormRef" :model="refundForm" :rules="refundRules" :label-col="{ span: 6 }">
<a-form-item :label="$t('payment.order.action.refundAmountLabel')" name="amount">
<a-input-number
v-model:value="refundForm.amount"
:min="0.01"

View File

@@ -74,9 +74,13 @@
// 复制到其他端弹窗
const copyVisible = ref(false);
const copyFormRef = ref();
const copySourceId = ref<string>('');
const copySourceClientType = ref<string>('');
const copyTargetType = ref<string>('');
const copyForm = ref<{ clientType?: string }>({ clientType: undefined });
const copyRules = {
clientType: [{ required: true, message: $t('system.protocol.selectClientType') }],
};
onMounted(() => {
xTable.value?.connectToolbar(xToolbar.value as VxeToolbarInstance);
@@ -213,17 +217,19 @@
function handleCopy(row: any) {
copySourceId.value = row.id;
copySourceClientType.value = row.clientType;
copyTargetType.value = '';
copyForm.value = { clientType: undefined };
copyVisible.value = true;
}
/** 确认复制 */
function confirmCopy() {
if (!copyTargetType.value) {
message.warning($t('system.protocol.selectClientType'));
return;
async function confirmCopy() {
try {
await copyFormRef.value?.validate();
} catch {
// 校验失败: 表单已显示错误提示; 拒绝以阻止 modal 关闭
return Promise.reject();
}
UserProtocolApi.copyToClient(copySourceId.value, copyTargetType.value).then(() => {
return UserProtocolApi.copyToClient(copySourceId.value, copyForm.value.clientType!).then(() => {
message.success($t('common.operationSuccess'));
copyVisible.value = false;
queryPage();
@@ -352,10 +358,16 @@
:cancel-text="$t('common.cancelText')"
@ok="confirmCopy"
>
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item :label="$t('system.protocol.clientType')">
<a-form
ref="copyFormRef"
:model="copyForm"
:rules="copyRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item :label="$t('system.protocol.clientType')" name="clientType">
<a-select
v-model:value="copyTargetType"
v-model:value="copyForm.clientType"
:options="clientTypeOptions.filter((i) => i.value !== copySourceClientType)"
:placeholder="$t('system.protocol.selectClientType')"
/>