Files
hjc-web/app/templates/template-07/pages/BuyDocument.vue
T
weicw-dshsa cadb2b837f feat(order): 下单页新增发票类型选择
- BuyDocument.vue:购买页新增「发票类型」单选(VAT_SPECIAL 电子专票 / VAT_NORMAL 电子普票),
  必填,未选时拦在 $fetch 之前并点名提示「请选择发票类型」
- 预选值取企业档案的 invoiceType(走 /api/tender/enterprise,用 useRequestFetch 保证 SSR/CSR 一致);
  取不到或值不在枚举内则不预选,由提交那道兜住
- 建单 body 带上 invoiceType
- order.post.ts 本就把 readBody 结果整体透传给后端,未做字段白名单,无需改动;
  仅更新接口契约注释
2026-10-08 14:54:22 +08:00

265 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>