738 lines
18 KiB
Vue
738 lines
18 KiB
Vue
<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>
|