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>
+321
View File
@@ -0,0 +1,321 @@
import {
hjcFormatCountdown,
hjcPayDeadlineMs,
hjcPayRemainMs
} from '~/utils/orderStatus'
/**
* 收银台 / 购标流程的支付流程状态机(hjc-web)。
*
* **为什么分两段**(`select` → `code`):买家先明确选「微信支付 / 支付宝」,点「立即支付」才
* 向渠道发起。改造前是"进页就按默认微信发起 + 页面上留个切换器",两个后果都被这次改掉:
* ①收银台替买家做了决定;②切换即重新发起一次**真实的渠道下单**,订单上的 `payMethod`
* (甲方对账、订单详情展示用)跟着被覆盖——而它建单时就是 `WECHAT_NATIVE`,与买家选过什么
* 无关(见 `CONTEXT.md` 的「支付渠道」)。H5 收银台本来就是"选择后发起"的形状,这里是向它对齐。
*
* 三条约束,改这个文件前先读懂:
* - **不预选**:`payType` 初值是 `null`,没选之前「立即支付」不可用——预选一个就等于替买家决定。
* - **出码即锁**:拿到 `codeUrl` / `alipayForm` 后进 `code` 段,页面上**不再有**切换控件;
* 要换只能 {@link changePayMethod} 显式回到选择态(清掉当前支付信息、**不自动重发**)。
* - **失败留在选择态**:发起失败说明渠道侧什么都没产生,没有理由把用户锁在一个没成功的方式上。
*
* 锁是**页面会话级**的:订单现有字段区分不了"从没选过"与"选过微信没付"(`pay_method` 建单即
* `WECHAT_NATIVE`),所以刷新/重进必然回到选择态。要做服务端硬锁就得在订单上加字段,本次不做。
*/
export type HjcPayMethod = 'wechat' | 'alipay'
export type HjcPayPhase = 'select' | 'code'
export interface HjcPayFlowOptions {
/** 订单号(收银台从路由取,购标流程下单成功后拿到) */
orderNo: () => string
/** 401 跳登录页时带上,便于登录后回跳 */
redirect: () => string
/**
* 该订单支付时限的到期时刻(后端下发的原始串)。**拿不到时既不显示倒计时、也不锁页面**——
* 拿一个假值去锁死收银台比不显示倒计时糟得多。
*/
deadline?: () => string | null | undefined
}
export function useHjcPayFlow(options: HjcPayFlowOptions) {
const { handleAuthCode } = useHjcAuth()
const orderNo = () => String(options.orderNo() || '')
const deadlineMs = () => hjcPayDeadlineMs({ payDeadline: options.deadline?.() ?? null })
/** 当前选择。**不预选**:`null` = 买家还没选,「立即支付」在这个状态下不可用 */
const payType = ref<HjcPayMethod | null>(null)
/** 选择态 / 出码态。出码后不能再直接改方式,只能走 `changePayMethod` */
const phase = ref<HjcPayPhase>('select')
const payOptions: Array<{ value: HjcPayMethod; label: string }> = [
{ value: 'wechat', label: '微信支付' },
{ value: 'alipay', label: '支付宝' }
]
const codeUrl = ref('')
/** 支付宝电脑网站支付的自提交表单(后端只在该渠道下返回) */
const alipayForm = ref('')
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)。
*
* 渠道名传当前选择;还没选时按微信传——它只影响提示里的"微信侧 / 支付宝侧",
* 状态词表不因此变化(见 `useHjcPayStatus` 的文件头注释)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
channelClosed,
start: beginQueryPolling,
query: queryPayStatus
} = useHjcPayStatus(() => options.redirect(), () => payType.value ?? 'wechat')
/**
* 倒计时用的"当前时刻"。
*
* 每秒只更新一个 ref(而不是每个用到它的地方各自 setInterval),且**秒级**就够了:
* 支付时限是 30 分钟量级,没必要为 1 秒的精度付出多次渲染。
*/
const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | null = null
onMounted(() => {
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (clock) {
clearInterval(clock)
clock = null
}
})
const remainMs = computed(() => hjcPayRemainMs({ payDeadline: options.deadline?.() ?? null }, now.value))
const remainText = computed(() => (remainMs.value == null ? '' : hjcFormatCountdown(remainMs.value)))
/** 剩余不足一分钟时把倒计时标红(与 H5 收银台同一口径) */
const remainWarn = computed(() => remainMs.value != null && remainMs.value <= 60_000)
/** 到期时刻已知且已归零:页面锁死,不再出码、不再轮询(到期时刻未知时**不算**超时) */
const timedOut = computed(() => remainMs.value !== null && remainMs.value <= 0)
/**
* 选择支付方式。出码后调它无效——锁定只能由 `changePayMethod` 解开,
* 否则"锁定"就等于没锁(模板在 `code` 段也确实不再渲染这两个按钮)。
*/
function select(method: HjcPayMethod) {
if (phase.value === 'code') return
payType.value = method
error.value = ''
}
/**
* 渠道错误码 → 可行动的中文提示。
*
* `OUT_TRADE_NO_USED` = 同一订单号换了支付类型重复下单。微信内(JSAPI)与 PC(Native)
* 各发起一次时必现,而"先选方式再发起"恰恰让用户更容易自己撞上它(比如在微信内付过一半,
* 又来 PC 选微信)。错误码由后端从支付异常的 cause 链上取,**不要**去匹配 message 字符串——
* 那种写法会在错误包装方式一改时静默失效。
*/
function payErrorMessage(res: any) {
if (res?.data?.wechatCode === 'OUT_TRADE_NO_USED') {
return '该订单已在微信内发起过支付,请回到微信内的收银台继续完成付款。'
}
return res?.message || '发起支付失败,请稍后重试'
}
/**
* 向渠道发起(或重新发起)一次支付。
*
* - `ok` 拿到了支付信息
* - `auth` 登录态问题(401 已跳登录页 / 403 已给出提示)——调用方**不要再起查单**
* - `fail` 其它失败(渠道拒单、网络、没返回支付信息),原因已写进 `error`
*/
async function issuePayment(): Promise<'ok' | 'auth' | 'fail'> {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await $fetch('/api/tender/pay', {
method: 'POST',
body: { orderNo: orderNo(), payType: payType.value }
})
const auth = await handleAuthCode(res, options.redirect())
if (auth.handled) {
error.value = auth.message || ''
return 'auth'
}
if (res?.code !== 0) {
error.value = payErrorMessage(res)
return 'fail'
}
codeUrl.value = res.data?.codeUrl || ''
alipayForm.value = res.data?.alipayForm || ''
if (!codeUrl.value && !alipayForm.value) {
error.value = '未获取到支付信息,请稍后重试'
return 'fail'
}
return 'ok'
}
/**
* 「立即支付」。
*
* 没选方式、没有订单号、已过支付时限都不发:后端也会拒(它按"本单剩余窗口"算,剩余不足
* 一分钟即拒绝),前端先拦住只是省一次注定失败的请求。
*/
async function start() {
if (!payType.value || !orderNo() || timedOut.value) return
error.value = ''
starting.value = true
let issued: 'ok' | 'auth' | 'fail' = 'fail'
try {
issued = await issuePayment()
if (issued === 'ok') {
phase.value = 'code'
// 网页支付:立刻把表单送去新页面,用户不必再点一次
if (alipayForm.value) openAlipayForm()
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '发起支付失败,请稍后重试'
} finally {
starting.value = false
// 401 已经把我们送去登录页了,再起查单只会又打一次匿名请求(改造前这条路径也是不查单的)
if (issued !== 'auth') {
// 无论这次有没有拿到支付信息都重新开始查单:订单可能**已经**在渠道侧付过了
//(例如在微信内付掉、或上一次支付成功但页面被关掉)。上一次查单若因渠道单已关闭
// 而停过表,这里也顺带恢复——否则换了方式再付就再也没人查了。
startPolling()
}
}
}
/** 「重新获取」:对**已经锁定的那个方式**再要一次支付信息,不成为换方式的后门 */
async function refresh() {
if (!payType.value || !orderNo() || timedOut.value) return
error.value = ''
refreshing.value = true
let issued: 'ok' | 'auth' | 'fail' = 'fail'
try {
issued = await issuePayment()
if (issued === 'ok') {
phase.value = 'code'
if (alipayForm.value) openAlipayForm()
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '获取支付信息失败,请稍后重试'
} finally {
refreshing.value = false
if (issued !== 'auth') startPolling()
}
}
/**
* 「更换支付方式」:显式回到选择态。
*
* 清掉当前支付信息、**不自动重发**(重发等于又替买家决定了一次),但**不停查单**——
* 买家可能刚把上一张码付掉,停了就漏掉那笔钱。刚才的选择保留着,他只改想改的那一半。
*/
function changePayMethod() {
if (phase.value !== 'code') return
phase.value = 'select'
codeUrl.value = ''
alipayForm.value = ''
error.value = ''
}
/** 手动确认(扫码后的兜底):**也走查单**,不代替渠道下结论 */
async function confirm() {
error.value = ''
confirming.value = true
try {
const res = await queryPayStatus()
if (res.handled) {
error.value = res.message || ''
return
}
// 已付款:`paid` 被置真,面板据此通知页面(收银台跳订单列表 / 购标流程进完成态)
if (res.settled) return
error.value = res.message || `${channelLabel.value}侧还未收到付款,请完成支付后再试`
} finally {
confirming.value = false
}
}
/**
* 把支付宝返回的自提交表单写进一个新页面。
*
* 用 `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 = ''
}
/**
* 开始查单轮询——**只查单,不发起支付**。
*
* 进页就开:这是认出"渠道侧其实已经付了、只是本地还没回写"那类订单的唯一路径。
* 改造前它挂在自动发起支付之后,现在发起点变成用户点按钮,它必须独立存在。
*/
function startPolling() {
if (!orderNo()) return
// 到期时刻传后端下发的那个:让轮询的终点与后端判定超时、渠道单失效对齐(ADR 0019)
beginQueryPolling(orderNo(), deadlineMs())
}
return {
// 状态
phase,
payType,
payOptions,
channelLabel,
qrAlt,
codeUrl,
alipayForm,
starting,
refreshing,
confirming,
error,
paid,
querying,
payError,
payExpired,
channelClosed,
remainMs,
remainText,
remainWarn,
timedOut,
// 动作
select,
start,
refresh,
changePayMethod,
confirm,
openAlipayForm,
startPolling
}
}
+15 -2
View File
@@ -68,6 +68,15 @@ export function useHjcPayStatus(
const error = ref('')
/** 支付码是否已超出计时(优先用后端下发的到期时刻,拿不到时才用本地兜底值) */
const expired = ref(false)
/**
* 渠道侧那张支付单已经终态、且不是成功(FAILED / CANCELLED / TIMEOUT / REFUND_FAILED)。
*
* 为什么要单独给一个结构化标记、而不是只给 `error` 里那句文案:收银台现在分两段
* (选择态 / 出码态),同一件事在**还没出过码**时该说"请重新选择支付方式",出过码才该说
* "请重新获取二维码"。让调用方去匹配那句 message 是一改就静默失效的写法,
* 所以**状态**归这里、**措辞**归调用方(见 `useHjcPayFlow` 的 failureText)。
*/
const channelClosed = ref(false)
/** 轮询是否应当进行(已开始、未付款、未过期) */
let active = false
@@ -109,12 +118,15 @@ export function useHjcPayStatus(
error.value = ''
const name = res.data?.paymentStatus ? String(res.data.paymentStatus) : null
status.value = name
channelClosed.value = false
if (name && SETTLED_STATUS.has(name)) {
paid.value = true
stop()
} else if (name && DEAD_STATUS.has(name)) {
// 渠道侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转
// 渠道侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转。
// 这里的文案是按"已经出过码"写的;还没出过码的调用方用 channelClosed 换成自己的措辞。
stop()
channelClosed.value = true
error.value = `该支付单在${channelLabel.value}已关闭,请重新获取二维码`
}
return { handled: false, status: name, settled: paid.value }
@@ -154,6 +166,7 @@ export function useHjcPayStatus(
orderNo = no
paid.value = false
expired.value = false
channelClosed.value = false
error.value = ''
status.value = null
deadline = typeof deadlineAtMs === 'number' && Number.isFinite(deadlineAtMs)
@@ -189,5 +202,5 @@ export function useHjcPayStatus(
document.removeEventListener('visibilitychange', onVisibilityChange)
})
return { status, paid, querying, error, expired, channelLabel, start, stop, query }
return { status, paid, querying, error, expired, channelClosed, channelLabel, start, stop, query }
}
+21 -1
View File
@@ -185,6 +185,7 @@ import type { HjcOrder } from '~/types/tender'
import {
hjcCanActOn,
hjcOrderStatusClass,
hjcOrderStatusKey,
hjcOrderStatusLabel
} from '~/utils/orderStatus'
@@ -221,7 +222,26 @@ const cancelling = ref(false)
/** 已支付:标书附件是否下发的唯一依据(与后端 `isPaid` 同一口径,只看 payStatus) */
const isPaid = computed(() => order.value?.payStatus === 1)
const payMethodLabel = computed(() => (order.value?.payMethod === 'ALIPAY' ? '支付宝' : '微信支付'))
/**
* 「支付方式」这一行的显示口径。
*
* **只有已完成的订单才把 `payMethod` 当事实**:它建单时就被写成 `WECHAT_NATIVE`,只有真发起过
* 一次支付才会被渠道响应改写——所以未支付订单上的值并不代表买家选过什么,也区分不了
* "没选过"与"选过微信没付"(见 CONTEXT.md 的「支付渠道」)。未支付一律显示「—」,
* 不去声称一种买家可能根本没选过的方式。
*
* 标签按**前缀**判而不是精确等值:支付宝侧有 `ALIPAY`(电脑网站支付)与 `ALIPAY_QR`(扫码),
* 原先只认 `ALIPAY` 一个值,扫码支付过的单在详情里会被显示成"微信支付"。
*/
const payMethodLabel = computed(() => {
const o = order.value
if (!o || hjcOrderStatusKey(o) !== 'done') return '—'
const method = String(o.payMethod || '')
if (method.startsWith('ALIPAY')) return '支付宝'
if (method.startsWith('WECHAT')) return '微信支付'
return '—'
})
/**
* 还能不能操作(去支付 / 取消):共享判定,**不在这里重写一遍**——
+36 -312
View File
@@ -24,7 +24,7 @@
<!--
终态:已支付 / 已取消 / 已退款。
**这一块必须先于发起支付判定**——进一张已支付的单却先拉起一次统一下单,既无意义,
**这一块必须先于支付面板判定**——进一张已支付的单却先拉起一次统一下单,既无意义,
又会多撞一次跨端 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">
@@ -40,7 +40,7 @@
再出一张码要么被渠道拒绝,要么给出一张我们马上就要取消的订单的码。
超时 = 这张订单终了,想要就重新下单。
-->
<div v-else-if="timedOut" class="mx-auto mt-6 max-w-2xl rounded-lg border border-amber-200 bg-amber-50 p-8 text-center text-amber-800">
<div v-else-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>
@@ -50,134 +50,37 @@
</div>
</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>
<!--
倒计时:驱动值来自**后端下发的 payDeadline**,不是前端拿创建时刻自己加时限。
它是「这张码还有多久失效」的唯一告知渠道——不给它,用户会拿一张已经不能付的码去扫。
没下发到期时刻(旧订单/字段缺失)时不显示,也不锁页面。
-->
<p v-if="remainText" class="mt-3 text-sm" :class="remainMs !== null && remainMs <= 60_000 ? 'text-red-500' : 'text-gray-500'">
支付剩余时间 <span class="font-mono font-semibold">{{ remainText }}</span>
</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>
<!--
支付宝走**电脑网站支付**:后端返回一段自提交表单,写进新页面即跳转支付宝收银台
(当面付扫码需单独签约并提交资料审核,见 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">
{{ starting ? `正在获取${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="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 || 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>
<!--
待支付:付款界面全在面板里(先选支付方式、点「立即支付」才发起;出码后只能显式更换)。
本页只负责订单侧的事——金额与到期时刻都来自上面 by-no 取回的那张订单。
-->
<HjcPayPanel
v-else
:order-no="orderNo"
:amount="amount"
:deadline="order?.payDeadline"
:project-name="order?.projectName"
:redirect="route.fullPath"
@paid="goOrders"
@timeout="deadlineExpired = true"
>
<NuxtLink to="/tender/orders" class="text-sm text-blue-600 hover:underline">← 返回我的订单</NuxtLink>
</HjcPayPanel>
</div>
</template>
<script setup lang="ts">
import {
hjcFormatCountdown,
hjcIsTimeoutCancelled,
hjcOrderStatusKey,
hjcPayDeadlineMs,
hjcPayRemainMs
} from '~/utils/orderStatus'
import { hjcIsPayExpired, hjcIsTimeoutCancelled, hjcOrderStatusKey } from '~/utils/orderStatus'
/**
* 收银台:买家为一张**已创建的订单**付款的独立页面(CONTEXT.md 的「收银台」)。
*
* 与购标流程(`/buy` 的 BuyDocument `step==='pay'`)的分界:购标流程是「先建单、随即付款」,
* 收银台是事后回到某张待支付订单继续付款。**本轮不重构 BuyDocument**,
* 两份实现共享的只有 `HjcPayQrcode` 与 `useHjcPayStatus`(重活已经都在那里了)。
* 与购标流程(`/buy` 的 BuyDocument 支付步)的分界:购标流程是「先建单、随即付款」,
* 收银台是事后回到某张待支付订单继续付款。两份实现共用的**付款界面**已经收进
* `HjcPayPanel`(+ `useHjcPayFlow`);本页只管订单侧的事:取单、终态、超时锁,
* 以及渠道侧确认收款后去哪。
*
* 本页只服务微信外(ADR 0010):不因 UA 在微信内就跳 hjc-h5。
* 支付方式可选**微信扫码**或**支付宝**;微信出二维码、支付宝走电脑网站支付
* (返回自提交表单,跳转支付宝收银台)。两者共用同一个查单轮询与状态判定。
*/
const route = useRoute()
const { isLoggedIn, handleAuthCode } = useHjcAuth()
@@ -192,64 +95,16 @@ const loading = ref(false)
/** by-no 失败(订单不存在/无权限/网络),与"发起支付失败"分开 */
const loadError = ref('')
const order = shallowRef<any | null>(null)
/** 终态(已完成/已取消/已退款):终态下**绝不发起支付** */
/** 终态(已完成/已取消/已退款):终态下**绝不渲染支付面板** */
const finalState = shallowRef<{ title: string; cls: string } | null>(null)
/**
* 支付时限已到:进页就过期,或者面板挂着挂着归零(面板的 `timeout` 事件)。
* 两种情况都锁死页面——不再出码、不再查单。
*/
const deadlineExpired = ref(false)
/** 当前支付方式。默认微信,与接入支付宝之前的行为一致 */
const payType = ref<'wechat' | 'alipay'>('wechat')
const payOptions = [
{ value: 'wechat' as const, label: '微信支付' },
{ value: 'alipay' as const, label: '支付宝' }
]
const codeUrl = ref('')
/** 支付宝电脑网站支付的自提交表单(后端只在该渠道下返回) */
const alipayForm = ref('')
/** 金额来自**订单本身**,与"发起支付"解耦:支付一失败页面就会停在 0.00 */
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)
}
/**
* 倒计时用的"当前时刻"。
*
* 每秒只更新一个 ref(而不是让每个用到它的地方各自 setInterval),
* 且**秒级**足够了:支付时限是 30 分钟量级,没必要为 1 秒的精度付出多次渲染。
*/
const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | null = null
const remainMs = computed(() => hjcPayRemainMs(order.value, now.value))
const remainText = computed(() => (remainMs.value == null ? '' : hjcFormatCountdown(remainMs.value)))
/** 到期时刻已知且已归零 —— 页面锁死,不再出码、不再轮询 */
const timedOut = computed(() => remainMs.value !== null && remainMs.value <= 0)
/** 「重新下单」的目标:回到该项目详情(不是购标流程,购标流程会再建一张新单) */
const projectLink = computed(() => {
@@ -275,118 +130,9 @@ function terminalStateOf(o: any) {
}
}
/**
* 渠道错误码 → 可行动的中文提示。
*
* `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() {
// 已过支付时限就不出码:后端也会拒(它按「本单剩余窗口」算,剩余不足一分钟即拒绝),
// 前端先拦住只是省一次注定失败的请求,并让页面停在"支付已超时"而不是闪一下错误。
if (timedOut.value) return
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 || ''
alipayForm.value = res.data?.alipayForm || ''
if (res.data?.amount) amount.value = Number(res.data.amount)
if (!codeUrl.value && !alipayForm.value) {
error.value = '未获取到支付信息,请稍后重试'
} else if (alipayForm.value) {
// 网页支付:立刻把表单送去新页面,用户不必再点一次
openAlipayForm()
}
} else {
error.value = payErrorMessage(res)
}
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在渠道侧付过了
//(例如在微信内由小程序付掉、或上一次支付成功但页面被关掉),查单会把它认出来并跳列表。
// 到期时刻传后端下发的那个:让轮询的终点与后端判定超时、渠道单失效对齐(ADR 0019)。
if (orderNo.value) startPayPolling(orderNo.value, hjcPayDeadlineMs(order.value))
} catch (e: any) {
error.value = e?.data?.message || e?.message || '发起支付失败,请稍后重试'
} finally {
starting.value = false
}
}
/**
* 把支付宝返回的自提交表单写进一个新页面。
*
* 用 `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 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
}
/** 渠道侧确认收款后跳订单列表(与 h5 收银台的 redirectTo 列表对齐) */
async function goOrders() {
await navigateTo('/tender/orders')
}
loggedIn.value = isLoggedIn()
@@ -417,35 +163,13 @@ onMounted(async () => {
finalState.value = terminal
return
}
// 已在这张单上发起过支付宝支付,就默认用支付宝继续——避免用户上次选了支付宝、
// 这次进来又被默认拉回微信,撞一次渠道侧的「订单号已使用」
if (typeof o?.payMethod === 'string' && o.payMethod.startsWith('ALIPAY')) {
payType.value = 'alipay'
}
loading.value = false
// 已过支付时限:锁在"支付已超时"那一屏,不出码、不轮询
if (timedOut.value) return
// 只有"确实还没付、也没取消"才发起支付
await startPay()
// 已过支付时限:锁在"支付已超时"那一屏——面板根本不会挂上,也就不会出码、不会查单。
// 到期时刻未知时**不算**超时(拿一个假值去锁死收银台更糟)。
deadlineExpired.value = hjcIsPayExpired(o)
} catch (e: any) {
loadError.value = e?.data?.message || e?.message || '订单加载失败'
} finally {
loading.value = false
}
})
onMounted(() => {
// 秒级 ticking 只服务倒计时显示;真正的"到点锁页面"由 timedOut 这个 computed 判定,
// 所以哪怕这一拍晚了几百毫秒,也不会出现"倒计时 00:00 了还能付款"。
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (clock) {
clearInterval(clock)
clock = null
}
})
</script>
+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()