feat(admin): 码牌管理页(批量生成/绑定商户/商户列优化)

批量生成批次号布局修复;绑定商户去掉应用选择;列表主显商户名悬停看商户号。
This commit is contained in:
DaxPay Dev
2026-07-10 16:50:47 +08:00
parent 3a54393293
commit f4a49fd7ae
8 changed files with 1306 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
import type { BaseEntity, PageResult, Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 支付码牌 API
*/
export const DeviceQrCodeApi = {
/**
* 分页查询码牌
*/
page(
params: DeviceQrCodeQuery & { current?: number; size?: number },
): Promise<Result<PageResult<DeviceQrCodeResult>>> {
return defHttp.get({ url: '/admin/device/qrcode/page', params });
},
/**
* 根据 id 查询码牌
*/
get(id: string): Promise<Result<DeviceQrCodeResult>> {
return defHttp.get({ url: '/admin/device/qrcode/get', params: { id } });
},
/**
* 新增码牌(快捷绑定商户)
*/
add(data: DeviceQrCodeParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/add', data });
},
/**
* 批量创建空白码牌
*/
createBatch(data: DeviceQrCodeBatchParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/create-batch', data });
},
/**
* 判断批次号是否已存在
*/
existsByBatchNo(batchNo: string): Promise<Result<boolean>> {
return defHttp.get({ url: '/admin/device/qrcode/exists-by-batch-no', params: { batchNo } });
},
/**
* 批量绑定商户
*/
bindMerchant(data: DeviceQrCodeBindMerchantParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/bind-merchant', data });
},
/**
* 批量解绑商户
*/
unbindMerchant(ids: string[]): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/unbind-merchant', data: ids });
},
/**
* 修改码牌
*/
update(data: DeviceQrCodeParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/update', data });
},
/**
* 删除码牌
*/
delete(id: string): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/delete', params: { id } });
},
/**
* 修改码牌状态(启用/停用)
*/
changeStatus(id: string, status: string): Promise<Result<void>> {
return defHttp.post({ url: '/admin/device/qrcode/change-status', params: { id, status } });
},
};
/** 支付码牌查询参数 */
export interface DeviceQrCodeQuery {
/** 码牌编码 */
code?: string;
/** 码牌名称 */
name?: string;
/** 批次号 */
batchNo?: string;
/** 商户号 */
mchNo?: string;
/** 金额类型 random/fixed */
amountType?: string;
/** 状态 enabled/disabled */
status?: string;
}
/** 支付码牌参数 */
export interface DeviceQrCodeParam {
/** 主键 */
id?: string;
/** 码牌名称 */
name?: string;
/** 商户号 */
mchNo?: string;
/** 关联应用号(空=商户默认应用) */
appId?: string;
/** 金额类型 random/fixed */
amountType?: string;
/** 固定金额(分) */
fixedAmount?: number;
/** 状态 */
status?: string;
/** 备注 */
remark?: string;
}
/** 批量创建空白码牌参数 */
export interface DeviceQrCodeBatchParam {
/** 批次号 */
batchNo?: string;
/** 创建数量 1-999 */
count?: number;
/** 码牌名称 */
name?: string;
/** 金额类型 random/fixed */
amountType?: string;
/** 固定金额(分) */
fixedAmount?: number;
/** 状态 enabled/disabled, 空则默认启用 */
status?: string;
/** 备注 */
remark?: string;
}
/** 绑定商户参数 */
export interface DeviceQrCodeBindMerchantParam {
/** 码牌主键列表 */
ids: string[];
/** 商户号 */
mchNo: string;
/** 关联应用号 */
appId?: string;
}
/** 支付码牌结果 */
export interface DeviceQrCodeResult extends BaseEntity {
/** 码牌编码 */
code?: string;
/** 码牌名称 */
name?: string;
/** 批次号 */
batchNo?: string;
/** 商户号 */
mchNo?: string;
/** 商户名称(由 mchNo 翻译) */
mchName?: string;
/** 关联应用号(空=商户默认应用) */
appId?: string;
/** 金额类型 random/fixed */
amountType?: string;
/** 固定金额(分) */
fixedAmount?: number;
/** 状态 enabled/disabled */
status?: string;
/** 备注 */
remark?: string;
}

View File

@@ -218,6 +218,11 @@ export const PermCodes = {
VIEW: 'device:speaker:view',
MANAGE: 'device:speaker:manage',
},
/** 码牌 menuCode=device:qrcode */
QrCode: {
VIEW: 'device:qrcode:view',
MANAGE: 'device:qrcode:manage',
},
/** 云打印 menuCode=device:printer */
Printer: {
VIEW: 'device:printer:view',

View File

@@ -0,0 +1,57 @@
{
"title": "QR Code Board",
"field": {
"code": "Code",
"name": "Name",
"batchNo": "Batch No.",
"count": "Count",
"mchNo": "Merchant No.",
"mchName": "Merchant Name",
"merchant": "Merchant",
"appId": "Application",
"amountType": "Amount Type",
"fixedAmount": "Fixed Amount",
"status": "Status",
"remark": "Remark",
"createTime": "Create Time"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"amountType": {
"random": "Custom Amount",
"fixed": "Fixed Amount"
},
"defaultApp": "Default App",
"unbound": "Unbound",
"mchNoTooltip": "Merchant No.: {mchNo}",
"batchCreate": "Batch Create",
"bindMerchant": "Bind Merchant",
"unbindMerchant": "Unbind Merchant",
"genBatchNo": "Generate",
"batchNoPlaceholder": "Click Generate; used as code prefix",
"batchNoExists": "Batch number already exists",
"batchCreateSuccess": "Successfully created {count} blank QR codes",
"selectRequired": "Please select QR code boards first",
"validateName": "Please enter the name",
"validateMchNo": "Please select a merchant",
"validateBatchNo": "Please enter the batch number",
"validateCount": "Please enter the count",
"validateAmountType": "Please select an amount type",
"validateFixedAmount": "Please enter the fixed amount",
"validateStatus": "Please select a status",
"pleaseSelectMch": "Please select a merchant",
"pleaseSelectApp": "Leave empty to use the merchant default app",
"enable": "Enable",
"disable": "Disable",
"confirmDelete": "Are you sure to delete this QR code board?",
"confirmEnable": "Are you sure to enable this QR code board?",
"confirmDisable": "Are you sure to disable this QR code board? Payment via scan will be unavailable.",
"confirmUnbind": "Unbind selected boards from merchants? They will return to blank inventory.",
"viewCode": "View Code",
"qrCodeTitle": "QR Code",
"qrCodeTip": "Scan to enter the payment page",
"download": "Download QR Code",
"amountLabel": "Amount"
}

View File

@@ -0,0 +1,57 @@
{
"title": "支付码牌",
"field": {
"code": "码牌编码",
"name": "码牌名称",
"batchNo": "批次号",
"count": "创建数量",
"mchNo": "商户号",
"mchName": "商户名称",
"merchant": "商户",
"appId": "关联应用",
"amountType": "金额类型",
"fixedAmount": "固定金额",
"status": "状态",
"remark": "备注",
"createTime": "创建时间"
},
"status": {
"enabled": "启用",
"disabled": "停用"
},
"amountType": {
"random": "自定义金额",
"fixed": "固定金额"
},
"defaultApp": "默认应用",
"unbound": "未绑定",
"mchNoTooltip": "商户号: {mchNo}",
"batchCreate": "批量生成",
"bindMerchant": "绑定商户",
"unbindMerchant": "解绑商户",
"genBatchNo": "生成",
"batchNoPlaceholder": "可点生成, 将作为码牌编码前缀",
"batchNoExists": "批次号已存在",
"batchCreateSuccess": "成功创建 {count} 张空白码牌",
"selectRequired": "请先勾选码牌",
"validateName": "请输入码牌名称",
"validateMchNo": "请选择商户",
"validateBatchNo": "请输入批次号",
"validateCount": "请输入创建数量",
"validateAmountType": "请选择金额类型",
"validateFixedAmount": "请输入固定金额",
"validateStatus": "请选择状态",
"pleaseSelectMch": "请选择商户",
"pleaseSelectApp": "不选则使用商户默认应用",
"enable": "启用",
"disable": "停用",
"confirmDelete": "确定要删除该码牌吗?",
"confirmEnable": "确定要启用该码牌吗?",
"confirmDisable": "确定要停用该码牌吗?扫码将无法支付。",
"confirmUnbind": "确定要解绑所选码牌的商户吗?解绑后将回到空白库存。",
"viewCode": "查看码牌",
"qrCodeTitle": "码牌二维码",
"qrCodeTip": "扫码即可进入支付页面",
"download": "下载二维码",
"amountLabel": "金额"
}

View File

@@ -0,0 +1,452 @@
<script lang="ts" setup>
import type { VxeTableInstance, VxeToolbarInstance } from 'vxe-table';
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { DeviceQrCodeApi, type DeviceQrCodeResult } from '#/api/payment/device/qrcode.api';
import { BQuery, type QueryField } from '#/components/query';
import { PermCodes } from '#/constants/perm-codes';
import { useMessage } from '#/hooks/useMessage';
import { usePermission } from '#/hooks/usePermission';
import DeviceQrCodeBatchCreate from './DeviceQrCodeBatchCreate.vue';
import DeviceQrCodeBindMerchant from './DeviceQrCodeBindMerchant.vue';
import DeviceQrCodeEdit from './DeviceQrCodeEdit.vue';
defineOptions({ name: 'DeviceQrCode' });
const { confirm, message } = useMessage();
const { hasPermission } = usePermission();
const loading = ref(false);
const xTable = ref<VxeTableInstance>();
const xToolbar = ref<VxeToolbarInstance>();
const editRef = ref<InstanceType<typeof DeviceQrCodeEdit>>();
const batchCreateRef = ref<InstanceType<typeof DeviceQrCodeBatchCreate>>();
const bindMerchantRef = ref<InstanceType<typeof DeviceQrCodeBindMerchant>>();
const queryForm = ref<Record<string, any>>({});
// 勾选行
const selectedRows = ref<DeviceQrCodeResult[]>([]);
const queryFields = computed<QueryField[]>(() => [
{
type: 'string',
field: 'mchNo',
name: $t('payment.device.qrcode.field.mchNo'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'string',
field: 'batchNo',
name: $t('payment.device.qrcode.field.batchNo'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'string',
field: 'code',
name: $t('payment.device.qrcode.field.code'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'string',
field: 'name',
name: $t('payment.device.qrcode.field.name'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'list',
field: 'amountType',
name: $t('payment.device.qrcode.field.amountType'),
placeholder: $t('common.pleaseSelect'),
selectList: [
{ label: $t('payment.device.qrcode.amountType.random'), value: 'random' },
{ label: $t('payment.device.qrcode.amountType.fixed'), value: 'fixed' },
],
},
{
type: 'list',
field: 'status',
name: $t('payment.device.qrcode.field.status'),
placeholder: $t('common.pleaseSelect'),
selectList: [
{ label: $t('payment.device.qrcode.status.enabled'), value: 'enabled' },
{ label: $t('payment.device.qrcode.status.disabled'), value: 'disabled' },
],
},
]);
const pageConfig = ref({
currentPage: 1,
pageSize: 10,
total: 0,
});
const tableData = ref<DeviceQrCodeResult[]>([]);
// 查看码牌弹窗
const codeVisible = ref(false);
const currentCode = ref('');
/**
* 同步勾选行
*/
function handleCheckboxChange() {
selectedRows.value = (xTable.value?.getCheckboxRecords() || []) as DeviceQrCodeResult[];
}
/**
* 分页查询码牌列表
*/
function queryPage() {
loading.value = true;
DeviceQrCodeApi.page({
current: pageConfig.value.currentPage,
size: pageConfig.value.pageSize,
...queryForm.value,
})
.then((res: any) => {
tableData.value = res.data.records || [];
pageConfig.value.total = Number(res.data.total) || 0;
selectedRows.value = [];
loading.value = false;
})
.catch(() => {
loading.value = false;
});
}
function resetQuery() {
queryForm.value = {};
pageConfig.value.currentPage = 1;
queryPage();
}
function handlePageChange({ currentPage, pageSize }: any) {
pageConfig.value.currentPage = currentPage;
pageConfig.value.pageSize = pageSize;
queryPage();
}
onMounted(() => {
xTable.value?.connectToolbar(xToolbar.value as VxeToolbarInstance);
queryPage();
});
function handleAdd() {
editRef.value?.show();
}
function handleBatchCreate() {
batchCreateRef.value?.show();
}
function handleEdit(row: DeviceQrCodeResult) {
editRef.value?.showEdit(row);
}
/**
* 获取勾选 id, 无勾选时提示
*/
function getSelectedIds(): null | string[] {
const ids = selectedRows.value.map((row) => row.id!).filter(Boolean);
if (ids.length === 0) {
message.warning($t('payment.device.qrcode.selectRequired'));
return null;
}
return ids;
}
/**
* 批量绑定商户
*/
function handleBindMerchant() {
const ids = getSelectedIds();
if (!ids) {
return;
}
bindMerchantRef.value?.show(ids);
}
/**
* 批量解绑商户
*/
function handleUnbindMerchant() {
const ids = getSelectedIds();
if (!ids) {
return;
}
confirm({
content: $t('payment.device.qrcode.confirmUnbind'),
onOk() {
return DeviceQrCodeApi.unbindMerchant(ids).then(() => {
message.success($t('common.operationSuccess'));
queryPage();
});
},
});
}
/**
* 删除码牌
*/
function handleDelete(row: DeviceQrCodeResult) {
confirm({
content: $t('payment.device.qrcode.confirmDelete'),
onOk() {
return DeviceQrCodeApi.delete(row.id!).then(() => {
message.success($t('common.operationSuccess'));
queryPage();
});
},
});
}
/**
* 修改状态(启用/停用)
*/
function handleChangeStatus(row: DeviceQrCodeResult, target: 'disabled' | 'enabled') {
confirm({
content:
target === 'enabled' ? $t('payment.device.qrcode.confirmEnable') : $t('payment.device.qrcode.confirmDisable'),
onOk() {
return DeviceQrCodeApi.changeStatus(row.id!, target).then(() => {
message.success($t('common.operationSuccess'));
queryPage();
});
},
});
}
/**
* 查看码牌编码
*/
function handleViewCode(row: DeviceQrCodeResult) {
currentCode.value = row.code || '';
codeVisible.value = true;
}
/**
* 复制码牌编码
*/
async function handleCopyCode() {
try {
await navigator.clipboard.writeText(currentCode.value);
message.success($t('common.operationSuccess'));
} catch {
message.error($t('common.operationFail'));
}
}
// import 放最后避免循环依赖(参照项目惯例)
</script>
<template>
<div class="m-3 p-3 bg-background rounded-lg list-page-compact">
<a-card>
<BQuery :fields="queryFields" :query-params="queryForm" @query="queryPage" @reset="resetQuery" />
</a-card>
<div class="mt-4">
<a-card>
<vxe-toolbar ref="xToolbar" custom refresh :refresh-options="{ queryMethod: queryPage }">
<template #buttons>
<a-space>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.MANAGE)"
type="primary"
@click="handleBatchCreate"
>{{ $t('payment.device.qrcode.batchCreate') }}</a-button
>
<a-button v-if="hasPermission(PermCodes.Device.QrCode.MANAGE)" @click="handleAdd">{{
$t('common.add')
}}</a-button>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.MANAGE)"
:disabled="selectedRows.length === 0"
@click="handleBindMerchant"
>{{ $t('payment.device.qrcode.bindMerchant') }}</a-button
>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.MANAGE)"
:disabled="selectedRows.length === 0"
danger
@click="handleUnbindMerchant"
>{{ $t('payment.device.qrcode.unbindMerchant') }}</a-button
>
</a-space>
</template>
</vxe-toolbar>
<vxe-table
ref="xTable"
:row-config="{ keyField: 'id' }"
:data="tableData"
:loading="loading"
@checkbox-change="handleCheckboxChange"
@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="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 }">
<span v-if="row.batchNo">{{ row.batchNo }}</span>
<span v-else style="color: var(--text-color-placeholder)">-</span>
</template>
</vxe-column>
<!-- 主显商户名, 悬停展示商户号; 未绑定无 tooltip -->
<vxe-column field="mchName" :title="$t('payment.device.qrcode.field.merchant')" :min-width="160">
<template #default="{ row }">
<a-tooltip v-if="row.mchNo" :title="$t('payment.device.qrcode.mchNoTooltip', { mchNo: row.mchNo })">
<a-tag color="blue">{{ row.mchName || row.mchNo }}</a-tag>
</a-tooltip>
<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')"
:min-width="120"
align="right"
>
<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>
</template>
</vxe-column>
<vxe-column field="status" :title="$t('payment.device.qrcode.field.status')" :min-width="100" align="center">
<template #default="{ row }">
<a-tag v-if="row.status === 'enabled'" color="green">
{{ $t('payment.device.qrcode.status.enabled') }}
</a-tag>
<a-tag v-else color="default">
{{ $t('payment.device.qrcode.status.disabled') }}
</a-tag>
</template>
</vxe-column>
<vxe-column
field="createTime"
:title="$t('payment.device.qrcode.field.createTime')"
:min-width="180"
formatter="formatDateTime"
/>
<vxe-column fixed="right" width="220" :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"
size="small"
@click="handleEdit(row)"
>{{ $t('common.edit') }}</a-button
>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.MANAGE) && row.status !== 'enabled'"
type="link"
size="small"
@click="handleChangeStatus(row, 'enabled')"
>{{ $t('payment.device.qrcode.enable') }}</a-button
>
<a-button
v-else-if="hasPermission(PermCodes.Device.QrCode.MANAGE) && row.status === 'enabled'"
type="link"
size="small"
@click="handleChangeStatus(row, 'disabled')"
>{{ $t('payment.device.qrcode.disable') }}</a-button
>
<a-button
v-if="hasPermission(PermCodes.Device.QrCode.MANAGE)"
type="link"
size="small"
danger
@click="handleDelete(row)"
>{{ $t('common.delete') }}</a-button
>
</a-space>
</template>
</vxe-column>
</vxe-table>
<vxe-pager
size="medium"
:loading="loading"
:current-page="pageConfig.currentPage"
:page-size="pageConfig.pageSize"
:total="pageConfig.total"
@page-change="handlePageChange"
/>
</a-card>
</div>
<DeviceQrCodeEdit ref="editRef" @ok="queryPage" />
<DeviceQrCodeBatchCreate ref="batchCreateRef" @ok="queryPage" />
<DeviceQrCodeBindMerchant ref="bindMerchantRef" @ok="queryPage" />
<!-- 查看码牌弹窗 -->
<a-modal
v-model:open="codeVisible"
:title="$t('payment.device.qrcode.qrCodeTitle')"
:width="420"
:footer="null"
:destroy-on-hidden="true"
>
<div class="code-modal">
<p class="code-tip">{{ $t('payment.device.qrcode.qrCodeTip') }}</p>
<div class="code-value">{{ currentCode }}</div>
<a-button type="primary" block @click="handleCopyCode">
{{ $t('common.copy') }}
</a-button>
</div>
</a-modal>
</div>
</template>
<style scoped>
.code-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.code-tip {
margin: 0;
color: var(--text-color-secondary);
font-size: 13px;
}
.code-value {
width: 100%;
padding: 12px;
background: var(--background-color);
border-radius: 8px;
text-align: center;
font-family: monospace;
font-size: 15px;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,207 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue';
import { $t } from '@vben/locales';
import dayjs from 'dayjs';
import {
DeviceQrCodeApi,
type DeviceQrCodeBatchParam,
} from '#/api/payment/device/qrcode.api';
import { useMessage } from '#/hooks/useMessage';
const emit = defineEmits(['ok']);
const { message } = useMessage();
const visible = ref(false);
const confirmLoading = ref(false);
const formRef = ref();
// 固定金额展示值(元)
const fixedAmountYuan = ref<number | undefined>(undefined);
const formState = ref<DeviceQrCodeBatchParam>({
batchNo: '',
count: 10,
amountType: 'random',
status: 'enabled',
});
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') }],
}));
/**
* 一键生成批次号: Q + YYMMDDHHmmss(对齐商业版)
*/
function genBatchNo() {
formState.value.batchNo = `Q${dayjs().format('YYMMDDHHmmss')}`;
// 生成后异步校验是否已存在
checkBatchNo();
}
/**
* 打开批量生成弹窗, 默认填入批次号
* destroy-on-hidden 时需先挂载表单再赋值, 避免 Form 与 model 时序错位
*/
async function show() {
formState.value = {
batchNo: '',
count: 10,
amountType: 'random',
status: 'enabled',
};
fixedAmountYuan.value = undefined;
visible.value = true;
await nextTick();
formRef.value?.clearValidate?.();
// 自动生成批次号
genBatchNo();
}
/**
* 关闭弹窗
*/
function handleCancel() {
visible.value = false;
}
/**
* 失焦/生成后校验批次号是否已存在
*/
async function checkBatchNo() {
const batchNo = formState.value.batchNo?.trim();
if (!batchNo) {
return;
}
const { data } = await DeviceQrCodeApi.existsByBatchNo(batchNo);
if (data) {
message.warning($t('payment.device.qrcode.batchNoExists'));
}
}
/**
* 提交批量创建
*/
async function handleOk() {
try {
await formRef.value?.validate();
} 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)
: undefined,
};
// 再校验一次批次号
const { data: exists } = await DeviceQrCodeApi.existsByBatchNo(payload.batchNo!);
if (exists) {
message.error($t('payment.device.qrcode.batchNoExists'));
return;
}
await DeviceQrCodeApi.createBatch(payload);
message.success(
$t('payment.device.qrcode.batchCreateSuccess', { count: payload.count ?? 0 }),
);
handleCancel();
emit('ok');
} finally {
confirmLoading.value = false;
}
}
defineExpose({ show });
</script>
<template>
<a-modal
v-model:open="visible"
:title="$t('payment.device.qrcode.batchCreate')"
:width="640"
:confirm-loading="confirmLoading"
:destroy-on-hidden="true"
:mask-closable="false"
@ok="handleOk"
@cancel="handleCancel"
>
<a-spin :spinning="confirmLoading">
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
class="form-compact"
>
<a-form-item :label="$t('payment.device.qrcode.field.batchNo')" name="batchNo">
<!-- antdv-next: Compact block 占满; 输入框 flex:1 吃掉剩余宽度, 与其它 width:100% 表单项对齐 -->
<a-space-compact block style="width: 100%">
<a-input
v-model:value="formState.batchNo"
:placeholder="$t('payment.device.qrcode.batchNoPlaceholder')"
style="flex: 1; min-width: 0"
@blur="checkBatchNo"
/>
<a-button type="primary" @click="genBatchNo">
{{ $t('payment.device.qrcode.genBatchNo') }}
</a-button>
</a-space-compact>
</a-form-item>
<a-form-item :label="$t('payment.device.qrcode.field.count')" name="count">
<a-input-number
v-model:value="formState.count"
:min="1"
:max="999"
:precision="0"
style="width: 100%"
:placeholder="$t('common.pleaseInput')"
/>
</a-form-item>
<a-form-item :label="$t('payment.device.qrcode.field.name')">
<a-input v-model:value="formState.name" :placeholder="$t('common.pleaseInput')" />
</a-form-item>
<a-form-item :label="$t('payment.device.qrcode.field.amountType')" name="amountType">
<a-radio-group v-model:value="formState.amountType" button-style="solid">
<a-radio-button value="random">{{ $t('payment.device.qrcode.amountType.random') }}</a-radio-button>
<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-input-number
v-model:value="fixedAmountYuan"
:min="0.01"
:step="0.01"
:precision="2"
style="width: 100%"
:placeholder="$t('common.pleaseInput')"
/>
</a-form-item>
<a-form-item :label="$t('payment.device.qrcode.field.status')" name="status">
<a-radio-group v-model:value="formState.status" button-style="solid">
<a-radio-button value="enabled">{{ $t('payment.device.qrcode.status.enabled') }}</a-radio-button>
<a-radio-button value="disabled">{{ $t('payment.device.qrcode.status.disabled') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item :label="$t('payment.device.qrcode.field.remark')">
<a-textarea v-model:value="formState.remark" :rows="2" :placeholder="$t('common.pleaseInput')" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import type { LabelValue } from '#/types/web';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import {
DeviceQrCodeApi,
type DeviceQrCodeBindMerchantParam,
} from '#/api/payment/device/qrcode.api';
import { MerchantApi } from '#/api/payment/merchant/merchant.api';
import { useMessage } from '#/hooks/useMessage';
const emit = defineEmits(['ok']);
const { message } = useMessage();
const visible = ref(false);
const confirmLoading = ref(false);
const formRef = ref();
// 待绑定码牌 id
const ids = ref<string[]>([]);
// 商户下拉
const mchOptions = ref<LabelValue[]>([]);
const formState = ref<{ mchNo?: string }>({
mchNo: undefined,
});
const formRules = computed(() => ({
mchNo: [{ required: true, message: $t('payment.device.qrcode.validateMchNo') }],
}));
/**
* 加载商户下拉
*/
async function loadMchOptions() {
const { data } = await MerchantApi.dropdown();
mchOptions.value = data || [];
}
/**
* 打开绑定弹窗
*/
async function show(selectedIds: string[]) {
ids.value = selectedIds;
formState.value = { mchNo: undefined };
formRef.value?.resetFields();
visible.value = true;
await loadMchOptions();
}
/**
* 关闭弹窗
*/
function handleCancel() {
visible.value = false;
}
/**
* 提交绑定: 不传 appId, 后端 resolveApp 取商户默认应用
*/
async function handleOk() {
try {
await formRef.value?.validate();
} catch {
return;
}
confirmLoading.value = true;
try {
const payload: DeviceQrCodeBindMerchantParam = {
ids: ids.value,
mchNo: formState.value.mchNo!,
};
await DeviceQrCodeApi.bindMerchant(payload);
message.success($t('common.operationSuccess'));
handleCancel();
emit('ok');
} finally {
confirmLoading.value = false;
}
}
defineExpose({ show });
</script>
<template>
<a-modal
v-model:open="visible"
:title="$t('payment.device.qrcode.bindMerchant')"
:width="640"
:confirm-loading="confirmLoading"
:destroy-on-hidden="true"
:mask-closable="false"
@ok="handleOk"
@cancel="handleCancel"
>
<a-spin :spinning="confirmLoading">
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
class="form-compact"
>
<a-form-item :label="$t('payment.device.qrcode.field.mchNo')" name="mchNo">
<a-select
v-model:value="formState.mchNo"
:options="mchOptions"
:placeholder="$t('payment.device.qrcode.pleaseSelectMch')"
show-search
option-filter-prop="label"
/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>

View File

@@ -0,0 +1,239 @@
<script lang="ts" setup>
import type { LabelValue } from '#/types/web';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { DeviceQrCodeApi, type DeviceQrCodeParam, type DeviceQrCodeResult } from '#/api/payment/device/qrcode.api';
import { MchAppInfoApi, type MchAppInfoResult } from '#/api/payment/merchant/mch-app-info.api';
import { MerchantApi } from '#/api/payment/merchant/merchant.api';
import { FormEditType } from '#/enums/formEditType';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
const emit = defineEmits(['ok']);
const { message } = useMessage();
const formRef = ref();
const { visible, confirmLoading, title, initFormEditType, handleCancel, formEditType } = useFormEdit();
// 是否编辑模式(编辑时商户/应用只读展示)
const isEdit = computed(() => formEditType.value === FormEditType.Edit);
// 商户下拉选项
const mchOptions = ref<LabelValue[]>([]);
// 应用下拉选项(根据商户联动)
const appOptions = ref<MchAppInfoResult[]>([]);
const formState = ref<DeviceQrCodeParam>({
name: '',
mchNo: '',
amountType: 'random',
});
const formRules = computed(() => {
const rules: Record<string, any[]> = {
name: [{ required: true, message: $t('payment.device.qrcode.validateName') }],
amountType: [{ required: true, message: $t('payment.device.qrcode.validateAmountType') }],
};
// 新增时商户必填; 编辑不改归属
if (!isEdit.value) {
rules.mchNo = [{ required: true, message: $t('payment.device.qrcode.validateMchNo') }];
}
return rules;
});
// 金额类型为固定时, 固定金额必填
const isFixedAmount = computed(() => formState.value.amountType === 'fixed');
// 固定金额展示值(元), 提交时转回分
const fixedAmountYuan = ref<number | undefined>(undefined);
/**
* 加载商户下拉
*/
async function loadMchOptions() {
const { data } = await MerchantApi.dropdown();
mchOptions.value = data || [];
}
/**
* 商户切换时重新加载应用列表并清空已选应用
*/
async function handleMchChange() {
formState.value.appId = undefined;
appOptions.value = [];
if (formState.value.mchNo) {
const { data } = await MchAppInfoApi.page({ mchNo: formState.value.mchNo, size: 999 });
appOptions.value = data?.records || [];
}
}
/**
* 重置表单
*/
function resetForm() {
formState.value = {
name: '',
mchNo: '',
amountType: 'random',
};
fixedAmountYuan.value = undefined;
appOptions.value = [];
formRef.value?.resetFields();
}
/**
* 打开新增弹窗
*/
function show() {
initFormEditType(FormEditType.Add);
resetForm();
loadMchOptions();
}
/**
* 打开编辑弹窗(归属只读)
*/
async function showEdit(record: DeviceQrCodeResult) {
initFormEditType(FormEditType.Edit);
resetForm();
confirmLoading.value = true;
try {
const { data } = await DeviceQrCodeApi.get(record.id!);
const row = data || record;
formState.value = {
id: row.id!,
name: row.name,
mchNo: row.mchNo,
appId: row.appId,
amountType: row.amountType,
fixedAmount: row.fixedAmount,
remark: row.remark,
};
fixedAmountYuan.value = row.fixedAmount ? row.fixedAmount / 100 : undefined;
} finally {
confirmLoading.value = false;
}
}
/**
* 保存
*/
async function handleOk() {
try {
await formRef.value?.validate();
} 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: DeviceQrCodeParam = {
...formState.value,
fixedAmount: isFixedAmount.value && fixedAmountYuan.value ? Math.round(fixedAmountYuan.value * 100) : undefined,
};
await (formEditType.value === FormEditType.Edit ? DeviceQrCodeApi.update(payload) : DeviceQrCodeApi.add(payload));
message.success($t('common.operationSuccess'));
handleCancel();
emit('ok');
} finally {
confirmLoading.value = false;
}
}
// import 放最后避免循环依赖(参照项目惯例)
defineExpose({ show, showEdit });
</script>
<template>
<a-modal
v-model:open="visible"
:title="title"
:width="640"
:confirm-loading="confirmLoading"
:destroy-on-hidden="true"
:mask-closable="false"
@ok="handleOk"
@cancel="handleCancel"
>
<a-spin :spinning="confirmLoading">
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
class="form-compact"
>
<!-- 新增: 可选商户/应用; 编辑: 只读展示归属 -->
<a-form-item v-if="!isEdit" :label="$t('payment.device.qrcode.field.mchNo')" name="mchNo">
<a-select
v-model:value="formState.mchNo"
:options="mchOptions"
:placeholder="$t('payment.device.qrcode.pleaseSelectMch')"
show-search
option-filter-prop="label"
@change="handleMchChange"
/>
</a-form-item>
<a-form-item v-if="!isEdit" :label="$t('payment.device.qrcode.field.appId')">
<a-select
v-model:value="formState.appId"
:options="appOptions"
:field-names="{ label: 'appName', value: 'appId' }"
:placeholder="$t('payment.device.qrcode.pleaseSelectApp')"
allow-clear
/>
</a-form-item>
<a-form-item v-if="isEdit" :label="$t('payment.device.qrcode.field.mchNo')">
<a-tag v-if="formState.mchNo" color="blue">{{ formState.mchNo }}</a-tag>
<a-tag v-else color="default">{{ $t('payment.device.qrcode.unbound') }}</a-tag>
</a-form-item>
<a-form-item v-if="isEdit" :label="$t('payment.device.qrcode.field.appId')">
<a-tag v-if="formState.appId" color="cyan">{{ formState.appId }}</a-tag>
<span v-else-if="formState.mchNo" style="color: var(--text-color-placeholder)">
{{ $t('payment.device.qrcode.defaultApp') }}
</span>
<span v-else style="color: var(--text-color-placeholder)">-</span>
</a-form-item>
<!-- 码牌名称 -->
<a-form-item :label="$t('payment.device.qrcode.field.name')" name="name">
<a-input v-model:value="formState.name" :placeholder="$t('common.pleaseInput')" />
</a-form-item>
<!-- 金额类型 -->
<a-form-item :label="$t('payment.device.qrcode.field.amountType')" name="amountType">
<a-radio-group v-model:value="formState.amountType" button-style="solid">
<a-radio-button value="random">{{ $t('payment.device.qrcode.amountType.random') }}</a-radio-button>
<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"
:min="0.01"
:step="0.01"
:precision="2"
style="width: 100%"
:placeholder="$t('common.pleaseInput')"
/>
</a-form-item>
<!-- 备注 -->
<a-form-item :label="$t('payment.device.qrcode.field.remark')">
<a-textarea v-model:value="formState.remark" :rows="2" :placeholder="$t('common.pleaseInput')" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>