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
+251
View File
@@ -0,0 +1,251 @@
<template>
<!--
支付面板:收银台(`/tender/pay`)与购标流程(`/buy`)共用。
两段:**选择态**(选支付方式 + 立即支付)→ **出码态**(微信二维码 / 支付宝表单)。
进页不发起支付、不预选方式;出码后没有切换控件,只有「更换支付方式」这一个出口。
为什么这么切,见 `useHjcPayFlow` 的文件头注释。
-->
<div v-if="!timedOut" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<!-- ============ 选择态 ============ -->
<template v-if="phase === 'select'">
<h2 class="text-xl font-bold text-gray-900">请选择支付方式</h2>
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
<p v-if="projectName" class="mt-1 text-sm text-gray-500">{{ projectName }}</p>
<!--
倒计时:驱动值来自**后端下发的 payDeadline**,不是前端拿创建时刻自己加时限。
它是「这张单还有多久不能付」的唯一告知渠道——不给它,用户会拿一张已经不能付的码去扫。
没下发到期时刻(旧订单/字段缺失)时不显示,也不锁页面。
-->
<p v-if="remainText" class="mt-3 text-sm" :class="remainWarn ? 'text-red-500' : 'text-gray-500'">
支付剩余时间 <span class="font-mono font-semibold">{{ remainText }}</span>
</p>
<!-- 金额一律来自**订单本身**,不来自支付响应:
支付一失败页面就会停在 0.00,而收银台恰恰是最需要看到金额的地方 -->
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(amount) }}</p>
<!--
支付方式选择。**不预选**:进来就替买家选中一个,等于替他做了决定;
没选之前「立即支付」是禁用的——必须由买家自己点一次。
-->
<div class="grid grid-cols-2 gap-3">
<button
v-for="opt in payOptions"
:key="opt.value"
type="button"
class="rounded-md border px-4 py-3 text-sm font-medium transition-colors disabled:opacity-50"
:class="payType === opt.value
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-300 text-gray-600 hover:bg-gray-50'"
:disabled="starting"
@click="select(opt.value)"
>
{{ opt.label }}
</button>
</div>
<button
class="mt-4 w-full rounded-md bg-blue-600 py-3 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="!payType || starting"
@click="start"
>
{{ starting ? '正在发起支付...' : '立即支付' }}
</button>
</template>
<!-- ============ 出码态 ============ -->
<template v-else>
<h2 class="text-xl font-bold text-gray-900">请使用{{ channelLabel }}支付</h2>
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
<p v-if="projectName" class="mt-1 text-sm text-gray-500">{{ projectName }}</p>
<p v-if="remainText" class="mt-3 text-sm" :class="remainWarn ? 'text-red-500' : 'text-gray-500'">
支付剩余时间 <span class="font-mono font-semibold">{{ remainText }}</span>
</p>
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(amount) }}</p>
<!--
支付宝走**电脑网站支付**:后端返回一段自提交表单,写进新页面即跳转支付宝收银台
(当面付扫码需单独签约并提交资料审核,见 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="confirm"
>
{{ 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">
{{ refreshing ? `正在获取${channelLabel}支付` : '暂未获取到支付信息,请点击下方按钮重新获取' }}
</div>
<button
v-if="codeUrl && !alipayForm"
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="confirm"
>
{{ 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 || starting"
@click="refresh"
>
{{ refreshing ? '获取中...' : (codeUrl ? '重新获取二维码' : '获取支付信息') }}
</button>
<!--
换支付方式**只有这一个入口**,而且是有意识的一次动作:不自动重发、也不停查单
(见 `useHjcPayFlow.changePayMethod`)。这就是"随意切换"与"显式更换"的区别。
-->
<button
class="mt-4 text-sm text-blue-600 hover:underline disabled:opacity-50"
:disabled="refreshing || starting || confirming"
@click="changePayMethod"
>
更换支付方式
</button>
</template>
<p v-if="failureText" class="mt-3 text-sm text-red-500">{{ failureText }}</p>
<p v-else-if="querying" class="mt-3 text-sm text-gray-400">正在确认支付状态...</p>
<div v-if="$slots.default" class="mt-6 border-t border-gray-100 pt-4">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
/**
* 支付面板(收银台 + 购标流程共用)。
*
* 页面只需要把**订单侧的事实**传进来(订单号、金额、支付时限、401 回跳地址、项目名),
* 以及决定付款成功后去哪;支付流程本身全在 `useHjcPayFlow` 里。
*
* 与改造前的区别(也是本面板存在的理由):进页**不**自动发起支付,先让买家选支付方式;
* 出码后不再有切换控件。详见 `useHjcPayFlow` 的文件头注释。
*/
const props = defineProps<{
/** 订单号:收银台从路由取,购标流程下单成功后拿到 */
orderNo: string
/** 应付金额(来自订单本身,不是支付响应) */
amount?: number | string
/** 支付时限到期时刻(后端下发的原始串)。拿不到时既不显示倒计时、也不锁页面 */
deadline?: string | null
/** 401 跳登录页时带上,便于登录后回跳 */
redirect: string
/** 订单所属项目名;收银台显示,购标流程不必传 */
projectName?: string
}>()
const emit = defineEmits<{
/** 渠道侧确认收款(收银台跳订单列表 / 购标流程进完成态) */
(e: 'paid'): void
/** 支付时限到点:页面切到"已超时"那一屏并锁死 */
(e: 'timeout'): void
}>()
const {
phase,
payType,
payOptions,
channelLabel,
qrAlt,
codeUrl,
alipayForm,
starting,
refreshing,
confirming,
error,
paid,
querying,
payError,
payExpired,
channelClosed,
remainText,
remainWarn,
timedOut,
select,
start,
refresh,
changePayMethod,
confirm,
openAlipayForm,
startPolling
} = useHjcPayFlow({
orderNo: () => props.orderNo,
redirect: () => props.redirect,
deadline: () => props.deadline
})
/**
* 展示哪一条失败文案。
*
* 动作失败(`error`)优先;渠道侧那张支付单已关闭时按**当前处在哪一段**说对:还没出过码时
* 是"重新选择支付方式",出过码才是"重新获取二维码"。这正是 `channelClosed` 这个结构化标记
* 存在的理由——**不要**退回去匹配 `payError` 的文案,那种写法一改就静默失效。
*/
const failureText = computed(() => {
if (error.value) return error.value
if (channelClosed.value && phase.value === 'select') {
return '上次的支付单已关闭,请重新选择支付方式后支付'
}
return payError.value
})
/** 渠道侧确认收款后,去哪由页面决定(面板不猜路由,也不猜流程) */
watch(paid, (v) => {
if (v) emit('paid')
})
onMounted(() => {
// 进页就已经过期:不出码也不查单,直接让页面切到"已超时"那一屏
if (timedOut.value) {
emit('timeout')
return
}
// **只查单、不发起支付**——这是本面板与改造前最关键的区别(见 issue 验收第 7 条)
startPolling()
})
/** 开着页面等到时限归零:同样交给页面锁死 */
watch(timedOut, (v) => {
if (v) emit('timeout')
})
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
</script>