a7399e0624
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 字符串(包装方式一改就会静默失效)
263 lines
11 KiB
Vue
263 lines
11 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="!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>
|