feat(order): 收银台与购标流程改为先选支付方式再发起支付

- 新增 useHjcPayFlow(select/code 两段状态机)与 HjcPayPanel(两处共用):
  进页只查单、不发起支付,不预选支付方式;出码后不再有切换控件,
  只能显式「更换支付方式」回到选择态(不自动重发、不停查单)
- 发起失败留在选择态并保留所选(渠道侧什么都没产生,没有理由锁住用户)
- 收银台 pages/tender/pay.vue 只留订单侧职责:取单、终态屏、超时锁、付款成功跳列表
- 购标流程 BuyDocument:下单后停在选择态(不再按默认微信自动发起),
  补支付剩余时间与超时锁,超时后回表单步重新下单
- 订单详情:只有已完成订单才显示支付方式渠道名,其余显示「—」;
  标签按前缀判,修掉 ALIPAY_QR 被显示成「微信支付」
- useHjcPayStatus:新增结构化 channelClosed——「渠道单已关闭」在选择态说
  「重新选择支付方式」、出码态才说「重新获取二维码」,不靠匹配 message 字符串

Refs .scratch/cashier-pay-method-selection/issues/01-pay-method-before-pay.md
This commit is contained in:
2026-09-30 09:16:36 +08:00
parent 85207e1648
commit 57543cfade
6 changed files with 690 additions and 550 deletions
+46 -235
View File
@@ -47,92 +47,35 @@
</form>
</div>
<!-- 支付 -->
<div v-else-if="step === 'pay'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<h1 class="text-xl font-bold text-gray-900">请使用{{ channelLabel }}扫码支付</h1>
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(totalAmount) }}</p>
<!-- 支付:下单成功后**不再自动发起**,先让买家选支付方式(面板负责) -->
<div v-else-if="step === 'pay'">
<!--
支付方式选择。**切换方式即重新发起支付**——同一订单号不能换支付类型重复下单
(渠道侧会拒),所以先清掉上一张码,避免两张码同时留在页面上。
支付时限已到(进支付步就过期,或面板挂着挂着归零)。
**锁死页面**:同一订单号对同一渠道重复下单会撞 OUT_TRADE_NO_USED,且渠道侧那张单
已随支付时限一同失效(ADR 0019)。超时 = 这张订单终了,想要就重新下单。
-->
<div class="mb-6 inline-flex rounded-md border border-gray-200 p-1">
<div v-if="deadlineExpired" class="mx-auto mt-6 max-w-2xl rounded-lg border border-amber-200 bg-amber-50 p-8 text-center text-amber-800">
<p class="text-xl font-bold">支付已超时</p>
<p class="mt-2 text-sm opacity-80">该订单因支付超时已取消,如需购买请重新下单。</p>
<p class="mt-1 text-sm opacity-80">订单号:{{ orderNo }}</p>
<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="refreshing"
@click="switchPayType(opt.value)"
class="mt-6 rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
@click="retry"
>
{{ opt.label }}
重新下单
</button>
</div>
<!--
支付宝走**电脑网站支付**:后端返回一段自提交表单,写进新页面即跳转支付宝收银台
(当面付扫码需单独签约并提交资料审核,见 ADR 0010 补记,故不作默认)。
微信仍是出码:两者在模板里各占一支。
-->
<template v-if="alipayForm">
<div class="rounded-md bg-blue-50 px-4 py-6 text-sm text-blue-700">
已为你打开支付宝收银台。若新页面没有自动打开,请点击下方按钮。
</div>
<button
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="openAlipayForm"
>
前往支付宝付款
</button>
<button
class="mt-3 w-full rounded-md border border-gray-300 py-3 font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="confirming"
@click="confirmPaid"
>
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
</button>
</template>
<template v-else-if="codeUrl">
<!-- 过期只是置灰提示,仍留在页面上:万一用户是刚过点就扫的,还能看见自己扫的是哪张码 -->
<div :class="payExpired ? 'opacity-30' : ''">
<HjcPayQrcode :value="codeUrl" :size="240" :alt="qrAlt" />
</div>
<!-- 这里**不写具体时长**:支付时限由后端配置,页面上写死一个数字(原先写的是 2 小时)迟早与事实不符 -->
<p class="mt-3 text-sm text-gray-500">请在支付时限内用{{ 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">
暂未获取到支付信息,请点击下方按钮重新获取
</div>
<button
v-if="codeUrl && !alipayForm"
class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
:disabled="confirming"
@click="confirmPaid"
>
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
</button>
<button
v-if="!alipayForm"
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"
@click="refreshQr"
>
{{ refreshing ? '获取中...' : (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>
<HjcPayPanel
v-else
:order-no="orderNo"
:amount="totalAmount"
:deadline="payDeadline"
:redirect="redirect"
@paid="step = 'done'"
@timeout="deadlineExpired = true"
/>
</div>
<!-- 完成 -->
@@ -149,7 +92,15 @@
<script setup lang="ts">
import type { Tender } from '~/types/tender'
import { hjcIsPayExpired } from '~/utils/orderStatus'
/**
* 购标流程:先建单、随即付款(与「收银台」的分界见 CONTEXT.md)。
*
* 本页只管订单侧的事——取项目、建单、完成后去哪;**付款界面在 `HjcPayPanel` 里**,
* 与收银台共用同一套「先选支付方式、点『立即支付』才发起」的规则。
* 改造前这里是"建单成功后立刻按默认微信发起支付",等于替买家做了决定(见 issue 01)。
*/
const route = useRoute()
const id = route.query.id as string
const { isLoggedIn, handleAuthCode } = useHjcAuth()
@@ -158,87 +109,22 @@ const loggedIn = ref(false)
const tender = shallowRef<Tender | null>(null)
const notFound = ref(false)
const submitting = ref(false)
const refreshing = ref(false)
const confirming = ref(false)
const error = ref('')
const step = ref<'form' | 'pay' | 'done'>('form')
const orderNo = ref('')
const codeUrl = ref('')
/** 支付宝电脑网站支付的自提交表单(后端只在该渠道下返回) */
const alipayForm = ref('')
const totalAmount = ref(0)
/** 当前支付方式。默认微信,与接入支付宝之前的行为一致 */
const payType = ref<'wechat' | 'alipay'>('wechat')
const payOptions = [
{ value: 'wechat' as const, label: '微信支付' },
{ value: 'alipay' as const, label: '支付宝' }
]
/** 提示文案里的渠道名(只影响措辞,不影响判定) */
const channelLabel = computed(() => (payType.value === 'alipay' ? '支付宝' : '微信'))
const qrAlt = computed(() => (payType.value === 'alipay' ? '支付宝支付二维码' : '微信支付二维码'))
/** 支付时限到期时刻:由**下单响应**下发(后端 `attachPayDeadlines`),面板的倒计时与超时锁都靠它 */
const payDeadline = ref<string | null>(null)
/** 支付时限已到:进支付步就过期、或面板挂着挂着归零(面板的 `timeout` 事件) */
const deadlineExpired = ref(false)
const form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
const redirect = computed(() => `/buy?id=${id}`)
/**
* 支付确认一律以**渠道侧**为准(轮询 + 手动按钮都走它)。
* 这里刻意不再调用 `mark-paid`:那个接口不查单,用户点一下就能把订单置为已支付。
* 状态词表由后端按渠道归一,前端无需知道支付宝的 trade_status(见 ADR 0010)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
start: startPayPolling,
query: queryPayStatus
} = useHjcPayStatus(() => redirect.value, () => payType.value)
/** 渠道侧确认已付款后,页面自己走到完成态——用户不必再点任何按钮 */
watch(paid, (v) => {
if (v) step.value = 'done'
})
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
/**
* 把支付宝返回的自提交表单写进一个新页面。
*
* 用 `document.write` 而不是 `v-html`:表单里带一段自动提交的脚本,
* 而 `v-html` 插入的脚本标签**不会执行**(HTML 规范如此),表单永远不会自动提交。
* 写进新窗口则脚本正常执行,且原页面保留在原标签页继续轮询查单。
*
* 弹窗被拦截时不报"失败"——按钮复用本函数,用户允许弹窗后再点一次即可。
*
* 注意:本注释不要写出脚本标签的字面量——SFC 的 script 块会被它提前闭合。
*/
function openAlipayForm() {
if (!alipayForm.value) return
const w = window.open('', '_blank')
if (!w) {
error.value = '浏览器拦截了新窗口,请允许弹出窗口后点「前往支付宝付款」'
return
}
w.document.open()
w.document.write(alipayForm.value)
w.document.close()
error.value = ''
}
/** 切换支付方式:清掉上一张码再重新发起,避免两种支付信息同时留在页面上 */
async function switchPayType(next: 'wechat' | 'alipay') {
if (next === payType.value) return
payType.value = next
codeUrl.value = ''
alipayForm.value = ''
error.value = ''
await refreshQr()
}
async function submit() {
error.value = ''
submitting.value = true
@@ -266,33 +152,11 @@ async function submit() {
}
orderNo.value = order.data?.orderNo || ''
totalAmount.value = order.data?.totalAmount || 0
// 发起支付
const payRes: any = await $fetch('/api/tender/pay', {
method: 'POST',
body: { orderNo: orderNo.value, payType: payType.value }
})
const payAuth = await handleAuthCode(payRes, redirect.value)
if (payAuth.handled) {
error.value = payAuth.message || ''
return
}
// 订单已创建:即使发起支付失败也停在支付步骤,避免用户重提交产生重复订单
payDeadline.value = order.data?.payDeadline ?? null
// 订单已创建:即使还没发起支付也停在支付步——重提交会产生第二张订单。
// **不在这里发起支付**:先让买家自己选支付方式(面板的"立即支付"才是发起点)。
deadlineExpired.value = hjcIsPayExpired({ payDeadline: payDeadline.value })
step.value = 'pay'
if (payRes?.code === 0) {
codeUrl.value = payRes.data?.codeUrl || ''
alipayForm.value = payRes.data?.alipayForm || ''
if (alipayForm.value) {
// 网页支付:立刻把表单送去新页面,用户不必再点一次
openAlipayForm()
} else if (!codeUrl.value) {
error.value = '未获取到支付信息,请稍后重试'
}
} else {
error.value = payRes?.message || '发起支付失败,请稍后重试'
}
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在渠道侧付过了
//(例如上一次支付成功但页面被关掉),查单会把它认出来并直接进完成态。
startPayPolling(orderNo.value)
} catch (e: any) {
error.value = e?.data?.message || e?.message || '下单失败'
} finally {
@@ -301,71 +165,18 @@ async function submit() {
}
/**
* 手动确认(二维码扫不了时的兜底):**也走查单**,不代替渠道下结论。
* 渠道侧没收到钱就明确这么说,而不是像改造前那样把它当成"已支付"。
*/
async function confirmPaid() {
error.value = ''
confirming.value = true
try {
const res = await queryPayStatus()
if (res.handled) {
// 401 已跳登录页;403 只提示
error.value = res.message || ''
return
}
if (res.settled) {
step.value = 'done'
return
}
error.value = res.message || `${channelLabel.value}侧还未收到付款,请完成扫码支付后再试`
} finally {
confirming.value = false
}
}
/**
* 重新获取支付码 / 切换渠道后重新发起。
* 超时后重新下单:回表单步重新提交。
*
* 允许对同一 `out_trade_no`(这里就是 hjc 订单号)重复发起下单,
* 所以"刷新"是重新调 `/api/tender/pay` 拿一张新码——但**不重置支付时限**:
* 时限从下单时刻起算(见 CONTEXT.md「支付时限」),重新出码不改变整张订单的到期时刻。
*
* 把当前 `payType` 一并带上:切换渠道时必须让后端按新渠道下单,
* 否则会拿着一张微信的码却显示在"支付宝"标签下。
* 旧单已随支付时限失效(ADR 0019),同一订单号不能复活;重新提交会生成一张新的订单号。
* 表单里已填的联系人信息保留着,买家不必重填。
*/
async function refreshQr() {
function retry() {
orderNo.value = ''
totalAmount.value = 0
payDeadline.value = null
deadlineExpired.value = false
error.value = ''
refreshing.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, redirect.value)
if (auth.handled) {
error.value = auth.message || ''
return
}
if (res?.code !== 0) {
error.value = res?.message || '获取支付信息失败,请稍后重试'
return
}
codeUrl.value = res.data?.codeUrl || ''
alipayForm.value = res.data?.alipayForm || ''
if (res.data?.amount) totalAmount.value = Number(res.data.amount)
if (alipayForm.value) {
openAlipayForm()
} else if (!codeUrl.value) {
error.value = '未获取到支付信息,请稍后重试'
return
}
startPayPolling(orderNo.value)
} catch (e: any) {
error.value = e?.data?.message || e?.message || '获取支付信息失败,请稍后重试'
} finally {
refreshing.value = false
}
step.value = 'form'
}
loggedIn.value = isLoggedIn()