Files
hjc-web/app/components/HjcPayPanel.vue
T
weicw-dshsa 57543cfade 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
2026-09-30 09:16:36 +08:00

252 lines
10 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>
<!--
支付面板:收银台(`/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>