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
View File
@@ -0,0 +1,128 @@
<template>
<ClientOnly>
<Teleport to="body">
<Transition name="hjc-confirm-fade">
<div
v-if="open"
class="fixed inset-0 z-[110] flex items-center justify-center p-4"
@click.self="onCancel"
>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/50" />
<!-- 弹窗卡片 ConsultDialog.vue 的手搓 Tailwind 风格不用 antd Modal
ant-design-vue 虽已装且自动导入但买家页没有先例视觉与站点自建风格不一致 -->
<Transition name="hjc-confirm-pop" appear>
<div
v-if="open"
class="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
:aria-label="title"
>
<h2 class="text-lg font-bold text-gray-900">{{ title }}</h2>
<p v-if="content" class="mt-2 text-sm leading-relaxed text-gray-600">{{ content }}</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="loading"
@click="onCancel"
>
{{ cancelText }}
</button>
<button
type="button"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="loading"
@click="emit('confirm')"
>
{{ loading ? loadingText : confirmText }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</ClientOnly>
</template>
<script setup lang="ts">
/**
* 通用二次确认弹窗(破坏性操作用)。
*
* 与 ConsultDialog.vue 同款实现路径:`ClientOnly` + `Teleport to="body"` + 手搓 Tailwind。
* 组件本身不持有任何业务状态——开合由父组件通过 `open` 控制,结果通过 `confirm`/`cancel` 事件回传,
* 这样"点遮罩不发请求"这类行为天然成立(父组件只在 confirm 里发请求)。
*/
const props = withDefaults(defineProps<{
open: boolean
title: string
content?: string
confirmText?: string
cancelText?: string
loadingText?: string
/** 确认动作进行中:两个按钮都禁用,防连点 */
loading?: boolean
}>(), {
content: '',
confirmText: '确定',
cancelText: '再想想',
loadingText: '处理中...',
loading: false
})
const emit = defineEmits<{ confirm: []; cancel: [] }>()
function onCancel() {
if (props.loading) return
emit('cancel')
}
/** ESC 关闭;打开时锁背景滚动,关闭时复位 */
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) onCancel()
}
watch(() => props.open, (isOpen) => {
if (!import.meta.client) return
document.body.style.overflow = isOpen ? 'hidden' : ''
})
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
if (import.meta.client) document.body.style.overflow = ''
})
</script>
<style scoped>
.hjc-confirm-fade-enter-active,
.hjc-confirm-fade-leave-active {
transition: opacity 0.2s ease;
}
.hjc-confirm-fade-enter-from,
.hjc-confirm-fade-leave-to {
opacity: 0;
}
.hjc-confirm-pop-enter-active {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease;
}
.hjc-confirm-pop-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.hjc-confirm-pop-enter-from {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
.hjc-confirm-pop-leave-to {
transform: scale(0.95);
opacity: 0;
}
</style>
+109 -13
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="error" class="py-20 text-center text-red-500">{{ error }}</div>
<div v-else-if="orders.length" class="space-y-4"> <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 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 class="flex items-start justify-between">
<div> <div>
<p class="font-semibold text-gray-900">{{ o.projectName }}</p> <p class="font-semibold text-gray-900">{{ o.projectName }}</p>
<p class="mt-1 text-sm text-gray-500">订单号{{ o.orderNo }} · 项目编号{{ o.projectNo }}</p> <p class="mt-1 text-sm text-gray-500">订单号{{ o.orderNo }} · 项目编号{{ o.projectNo }}</p>
</div> </div>
<span class="rounded px-2 py-0.5 text-xs" :class="payStatusClass(o.payStatus)">{{ payStatusText(o.payStatus) }}</span> <span class="rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(o)">{{ hjcOrderStatusLabel(o) }}</span>
</div> </div>
<div class="mt-4 flex items-center justify-between text-sm"> <div class="mt-4 flex items-center justify-between text-sm">
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span> <span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
<span class="text-blue-600 font-bold">¥{{ formatMoney(o.totalAmount) }}</span> <span class="text-blue-600 font-bold">¥{{ formatMoney(o.totalAmount) }}</span>
</div> </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>
</div> </div>
</div> </div>
<div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无订单</div> <div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无订单</div>
<SiteLoading v-else /> <SiteLoading v-else />
</template>
<HjcConfirmDialog
:open="cancelDialogOpen"
title="确定取消该订单吗?"
content="取消后订单不可恢复,如需购买请重新下单。"
confirm-text="确定取消"
cancel-text="再想想"
loading-text="取消中..."
:loading="cancelling"
@confirm="confirmCancel"
@cancel="cancelDialogOpen = false"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
const { isLoggedIn, handleAuthCode } = useHjcAuth() const { isLoggedIn, handleAuthCode } = useHjcAuth()
/** /**
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。 * 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
@@ -52,6 +91,13 @@ const loggedIn = ref(false)
const orders = shallowRef<any[]>([]) const orders = shallowRef<any[]>([])
const loaded = ref(false) const loaded = ref(false)
const error = ref('') 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) { function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2) return Number(n ?? 0).toFixed(2)
@@ -59,17 +105,10 @@ function formatMoney(n?: number | string) {
function fmtTime(s?: string) { function fmtTime(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-' return s ? s.slice(0, 19).replace('T', ' ') : '-'
} }
function payStatusText(s?: number) {
return ({ 0: '待支付', 1: '支付成功', 2: '支付失败', 3: '已退款' } as any)[s ?? 0] ?? '-'
}
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'
}
loggedIn.value = isLoggedIn() /** 拉取(或重新拉取)订单列表。取消成功后走这里就地更新,不留整页 loading。 */
if (loggedIn.value) { async function reload() {
try {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定) // 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await requestFetch('/api/tender/my-orders') const res: any = await requestFetch('/api/tender/my-orders')
const auth = await handleAuthCode(res, '/tender/orders') const auth = await handleAuthCode(res, '/tender/orders')
@@ -77,11 +116,68 @@ if (loggedIn.value) {
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token // 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
error.value = auth.message || '' error.value = auth.message || ''
loggedIn.value = isLoggedIn() loggedIn.value = isLoggedIn()
} else if (res?.code === 0) { return
}
if (res?.code === 0) {
orders.value = Array.isArray(res.data) ? res.data : [] orders.value = Array.isArray(res.data) ? res.data : []
error.value = ''
} else { } else {
error.value = res?.message || '订单加载失败' error.value = res?.message || '订单加载失败'
} }
} catch (e: any) {
error.value = e?.data?.message || e?.message || '订单加载失败'
}
}
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) {
await reload()
loaded.value = true loaded.value = true
} else { } else {
loaded.value = true loaded.value = true
+262
View File
@@ -0,0 +1,262 @@
<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="!orderNo" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<p class="text-gray-700">缺少订单号无法发起支付</p>
<NuxtLink to="/tender/orders" class="mt-6 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">返回我的订单</NuxtLink>
</div>
<SiteLoading v-else-if="loading" />
<div v-else-if="loadError" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<p class="text-red-500">{{ loadError }}</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>
<!--
终态已支付 / 已取消 / 已退款
**这一块必须先于发起支付判定**进一张已支付的单却先拉起一次统一下单既无意义
又会多撞一次跨端 OUT_TRADE_NO_USED多一次失败尝试不改变结论
-->
<div v-else-if="finalState" class="mx-auto mt-6 max-w-2xl rounded-lg border p-8 text-center" :class="finalState.cls">
<p class="text-xl font-bold">{{ finalState.title }}</p>
<p class="mt-2 text-sm opacity-80">订单号{{ orderNo }}</p>
<NuxtLink to="/tender/orders" class="mt-6 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">返回我的订单</NuxtLink>
</div>
<!-- 待支付进入即自动发起支付与购标流程的支付步骤h5 收银台一致 -->
<div v-else class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<h2 class="text-xl font-bold text-gray-900">请使用微信扫码支付</h2>
<p class="mt-2 text-sm text-gray-500">订单号{{ orderNo }}</p>
<p class="mt-1 text-sm text-gray-500">{{ order?.projectName }}</p>
<!-- 金额一律来自**订单本身**by-no不来自支付响应
支付一失败页面就会停在 0.00而收银台恰恰是最需要看到金额的地方 -->
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(amount) }}</p>
<template v-if="codeUrl">
<!-- 过期只是置灰提示仍留在页面上万一用户是刚过点就扫的还能看见自己扫的是哪张码 -->
<div :class="payExpired ? 'opacity-30' : ''">
<HjcPayQrcode :value="codeUrl" :size="240" />
</div>
<p class="mt-3 text-sm text-gray-500">请在 2 小时内用微信扫描上方二维码完成支付</p>
<p v-if="payExpired" class="mt-2 text-sm text-amber-600">二维码已过期请重新获取后再扫码</p>
</template>
<div v-else class="rounded-md bg-gray-50 px-4 py-6 text-sm text-gray-500">
{{ starting ? '正在获取支付二维码...' : '暂未获取到支付二维码,请点击下方按钮重新获取' }}
</div>
<button
v-if="codeUrl"
class="mt-6 w-full rounded-md bg-blue-600 py-3 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="confirming"
@click="confirmPaid"
>
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
</button>
<button
class="mt-6 w-full rounded-md py-3 font-medium disabled:opacity-50"
:class="codeUrl
? 'border border-gray-300 text-gray-700 hover:bg-gray-50'
: 'bg-blue-600 text-white hover:bg-blue-700'"
:disabled="refreshing || starting"
@click="refreshQr"
>
{{ (refreshing || starting) ? '获取中...' : (codeUrl ? '重新获取二维码' : '获取支付二维码') }}
</button>
<p v-if="error || payError" class="mt-3 text-sm text-red-500">{{ error || payError }}</p>
<p v-else-if="querying" class="mt-3 text-sm text-gray-400">正在确认支付状态...</p>
<div class="mt-6 border-t border-gray-100 pt-4">
<NuxtLink to="/tender/orders" class="text-sm text-blue-600 hover:underline"> 返回我的订单</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { hjcOrderStatusKey } from '~/utils/orderStatus'
/**
* 收银台:买家为一张**已创建的订单**付款的独立页面(CONTEXT.md 的「收银台」)。
*
* 与购标流程(`/buy` 的 BuyDocument `step==='pay'`)的分界:购标流程是「先建单、随即付款」,
* 收银台是事后回到某张待支付订单继续付款。**本轮不重构 BuyDocument**
* 两份实现共享的只有 `HjcPayQrcode` 与 `useHjcPayStatus`(重活已经都在那里了)。
*
* 本页**只服务微信外**ADR 0009):恒走 Native 扫码。不要在这里加"微信 UA 就跳 hjc-h5"
* 那条链在 .scratch/hjc-web-pay-qrcode/issues/06 里被"hjc-h5 上线"阻塞着。
*/
const route = useRoute()
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/** 收银台只认订单号(凭它取应付金额),没有自增 id */
const orderNo = computed(() => String(route.query.orderNo || ''))
const loginRedirect = computed(() => encodeURIComponent(route.fullPath))
const loggedIn = ref(false)
/** by-no 取单中 */
const loading = ref(false)
/** by-no 失败(订单不存在/无权限/网络),与"发起支付失败"分开 */
const loadError = ref('')
const order = shallowRef<any | null>(null)
/** 终态(已完成/已取消/已退款):终态下**绝不发起支付** */
const finalState = shallowRef<{ title: string; cls: string } | null>(null)
const codeUrl = ref('')
const amount = ref(0)
const starting = ref(false)
const refreshing = ref(false)
const confirming = ref(false)
const error = ref('')
/**
* 支付确认一律以**微信侧**为准(轮询 + 手动按钮都走它)。
* 绝不调用 `mark-paid`:那个接口不查单,点一下就能把订单置为已支付(见 ADR 0009)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
start: startPayPolling,
query: queryPayStatus
} = useHjcPayStatus(() => route.fullPath)
// 微信侧确认收款后跳订单列表(与 h5 收银台的 redirectTo 列表对齐)
watch(paid, async (v) => {
if (v) await navigateTo('/tender/orders')
})
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
/** 状态口径与订单列表同源(`~/utils/orderStatus`),避免两处各判一次导致分叉 */
function terminalStateOf(o: any) {
switch (hjcOrderStatusKey(o)) {
case 'done':
return { title: '该订单已完成支付', cls: 'border-green-200 bg-green-50 text-green-700' }
case 'cancel':
return { title: '该订单已取消', cls: 'border-gray-200 bg-gray-50 text-gray-600' }
case 'refund':
return { title: '该订单已退款', cls: 'border-gray-200 bg-gray-50 text-gray-600' }
default:
return null
}
}
/**
* 微信错误码 → 可行动的中文提示。
*
* `OUT_TRADE_NO_USED` = 同一订单号换了支付类型重复下单。微信内(h5 → 小程序,JSAPI)与
* PC 端(Native)各发起一次时必现,而"从订单列表回到旧单支付"这条路径正是本轮新开的。
* 错误码由后端从支付异常的 cause 链上取(`ServiceException.getErrorCode()`),
* 前端**不要**去匹配 message 字符串——那种写法会在错误包装方式一改时静默失效。
*/
function payErrorMessage(res: any) {
if (res?.data?.wechatCode === 'OUT_TRADE_NO_USED') {
return '该订单已在微信内发起过支付,请回到微信内的收银台继续完成付款。'
}
return res?.message || '发起支付失败,请稍后重试'
}
async function startPay() {
error.value = ''
starting.value = true
try {
const res: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
error.value = auth.message || ''
return
}
if (res?.code === 0) {
codeUrl.value = res.data?.codeUrl || ''
if (res.data?.amount) amount.value = Number(res.data.amount)
if (!codeUrl.value) error.value = '未获取到支付二维码,请稍后重试'
} else {
error.value = payErrorMessage(res)
}
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在微信侧付过了
//(例如在微信内由小程序付掉、或上一次支付成功但页面被关掉),查单会把它认出来并跳列表。
if (orderNo.value) startPayPolling(orderNo.value)
} catch (e: any) {
error.value = e?.data?.message || e?.message || '发起支付失败,请稍后重试'
} finally {
starting.value = false
}
}
/** 重新获取二维码:微信允许对同一订单号重复发起 Native 下单,顺带重置 2 小时计时 */
async function refreshQr() {
await startPay()
}
/** 手动确认(扫码后的兜底):**也走查单**,不代替微信下结论 */
async function confirmPaid() {
error.value = ''
confirming.value = true
try {
const res = await queryPayStatus()
if (res.handled) {
error.value = res.message || ''
return
}
if (res.settled) {
await navigateTo('/tender/orders')
return
}
error.value = res.message || '微信侧还未收到付款,请完成扫码支付后再试'
} finally {
confirming.value = false
}
}
loggedIn.value = isLoggedIn()
onMounted(async () => {
if (!loggedIn.value || !orderNo.value) return
loading.value = true
try {
// 客户端发起:这里用裸 $fetch 是安全的(浏览器会带 Cookie)。
// 服务端渲染期间才必须用 useRequestFetch——本页的取数刻意放在 onMounted,规避那个坑。
const res: any = await $fetch('/api/tender/order-by-no', { query: { orderNo: orderNo.value } })
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
loadError.value = auth.message || ''
return
}
if (res?.code !== 0) {
loadError.value = res?.message || '订单加载失败'
return
}
const o = res.data
order.value = o
// 金额来自订单本身,与"发起支付"解耦
amount.value = Number(o?.totalAmount ?? 0)
const terminal = terminalStateOf(o)
if (terminal) {
finalState.value = terminal
return
}
// 只有"确实还没付、也没取消"才发起支付
loading.value = false
await startPay()
} catch (e: any) {
loadError.value = e?.data?.message || e?.message || '订单加载失败'
} finally {
loading.value = false
}
})
</script>
@@ -54,7 +54,9 @@
<p class="mt-2 text-xs text-gray-400">购买成功后可在我的订单查看并下载标书</p> <p class="mt-2 text-xs text-gray-400">购买成功后可在我的订单查看并下载标书</p>
<div class="mt-5 space-y-2 text-sm text-gray-600"> <div class="mt-5 space-y-2 text-sm text-gray-600">
<p>购买人数{{ tender.saleCount || 0 }}</p> <!-- 购买人数取后端实时统计的**已付款订单数**buyerCount不是 saleCount
后者在下单时就 +quantity 且从不回退含未付款与已取消的单一直是假数 -->
<p>购买人数{{ tender.buyerCount || 0 }}</p>
<p>售卖方式{{ sellingMethodText }}</p> <p>售卖方式{{ sellingMethodText }}</p>
</div> </div>
+6 -1
View File
@@ -39,8 +39,13 @@ export interface Tender {
status?: number status?: number
/** 来源 */ /** 来源 */
dataSource?: string dataSource?: string
/** 已售数量 */ /** 已售数量(下单即加、从不回退;当前已无展示用途,历史字段) */
saleCount?: number saleCount?: number
/**
* 购买人数:后端实时统计的**已付款订单数**`COUNT(*) WHERE pay_status=1`)。
* 口径与「人数」字面有偏差(同一企业买 2 单计 2),这是明确拍定的取值。
*/
buyerCount?: number
} }
/** 分页返回结构 */ /** 分页返回结构 */
+69
View File
@@ -0,0 +1,69 @@
/**
* 订单状态展示口径(hjc-web)。
*
* **必须与 `hjc-h5/src/utils/orderStatus.js` 保持一致**——两端是不同仓库、不同技术栈
* Nuxt4 + TS / uni-app + JS),代码无法真正共享,只能靠"两处同一套定义"来对齐。
* 改这里的时候,另一边也要改。
*
* 优先级(从高到低):
* payStatus === 3 → 已退款
* orderStatus === 2 → 已取消
* payStatus === 1 || orderStatus === 1 → 已完成
* 其余 → 待支付
*
* 为什么需要它:hjc-web 此前**完全不读 `orderStatus`**(只看 payStatus),
* 于是订单一旦被置为已取消,卡片仍会显示「待支付」并带上「去支付」按钮。
*/
/** 订单状态归类键。四端(web / h5 / 小程序 / 后台)未来的展示都应落在这四个之一。 */
export type HjcOrderStatusKey = 'pending' | 'done' | 'cancel' | 'refund'
/** 判定所需的订单字段(只用到这两个,避免依赖完整的 HjcOrder 类型) */
export interface HjcOrderLike {
payStatus?: number | null
orderStatus?: number | null
}
export function hjcOrderStatusKey(o?: HjcOrderLike | null): HjcOrderStatusKey {
if (!o) return 'pending'
if (o.payStatus === 3) return 'refund'
if (o.orderStatus === 2) return 'cancel'
if (o.payStatus === 1 || o.orderStatus === 1) return 'done'
return 'pending'
}
const LABELS: Record<HjcOrderStatusKey, string> = {
pending: '待支付',
done: '已完成',
cancel: '已取消',
refund: '已退款'
}
export function hjcOrderStatusLabel(o?: HjcOrderLike | null): string {
return LABELS[hjcOrderStatusKey(o)]
}
/** 状态徽标的 Tailwind 配色 */
export function hjcOrderStatusClass(o?: HjcOrderLike | null): string {
switch (hjcOrderStatusKey(o)) {
case 'done':
return 'bg-green-100 text-green-700'
case 'cancel':
case 'refund':
return 'bg-gray-100 text-gray-500'
default:
return 'bg-amber-100 text-amber-700'
}
}
/**
* 这张订单是否该出现「去支付」「取消订单」。
*
* **不要用 `hjcOrderStatusKey(o) === 'pending'` 当条件**`payStatus=2`(支付失败)也会落进
* `pending` 这个兜底键。虽然它目前是死状态(全仓 `setPayStatus` 只有 0/1/3 三个写入点),
* 但把"能不能操作"绑在一个展示口径的兜底分支上,是下次改动最容易踩的坑。
*/
export function hjcCanActOnOrder(o?: HjcOrderLike | null): boolean {
if (!o) return false
return o.payStatus === 0 && o.orderStatus !== 2
}
+44
View File
@@ -0,0 +1,44 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 取消订单(买家取消自己的待支付订单,幂等)
* POST /api/tender/cancel { orderNo }
* 代理到 mp-api /api/hjc/order/cancel;需登录态。
*
* 约定:原样透传 ApiResult{code,message,data},由前端按 body.code 判定登录态与业务失败。
*
* 注意后端的返回语义:**「其实已经付了」不是错误**,它同样返回 code=0,
* 结论放在 data 的结构化字段里 —— `{ orderNo, cancelled, paid, verified, payStatus, orderStatus }`。
* 前端必须按字段判定,**不要**去匹配 message 字符串。
* - cancelled=true → 取消成功
* - cancelled=false, paid=true → 该订单已支付(后端已顺手把本地状态补齐),刷新列表即可看到「已完成」
* - verified=false → 本次未能在微信侧核对(微信不可达),取消仍然生效
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = await readBody(event)
const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try {
return await $fetch('/hjc/order/cancel', {
baseURL: config.public.modulesApiBase,
method: 'POST',
headers: {
TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {})
},
query: { TenantId: ctx.tenantId },
body
})
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to cancel order'
})
}
})
+41
View File
@@ -0,0 +1,41 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 按订单号取订单(收银台用:它手上只有订单号,靠这个拿应付金额与当前状态)
* GET /api/tender/order-by-no?orderNo=xxx
* 代理到 mp-api /api/hjc/order/by-no/{orderNo};需登录态。
*
* 为什么金额不跟 URL 传:收银台可能被刷新、也可能在网页授权回跳后重新进入;
* 钱数从 URL 来意味着展示值可被改(扣款仍以服务端为准,但纯展示也不该给假数)。
* 后端 `GET /api/hjc/order/by-no/{orderNo}` 的注释就写着「收银台只有订单号,用它取应付金额」。
*
* 约定:原样透传 ApiResult{code,message,data}。HTTP 状态码恒为 200
* 「未登录/token 失效」是 code=401、「无权限」是 code=403,前端必须按 code 判定。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const { orderNo } = getQuery(event)
const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try {
return await $fetch(`/hjc/order/by-no/${encodeURIComponent(String(orderNo ?? ''))}`, {
baseURL: config.public.modulesApiBase,
method: 'GET',
headers: {
TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {})
},
query: { TenantId: ctx.tenantId }
})
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch order by orderNo'
})
}
})