Files
hjc-web/app/pages/tender/orders/[id].vue
T
weicw-dshsa 57543cfade feat(order): 收银台与购标流程改为先选支付方式再发起支付
- 新增 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
2026-09-30 09:16:36 +08:00

334 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 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>