feat(tender): 我的订单加「去支付」「取消订单」入口,并新建收银台

hjc-web 此前**没有收银台**——支付步骤内嵌在购标流程(BuyDocument 的 step='pay')里,
买家一旦离开就再也回不到未完成的支付。本提交补上按订单号付款的独立页面。

- 新建 /tender/pay 收银台:只认订单号,金额取自**订单本身**(by-no)而非支付响应
  (支付一失败页面就会停在 0.00);进入先判状态,已支付/已取消/已退款**不发**统一下单,
  就地显示结论——既无意义,也会多撞一次跨端 OUT_TRADE_NO_USED
- 订单列表:待支付卡片加两个按钮 + 手搓 Tailwind 二次确认弹窗(照 ConsultDialog 的风格,
  不用 antd 的 Modal:买家页无先例,视觉与站点自建风格不一致)
- 状态口径改读 orderStatus(原先只看 payStatus,取消后会把已取消显示成「待支付」
  并带上「去支付」),抽 app/utils/orderStatus.ts,与 hjc-h5 刻意保持同一套优先级
- 新增两个 Nitro 代理 order-by-no.get.ts / cancel.post.ts,照既有 my-orders / pay-status
  的范式原样透传 ApiResult
- 标书详情「购买人数」改读 buyerCount(后端实时统计的已付款订单数)
- 跨端 OUT_TRADE_NO_USED:识别后端回带的 data.wechatCode 并给可行动提示,
  不匹配 message 字符串(包装方式一改就会静默失效)
This commit is contained in:
2026-09-17 03:39:59 +08:00
parent 81ef9317c7
commit a7399e0624
8 changed files with 681 additions and 34 deletions
+128 -32
View File
@@ -12,28 +12,67 @@
<div v-else-if="error" class="py-20 text-center text-red-500">{{ error }}</div>
<div v-else-if="orders.length" class="space-y-4">
<div v-for="o in orders" :key="o.id" class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-gray-900">{{ o.projectName }}</p>
<p class="mt-1 text-sm text-gray-500">订单号{{ o.orderNo }} · 项目编号{{ o.projectNo }}</p>
<template v-else>
<p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
<div v-if="orders.length" class="space-y-4">
<div v-for="o in orders" :key="o.id" class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-gray-900">{{ o.projectName }}</p>
<p class="mt-1 text-sm text-gray-500">订单号{{ o.orderNo }} · 项目编号{{ o.projectNo }}</p>
</div>
<span class="rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(o)">{{ hjcOrderStatusLabel(o) }}</span>
</div>
<div class="mt-4 flex items-center justify-between text-sm">
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
<span class="text-blue-600 font-bold">¥{{ formatMoney(o.totalAmount) }}</span>
</div>
<!--
待支付订单的操作区判定用 hjcCanActOnOrderpayStatus===0 && orderStatus!==2
**不用** statusKey==='pending'payStatus=2支付失败也会落进 pending 兜底
-->
<div v-if="hjcCanActOnOrder(o)" class="mt-4 flex justify-end gap-3 border-t border-gray-100 pt-4">
<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 && cancelTarget?.id === o.id"
@click="askCancel(o)"
>
取消订单
</button>
<NuxtLink
:to="`/tender/pay?orderNo=${encodeURIComponent(o.orderNo)}`"
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
>
去支付
</NuxtLink>
</div>
<span class="rounded px-2 py-0.5 text-xs" :class="payStatusClass(o.payStatus)">{{ payStatusText(o.payStatus) }}</span>
</div>
<div class="mt-4 flex items-center justify-between text-sm">
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
<span class="text-blue-600 font-bold">¥{{ formatMoney(o.totalAmount) }}</span>
</div>
</div>
</div>
<div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无订单</div>
<SiteLoading v-else />
<div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无订单</div>
<SiteLoading v-else />
</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 { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/**
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
@@ -52,6 +91,13 @@ const loggedIn = ref(false)
const orders = shallowRef<any[]>([])
const loaded = ref(false)
const error = ref('')
/** 操作结果提示(取消成功/已支付/未核对上),与 error 分开:它不是错误 */
const notice = ref('')
/** 正在确认取消的那张订单 + 弹窗开合 */
const cancelTarget = shallowRef<any | null>(null)
const cancelDialogOpen = ref(false)
const cancelling = ref(false)
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
@@ -59,29 +105,79 @@ function formatMoney(n?: number | string) {
function fmtTime(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-'
}
function payStatusText(s?: number) {
return ({ 0: '待支付', 1: '支付成功', 2: '支付失败', 3: '已退款' } as any)[s ?? 0] ?? '-'
/** 拉取(或重新拉取)订单列表。取消成功后走这里就地更新,不留整页 loading。 */
async function reload() {
try {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await requestFetch('/api/tender/my-orders')
const auth = await handleAuthCode(res, '/tender/orders')
if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
error.value = auth.message || ''
loggedIn.value = isLoggedIn()
return
}
if (res?.code === 0) {
orders.value = Array.isArray(res.data) ? res.data : []
error.value = ''
} else {
error.value = res?.message || '订单加载失败'
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '订单加载失败'
}
}
function payStatusClass(s?: number) {
if (s === 1) return 'bg-green-100 text-green-700'
if (s === 3) return 'bg-gray-100 text-gray-500'
return 'bg-amber-100 text-amber-700'
function askCancel(o: any) {
notice.value = ''
cancelTarget.value = o
cancelDialogOpen.value = true
}
async function confirmCancel() {
const o = cancelTarget.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, '/tender/orders')
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
cancelTarget.value = null
}
}
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await requestFetch('/api/tender/my-orders')
const auth = await handleAuthCode(res, '/tender/orders')
if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
error.value = auth.message || ''
loggedIn.value = isLoggedIn()
} else if (res?.code === 0) {
orders.value = Array.isArray(res.data) ? res.data : []
} else {
error.value = res?.message || '订单加载失败'
}
await reload()
loaded.value = true
} else {
loaded.value = true