cadb2b837f
- BuyDocument.vue:购买页新增「发票类型」单选(VAT_SPECIAL 电子专票 / VAT_NORMAL 电子普票), 必填,未选时拦在 $fetch 之前并点名提示「请选择发票类型」 - 预选值取企业档案的 invoiceType(走 /api/tender/enterprise,用 useRequestFetch 保证 SSR/CSR 一致); 取不到或值不在枚举内则不预选,由提交那道兜住 - 建单 body 带上 invoiceType - order.post.ts 本就把 readBody 结果整体透传给后端,未做字段白名单,无需改动; 仅更新接口契约注释
265 lines
11 KiB
Vue
265 lines
11 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>
|
||
<div>
|
||
<span class="text-sm text-gray-600">发票类型</span>
|
||
<!--
|
||
预选值来自**企业档案**里的 invoiceType(注册时已必填);买家改了以买家为准,
|
||
企业档案不因下单而改变(订单是快照)。档案取不到或值非法时**不预选**。
|
||
-->
|
||
<div class="mt-2 flex flex-wrap items-center gap-6">
|
||
<label
|
||
v-for="t in INVOICE_TYPES"
|
||
:key="t.value"
|
||
class="flex items-center gap-2 text-sm text-gray-700"
|
||
>
|
||
<input v-model="form.invoiceType" type="radio" :value="t.value" :disabled="submitting" />
|
||
{{ t.label }}
|
||
</label>
|
||
</div>
|
||
<p class="mt-1 text-xs text-gray-400">默认取企业档案里的发票类型,可改;按下单时选的类型开票</p>
|
||
</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, invoiceType: '' })
|
||
const redirect = computed(() => `/buy?id=${id}`)
|
||
|
||
/**
|
||
* 发票类型:单选、必填,取值与后端 `hjc_invoice.invoice_type` 口径一致。
|
||
* 初始值取企业档案里的 invoiceType(见下方 setup 取数),买家可改。
|
||
*/
|
||
const INVOICE_TYPES = [
|
||
{ value: 'VAT_SPECIAL', label: '电子专票' },
|
||
{ value: 'VAT_NORMAL', label: '电子普票' }
|
||
]
|
||
|
||
function isValidInvoiceType(v: unknown): v is string {
|
||
return INVOICE_TYPES.some((t) => t.value === v)
|
||
}
|
||
|
||
function formatMoney(n?: number | string) {
|
||
return Number(n ?? 0).toFixed(2)
|
||
}
|
||
|
||
async function submit() {
|
||
error.value = ''
|
||
if (!isValidInvoiceType(form.invoiceType)) {
|
||
// 必填、点名到项:没有值就拦在发请求之前
|
||
error.value = '请选择发票类型'
|
||
return
|
||
}
|
||
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,
|
||
// 下单那一刻的发票类型(订单是快照,不回写企业档案)
|
||
invoiceType: form.invoiceType
|
||
}
|
||
})
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 预选发票类型 = 企业档案里的 `invoiceType`(注册时已必填,正常不为空)。
|
||
*
|
||
* 与上面同理必须用 `useRequestFetch`:裸 `$fetch` 在 SSR 不转发浏览器原始请求头,
|
||
* 代理会当成未登录/空租户,服务端取不到档案而客户端取得到 → hydration mismatch。
|
||
* 档案取不到、或值不在枚举内 → **不预选**,由提交那道的必选提示兜住:
|
||
* 拿不到档案不该阻断下单(接口失败时买家仍可自己选一个继续)。
|
||
*/
|
||
if (loggedIn.value) {
|
||
try {
|
||
const ent: any = await requestFetch('/api/tender/enterprise')
|
||
const archiveType = ent?.code === 0 ? ent?.data?.invoiceType : ''
|
||
if (isValidInvoiceType(archiveType)) form.invoiceType = archiveType
|
||
} catch {
|
||
// 取不到档案不影响下单:不预选,提交时必选兜底
|
||
}
|
||
}
|
||
</script>
|