Files
hjc-web/app/pages/tender/orders/[id].vue
T
weicw-dshsa 9587a27ede feat(order): 收银台支付倒计时,超时锁死并引导重新下单
- 到期时刻由后端下发(payDeadline),倒计时与"到点锁页面"都用它,前端不自己算时限
- 解析后端时间时把空格换成 T:后端格式是 `yyyy-MM-dd HH:mm:ss`,
  而 iOS Safari / 微信内置浏览器解析不了不带 T 的写法(返回 NaN,
  会让倒计时与锁页在最主要的那一端静默失效)
- 修正收银台写死的「2 小时」文案:有效期由后端配置,页面不该复述一个具体数字
- 订单列表/详情:超时后不再给「去支付」,并显示「已取消(支付超时)」
- `hjcCanActOn` 收成共享导出,两个页面不再各写一份判定
- 与 hjc-h5 的 utils/orderStatus 保持同一套定义(两仓逐字对齐)
2026-09-29 17:44:59 +08:00

314 lines
12 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,
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)
const payMethodLabel = computed(() => (order.value?.payMethod === 'ALIPAY' ? '支付宝' : '微信支付'))
/**
* 还能不能操作(去支付 / 取消):共享判定,**不在这里重写一遍**——
* 两个页面各写一份必然分叉,出现"列表里不能点、详情里能点"。
*/
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>