57543cfade
- 新增 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
252 lines
10 KiB
Vue
252 lines
10 KiB
Vue
<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>
|