feat: 支付商户

This commit is contained in:
luo
2026-09-10 16:50:06 +08:00
parent ef966171b4
commit d3d257cdf8
21 changed files with 2884 additions and 27 deletions

View File

@@ -0,0 +1,871 @@
<template>
<div class="pool-management">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="loadPools"
>
<template #left>
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
新增商户池
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="pools"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="220"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<ElDrawer
v-model="formDrawerVisible"
:title="formMode === 'create' ? '新增商户池' : '编辑商户池'"
size="780px"
destroy-on-close
@closed="resetForm"
>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
<ElFormItem label="商户池名称" prop="name">
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户池名称" />
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElSelect
v-model="form.payment_method"
:disabled="formMode === 'edit'"
style="width: 100%"
@change="handlePaymentMethodChange"
>
<ElOption
v-for="item in PAYMENT_METHOD_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="成员商户" prop="member_ids">
<VueDraggable
v-if="orderedMemberIds.length"
v-model="orderedMemberIds"
:animation="150"
handle=".drag-handle"
class="pool-member-list"
>
<div v-for="memberId in orderedMemberIds" :key="memberId" class="pool-member-row">
<ElIcon class="drag-handle"><Rank /></ElIcon>
<span class="member-name">{{ getMemberName(memberId) }}</span>
<ElButton type="danger" link :icon="Delete" @click="removeMember(memberId)" />
</div>
</VueDraggable>
<div v-else class="pool-member-empty">尚未选择成员请从下方选择</div>
<ElDivider />
<ElSelect
v-model="pendingMemberId"
filterable
placeholder="选择同支付方式的商户"
style="width: 100%"
:disabled="!form.payment_method"
@change="appendMember"
>
<ElOption
v-for="item in availableMerchantOptions"
:key="item.id"
:label="item.name"
:value="item.id"
:disabled="form.member_ids.includes(item.id)"
/>
</ElSelect>
<div v-if="memberError" class="field-error" role="alert">{{ memberError }}</div>
</ElFormItem>
<ElFormItem label="轮询策略" prop="strategy">
<ElSelect v-model="form.strategy" style="width: 100%">
<ElOption
v-for="item in PAYMENT_POOL_STRATEGY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.strategy === 'amount' || form.strategy === 'count'"
label="统计周期"
prop="statistic_cycle"
>
<ElSelect v-model="form.statistic_cycle" style="width: 100%">
<ElOption
v-for="item in PAYMENT_STATISTIC_CYCLE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.strategy === 'amount'"
label="金额阈值(元)"
prop="threshold_amount_yuan"
>
<ElInputNumber
v-model="form.threshold_amount_yuan"
:min="0.01"
:precision="2"
:step="100"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem v-else-if="form.strategy === 'count'" label="笔数阈值" prop="threshold_count">
<ElInputNumber
v-model="form.threshold_count"
:min="1"
:precision="0"
:step="1"
style="width: 100%"
/>
</ElFormItem>
<template v-else-if="form.strategy === 'time'">
<ElFormItem label="时间单位" prop="time_period_unit">
<ElSelect v-model="form.time_period_unit" style="width: 100%">
<ElOption
v-for="item in PAYMENT_TIME_PERIOD_UNIT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="时间长度" prop="time_period_value">
<ElInputNumber
v-model="form.time_period_value"
:min="1"
:precision="0"
:step="1"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="时间起点" prop="time_period_started_at">
<ElDatePicker
v-model="form.time_period_started_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择时间起点"
style="width: 100%"
/>
</ElFormItem>
</template>
<ElFormItem label="启停状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="formDrawerVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="submitLoading"
:disabled="!canManage"
@click="handleSubmit"
>
保存
</ElButton>
</template>
</ElDrawer>
<ElDrawer v-model="detailDrawerVisible" title="商户池详情" size="640px">
<ElDescriptions v-if="detail" :column="1" border>
<ElDescriptionsItem label="商户池名称">{{ detail.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">{{
getPaymentMethodLabel(detail.payment_method)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成员数量">{{ detail.member_ids.length }}</ElDescriptionsItem>
<ElDescriptionsItem label="轮询策略">{{
getPaymentPoolStrategyLabel(detail.strategy)
}}</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.strategy === 'amount' || detail.strategy === 'count'"
label="统计周期"
>
{{ getPaymentStatisticCycleLabel(detail.statistic_cycle) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'amount'" label="金额阈值">
{{ (Number(detail.threshold_amount) / 100).toFixed(2) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'count'" label="笔数阈值">
{{ detail.threshold_count }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'time'" label="时间周期">
{{ detail.time_period_value }}
{{ getPaymentTimePeriodUnitLabel(detail.time_period_unit) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">
{{ detail.enabled ? '启用' : '停用' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="路由世代">v{{ detail.routing_epoch }}</ElDescriptionsItem>
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { Delete, Plus, Rank } from '@element-plus/icons-vue'
import { VueDraggable } from 'vue-draggable-plus'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { usePermission } from '@/composables/usePermission'
import type { SearchFormItem } from '@/types/component'
import {
PAYMENT_METHOD_OPTIONS,
PAYMENT_POOL_STRATEGY_OPTIONS,
PAYMENT_STATISTIC_CYCLE_OPTIONS,
PAYMENT_TIME_PERIOD_UNIT_OPTIONS,
getPaymentMethodLabel,
getPaymentPoolStrategyLabel,
getPaymentStatisticCycleLabel,
getPaymentTimePeriodUnitLabel,
type PaymentMerchantMethod,
type PaymentMerchantPool,
type PaymentMerchantPoolPageResult,
type PaymentMerchantPoolPayload,
type PaymentMerchantPoolMemberOption,
type PaymentPoolStrategy
} from '@/types/api/paymentMerchantPools'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'PaymentMerchantPoolManagement' })
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const canManage = computed(() => isPlatformAccount.value)
const searchForm = reactive<Record<string, FilterVo>>({
page: 1,
page_size: 10,
payment_method: null
})
const pools = ref<PaymentMerchantPool[]>([])
const pagination = reactive({ page: 1, page_size: 10, total: 0 })
const loading = ref(false)
const searchItems: SearchFormItem[] = [
{
label: '支付方式',
prop: 'payment_method',
type: 'select',
options: PAYMENT_METHOD_OPTIONS.map((item) => ({
label: item.label,
value: item.value
})),
config: { clearable: true }
}
]
const columnOptions = [
{ label: '商户池名称', prop: 'name', minWidth: 180 },
{
label: '支付方式',
prop: 'payment_method',
width: 110,
formatter: (row: PaymentMerchantPool) => getPaymentMethodLabel(row.payment_method)
},
{
label: '成员数量',
prop: 'member_ids',
width: 110,
formatter: (row: PaymentMerchantPool) => `${row.member_ids.length}`
},
{
label: '启停状态',
prop: 'enabled',
width: 110,
formatter: (row: PaymentMerchantPool) =>
h(
ElTag,
{ type: row.enabled ? 'success' : 'info' },
{ default: () => (row.enabled ? '启用' : '停用') }
)
},
{
label: '轮询策略',
prop: 'strategy',
width: 130,
formatter: (row: PaymentMerchantPool) => getPaymentPoolStrategyLabel(row.strategy)
},
{
label: '统计周期',
prop: 'statistic_cycle',
width: 110,
formatter: (row: PaymentMerchantPool) =>
row.strategy === 'time' ? '-' : getPaymentStatisticCycleLabel(row.statistic_cycle)
},
{
label: '阈值',
prop: 'threshold_summary',
width: 150,
formatter: (row: PaymentMerchantPool) => describeThreshold(row)
},
{
label: '更新时间',
prop: 'updated_at',
width: 170,
formatter: (row: PaymentMerchantPool) => formatDateTime(row.updated_at)
}
]
const { columnChecks, columns } = useCheckedColumns(() => columnOptions)
const describeThreshold = (row: PaymentMerchantPool): string => {
if (row.strategy === 'amount') {
return `${(Number(row.threshold_amount) / 100).toFixed(2)}`
}
if (row.strategy === 'count') {
return `${row.threshold_count}`
}
return `${row.time_period_value} ${getPaymentTimePeriodUnitLabel(row.time_period_unit)}`
}
const loadPools = async () => {
if (!canManage.value) return
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.page_size,
payment_method: searchForm.payment_method as PaymentMerchantMethod | undefined
}
const res = await PaymentMerchantPoolsService.getPaymentMerchantPools(params)
if (res.code === 0) {
const data = (res.data as PaymentMerchantPoolPageResult) || {
items: [],
total: 0,
page: 1,
size: 10
}
pools.value = data.items || []
pagination.total = data.total || 0
pagination.page = data.page || pagination.page
pagination.page_size = data.size || pagination.page_size
}
} catch (error) {
console.error('加载商户池失败:', error)
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadPools()
}
const handleReset = () => {
searchForm.payment_method = null
pagination.page = 1
loadPools()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
loadPools()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadPools()
}
// 候选成员
const availableMerchants = ref<PaymentMerchantPoolMemberOption[]>([])
const loadAvailableMerchants = async (paymentMethod: PaymentMerchantMethod) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchants({
page: 1,
page_size: 100,
payment_method: paymentMethod
})
if (res.code === 0) {
const items = res.data?.items || []
availableMerchants.value = items.map((item) => ({
id: item.id,
name: item.name,
payment_method: item.payment_method,
enabled: item.enabled
}))
}
} catch (error) {
console.error('加载候选商户失败:', error)
}
}
const availableMerchantOptions = computed(() =>
availableMerchants.value.filter((m) => m.payment_method === form.payment_method)
)
const getMemberName = (id: number) =>
availableMerchants.value.find((m) => m.id === id)?.name || `#${id}`
// 表单
type FormMode = 'create' | 'edit'
const formDrawerVisible = ref(false)
const formMode = ref<FormMode>('create')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const memberError = ref('')
const pendingMemberId = ref<number | undefined>(undefined)
const initialFormState = () => ({
id: 0,
name: '',
payment_method: 'wechat' as PaymentMerchantMethod,
member_ids: [] as number[],
enabled: true,
strategy: 'amount' as PaymentPoolStrategy,
statistic_cycle: 'round' as PaymentMerchantPoolPayload['statistic_cycle'],
threshold_amount_yuan: 0,
threshold_count: 1,
time_period_unit: 'hour' as PaymentMerchantPoolPayload['time_period_unit'],
time_period_value: 1,
time_period_started_at: '',
remark: ''
})
const form = reactive(initialFormState())
const orderedMemberIds = ref<number[]>([])
watch(
() => form.member_ids,
(val) => {
orderedMemberIds.value = [...val]
},
{ immediate: true, deep: true }
)
watch(orderedMemberIds, (val) => {
form.member_ids = [...val]
})
const formRules = reactive<FormRules>({
name: [
{ required: true, message: '请输入商户池名称', trigger: 'blur' },
{ max: 100, message: '商户池名称不超过 100 个字符', trigger: 'blur' }
],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
member_ids: [
{
validator: (_rule, value: number[], callback) => {
if (!value || value.length === 0) {
callback(new Error('请至少选择一个成员'))
return
}
const unique = new Set(value)
if (unique.size !== value.length) {
callback(new Error('成员不可重复'))
return
}
callback()
},
trigger: 'change'
}
],
strategy: [{ required: true, message: '请选择轮询策略', trigger: 'change' }],
statistic_cycle: [
{
validator: (_rule, value, callback) => {
if ((form.strategy === 'amount' || form.strategy === 'count') && !value) {
callback(new Error('请选择统计周期'))
return
}
callback()
},
trigger: 'change'
}
],
threshold_amount_yuan: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'amount') {
callback()
return
}
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('金额阈值必须大于 0'))
return
}
callback()
},
trigger: 'change'
}
],
threshold_count: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'count') {
callback()
return
}
const num = Number(value)
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) {
callback(new Error('笔数阈值必须为正整数'))
return
}
callback()
},
trigger: 'change'
}
],
time_period_unit: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'time') {
callback()
return
}
if (!value) {
callback(new Error('请选择时间单位'))
return
}
callback()
},
trigger: 'change'
}
],
time_period_value: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'time') {
callback()
return
}
const num = Number(value)
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) {
callback(new Error('时间长度必须为正整数'))
return
}
callback()
},
trigger: 'change'
}
],
time_period_started_at: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'time') {
callback()
return
}
if (!value) {
callback(new Error('请选择时间起点'))
return
}
callback()
},
trigger: 'change'
}
]
})
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
form.payment_method = value
form.member_ids = []
orderedMemberIds.value = []
availableMerchants.value = []
memberError.value = ''
loadAvailableMerchants(value)
}
const appendMember = (id?: number | string) => {
const memberId = Number(id)
if (!memberId) {
pendingMemberId.value = undefined
return
}
if (form.member_ids.includes(memberId)) {
memberError.value = '成员不可重复'
} else {
form.member_ids = [...form.member_ids, memberId]
memberError.value = ''
}
pendingMemberId.value = undefined
}
const removeMember = (id: number) => {
form.member_ids = form.member_ids.filter((memberId) => memberId !== id)
}
const buildPayload = (): PaymentMerchantPoolPayload | null => {
if (!form.payment_method) {
memberError.value = '请选择支付方式'
return null
}
if (form.member_ids.length === 0) {
memberError.value = '请至少选择一个成员'
return null
}
if (new Set(form.member_ids).size !== form.member_ids.length) {
memberError.value = '成员不可重复'
return null
}
memberError.value = ''
const payload: PaymentMerchantPoolPayload = {
name: form.name.trim(),
payment_method: form.payment_method,
member_ids: [...form.member_ids],
enabled: form.enabled,
strategy: form.strategy,
remark: form.remark?.trim() || ''
}
if (form.strategy === 'amount') {
if (!form.statistic_cycle) return null
const yuan = Number(form.threshold_amount_yuan)
if (!Number.isFinite(yuan) || yuan <= 0) return null
payload.statistic_cycle = form.statistic_cycle
payload.threshold_amount = Math.round(yuan * 100)
} else if (form.strategy === 'count') {
if (!form.statistic_cycle) return null
const count = Number(form.threshold_count)
if (!Number.isInteger(count) || count <= 0) return null
payload.statistic_cycle = form.statistic_cycle
payload.threshold_count = count
} else {
if (!form.time_period_unit) return null
const value = Number(form.time_period_value)
if (!Number.isInteger(value) || value <= 0) return null
payload.time_period_unit = form.time_period_unit
payload.time_period_value = value
if (form.time_period_started_at) {
payload.time_period_started_at = form.time_period_started_at
}
}
return payload
}
const showCreateDrawer = async () => {
if (!canManage.value) return
Object.assign(form, initialFormState())
orderedMemberIds.value = []
formDrawerVisible.value = true
formMode.value = 'create'
await loadAvailableMerchants(form.payment_method)
}
const openEditDrawer = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
await loadAvailableMerchants(pool.payment_method)
Object.assign(form, initialFormState(), {
id: pool.id,
name: pool.name,
payment_method: pool.payment_method,
member_ids: [...pool.member_ids],
enabled: pool.enabled,
strategy: pool.strategy,
statistic_cycle: pool.statistic_cycle,
threshold_amount_yuan: Number(pool.threshold_amount) / 100,
threshold_count: pool.threshold_count,
time_period_unit: pool.time_period_unit,
time_period_value: pool.time_period_value,
time_period_started_at: pool.time_period_started_at || '',
remark: pool.remark || ''
})
orderedMemberIds.value = [...pool.member_ids]
formDrawerVisible.value = true
formMode.value = 'edit'
}
const resetForm = () => {
Object.assign(form, initialFormState())
orderedMemberIds.value = []
memberError.value = ''
pendingMemberId.value = undefined
formRef.value?.clearValidate()
}
const handleSubmit = async () => {
if (!canManage.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
const payload = buildPayload()
if (!payload) return
submitLoading.value = true
try {
if (formMode.value === 'create') {
await PaymentMerchantPoolsService.createPaymentMerchantPool(payload)
ElMessage.success('商户池创建成功')
} else {
await PaymentMerchantPoolsService.updatePaymentMerchantPool(form.id, payload)
ElMessage.success('商户池已更新')
}
formDrawerVisible.value = false
await loadPools()
} catch (error) {
console.error('提交商户池失败:', error)
} finally {
submitLoading.value = false
}
}
// 详情
const detailDrawerVisible = ref(false)
const detail = ref<PaymentMerchantPool | null>(null)
const tableRef = ref()
const showDetail = async (id: number) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantPoolById(id)
if (res.code === 0) {
detail.value = res.data
detailDrawerVisible.value = true
}
} catch (error) {
console.error('加载商户池详情失败:', error)
}
}
const toggleEnabled = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
const target = !pool.enabled
const action = target ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${action}商户池 “${pool.name}”?`, '操作确认', {
type: 'warning'
})
} catch {
return
}
try {
const res = target
? await PaymentMerchantPoolsService.enablePaymentMerchantPool(pool.id)
: await PaymentMerchantPoolsService.disablePaymentMerchantPool(pool.id)
if (res.code === 0) {
ElMessage.success(`${action}`)
await loadPools()
}
} catch (error) {
console.error(`${action}商户池失败:`, error)
}
}
const getActions = (row: PaymentMerchantPool) => [
{
label: '详情',
type: 'primary' as const,
handler: () => showDetail(row.id)
},
{
label: '编辑',
type: 'primary' as const,
handler: () => openEditDrawer(row),
permission: canManage.value ? '' : 'hidden'
},
{
label: row.enabled ? '停用' : '启用',
type: 'primary' as const,
handler: () => toggleEnabled(row),
permission: canManage.value ? '' : 'hidden'
}
]
onMounted(() => {
if (canManage.value) {
loadPools()
}
})
onUnmounted(() => {
pools.value = []
detail.value = null
availableMerchants.value = []
orderedMemberIds.value = []
})
</script>
<style lang="scss" scoped>
.pool-management {
.pool-member-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.pool-member-row {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background-color: rgba(var(--art-gray-200-rgb), 0.6);
border-radius: 4px;
}
.pool-member-row :deep(.drag-handle) {
color: var(--el-text-color-secondary);
cursor: move;
}
.pool-member-empty {
padding: 12px;
color: var(--el-text-color-secondary);
text-align: center;
border: 1px dashed var(--el-border-color);
border-radius: 4px;
}
.member-name {
flex: 1;
}
.field-error {
margin-top: 8px;
font-size: 12px;
color: var(--el-color-danger);
}
}
</style>