Files
hjc-web/app/pages/tender/pay.vue
T
weicw-dshsa 1b4dbda28e feat: PC 两处支付入口的支付宝统一改为扫码出码(与微信交互一致)
用户选择:PC 不再跳支付宝账单页,改为像微信一样直接出一张二维码。
此前 PC(收银台 + 购标流程)走电脑网站支付(自提交表单 + 跳转),
H5 走扫码,两个渠道两种交互。

改动:
- tender/pay.vue 与 template-07/BuyDocument.vue 删除自提交表单整套逻辑
  (alipayForm 状态、openAlipayForm 的 document.write、弹窗拦截兜底、
  模板里的「前往支付宝付款」分支)——两个渠道现在都是 codeUrl 出码,
  渲染与轮询完全复用同一套。
- 支付宝提交 payType=alipay(裸值),由后端按默认渠道(当面付扫码)下单。
- 两处文件头注释同步:支付宝默认当面付,与微信 Native 同一交互。

不改的部分:
- 后端本就两条路都实现(precreate / page.pay),本次只翻转默认,
  网页支付保留为显式 alipay_page / alipay_web 的可选路径——
  扫码签约「当面付」、网页支付签约「电脑网站支付」,是不同的产品签约,
  留一条备用便于按实际签约情况切换,不必改代码。
- H5 原本就是扫码(显式 alipay_qr),行为不变。

验证:eslint 0 error;npm run build:dev 通过。
2026-09-20 21:16:07 +08:00

317 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="!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">请使用{{ channelLabel }}支付</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>
<!--
支付方式选择。**切换方式即重新发起支付**——同一订单号不能换支付类型重复下单
(微信会回 OUT_TRADE_NO_USED),所以切换前把上一张码清掉,避免两张码同时留在页面上。
-->
<div class="mb-6 inline-flex rounded-md border border-gray-200 p-1">
<button
v-for="opt in payOptions"
:key="opt.value"
type="button"
class="rounded px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50"
:class="payType === opt.value
? 'bg-blue-600 text-white'
: 'text-gray-600 hover:bg-gray-50'"
:disabled="starting || refreshing"
@click="switchPayType(opt.value)"
>
{{ opt.label }}
</button>
</div>
<!--
两个渠道都是出码:微信 Native 与支付宝当面付(默认渠道)返回同一形态的 codeUrl,
渲染与轮询完全复用。支付宝不跳转账单——当面付需要在开放平台签约「当面付」,
与「电脑网站支付」是不同的产品签约(见 ADR 0010)。
-->
<template v-if="codeUrl">
<!-- 过期只是置灰提示,仍留在页面上:万一用户是刚过点就扫的,还能看见自己扫的是哪张码 -->
<div :class="payExpired ? 'opacity-30' : ''">
<HjcPayQrcode :value="codeUrl" :size="240" :alt="qrAlt" />
</div>
<p class="mt-3 text-sm text-gray-500">请在 2 小时内用{{ channelLabel }}扫描上方二维码完成支付</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 ? `正在获取${channelLabel}支付` : '暂未获取到支付信息,请点击下方按钮重新获取' }}
</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 0010):不因 UA 在微信内就跳 hjc-h5。
* 支付方式可选**微信扫码**或**支付宝**;两者都是出码(支付宝默认当面付,
* 与微信 Native 同一交互),渲染与轮询完全复用。
*/
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 payType = ref<'wechat' | 'alipay'>('wechat')
const payOptions = [
{ value: 'wechat' as const, label: '微信支付' },
{ value: 'alipay' as const, label: '支付宝' }
]
const codeUrl = ref('')
const amount = ref(0)
const starting = ref(false)
const refreshing = ref(false)
const confirming = ref(false)
const error = ref('')
/** 提示文案里的渠道名(只影响措辞,不影响判定) */
const channelLabel = computed(() => (payType.value === 'alipay' ? '支付宝' : '微信'))
const qrAlt = computed(() => (payType.value === 'alipay' ? '支付宝支付二维码' : '微信支付二维码'))
/**
* 支付确认一律以**渠道侧**为准(轮询 + 手动按钮都走它)。
* 绝不调用 `mark-paid`:那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
start: startPayPolling,
query: queryPayStatus
} = useHjcPayStatus(() => route.fullPath, () => payType.value)
// 渠道侧确认收款后跳订单列表(与 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 链上取,前端**不要**去匹配 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, payType: payType.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
}
}
/** 切换支付方式:清掉上一张码再重新发起,避免两种支付信息同时留在页面上 */
async function switchPayType(next: 'wechat' | 'alipay') {
if (next === payType.value) return
payType.value = next
codeUrl.value = ''
error.value = ''
await startPay()
}
/** 重新获取二维码:允许对同一订单号重复发起下单,顺带重置计时 */
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 || `${channelLabel.value}侧还未收到付款,请完成支付后再试`
} 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
}
// 已在这张单上发起过支付宝支付,就默认用支付宝继续——避免用户上次选了支付宝、
// 这次进来又被默认拉回微信,撞一次渠道侧的「订单号已使用」
if (typeof o?.payMethod === 'string' && o.payMethod.startsWith('ALIPAY')) {
payType.value = 'alipay'
}
// 只有"确实还没付、也没取消"才发起支付
loading.value = false
await startPay()
} catch (e: any) {
loadError.value = e?.data?.message || e?.message || '订单加载失败'
} finally {
loading.value = false
}
})
</script>