57543cfade
- 新增 useHjcPayFlow(select/code 两段状态机)与 HjcPayPanel(两处共用): 进页只查单、不发起支付,不预选支付方式;出码后不再有切换控件, 只能显式「更换支付方式」回到选择态(不自动重发、不停查单) - 发起失败留在选择态并保留所选(渠道侧什么都没产生,没有理由锁住用户) - 收银台 pages/tender/pay.vue 只留订单侧职责:取单、终态屏、超时锁、付款成功跳列表 - 购标流程 BuyDocument:下单后停在选择态(不再按默认微信自动发起), 补支付剩余时间与超时锁,超时后回表单步重新下单 - 订单详情:只有已完成订单才显示支付方式渠道名,其余显示「—」; 标签按前缀判,修掉 ALIPAY_QR 被显示成「微信支付」 - useHjcPayStatus:新增结构化 channelClosed——「渠道单已关闭」在选择态说 「重新选择支付方式」、出码态才说「重新获取二维码」,不靠匹配 message 字符串 Refs .scratch/cashier-pay-method-selection/issues/01-pay-method-before-pay.md
334 lines
13 KiB
Vue
334 lines
13 KiB
Vue
<template>
|
||
<div class="container mx-auto px-4 py-10">
|
||
<div class="mb-6 flex items-center justify-between">
|
||
<h1 class="text-2xl font-bold text-gray-900">订单详情</h1>
|
||
<HjcUserBar />
|
||
</div>
|
||
|
||
<div v-if="!loggedIn" class="py-20 text-center">
|
||
<p class="text-gray-500">请先登录后查看订单</p>
|
||
<NuxtLink
|
||
:to="`/login?redirect=${loginRedirect}`"
|
||
class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
||
>
|
||
去登录
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div v-else-if="error" class="py-20 text-center">
|
||
<p class="text-red-500">{{ error }}</p>
|
||
<NuxtLink
|
||
to="/tender/orders"
|
||
class="mt-6 inline-block rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
|
||
>
|
||
返回我的订单
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<SiteLoading v-else-if="!loaded" />
|
||
|
||
<template v-else-if="order">
|
||
<p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
|
||
|
||
<!-- 标题与状态 -->
|
||
<div class="rounded-lg border border-gray-200 p-5">
|
||
<div class="flex items-start justify-between gap-4">
|
||
<p class="font-semibold text-gray-900">{{ order.projectName }}</p>
|
||
<span class="shrink-0 rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(order)">
|
||
{{ hjcOrderStatusLabel(order) }}
|
||
</span>
|
||
</div>
|
||
<p class="mt-1 text-sm text-gray-500">订单号:{{ order.orderNo }}</p>
|
||
</div>
|
||
|
||
<!--
|
||
标书附件:**已支付订单**才有(`tenderFiles` 由后端按 payStatus=1 下发)。
|
||
未支付的单这里给一句说明而不是空区块——用户要知道「下载在什么时候出现」,
|
||
而不是以为平台没有这个功能。
|
||
-->
|
||
<div v-if="isPaid" class="mt-4 rounded-lg border border-gray-200 p-5">
|
||
<h2 class="mb-3 font-bold text-gray-900">标书附件</h2>
|
||
<HjcTenderFiles v-if="order.tenderFiles?.length" :files="order.tenderFiles" />
|
||
<p v-else class="text-sm text-gray-500">该项目暂无标书附件,如有疑问请联系平台客服。</p>
|
||
</div>
|
||
<div v-else class="mt-4 rounded-lg border border-gray-200 p-5">
|
||
<h2 class="mb-2 font-bold text-gray-900">标书附件</h2>
|
||
<p class="text-sm text-gray-500">支付成功后可在此下载标书附件。</p>
|
||
</div>
|
||
|
||
<!--
|
||
电子发票:**有才有**——甲方开票后推给平台,平台留副本再下发(ADR 0006)。
|
||
所以这里不判"是否已支付":没开票就是没有这条记录,跟付款状态无关;
|
||
作废(红冲)的也列出来且仍可下载(留档凭证)。
|
||
-->
|
||
<div v-if="order.invoices?.length" class="mt-4 rounded-lg border border-gray-200 p-5">
|
||
<h2 class="mb-3 font-bold text-gray-900">电子发票</h2>
|
||
<HjcInvoiceFiles :files="order.invoices" />
|
||
</div>
|
||
|
||
<!--
|
||
待支付订单的操作区(与列表页同一判定)。
|
||
`canAct` = hjcCanActOnOrder 再排除「支付时限已到」——已超时的单后端会拒绝出码,
|
||
留一个点了必然失败的「去支付」只会让人白点一次。
|
||
-->
|
||
<div v-if="canAct" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
|
||
<button
|
||
type="button"
|
||
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||
:disabled="cancelling"
|
||
@click="askCancel"
|
||
>
|
||
{{ cancelling ? '取消中...' : '取消订单' }}
|
||
</button>
|
||
<NuxtLink
|
||
:to="`/tender/pay?orderNo=${encodeURIComponent(order.orderNo)}`"
|
||
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
|
||
>
|
||
去支付
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- 标书信息 -->
|
||
<div class="mt-4 rounded-lg border border-gray-200 p-5">
|
||
<h2 class="mb-3 font-bold text-gray-900">标书信息</h2>
|
||
<dl class="space-y-2 text-sm">
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">项目编号</dt>
|
||
<dd class="text-right text-gray-900">{{ order.projectNo || '-' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">单价</dt>
|
||
<dd class="text-right text-gray-900">¥{{ formatMoney(order.unitPrice) }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">数量</dt>
|
||
<dd class="text-right text-gray-900">x{{ order.quantity ?? 1 }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">实付金额</dt>
|
||
<dd class="text-right font-bold text-blue-600">¥{{ formatMoney(order.totalAmount) }}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<!-- 支付信息 -->
|
||
<div class="mt-4 rounded-lg border border-gray-200 p-5">
|
||
<h2 class="mb-3 font-bold text-gray-900">支付信息</h2>
|
||
<dl class="space-y-2 text-sm">
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">订单号</dt>
|
||
<dd class="break-all text-right text-gray-900">{{ order.orderNo }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">支付方式</dt>
|
||
<dd class="text-right text-gray-900">{{ payMethodLabel }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">支付状态</dt>
|
||
<dd class="text-right text-gray-900">{{ hjcOrderStatusLabel(order) }}</dd>
|
||
</div>
|
||
<div v-if="order.payTime" class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">支付时间</dt>
|
||
<dd class="text-right text-gray-900">{{ fmtTime(order.payTime) }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">下单时间</dt>
|
||
<dd class="text-right text-gray-900">{{ fmtTime(order.createTime) }}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<!-- 购买人信息 -->
|
||
<div class="mt-4 rounded-lg border border-gray-200 p-5">
|
||
<h2 class="mb-3 font-bold text-gray-900">购买人信息</h2>
|
||
<dl class="space-y-2 text-sm">
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">企业</dt>
|
||
<dd class="text-right text-gray-900">{{ order.enterpriseName || '-' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">联系人</dt>
|
||
<dd class="text-right text-gray-900">{{ order.contactName || '-' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">联系电话</dt>
|
||
<dd class="text-right text-gray-900">{{ order.contactPhone || '-' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt class="text-gray-500">联系邮箱</dt>
|
||
<dd class="break-all text-right text-gray-900">{{ order.contactEmail || '-' }}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<NuxtLink to="/tender/orders" class="mt-6 inline-block text-sm text-blue-600 hover:underline">
|
||
← 返回我的订单
|
||
</NuxtLink>
|
||
</template>
|
||
|
||
<HjcConfirmDialog
|
||
:open="cancelDialogOpen"
|
||
title="确定取消该订单吗?"
|
||
content="取消后订单不可恢复,如需购买请重新下单。"
|
||
confirm-text="确定取消"
|
||
cancel-text="再想想"
|
||
loading-text="取消中..."
|
||
:loading="cancelling"
|
||
@confirm="confirmCancel"
|
||
@cancel="cancelDialogOpen = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { HjcOrder } from '~/types/tender'
|
||
import {
|
||
hjcCanActOn,
|
||
hjcOrderStatusClass,
|
||
hjcOrderStatusKey,
|
||
hjcOrderStatusLabel
|
||
} from '~/utils/orderStatus'
|
||
|
||
/**
|
||
* 订单详情页(PC 端)。
|
||
*
|
||
* 此前 PC 没有这个页面:买家从列表只能看状态、点不了任何东西,「已支付后下载标书」在 PC
|
||
* 完全没有落点(`.scratch/hjc-web-order-detail/issues/01`)。本页的两个职责就是
|
||
* ①展示订单全貌 ②给**已支付**订单提供标书附件下载。
|
||
*
|
||
* 附件地址来自后端 `tenderFiles`(只有 payStatus=1 才下发),本页**不做权限判断**、
|
||
* 也不从项目字段拼地址——门槛在服务端。
|
||
*
|
||
* 取数必须用 `useRequestFetch()`,不能用裸 `$fetch`:SSR 期间裸 `$fetch` 不转发浏览器
|
||
* Cookie,代理会变成匿名调用并拿到 code=401,`handleAuthCode` 于是清掉 Token ——
|
||
* 刷新详情页 = 把自己登出(完整说明见 app/pages/tender/orders/index.vue:101-110)。
|
||
*/
|
||
const route = useRoute()
|
||
const { isLoggedIn, handleAuthCode } = useHjcAuth()
|
||
const requestFetch = useRequestFetch()
|
||
|
||
const orderId = computed(() => String(route.params.id || ''))
|
||
const loginRedirect = computed(() => encodeURIComponent(route.fullPath))
|
||
|
||
const loggedIn = ref(false)
|
||
const order = shallowRef<HjcOrder | null>(null)
|
||
const loaded = ref(false)
|
||
const error = ref('')
|
||
/** 操作结果提示(取消成功/已支付),与 error 分开:它不是错误 */
|
||
const notice = ref('')
|
||
|
||
const cancelDialogOpen = ref(false)
|
||
const cancelling = ref(false)
|
||
|
||
/** 已支付:标书附件是否下发的唯一依据(与后端 `isPaid` 同一口径,只看 payStatus) */
|
||
const isPaid = computed(() => order.value?.payStatus === 1)
|
||
|
||
/**
|
||
* 「支付方式」这一行的显示口径。
|
||
*
|
||
* **只有已完成的订单才把 `payMethod` 当事实**:它建单时就被写成 `WECHAT_NATIVE`,只有真发起过
|
||
* 一次支付才会被渠道响应改写——所以未支付订单上的值并不代表买家选过什么,也区分不了
|
||
* "没选过"与"选过微信没付"(见 CONTEXT.md 的「支付渠道」)。未支付一律显示「—」,
|
||
* 不去声称一种买家可能根本没选过的方式。
|
||
*
|
||
* 标签按**前缀**判而不是精确等值:支付宝侧有 `ALIPAY`(电脑网站支付)与 `ALIPAY_QR`(扫码),
|
||
* 原先只认 `ALIPAY` 一个值,扫码支付过的单在详情里会被显示成"微信支付"。
|
||
*/
|
||
const payMethodLabel = computed(() => {
|
||
const o = order.value
|
||
if (!o || hjcOrderStatusKey(o) !== 'done') return '—'
|
||
const method = String(o.payMethod || '')
|
||
if (method.startsWith('ALIPAY')) return '支付宝'
|
||
if (method.startsWith('WECHAT')) return '微信支付'
|
||
return '—'
|
||
})
|
||
|
||
/**
|
||
* 还能不能操作(去支付 / 取消):共享判定,**不在这里重写一遍**——
|
||
* 两个页面各写一份必然分叉,出现"列表里不能点、详情里能点"。
|
||
*/
|
||
const canAct = computed(() => hjcCanActOn(order.value))
|
||
|
||
function formatMoney(n?: number | string) {
|
||
return Number(n ?? 0).toFixed(2)
|
||
}
|
||
function fmtTime(s?: string) {
|
||
return s ? s.slice(0, 19).replace('T', ' ') : '-'
|
||
}
|
||
|
||
async function reload() {
|
||
try {
|
||
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
|
||
const res: any = await requestFetch(`/api/tender/order-detail?id=${encodeURIComponent(orderId.value)}`)
|
||
const auth = await handleAuthCode(res, route.fullPath)
|
||
if (auth.handled) {
|
||
// 401 已清凭据并跳登录页;403 只提示,不清 token
|
||
error.value = auth.message || ''
|
||
loggedIn.value = isLoggedIn()
|
||
return
|
||
}
|
||
if (res?.code === 0 && res.data) {
|
||
order.value = res.data as HjcOrder
|
||
error.value = ''
|
||
} else {
|
||
order.value = null
|
||
error.value = res?.message || '订单加载失败'
|
||
}
|
||
} catch (e: any) {
|
||
order.value = null
|
||
error.value = e?.data?.message || e?.message || '订单加载失败'
|
||
}
|
||
}
|
||
|
||
function askCancel() {
|
||
notice.value = ''
|
||
cancelDialogOpen.value = true
|
||
}
|
||
|
||
async function confirmCancel() {
|
||
const o = order.value
|
||
if (!o) return
|
||
cancelling.value = true
|
||
notice.value = ''
|
||
try {
|
||
const res: any = await $fetch('/api/tender/cancel', { method: 'POST', body: { orderNo: o.orderNo } })
|
||
const auth = await handleAuthCode(res, route.fullPath)
|
||
if (auth.handled) {
|
||
notice.value = auth.message || ''
|
||
return
|
||
}
|
||
if (res?.code !== 0) {
|
||
notice.value = res?.message || '取消失败,请稍后重试'
|
||
return
|
||
}
|
||
// 「其实已经付了」**不是错误**:后端同样返回 code=0,结论在结构化字段里。
|
||
// 按字段判定,绝不匹配 message 字符串(与列表页同一口径)。
|
||
const d = res.data || {}
|
||
if (d.cancelled) {
|
||
notice.value = d.verified === false
|
||
// 微信不可达时如实告知没核对上,而不是替它下结论
|
||
? '订单已取消(未能在微信侧核对,如你已付款请稍后刷新订单)'
|
||
: '订单已取消'
|
||
} else if (d.paid) {
|
||
notice.value = '该订单已支付成功,无需取消'
|
||
} else {
|
||
notice.value = res?.message || '取消未生效,请刷新后查看订单状态'
|
||
}
|
||
// 无论哪种结论都就地重取:状态可能已在服务端变化(含被自愈成已支付),
|
||
// 而详情页正是「支没支付、能不能下载」要显示对的地方。
|
||
await reload()
|
||
} catch (e: any) {
|
||
notice.value = e?.data?.message || e?.message || '取消失败,请稍后重试'
|
||
} finally {
|
||
cancelling.value = false
|
||
cancelDialogOpen.value = false
|
||
}
|
||
}
|
||
|
||
loggedIn.value = isLoggedIn()
|
||
if (loggedIn.value) {
|
||
await reload()
|
||
}
|
||
loaded.value = true
|
||
</script>
|