537 lines
13 KiB
Vue
537 lines
13 KiB
Vue
<template>
|
||
<div class="container-box">
|
||
<div class="header-bar">
|
||
<div>
|
||
<h2>支付管理</h2>
|
||
<p class="sub-title">
|
||
平台向租户收取平台使用费,款项直接进入平台对公账户;不涉及分账与业务退款(仅保留后台手动原路退回兜底),推广佣金为平台自有资金支出。
|
||
</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<el-button :loading="loading" @click="refresh">
|
||
<el-icon><Refresh /></el-icon>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<el-divider></el-divider>
|
||
|
||
<!-- 一、渠道状态概览 -->
|
||
<el-card shadow="never" class="block-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span class="card-title">渠道状态概览</span>
|
||
<div class="card-header-right">
|
||
<span class="card-tip">共 {{ channelList.length }} 个渠道,已启用 {{ enabledCount }} 个</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="channel-card-grid" v-loading="loading">
|
||
<ChannelCard
|
||
v-for="row in channelList"
|
||
:key="row.channel"
|
||
:channel="row.channel"
|
||
:name="row.name"
|
||
:status="row.status"
|
||
:merchant-no="row.merchant_no"
|
||
:icon="row.icon"
|
||
:desc="row.remark"
|
||
@config="goChannelConfig(row)"
|
||
@enable="toggleChannelStatus(row, true)"
|
||
@disable="toggleChannelStatus(row, false)"
|
||
/>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 二、快捷入口 -->
|
||
<div class="entry-grid">
|
||
<el-card
|
||
v-for="entry in entries"
|
||
:key="entry.key"
|
||
shadow="hover"
|
||
class="entry-card"
|
||
@click="goEntry(entry.path)"
|
||
>
|
||
<div class="entry-body">
|
||
<div class="entry-icon" :style="{ background: entry.color }">
|
||
<el-icon :size="22"><component :is="entry.icon" /></el-icon>
|
||
</div>
|
||
<div class="entry-text">
|
||
<div class="entry-title">{{ entry.title }}</div>
|
||
<div class="entry-desc">{{ entry.desc }}</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 三、最近交易 -->
|
||
<el-card shadow="never" class="block-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span class="card-title">最近交易(最近 10 条)</span>
|
||
<el-button size="small" type="primary" link @click="goEntry(ROUTES.orders)">
|
||
查看全部
|
||
<el-icon><ArrowRight /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<el-table :data="recentOrders" stripe v-loading="loading" style="width: 100%">
|
||
<el-table-column prop="tenant_name" label="租户" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="金额" width="140" align="right">
|
||
<template #default="{ row }">
|
||
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="渠道" width="140" align="center">
|
||
<template #default="{ row }">{{ channelName(row.channel) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<StatusTag :status="row.status" dict="order" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="时间" width="180" align="center">
|
||
<template #default="{ row }">{{ row.create_time || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="primary" link @click="goOrderDetail(row)">详情</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
Refresh,
|
||
ArrowRight,
|
||
Tickets,
|
||
Coin,
|
||
DataAnalysis,
|
||
Document
|
||
} from '@element-plus/icons-vue'
|
||
import StatusTag from './components/StatusTag.vue'
|
||
import ChannelCard from './components/ChannelCard.vue'
|
||
import { getChannelList, getOrders, toggleChannel } from '@/api/payment'
|
||
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
|
||
/** TODO: 路由 path 需与你配置的菜单 path 保持一致,例如实际为 /payment/orders 时请同步修改 */
|
||
const ROUTES = {
|
||
orders: '/system/payment/orders',
|
||
orderDetail: '/system/payment/orders/detail',
|
||
commission: '/system/payment/commission',
|
||
reconcile: '/system/payment/reconcile',
|
||
guide: '/system/payment/guide',
|
||
channels: {
|
||
wechat: '/system/payment/channels/wechat',
|
||
alipay: '/system/payment/channels/alipay',
|
||
unionpay: '/system/payment/channels/unionpay',
|
||
cloudpay: '/system/payment/channels/cloudpay',
|
||
paypal: '/system/payment/channels/paypal'
|
||
} as Record<string, string>
|
||
}
|
||
|
||
/* ---------------- 渠道概览 ---------------- */
|
||
|
||
interface ChannelRow {
|
||
channel: string
|
||
name: string
|
||
status: 'enabled' | 'disabled' | 'unconfigured'
|
||
merchant_no: string
|
||
icon: string
|
||
remark: string
|
||
}
|
||
|
||
const DEFAULT_CHANNEL_META: Record<string, { name: string; icon: string; remark: string }> = {
|
||
wechat: {
|
||
name: '微信支付',
|
||
icon: 'ChatDotRound',
|
||
remark: 'JSAPI / Native 扫码收款,平台使用费主渠道'
|
||
},
|
||
alipay: {
|
||
name: '支付宝',
|
||
icon: 'Wallet',
|
||
remark: '电脑网站支付 / 手机网站支付'
|
||
},
|
||
unionpay: {
|
||
name: '银联',
|
||
icon: 'CreditCard',
|
||
remark: '银联全渠道收单,需上传商户证书'
|
||
},
|
||
cloudpay: {
|
||
name: '云闪付',
|
||
icon: 'Lightning',
|
||
remark: '复用银联商户参数,仅控制收银台是否展示云闪付标识'
|
||
},
|
||
paypal: {
|
||
name: 'PayPal',
|
||
icon: 'Money',
|
||
remark: '面向港澳台及海外租户,当前无跨境需求,默认关闭'
|
||
}
|
||
}
|
||
|
||
const channelList = ref<ChannelRow[]>([
|
||
{
|
||
channel: 'wechat',
|
||
name: '微信支付',
|
||
status: 'unconfigured',
|
||
merchant_no: '',
|
||
icon: 'ChatDotRound',
|
||
remark: 'JSAPI / Native 扫码收款,平台使用费主渠道'
|
||
},
|
||
{
|
||
channel: 'alipay',
|
||
name: '支付宝',
|
||
status: 'unconfigured',
|
||
merchant_no: '',
|
||
icon: 'Wallet',
|
||
remark: '电脑网站支付 / 手机网站支付'
|
||
},
|
||
{
|
||
channel: 'unionpay',
|
||
name: '银联',
|
||
status: 'unconfigured',
|
||
merchant_no: '',
|
||
icon: 'CreditCard',
|
||
remark: '银联全渠道收单,需上传商户证书'
|
||
},
|
||
{
|
||
channel: 'cloudpay',
|
||
name: '云闪付',
|
||
status: 'unconfigured',
|
||
merchant_no: '',
|
||
icon: 'Lightning',
|
||
remark: '复用银联商户参数,仅控制收银台是否展示云闪付标识'
|
||
},
|
||
{
|
||
channel: 'paypal',
|
||
name: 'PayPal',
|
||
status: 'unconfigured',
|
||
merchant_no: '',
|
||
icon: 'Money',
|
||
remark: '面向港澳台及海外租户,当前无跨境需求,默认关闭'
|
||
}
|
||
])
|
||
|
||
const enabledCount = computed(() => channelList.value.filter((i) => i.status === 'enabled').length)
|
||
|
||
const channelName = (channel: string) =>
|
||
channelList.value.find((i) => i.channel === channel)?.name || channel
|
||
|
||
/* ---------------- 快捷入口 ---------------- */
|
||
|
||
const entries = [
|
||
{
|
||
key: 'orders',
|
||
title: '支付订单',
|
||
desc: '支付单、业务订单与状态流转',
|
||
icon: Tickets,
|
||
color: 'linear-gradient(135deg, #409eff, #79bbff)',
|
||
path: ROUTES.orders
|
||
},
|
||
{
|
||
key: 'commission',
|
||
title: '推广佣金',
|
||
desc: '佣金台账、规则与发放登记',
|
||
icon: Coin,
|
||
color: 'linear-gradient(135deg, #e6a23c, #f3d19e)',
|
||
path: ROUTES.commission
|
||
},
|
||
{
|
||
key: 'reconcile',
|
||
title: '对账管理',
|
||
desc: '渠道账单与本地的差异核对',
|
||
icon: DataAnalysis,
|
||
color: 'linear-gradient(135deg, #67c23a, #95de64)',
|
||
path: ROUTES.reconcile
|
||
},
|
||
{
|
||
key: 'guide',
|
||
title: '使用说明',
|
||
desc: '渠道配置、调用链与常见问题',
|
||
icon: Document,
|
||
color: 'linear-gradient(135deg, #909399, #b1b3b8)',
|
||
path: ROUTES.guide
|
||
}
|
||
]
|
||
|
||
/* ---------------- 最近交易 ---------------- */
|
||
|
||
interface RecentOrder {
|
||
pay_no: string
|
||
tenant_name: string
|
||
amount: number
|
||
channel: string
|
||
status: string
|
||
create_time: string
|
||
}
|
||
|
||
const recentOrders = ref<RecentOrder[]>([])
|
||
|
||
/** 最近 10 条 */
|
||
async function loadRecentOrders() {
|
||
try {
|
||
const res: any = await getOrders({ page: 1, pageSize: 10 })
|
||
if (res?.code === 200 && res.data) {
|
||
const list = res.data.list || []
|
||
recentOrders.value = list.map((item: any) => ({
|
||
pay_no: item.pay_no,
|
||
tenant_name: item.tenant_name || '-',
|
||
amount: item.amount,
|
||
channel: item.channel,
|
||
status: item.status,
|
||
create_time: item.create_time
|
||
}))
|
||
}
|
||
} catch (error: any) {
|
||
console.error('加载最近交易失败:', error)
|
||
}
|
||
}
|
||
|
||
/* ---------------- 数据加载与操作 ---------------- */
|
||
|
||
async function fetchChannels() {
|
||
try {
|
||
const res: any = await getChannelList()
|
||
if (res?.code === 200 && res.data) {
|
||
const list = res.data.list || []
|
||
channelList.value = list.map((item: any) => {
|
||
const meta = DEFAULT_CHANNEL_META[item.channel] || {
|
||
name: item.name,
|
||
icon: 'Money',
|
||
remark: ''
|
||
}
|
||
let status: 'enabled' | 'disabled' | 'unconfigured' = 'unconfigured'
|
||
if (item.status) {
|
||
status = item.status
|
||
} else if (item.enabled) {
|
||
status = 'enabled'
|
||
} else if (item.merchant_no || item.has_config || item.last_test_result) {
|
||
status = 'disabled'
|
||
}
|
||
return {
|
||
channel: item.channel,
|
||
name: item.name || meta.name,
|
||
status,
|
||
merchant_no: item.merchant_no || '',
|
||
icon: meta.icon || 'Money',
|
||
remark: item.remark || meta.remark || ''
|
||
}
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.response?.data?.msg || error?.message || '获取渠道列表失败')
|
||
}
|
||
}
|
||
|
||
async function fetchData() {
|
||
loading.value = true
|
||
try {
|
||
await Promise.allSettled([fetchChannels(), loadRecentOrders()])
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '加载支付概览失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
await fetchData()
|
||
ElMessage.success('刷新成功')
|
||
}
|
||
|
||
function goChannelConfig(row: ChannelRow) {
|
||
const path = ROUTES.channels[row.channel]
|
||
if (!path) {
|
||
ElMessage.warning('该渠道配置页路由未配置')
|
||
return
|
||
}
|
||
router.push(path)
|
||
}
|
||
|
||
function goEntry(path?: string) {
|
||
if (!path) return
|
||
router.push(path)
|
||
}
|
||
|
||
function goOrderDetail(row: RecentOrder) {
|
||
router.push({ path: ROUTES.orderDetail, query: { payNo: row.pay_no } })
|
||
}
|
||
|
||
async function toggleChannelStatus(row: ChannelRow, enable: boolean) {
|
||
try {
|
||
const res: any = await toggleChannel(row.channel, enable)
|
||
if (res?.code === 200) {
|
||
row.status = enable ? 'enabled' : 'disabled'
|
||
ElMessage.success(`${row.name}已${enable ? '启用' : '停用'}`)
|
||
} else {
|
||
ElMessage.error(res?.msg || '操作失败')
|
||
}
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
/* ---------------- 工具函数 ---------------- */
|
||
|
||
/** 金额:分 -> 元 */
|
||
function fenToYuan(fen: number | string | null | undefined): string {
|
||
const n = Number(fen ?? 0)
|
||
if (!Number.isFinite(n)) return '0.00'
|
||
return (n / 100).toFixed(2)
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.container-box {
|
||
padding: 20px;
|
||
}
|
||
|
||
.header-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 20px;
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sub-title {
|
||
margin: 8px 0 0;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: #909399;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.block-card {
|
||
margin-bottom: 20px;
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.card-header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.card-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
}
|
||
|
||
.channel-card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.channel-card-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.channel-card-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.text-muted {
|
||
color: #909399;
|
||
}
|
||
|
||
.amount {
|
||
font-weight: 600;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.entry-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.entry-card {
|
||
cursor: pointer;
|
||
transition: transform 0.2s;
|
||
|
||
&:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.entry-body {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.entry-icon {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 10px;
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.entry-text {
|
||
min-width: 0;
|
||
}
|
||
|
||
.entry-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.entry-desc {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.5;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.entry-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|