feat(hjc-web): 新建订单详情页,订单列表与详情支持下载已支付订单的标书

此前 PC 没有订单详情页,标书下载只有两句文案(`.scratch/hjc-web-order-detail/01`)。

- 新增 `app/pages/tender/orders/[id].vue`:订单状态、标书附件、待支付操作区、
  标书/支付/购买人信息;取数走 `useRequestFetch()`(裸 `$fetch` 在 SSR 不转发 Cookie,
  会把用户登出)
- `app/pages/tender/orders.vue` → `orders/index.vue`:Nuxt 里同名 file+目录会生成父子路由,
  详情页会被父页吞掉,必须让列表页是 `index.vue`
- 列表页每张卡片加「标书附件」(仅已支付)与「查看详情」,不整卡可点(卡内已有按钮与链接)
- 新增 `app/components/HjcTenderFiles.vue` 与代理 `server/api/tender/order-detail.get.ts`
- 附件地址来自后端按 `payStatus=1` 下发的 `tenderFiles`,前端不做权限判断、不自己拼地址

规格与验收:`.scratch/hjc-tender-download/`
This commit is contained in:
2026-09-17 18:09:27 +08:00
parent 9299485899
commit 2e847b5e09
8 changed files with 490 additions and 17 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ const { isLoggedIn, handleAuthCode } = useHjcAuth()
* **不会**把浏览器请求里的 Cookie 转发给站内 `/api` 代理。代理拿不到 `hjc_token`
* 就等于匿名调用,后端按契约返回 `code=401``handleAuthCode` 据此判定「凭据失效」
* → 在服务端清 Token → 结果是**刷新「我的收藏」= 把自己登出**。
* (同一段坑的完整说明见 app/pages/tender/orders.vue:77-88。)
* (同一段坑的完整说明见 app/pages/tender/orders/index.vue:101-110。)
*
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
*/
+289
View File
@@ -0,0 +1,289 @@
<template>
<div class="container mx-auto px-4 py-10">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">订单详情</h1>
<HjcUserBar />
</div>
<div v-if="!loggedIn" class="py-20 text-center">
<p class="text-gray-500">请先登录后查看订单</p>
<NuxtLink
:to="`/login?redirect=${loginRedirect}`"
class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
去登录
</NuxtLink>
</div>
<div v-else-if="error" class="py-20 text-center">
<p class="text-red-500">{{ error }}</p>
<NuxtLink
to="/tender/orders"
class="mt-6 inline-block rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
>
返回我的订单
</NuxtLink>
</div>
<SiteLoading v-else-if="!loaded" />
<template v-else-if="order">
<p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
<!-- 标题与状态 -->
<div class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between gap-4">
<p class="font-semibold text-gray-900">{{ order.projectName }}</p>
<span class="shrink-0 rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(order)">
{{ hjcOrderStatusLabel(order) }}
</span>
</div>
<p class="mt-1 text-sm text-gray-500">订单号{{ order.orderNo }}</p>
</div>
<!--
标书附件**已支付订单**才有`tenderFiles` 由后端按 payStatus=1 下发
未支付的单这里给一句说明而不是空区块用户要知道下载在什么时候出现
而不是以为平台没有这个功能
-->
<div v-if="isPaid" class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">标书附件</h2>
<HjcTenderFiles v-if="order.tenderFiles?.length" :files="order.tenderFiles" />
<p v-else class="text-sm text-gray-500">该项目暂无标书附件如有疑问请联系平台客服</p>
</div>
<div v-else class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-2 font-bold text-gray-900">标书附件</h2>
<p class="text-sm text-gray-500">支付成功后可在此下载标书附件</p>
</div>
<!-- 待支付订单的操作区与列表页同一判定hjcCanActOnOrder -->
<div v-if="hjcCanActOnOrder(order)" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="cancelling"
@click="askCancel"
>
{{ cancelling ? '取消中...' : '取消订单' }}
</button>
<NuxtLink
:to="`/tender/pay?orderNo=${encodeURIComponent(order.orderNo)}`"
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
>
去支付
</NuxtLink>
</div>
<!-- 标书信息 -->
<div class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">标书信息</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-gray-500">项目编号</dt>
<dd class="text-right text-gray-900">{{ order.projectNo || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">单价</dt>
<dd class="text-right text-gray-900">¥{{ formatMoney(order.unitPrice) }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">数量</dt>
<dd class="text-right text-gray-900">x{{ order.quantity ?? 1 }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">实付金额</dt>
<dd class="text-right font-bold text-blue-600">¥{{ formatMoney(order.totalAmount) }}</dd>
</div>
</dl>
</div>
<!-- 支付信息 -->
<div class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">支付信息</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-gray-500">订单号</dt>
<dd class="break-all text-right text-gray-900">{{ order.orderNo }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">支付方式</dt>
<dd class="text-right text-gray-900">{{ payMethodLabel }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">支付状态</dt>
<dd class="text-right text-gray-900">{{ hjcOrderStatusLabel(order) }}</dd>
</div>
<div v-if="order.payTime" class="flex justify-between gap-4">
<dt class="text-gray-500">支付时间</dt>
<dd class="text-right text-gray-900">{{ fmtTime(order.payTime) }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">下单时间</dt>
<dd class="text-right text-gray-900">{{ fmtTime(order.createTime) }}</dd>
</div>
</dl>
</div>
<!-- 购买人信息 -->
<div class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">购买人信息</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-gray-500">企业</dt>
<dd class="text-right text-gray-900">{{ order.enterpriseName || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">联系人</dt>
<dd class="text-right text-gray-900">{{ order.contactName || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">联系电话</dt>
<dd class="text-right text-gray-900">{{ order.contactPhone || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">联系邮箱</dt>
<dd class="break-all text-right text-gray-900">{{ order.contactEmail || '-' }}</dd>
</div>
</dl>
</div>
<NuxtLink to="/tender/orders" class="mt-6 inline-block text-sm text-blue-600 hover:underline">
返回我的订单
</NuxtLink>
</template>
<HjcConfirmDialog
:open="cancelDialogOpen"
title="确定取消该订单吗?"
content="取消后订单不可恢复,如需购买请重新下单。"
confirm-text="确定取消"
cancel-text="再想想"
loading-text="取消中..."
:loading="cancelling"
@confirm="confirmCancel"
@cancel="cancelDialogOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import type { HjcOrder } from '~/types/tender'
import { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
/**
* 订单详情页(PC 端)。
*
* 此前 PC 没有这个页面:买家从列表只能看状态、点不了任何东西,「已支付后下载标书」在 PC
* 完全没有落点(`.scratch/hjc-web-order-detail/issues/01`)。本页的两个职责就是
* ①展示订单全貌 ②给**已支付**订单提供标书附件下载。
*
* 附件地址来自后端 `tenderFiles`(只有 payStatus=1 才下发),本页**不做权限判断**、
* 也不从项目字段拼地址——门槛在服务端。
*
* 取数必须用 `useRequestFetch()`,不能用裸 `$fetch`SSR 期间裸 `$fetch` 不转发浏览器
* Cookie,代理会变成匿名调用并拿到 code=401`handleAuthCode` 于是清掉 Token ——
* 刷新详情页 = 把自己登出(完整说明见 app/pages/tender/orders/index.vue:101-110)。
*/
const route = useRoute()
const { isLoggedIn, handleAuthCode } = useHjcAuth()
const requestFetch = useRequestFetch()
const orderId = computed(() => String(route.params.id || ''))
const loginRedirect = computed(() => encodeURIComponent(route.fullPath))
const loggedIn = ref(false)
const order = shallowRef<HjcOrder | null>(null)
const loaded = ref(false)
const error = ref('')
/** 操作结果提示(取消成功/已支付),与 error 分开:它不是错误 */
const notice = ref('')
const cancelDialogOpen = ref(false)
const cancelling = ref(false)
/** 已支付:标书附件是否下发的唯一依据(与后端 `isPaid` 同一口径,只看 payStatus */
const isPaid = computed(() => order.value?.payStatus === 1)
const payMethodLabel = computed(() => (order.value?.payMethod === 'ALIPAY' ? '支付宝' : '微信支付'))
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
function fmtTime(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-'
}
async function reload() {
try {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await requestFetch(`/api/tender/order-detail?id=${encodeURIComponent(orderId.value)}`)
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示,不清 token
error.value = auth.message || ''
loggedIn.value = isLoggedIn()
return
}
if (res?.code === 0 && res.data) {
order.value = res.data as HjcOrder
error.value = ''
} else {
order.value = null
error.value = res?.message || '订单加载失败'
}
} catch (e: any) {
order.value = null
error.value = e?.data?.message || e?.message || '订单加载失败'
}
}
function askCancel() {
notice.value = ''
cancelDialogOpen.value = true
}
async function confirmCancel() {
const o = order.value
if (!o) return
cancelling.value = true
notice.value = ''
try {
const res: any = await $fetch('/api/tender/cancel', { method: 'POST', body: { orderNo: o.orderNo } })
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
notice.value = auth.message || ''
return
}
if (res?.code !== 0) {
notice.value = res?.message || '取消失败,请稍后重试'
return
}
// 「其实已经付了」**不是错误**:后端同样返回 code=0,结论在结构化字段里。
// 按字段判定,绝不匹配 message 字符串(与列表页同一口径)。
const d = res.data || {}
if (d.cancelled) {
notice.value = d.verified === false
// 微信不可达时如实告知没核对上,而不是替它下结论
? '订单已取消(未能在微信侧核对,如你已付款请稍后刷新订单)'
: '订单已取消'
} else if (d.paid) {
notice.value = '该订单已支付成功,无需取消'
} else {
notice.value = res?.message || '取消未生效,请刷新后查看订单状态'
}
// 无论哪种结论都就地重取:状态可能已在服务端变化(含被自愈成已支付),
// 而详情页正是「支没支付、能不能下载」要显示对的地方。
await reload()
} catch (e: any) {
notice.value = e?.data?.message || e?.message || '取消失败,请稍后重试'
} finally {
cancelling.value = false
cancelDialogOpen.value = false
}
}
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
await reload()
}
loaded.value = true
</script>
@@ -30,24 +30,47 @@
</div>
<!--
待支付订单的操作区判定用 hjcCanActOnOrderpayStatus===0 && orderStatus!==2
**不用** statusKey==='pending'payStatus=2支付失败也会落进 pending 兜底
标书附件**只有已支付订单**才有这一块`tenderFiles` 由后端按 payStatus=1
下发hjc-h5 的列表与详情同一口径未支付订单这里什么都不渲染
因为地址压根没下发不是前端藏起来的
-->
<div v-if="hjcCanActOnOrder(o)" class="mt-4 flex justify-end gap-3 border-t border-gray-100 pt-4">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="cancelling && cancelTarget?.id === o.id"
@click="askCancel(o)"
>
取消订单
</button>
<div v-if="o.tenderFiles?.length" class="mt-4 border-t border-gray-100 pt-4">
<p class="mb-2 text-sm font-medium text-gray-700">标书附件</p>
<HjcTenderFiles :files="o.tenderFiles" />
</div>
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
<!--
详情入口****把整张卡片做成可点卡片里已经有取消订单去支付
和被撑开的一列附件链接嵌套点击的误触代价比省一次点击更高
-->
<NuxtLink
:to="`/tender/pay?orderNo=${encodeURIComponent(o.orderNo)}`"
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
:to="`/tender/orders/${o.id}`"
class="text-sm text-blue-600 hover:underline"
>
去支付
查看详情
</NuxtLink>
<!--
待支付订单的操作区判定用 hjcCanActOnOrderpayStatus===0 && orderStatus!==2
**不用** statusKey==='pending'payStatus=2支付失败也会落进 pending 兜底
-->
<div v-if="hjcCanActOnOrder(o)" class="flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="cancelling && cancelTarget?.id === o.id"
@click="askCancel(o)"
>
取消订单
</button>
<NuxtLink
:to="`/tender/pay?orderNo=${encodeURIComponent(o.orderNo)}`"
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
>
去支付
</NuxtLink>
</div>
</div>
</div>
</div>