增加支付功能
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user