Files
hfb_sys/frontend/src/features/admin/views/AdminPaymentConfigsView.vue
T

738 lines
18 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.
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { Plus, Refresh, View, Edit, Delete, Download, Upload, StarFilled } from '@element-plus/icons-vue'
import {
fetchPaymentConfigs,
deletePaymentConfig,
exportPaymentConfigBackup,
importPaymentConfigBackup,
type PaymentConfig,
} from '@/features/admin/api/paymentConfig'
import { formatDateTime } from '@/shared/utils/time'
import { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
import PaymentConfigDialog from '../components/PaymentConfigDialog.vue'
const loading = ref(false)
const configs = ref<PaymentConfig[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const exporting = ref(false)
const importing = ref(false)
const backupFileInput = ref<HTMLInputElement | null>(null)
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const currentConfig = ref<PaymentConfig | null>(null)
const filterProvider = ref('')
const filterPayWay = ref('')
const filterStatus = ref('')
const filterEnvironment = ref('')
const providerOptions = [
{ label: '全部', value: '' },
{ label: '乐刷支付', value: 'leshua' },
{ label: '拉卡拉支付', value: 'lakala' },
{ label: '顺成支付', value: 'shuncheng' },
{ label: '模拟支付', value: 'mock' },
]
const statusOptions = [
{ label: '全部', value: '' },
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'disabled' },
{ label: '测试中', value: 'testing' },
]
const payWayOptions = [
{ label: '全部', value: '' },
{ label: '支付宝', value: 'ZFBZF' },
{ label: '微信', value: 'WXZF' },
]
const environmentOptions = [
{ label: '全部', value: '' },
{ label: '生产环境', value: 'production' },
{ label: '测试环境', value: 'sandbox' },
]
const filteredConfigs = computed(() => {
return configs.value
})
onMounted(() => {
loadConfigs()
})
async function loadConfigs() {
loading.value = true
try {
const res = await fetchPaymentConfigs({
provider: filterProvider.value || undefined,
pay_way: filterPayWay.value || undefined,
status: filterStatus.value || undefined,
environment: filterEnvironment.value || undefined,
page: currentPage.value,
page_size: pageSize.value,
})
configs.value = res.items
total.value = res.total
} catch (error) {
ElMessage.error(readError(error, '加载配置失败'))
} finally {
loading.value = false
}
}
function handleCreate() {
currentConfig.value = null
dialogMode.value = 'create'
dialogVisible.value = true
}
function handleView(row: PaymentConfig) {
currentConfig.value = row
dialogMode.value = 'view'
dialogVisible.value = true
}
function handleEdit(row: PaymentConfig) {
currentConfig.value = row
dialogMode.value = 'edit'
dialogVisible.value = true
}
async function handleDelete(row: PaymentConfig) {
if (!canDeleteConfig(row)) {
ElMessage.warning('该配置已有交易记录,需要保留用于退款、查询和回调验签')
return
}
try {
await ElMessageBox.confirm(`确定要删除配置"${row.name}"吗?`, '确认删除', {
type: 'warning',
})
await deletePaymentConfig(row.id)
ElMessage.success('删除成功')
loadConfigs()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(readError(error, '删除失败'))
}
}
}
async function handleExportBackup() {
try {
await ElMessageBox.confirm(
'备份文件会包含支付密钥明文,请妥善保管。确定导出吗?',
'导出支付配置备份',
{
type: 'warning',
confirmButtonText: '导出',
cancelButtonText: '取消',
}
)
exporting.value = true
const { blob, filename } = await exportPaymentConfigBackup()
downloadBlob(blob, filename)
ElMessage.success('导出成功')
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(readError(error, '导出备份失败'))
}
} finally {
exporting.value = false
}
}
function handleImportBackup() {
backupFileInput.value?.click()
}
async function handleImportFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
let backup: unknown
try {
backup = JSON.parse(await file.text())
} catch {
ElMessage.error('备份文件不是有效的 JSON')
return
}
try {
await ElMessageBox.confirm(
'导入会按服务商、商户号和支付方式新增或更新配置,并恢复备份中的启用配置。备份文件包含支付密钥明文,请确认来源可信。',
'导入支付配置备份',
{
type: 'warning',
confirmButtonText: '导入',
cancelButtonText: '取消',
}
)
importing.value = true
const result = await importPaymentConfigBackup(backup)
ElMessage.success(`导入成功:新增 ${result.created} 个,更新 ${result.updated} 个`)
await loadConfigs()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(readError(error, '导入备份失败'))
}
} finally {
importing.value = false
}
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function handleDialogClose() {
dialogVisible.value = false
currentConfig.value = null
}
function handleSaved() {
dialogVisible.value = false
loadConfigs()
}
function formatProvider(provider: string) {
const map: Record<string, string> = {
leshua: '乐刷支付',
lakala: '拉卡拉支付',
shuncheng: '顺成支付',
mock: '模拟支付',
}
return map[provider] || provider
}
// formatPayWay 将支付方式编码展示为运营可读名称。
function formatPayWay(payWay: string) {
const map: Record<string, string> = {
ZFBZF: '支付宝',
WXZF: '微信',
}
return map[payWay] || payWay || '-'
}
// getPayWayClass 按支付方式返回标签样式,避免与状态/环境标签混淆。
function getPayWayClass(payWay: string) {
const map: Record<string, string> = {
ZFBZF: 'pay-way-alipay',
WXZF: 'pay-way-wechat',
}
return map[payWay] || 'pay-way-other'
}
function formatStatus(status: string) {
const map: Record<string, string> = {
active: '启用',
disabled: '禁用',
testing: '测试中',
}
return map[status] || status
}
function formatEnvironment(env: string) {
const map: Record<string, string> = {
production: '生产',
sandbox: '测试',
}
return map[env] || env
}
function formatAmount(amountCent: number) {
return formatCent(amountCent)
}
function getEnvironmentClass(env: string) {
const map: Record<string, string> = {
production: 'env-production',
sandbox: 'env-sandbox',
}
return map[env] || 'env-other'
}
function getStatusClass(status: string) {
const map: Record<string, string> = {
active: 'config-active',
disabled: 'config-disabled',
testing: 'config-testing',
}
return map[status] || 'config-disabled'
}
function isDefaultConfig(row: PaymentConfig) {
const value = row.is_default as unknown
return value === true || value === 'true' || value === 1
}
function tableRowClassName({ row }: { row: PaymentConfig }) {
return isDefaultConfig(row) ? 'payment-config-row-default' : ''
}
function canDeleteConfig(row: PaymentConfig) {
return (row.reference_count || 0) <= 0
}
function deleteDisabledReason(row: PaymentConfig) {
if (canDeleteConfig(row)) return '删除配置'
return `已有 ${row.reference_count || 0} 条支付记录引用,需要保留用于退款、查询和回调验签`
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Payment Configs</p>
<h1>支付配置管理</h1>
<p>管理多个支付商户配置,支付宝和微信可分别启用默认渠道。</p>
</div>
<div class="filter-bar">
<el-select
v-model="filterProvider"
placeholder="支付服务商"
style="width: 150px"
@change="loadConfigs"
>
<el-option
v-for="opt in providerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filterPayWay"
placeholder="支付方式"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in payWayOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filterStatus"
placeholder="状态"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filterEnvironment"
placeholder="环境"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in environmentOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button :icon="Refresh" @click="loadConfigs">刷新</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup"
>导入备份</el-button
>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup"
>导出备份</el-button
>
<el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button>
</div>
<input
ref="backupFileInput"
class="backup-file-input"
type="file"
accept="application/json,.json"
@change="handleImportFile"
/>
<el-table
:data="filteredConfigs"
v-loading="loading"
class="payment-config-table"
:row-class-name="tableRowClassName"
stripe
>
<el-table-column prop="name" label="配置名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="provider" label="服务商" width="120">
<template #default="{ row }">
{{ formatProvider(row.provider) }}
</template>
</el-table-column>
<el-table-column prop="pay_way" label="支付方式" width="104" align="center">
<template #default="{ row }">
<el-tag class="config-tag" :class="getPayWayClass(row.pay_way)" size="small">
{{ formatPayWay(row.pay_way) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="merchant_id" label="商户号" min-width="150" />
<el-table-column prop="environment" label="环境" width="86" align="center">
<template #default="{ row }">
<el-tag class="config-tag" :class="getEnvironmentClass(row.environment)" size="small">
{{ formatEnvironment(row.environment) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="86" align="center">
<template #default="{ row }">
<el-tag class="config-tag" :class="getStatusClass(row.status)" size="small">
{{ formatStatus(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_default" label="默认渠道" width="118" align="center">
<template #default="{ row }">
<span v-if="isDefaultConfig(row)" class="default-badge">
<el-icon><StarFilled /></el-icon>
默认渠道
</span>
<span v-else class="standby-badge">备用</span>
</template>
</el-table-column>
<el-table-column label="支付记录" min-width="150">
<template #default="{ row }">
<div class="usage-cell">
<div>成功: {{ row.total_transactions }} 笔</div>
<div :class="{ 'usage-reference-locked': (row.reference_count || 0) > 0 }">
引用: {{ row.reference_count || 0 }} 条
</div>
<div>金额: ¥{{ formatAmount(row.total_amount_cent) }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="last_used_at" label="最后成功支付" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.last_used_at ? formatDateTime(row.last_used_at, '-') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="226" class-name="operation-column">
<template #default="{ row }">
<div class="action-buttons">
<el-button
class="action-button view"
size="small"
:icon="View"
@click="handleView(row)"
>
查看
</el-button>
<el-button
class="action-button edit"
size="small"
:icon="Edit"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-tooltip :content="deleteDisabledReason(row)" placement="top">
<span>
<el-button
class="action-button delete"
size="small"
:icon="Delete"
:disabled="!canDeleteConfig(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadConfigs"
@size-change="loadConfigs"
/>
</div>
<PaymentConfigDialog
v-model="dialogVisible"
:mode="dialogMode"
:config="currentConfig"
@close="handleDialogClose"
@saved="handleSaved"
/>
</section>
</template>
<style scoped>
.page {
padding: 24px;
}
.page-header {
margin-bottom: 24px;
}
.page-header .eyebrow {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
margin-bottom: 8px;
}
.page-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
}
.page-header p {
color: #6b7280;
margin: 0;
}
.filter-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.backup-file-input {
display: none;
}
.payment-config-table {
width: 100%;
border-radius: 0;
}
.payment-config-table :deep(.el-table__cell) {
padding: 11px 0;
}
.payment-config-table :deep(.cell) {
padding: 0 14px;
line-height: 1.45;
}
.payment-config-table :deep(th.el-table__cell) {
background: #ffffff;
color: #737b8b;
font-weight: 600;
}
.payment-config-table :deep(.payment-config-row-default td.el-table__cell) {
background: #f8fbff;
}
.payment-config-table :deep(.payment-config-row-default td.el-table__cell:first-child) {
box-shadow: inset 3px 0 0 #2563eb;
}
.config-tag {
min-width: 46px;
justify-content: center;
border-radius: 5px;
font-weight: 600;
}
.config-tag.pay-way-alipay {
color: #1d4ed8;
background: #eff6ff;
border-color: #bfdbfe;
}
.config-tag.pay-way-wechat {
color: #047857;
background: #ecfdf5;
border-color: #a7f3d0;
}
.config-tag.pay-way-other {
color: #475569;
background: #f8fafc;
border-color: #cbd5e1;
}
.config-tag.env-production {
color: #475569;
background: #f8fafc;
border-color: #cbd5e1;
}
.config-tag.env-sandbox {
color: #92400e;
background: #fffbeb;
border-color: #fde68a;
}
.config-tag.env-other {
color: #64748b;
background: #f8fafc;
border-color: #cbd5e1;
}
.config-tag.config-active {
color: #075985;
background: #e0f2fe;
border-color: #7dd3fc;
}
.config-tag.config-disabled {
color: #64748b;
background: #f1f5f9;
border-color: #cbd5e1;
}
.config-tag.config-testing {
color: #92400e;
background: #fffbeb;
border-color: #fde68a;
}
.default-badge,
.standby-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
height: 24px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.default-badge {
gap: 4px;
color: #ffffff;
background: #1d4ed8;
box-shadow: 0 4px 10px rgb(29 78 216 / 0.16);
}
.default-badge .el-icon {
font-size: 13px;
}
.standby-badge {
color: #94a3b8;
background: transparent;
font-weight: 600;
}
.usage-cell {
display: grid;
gap: 4px;
font-size: 12px;
line-height: 1.35;
color: #3f4654;
white-space: nowrap;
}
.usage-reference-locked {
color: #dc2626;
font-weight: 600;
}
.action-buttons {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.action-buttons :deep(.el-button + .el-button) {
margin-left: 0;
}
.action-button {
min-width: 54px;
height: 28px;
padding: 0 7px;
border-radius: 6px;
font-weight: 500;
}
.payment-config-table :deep(.operation-column .cell) {
padding: 0 10px;
}
.action-button.view {
color: #1d4ed8;
background: #eff6ff;
border-color: #bfdbfe;
}
.action-button.view:hover {
color: #ffffff;
background: #2563eb;
border-color: #2563eb;
}
.action-button.edit {
color: #047857;
background: #ecfdf5;
border-color: #a7f3d0;
}
.action-button.edit:hover {
color: #ffffff;
background: #059669;
border-color: #059669;
}
.action-button.delete {
color: #dc2626;
background: #fef2f2;
border-color: #fecaca;
}
.action-button.delete:hover {
color: #ffffff;
background: #dc2626;
border-color: #dc2626;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>