Files
one-pipe-system/src/views/settings/payment-merchant-pools/components/PoolManagement.vue
2026-09-17 12:16:20 +08:00

834 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="canCreatePool" 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="120"
@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="formMode === 'create' ? !canCreatePool : !canEditPool"
@click="handleSubmit"
>
保存
</ElButton>
</template>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
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 { useAuth } from '@/composables/useAuth'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
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'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'PaymentMerchantPoolManagement' })
type FilterVo = string | number | undefined | null | unknown[]
const router = useRouter()
const { hasAuth } = useAuth()
const canCreatePool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolCreate))
const canEditPool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolEdit))
const canTogglePool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolToggle))
const canViewPoolDetail = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolDetail))
const handleNameClick = (row: PaymentMerchantPool) => {
router.push({ path: `${RoutesAlias.PaymentMerchantPoolDetail}/${row.id}` })
}
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,
showOverflowTooltip: true,
formatter: (row: PaymentMerchantPool) =>
canViewPoolDetail.value
? h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
: row.name
},
{
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 () => {
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 || '未知商户'
// 表单
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())
// 成员顺序以 form.member_ids 为单一数据源VueDraggable 的 v-model 直接写回该数组,
// 避免双向 watch 互相赋值导致的递归更新。
const orderedMemberIds = computed<number[]>({
get: () => form.member_ids,
set: (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 = []
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 (!canCreatePool.value) return
Object.assign(form, initialFormState())
formDrawerVisible.value = true
formMode.value = 'create'
await loadAvailableMerchants(form.payment_method)
}
const openEditDrawer = async (pool: PaymentMerchantPool) => {
if (!canEditPool.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 || ''
})
formDrawerVisible.value = true
formMode.value = 'edit'
}
const resetForm = () => {
Object.assign(form, initialFormState())
memberError.value = ''
pendingMemberId.value = undefined
formRef.value?.clearValidate()
}
const handleSubmit = async () => {
if (formMode.value === 'create' ? !canCreatePool.value : !canEditPool.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 tableRef = ref()
const toggleEnabled = async (pool: PaymentMerchantPool) => {
if (!canTogglePool.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: () => openEditDrawer(row),
permission: canEditPool.value ? '' : 'hidden'
},
{
label: row.enabled ? '停用' : '启用',
type: 'primary' as const,
handler: () => toggleEnabled(row),
permission: canTogglePool.value ? '' : 'hidden'
}
]
onMounted(() => {
loadPools()
})
onUnmounted(() => {
pools.value = []
availableMerchants.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>