Files
yunzerwebsiteallinone/platform/src/views/system/payment/orders/detail.vue
T
2026-09-15 12:46:29 +08:00

417 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" 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>