From 57543cfadea47ddf6279f15b8559198b5513d153 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Wed, 30 Sep 2026 09:16:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(order):=20=E6=94=B6=E9=93=B6=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=E8=B4=AD=E6=A0=87=E6=B5=81=E7=A8=8B=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=85=88=E9=80=89=E6=94=AF=E4=BB=98=E6=96=B9=E5=BC=8F=E5=86=8D?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- app/components/HjcPayPanel.vue | 251 +++++++++++++ app/composables/useHjcPayFlow.ts | 321 ++++++++++++++++ app/composables/useHjcPayStatus.ts | 17 +- app/pages/tender/orders/[id].vue | 22 +- app/pages/tender/pay.vue | 348 ++---------------- .../template-07/pages/BuyDocument.vue | 281 +++----------- 6 files changed, 690 insertions(+), 550 deletions(-) create mode 100644 app/components/HjcPayPanel.vue create mode 100644 app/composables/useHjcPayFlow.ts diff --git a/app/components/HjcPayPanel.vue b/app/components/HjcPayPanel.vue new file mode 100644 index 0000000..4e6ee2e --- /dev/null +++ b/app/components/HjcPayPanel.vue @@ -0,0 +1,251 @@ + + + diff --git a/app/composables/useHjcPayFlow.ts b/app/composables/useHjcPayFlow.ts new file mode 100644 index 0000000..7808390 --- /dev/null +++ b/app/composables/useHjcPayFlow.ts @@ -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(null) + /** 选择态 / 出码态。出码后不能再直接改方式,只能走 `changePayMethod` */ + const phase = ref('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 | 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 + } +} diff --git a/app/composables/useHjcPayStatus.ts b/app/composables/useHjcPayStatus.ts index ca7e9cc..b7dde1c 100644 --- a/app/composables/useHjcPayStatus.ts +++ b/app/composables/useHjcPayStatus.ts @@ -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 } } diff --git a/app/pages/tender/orders/[id].vue b/app/pages/tender/orders/[id].vue index 7d5635f..fe7b1b8 100644 --- a/app/pages/tender/orders/[id].vue +++ b/app/pages/tender/orders/[id].vue @@ -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 '—' +}) /** * 还能不能操作(去支付 / 取消):共享判定,**不在这里重写一遍**—— diff --git a/app/pages/tender/pay.vue b/app/pages/tender/pay.vue index ae8f3ef..0a5b19a 100644 --- a/app/pages/tender/pay.vue +++ b/app/pages/tender/pay.vue @@ -24,7 +24,7 @@
@@ -40,7 +40,7 @@ 再出一张码要么被渠道拒绝,要么给出一张我们马上就要取消的订单的码。 超时 = 这张订单终了,想要就重新下单。 --> -
+

支付已超时

该订单因支付超时已取消,如需购买请重新下单。

订单号:{{ orderNo }}

@@ -50,134 +50,37 @@
- -
-

请使用{{ channelLabel }}支付

-

订单号:{{ orderNo }}

-

{{ order?.projectName }}

- - -

- 支付剩余时间 {{ remainText }} -

- -

¥{{ formatMoney(amount) }}

- - -
- -
- - - - - -
- {{ starting ? `正在获取${channelLabel}支付` : '暂未获取到支付信息,请点击下方按钮重新获取' }} -
- - - - -

{{ error || payError }}

-

正在确认支付状态...

- -
- ← 返回我的订单 -
-
+ + + ← 返回我的订单 +
diff --git a/app/templates/template-07/pages/BuyDocument.vue b/app/templates/template-07/pages/BuyDocument.vue index 59b3f0d..3d624e2 100644 --- a/app/templates/template-07/pages/BuyDocument.vue +++ b/app/templates/template-07/pages/BuyDocument.vue @@ -47,92 +47,35 @@ - -
-

请使用{{ channelLabel }}扫码支付

-

订单号:{{ orderNo }}

-

¥{{ formatMoney(totalAmount) }}

- + +
-
+
+

支付已超时

+

该订单因支付超时已取消,如需购买请重新下单。

+

订单号:{{ orderNo }}

- - - - -
- 暂未获取到支付信息,请点击下方按钮重新获取 -
- - - - -

{{ error || payError }}

-

正在确认支付状态...

+
@@ -149,7 +92,15 @@