增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+549
View File
@@ -0,0 +1,549 @@
<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>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="table">表格</el-radio-button>
<el-radio-button value="card">卡片</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<el-table
v-if="viewMode === 'table'"
:data="channelList"
stripe
v-loading="loading"
style="width: 100%"
>
<el-table-column label="渠道名称" min-width="180">
<template #default="{ row }">
<div class="channel-cell">
<el-icon :size="16" class="channel-icon">
<component :is="channelIcon(row.icon)" />
</el-icon>
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="channel" />
</template>
</el-table-column>
<el-table-column label="商户号" min-width="200">
<template #default="{ row }">
<span v-if="row.merchant_no">{{ row.merchant_no }}</span>
<span v-else class="text-muted">未配置</span>
</template>
</el-table-column>
<el-table-column label="说明" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goChannelConfig(row)">
<el-icon><Setting /></el-icon>
配置
</el-button>
<el-button
v-if="row.status === 'enabled'"
size="small"
type="danger"
link
@click="toggleChannelStatus(row, false)"
>
停用
</el-button>
<el-button
v-else
size="small"
type="success"
link
:disabled="row.status === 'unconfigured'"
@click="toggleChannelStatus(row, true)"
>
启用
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="channel-card-grid" v-loading="loading">
<ChannelCard
v-for="row in channelList"
:key="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,
Setting,
ArrowRight,
Tickets,
Coin,
DataAnalysis,
Document,
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money
} from '@element-plus/icons-vue'
import StatusTag from './components/StatusTag.vue'
import ChannelCard from './components/ChannelCard.vue'
// TODO: 接口联调时打开
// import { getChannelList, getOrders, toggleChannel } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
/** 渠道概览视图:table 表格 / card 卡片 */
const viewMode = ref<'table' | 'card'>('table')
/** 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>
}
/** 渠道图标映射(图标名与 ChannelCard 组件保持一致) */
const CHANNEL_ICONS: Record<string, any> = {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money
}
const channelIcon = (icon: string) => CHANNEL_ICONS[icon] || Money
/* ---------------- 渠道概览 ---------------- */
interface ChannelRow {
channel: string
name: string
status: 'enabled' | 'disabled' | 'unconfigured'
merchant_no: string
icon: string
remark: string
}
const channelList = ref<ChannelRow[]>([
{
channel: 'wechat',
name: '微信支付',
status: 'enabled',
merchant_no: '1620888999',
icon: 'ChatDotRound',
remark: 'JSAPI / Native 扫码收款,平台使用费主渠道'
},
{
channel: 'alipay',
name: '支付宝',
status: 'enabled',
merchant_no: '2088123456789012',
icon: 'Wallet',
remark: '电脑网站支付 / 手机网站支付'
},
{
channel: 'unionpay',
name: '银联',
status: 'unconfigured',
merchant_no: '',
icon: 'CreditCard',
remark: '银联全渠道收单,需上传商户证书'
},
{
channel: 'cloudpay',
name: '云闪付',
status: 'disabled',
merchant_no: '898110158000000',
icon: 'Lightning',
remark: '复用银联商户参数,仅控制收银台是否展示云闪付标识'
},
{
channel: 'paypal',
name: 'PayPal',
status: 'disabled',
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 条:TODO 换成 getOrders({ page: 1, pageSize: 10 }) */
function loadRecentOrders() {
recentOrders.value = [
{ pay_no: 'P202609150009', tenant_name: '杭州云泽科技有限公司', amount: 299900, channel: 'wechat', status: 'paid', create_time: '2026-09-15 10:24:31' },
{ pay_no: 'P202609150008', tenant_name: '宁波海曙贸易有限公司', amount: 99900, channel: 'alipay', status: 'paid', create_time: '2026-09-15 09:58:02' },
{ pay_no: 'P202609150007', tenant_name: '上海弘远信息技术有限公司', amount: 199900, channel: 'wechat', status: 'pending', create_time: '2026-09-15 09:31:47' },
{ pay_no: 'P202609150006', tenant_name: '深圳市博通电子商务有限公司', amount: 499900, channel: 'alipay', status: 'paid', create_time: '2026-09-15 09:12:15' },
{ pay_no: 'P202609150005', tenant_name: '北京中科智联科技有限公司', amount: 99900, channel: 'wechat', status: 'failed', create_time: '2026-09-15 08:56:39' },
{ pay_no: 'P202609140021', tenant_name: '成都天府软件有限公司', amount: 299900, channel: 'wechat', status: 'paid', create_time: '2026-09-14 18:42:10' },
{ pay_no: 'P202609140020', tenant_name: '武汉光谷数字科技有限公司', amount: 199900, channel: 'alipay', status: 'closed', create_time: '2026-09-14 17:20:55' },
{ pay_no: 'P202609140019', tenant_name: '西安丝路网络科技有限公司', amount: 99900, channel: 'wechat', status: 'paid', create_time: '2026-09-14 16:05:23' },
{ pay_no: 'P202609140018', tenant_name: '青岛海洋装备有限公司', amount: 599900, channel: 'alipay', status: 'paid', create_time: '2026-09-14 14:48:07' },
{ pay_no: 'P202609140017', tenant_name: '长沙湘江云计算有限公司', amount: 299900, channel: 'wechat', status: 'refunded', create_time: '2026-09-14 11:33:41' }
]
}
/* ---------------- 数据加载与操作 ---------------- */
async function fetchChannels() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelList()
// if (res.success) channelList.value = res.data.list || res.data || []
}
async function fetchData() {
loading.value = true
try {
await Promise.all([fetchChannels(), Promise.resolve(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) {
// TODO: 接口联通后替换为 toggleChannel(row.channel, enable)
row.status = enable ? 'enabled' : 'disabled'
ElMessage.success(`${row.name}已${enable ? '启用' : '停用'}`)
}
/* ---------------- 工具函数 ---------------- */
/** 金额:分 -> 元 */
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;
}
.channel-cell {
display: flex;
align-items: center;
gap: 8px;
.channel-icon {
color: #409eff;
}
}
.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>