增加支付功能

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
@@ -0,0 +1,416 @@
<template>
<div class="container-box" v-loading="loading">
<el-page-header :content="payNo || '支付订单详情'" @back="goBack">
<template #extra>
<div class="header-actions">
<el-button :loading="querying" @click="handleQueryChannel">
<el-icon><Refresh /></el-icon>
手动查询渠道状态
</el-button>
<el-button type="danger" plain :disabled="!canRefund" @click="handleManualRefund">
手动原路退回
</el-button>
<el-button @click="goBack">返回列表</el-button>
</div>
</template>
</el-page-header>
<el-divider></el-divider>
<!-- 支付单基本信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">支付单信息</span>
<StatusTag :status="detail.status" dict="order" />
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="支付单号">{{ detail.pay_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付状态">
<StatusTag :status="detail.status" dict="order" />
</el-descriptions-item>
<el-descriptions-item label="支付金额">
<span class="amount">¥{{ fenToYuan(detail.amount) }}</span>
<span class="text-muted">({{ detail.amount }} 分)</span>
</el-descriptions-item>
<el-descriptions-item label="币种">{{ detail.currency || 'CNY' }}</el-descriptions-item>
<el-descriptions-item label="支付渠道">{{ channelLabel(detail.channel) }}</el-descriptions-item>
<el-descriptions-item label="渠道流水号">{{ detail.channel_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.create_time || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付完成时间">{{ detail.paid_at || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 业务订单信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">业务订单信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="业务订单号">{{ detail.out_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单类型">{{ orderTypeLabel(detail.order_type) }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ detail.tenant_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户编号">{{ detail.tenant_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单金额">
<span class="amount">¥{{ fenToYuan(detail.order_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="业务订单状态">
<StatusTag :status="detail.order_status" dict="order" />
</el-descriptions-item>
<el-descriptions-item label="订单摘要" :span="2">
{{ detail.subject || '-' }}
</el-descriptions-item>
</el-descriptions>
<el-alert
class="order-tip"
type="info"
:closable="false"
show-icon
title="业务订单与支付单是两张表:一个业务订单可能对应多笔支付单(如超时重下),以状态为 paid 的支付单为准。"
/>
</el-card>
<!-- 渠道信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="渠道">{{ channelLabel(detail.channel) }}</el-descriptions-item>
<el-descriptions-item label="商户号">{{ detail.merchant_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道流水号">{{ detail.channel_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="回调时间">{{ detail.notify_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="回调地址" :span="2">
{{ detail.notify_url || '-' }}
</el-descriptions-item>
<el-descriptions-item label="最近查询时间" :span="2">
{{ detail.last_query_at || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="raw-block">
<div class="raw-title">渠道回调原文(脱敏后摘要)</div>
<pre class="raw-json">{{ detail.callback_raw || '暂无回调数据' }}</pre>
</div>
</el-card>
<!-- 状态流转记录 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">状态流转记录</span>
</template>
<el-timeline v-if="detail.records && detail.records.length">
<el-timeline-item
v-for="(item, index) in detail.records"
:key="index"
:timestamp="item.create_time"
placement="top"
>
<div class="record-row">
<StatusTag :status="item.to_status" dict="order" />
<span class="record-remark">{{ item.remark }}</span>
</div>
<div class="record-operator">操作来源:{{ item.operator }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无流转记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getOrderDetail, queryPayment, refundPayment } from '@/api/payment'
const route = useRoute()
const router = useRouter()
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const LIST_PATH = '/system/payment/orders'
const payNo = computed(() => String(route.query.payNo || ''))
/** 从佣金台账等入口跳转时可能只带业务订单号 */
const outTradeNoQuery = computed(() => String(route.query.outTradeNo || ''))
const loading = ref(false)
const querying = ref(false)
/** 状态流转记录:对应 yz_platform_payment_order_log(to_status / operator / remark / create_time) */
interface RecordItem {
to_status: string
create_time: string
operator: string
remark: string
}
const detail = reactive<Record<string, any>>({
pay_no: '',
status: '',
amount: 0,
currency: 'CNY',
channel: '',
channel_trade_no: '',
create_time: '',
paid_at: '',
out_trade_no: '',
order_type: 'platform_usage',
tenant_name: '',
tenant_id: '',
order_amount: 0,
order_status: '',
subject: '',
merchant_no: '',
notify_at: '',
notify_url: '',
last_query_at: '',
callback_raw: '',
records: [] as RecordItem[]
})
/** 仅支付成功且未退款时允许手动原路退回(兜底能力) */
const canRefund = computed(() => detail.status === 'paid')
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
const orderTypeMap: Record<string, string> = {
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/* ---------------- 数据加载 ---------------- */
async function loadDetail() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求(后端支持按 payNo 或 outTradeNo 查询)
// const res = await getOrderDetail(payNo.value || outTradeNoQuery.value)
// if (res.success) Object.assign(detail, res.data)
Object.assign(detail, {
pay_no: payNo.value || 'P202609150009',
out_trade_no: outTradeNoQuery.value || 'ORD202609150001',
status: 'paid',
amount: 299900,
currency: 'CNY',
channel: 'wechat',
channel_trade_no: '4200001234202609150987654321',
create_time: '2026-09-15 10:23:12',
paid_at: '2026-09-15 10:24:31',
out_trade_no: 'ORD202609150001',
order_type: 'platform_usage',
tenant_name: '杭州云泽科技有限公司',
tenant_id: 'T10001',
order_amount: 299900,
order_status: 'paid',
subject: '平台使用费 - 2026 年度(标准版)',
merchant_no: '1620888999',
notify_at: '2026-09-15 10:24:33',
notify_url: `${window.location.origin}/api/payment/callback/wechat`,
last_query_at: '2026-09-15 10:25:02',
callback_raw: JSON.stringify(
{
id: 'evt_20260915102431001',
event_type: 'TRANSACTION.SUCCESS',
resource: {
out_trade_no: 'ORD202609150001',
transaction_id: '4200001234202609150987654321',
trade_state: 'SUCCESS',
amount: { total: 299900, payer_total: 299900, currency: 'CNY' }
}
},
null,
2
),
records: [
{
to_status: 'created',
create_time: '2026-09-15 10:23:12',
operator: '租户端下单页',
remark: '创建支付单,渠道:微信支付(Native 扫码)'
},
{
to_status: 'paying',
create_time: '2026-09-15 10:23:35',
operator: '渠道回调',
remark: '用户扫码完成付款,渠道返回支付中'
},
{
to_status: 'paid',
create_time: '2026-09-15 10:24:31',
operator: '渠道回调',
remark: '支付成功,订单金额 299900 分,已同步业务订单状态为已支付'
}
] as RecordItem[]
})
} catch (error: any) {
ElMessage.error(error?.message || '获取支付单详情失败')
} finally {
loading.value = false
}
}
/* ---------------- 操作 ---------------- */
/** 手动查询渠道状态:主动调用渠道查询接口并回写本地状态(兜底回调丢失) */
async function handleQueryChannel() {
querying.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await queryPayment(payNo.value, { sync: true })
await new Promise((resolve) => setTimeout(resolve, 600))
detail.last_query_at = new Date().toLocaleString('zh-CN')
ElMessage.success('渠道状态已查询:SUCCESS(本地状态已同步)')
await loadDetail()
} catch (error: any) {
ElMessage.error(error?.message || '查询渠道状态失败')
} finally {
querying.value = false
}
}
/** 手动原路退回(兜底,不做业务层退款流程) */
async function handleManualRefund() {
let reason = ''
try {
const { value } = await ElMessageBox.prompt(
`确定对支付单 ${detail.pay_no} 发起原路退回吗?金额 ¥${fenToYuan(detail.amount)},此操作会真实出账且不可撤销。`,
'手动原路退回',
{
type: 'warning',
inputPlaceholder: '请输入退回原因(必填)',
inputValidator: (val: string) => (val && val.trim() ? true : '退回原因不能为空'),
confirmButtonText: '确认退回'
}
)
reason = value
} catch {
return
}
try {
// TODO: 接口联通后替换为真实请求
// await refundPayment({ pay_no: detail.pay_no, amount: detail.amount, reason })
detail.status = 'refunding'
detail.records.unshift({
to_status: 'refunding',
create_time: new Date().toLocaleString('zh-CN'),
operator: '平台管理员',
remark: `手动原路退回:${reason}`
})
ElMessage.success('退回申请已提交,请以渠道退回结果为准')
} catch (error: any) {
ElMessage.error(error?.message || '退回失败')
}
}
function goBack() {
router.push(LIST_PATH)
}
/** 金额:分 -> 元 */
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(() => {
loadDetail()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
.block-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.text-muted {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.order-tip {
margin-top: 12px;
}
.raw-block {
margin-top: 16px;
.raw-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.raw-json {
margin: 0;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 6px;
font-size: 12px;
line-height: 1.7;
color: #606266;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
}
.record-row {
display: flex;
align-items: center;
gap: 8px;
}
.record-remark {
font-size: 13px;
color: #303133;
}
.record-operator {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
</style>