417 lines
13 KiB
Vue
417 lines
13 KiB
Vue
<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>
|