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
209 lines
8.9 KiB
Vue
209 lines
8.9 KiB
Vue
<template>
|
||
<div class="container mx-auto px-4 py-10">
|
||
<NuxtLink to="/tender" class="text-sm text-blue-600 hover:underline">← 返回标书中心</NuxtLink>
|
||
|
||
<!-- 未登录提示 -->
|
||
<div v-if="!loggedIn" class="mx-auto mt-6 max-w-xl rounded-lg border border-gray-200 p-8 text-center">
|
||
<p class="text-lg text-gray-700">购买标书需要先登录企业账号</p>
|
||
<div class="mt-6 flex gap-4 justify-center">
|
||
<NuxtLink :to="`/login?redirect=${redirect}`" class="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">去登录</NuxtLink>
|
||
<NuxtLink :to="`/register?redirect=${redirect}`" class="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50">企业注册</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表单 -->
|
||
<div v-else-if="tender && step === 'form'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-6">
|
||
<h1 class="text-xl font-bold text-gray-900">购买标书</h1>
|
||
<div class="mt-4 rounded-md bg-gray-50 p-4 text-sm">
|
||
<p class="font-semibold text-gray-800">{{ tender.projectName }}</p>
|
||
<p class="mt-1 text-gray-500">项目编号:{{ tender.projectNo }}</p>
|
||
<p class="mt-2 text-blue-600">
|
||
信息服务费:<span class="text-2xl font-bold">¥{{ formatMoney(tender.tenderPrice) }}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<form class="mt-6 space-y-4" @submit.prevent="submit">
|
||
<div>
|
||
<label class="text-sm text-gray-600">购买联系人</label>
|
||
<input v-model="form.contactName" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="姓名" />
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-600">联系电话</label>
|
||
<input v-model="form.contactPhone" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="手机号" />
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-600">联系邮箱</label>
|
||
<input v-model="form.contactEmail" type="email" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="邮箱" />
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-600">购买数量</label>
|
||
<input v-model.number="form.quantity" type="number" min="1" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" />
|
||
</div>
|
||
|
||
<button type="submit" class="w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50" :disabled="submitting">
|
||
{{ submitting ? '提交中...' : '提交订单并支付' }}
|
||
</button>
|
||
<p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- 支付:下单成功后**不再自动发起**,先让买家选支付方式(面板负责) -->
|
||
<div v-else-if="step === 'pay'">
|
||
<!--
|
||
支付时限已到(进支付步就过期,或面板挂着挂着归零)。
|
||
**锁死页面**:同一订单号对同一渠道重复下单会撞 OUT_TRADE_NO_USED,且渠道侧那张单
|
||
已随支付时限一同失效(ADR 0019)。超时 = 这张订单终了,想要就重新下单。
|
||
-->
|
||
<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
|
||
type="button"
|
||
class="mt-6 rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
||
@click="retry"
|
||
>
|
||
重新下单
|
||
</button>
|
||
</div>
|
||
|
||
<HjcPayPanel
|
||
v-else
|
||
:order-no="orderNo"
|
||
:amount="totalAmount"
|
||
:deadline="payDeadline"
|
||
:redirect="redirect"
|
||
@paid="step = 'done'"
|
||
@timeout="deadlineExpired = true"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 完成 -->
|
||
<div v-else-if="step === 'done'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-green-200 bg-green-50 p-8 text-center">
|
||
<p class="text-xl font-bold text-green-700">购买成功</p>
|
||
<p class="mt-2 text-sm text-green-600">订单号:{{ orderNo }},标书已可查看/下载</p>
|
||
<NuxtLink to="/tender" class="mt-6 inline-block rounded-md bg-green-600 px-6 py-2 text-white hover:bg-green-700">返回标书中心</NuxtLink>
|
||
</div>
|
||
|
||
<div v-else-if="notFound" class="py-20 text-center text-gray-500">项目不存在</div>
|
||
<SiteLoading v-else />
|
||
</div>
|
||
</template>
|
||
|
||
<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()
|
||
|
||
const loggedIn = ref(false)
|
||
const tender = shallowRef<Tender | null>(null)
|
||
const notFound = ref(false)
|
||
const submitting = ref(false)
|
||
const error = ref('')
|
||
const step = ref<'form' | 'pay' | 'done'>('form')
|
||
const orderNo = ref('')
|
||
const totalAmount = ref(0)
|
||
/** 支付时限到期时刻:由**下单响应**下发(后端 `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}`)
|
||
|
||
function formatMoney(n?: number | string) {
|
||
return Number(n ?? 0).toFixed(2)
|
||
}
|
||
|
||
async function submit() {
|
||
error.value = ''
|
||
submitting.value = true
|
||
try {
|
||
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
|
||
const order: any = await $fetch('/api/tender/order', {
|
||
method: 'POST',
|
||
body: {
|
||
projectId: Number(id),
|
||
quantity: form.quantity || 1,
|
||
contactName: form.contactName,
|
||
contactPhone: form.contactPhone,
|
||
contactEmail: form.contactEmail
|
||
}
|
||
})
|
||
const orderAuth = await handleAuthCode(order, redirect.value)
|
||
if (orderAuth.handled) {
|
||
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
|
||
error.value = orderAuth.message || ''
|
||
return
|
||
}
|
||
if (order?.code !== 0) {
|
||
error.value = order?.message || '下单失败'
|
||
return
|
||
}
|
||
orderNo.value = order.data?.orderNo || ''
|
||
totalAmount.value = order.data?.totalAmount || 0
|
||
payDeadline.value = order.data?.payDeadline ?? null
|
||
// 订单已创建:即使还没发起支付也停在支付步——重提交会产生第二张订单。
|
||
// **不在这里发起支付**:先让买家自己选支付方式(面板的"立即支付"才是发起点)。
|
||
deadlineExpired.value = hjcIsPayExpired({ payDeadline: payDeadline.value })
|
||
step.value = 'pay'
|
||
} catch (e: any) {
|
||
error.value = e?.data?.message || e?.message || '下单失败'
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 超时后重新下单:回表单步重新提交。
|
||
*
|
||
* 旧单已随支付时限失效(ADR 0019),同一订单号不能复活;重新提交会生成一张新的订单号。
|
||
* 表单里已填的联系人信息保留着,买家不必重填。
|
||
*/
|
||
function retry() {
|
||
orderNo.value = ''
|
||
totalAmount.value = 0
|
||
payDeadline.value = null
|
||
deadlineExpired.value = false
|
||
error.value = ''
|
||
step.value = 'form'
|
||
}
|
||
|
||
loggedIn.value = isLoggedIn()
|
||
|
||
/**
|
||
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
|
||
*
|
||
* 下面这段在 setup 顶层执行(SSR 期间就会跑):裸 `$fetch` 在服务端渲染时**不会**
|
||
* 转发浏览器原始请求头给站内 `/api` 代理,代理收到的是 host=localhost 的「新请求」,
|
||
* 租户中间件解析不到租户 → 上游按空租户查询 → SSR 拿不到数据而渲染 notFound 分支;
|
||
* 客户端水合时却拿到了真实数据,两棵树不匹配触发 hydration mismatch。
|
||
* (同一个坑的表现见 TenderDetail.vue / TenderList.vue 的注释。)
|
||
*
|
||
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
|
||
*/
|
||
const requestFetch = useRequestFetch()
|
||
|
||
try {
|
||
if (!id) {
|
||
notFound.value = true
|
||
} else {
|
||
const res: any = await requestFetch(`/api/tender/detail?id=${id}`)
|
||
if (res && res.id) tender.value = res
|
||
else notFound.value = true
|
||
}
|
||
} catch {
|
||
notFound.value = true
|
||
}
|
||
</script>
|