Files
yunzerwebsiteallinone/platform/src/views/system/payment/index.vue
T

537 lines
13 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="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>