Files
hjc-web/app/templates/template-07/pages/TenderDetail.vue
T
weicw1996 2e847b5e09 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/`
2026-09-17 18:09:27 +08:00

243 lines
9.7 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">
<div v-if="tender" class="grid lg:grid-cols-[1fr_320px] gap-8">
<!-- 左侧项目信息 -->
<div>
<div class="mb-3 flex items-center justify-between">
<NuxtLink to="/tender" class="text-sm text-blue-600 hover:underline"> 返回标书中心</NuxtLink>
<HjcUserBar />
</div>
<h1 class="mt-3 text-2xl font-bold text-gray-900">{{ tender.projectName }}</h1>
<div class="mt-2 flex flex-wrap gap-3 text-sm text-gray-500">
<span>项目编号{{ tender.projectNo }}</span>
<span class="rounded bg-gray-100 px-2 py-0.5">{{ tender.category || '未分类' }}</span>
</div>
<div class="mt-6 space-y-3 text-sm">
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">发布时间</span>
<span>{{ fmt(tender.publishTime) }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">投标截止</span>
<span>{{ fmt(tender.deadlineTime) }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">招标人</span>
<span>{{ tender.tenderer || '-' }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">开售时间</span>
<span>{{ fmt(tender.onsaleTime) }}</span>
</div>
<div class="flex gap-3">
<span class="w-28 shrink-0 text-gray-400">停售时间</span>
<span>{{ fmt(tender.offsaleTime) }}</span>
</div>
</div>
<div v-if="tender.bulletinTitle || tender.bulletinContent" class="mt-8">
<h2 class="mb-3 text-lg font-semibold text-gray-900">中标公告</h2>
<div class="rounded-md border border-gray-200 p-5 text-sm text-gray-700">
<h3 class="mb-2 font-semibold">{{ tender.bulletinTitle }}</h3>
<p class="whitespace-pre-line leading-relaxed">{{ tender.bulletinContent }}</p>
</div>
</div>
</div>
<!-- 右侧购买卡片 -->
<aside class="lg:sticky lg:top-24 h-fit rounded-lg border border-gray-200 p-6">
<div class="flex items-end gap-1 text-blue-600">
<span class="text-3xl font-bold">¥{{ formatMoney(tender.tenderPrice) }}</span>
<span class="text-xs text-gray-400 mb-1">信息服务费</span>
</div>
<p class="mt-2 text-xs text-gray-400">购买成功后可在我的订单查看并下载标书</p>
<div class="mt-5 space-y-2 text-sm text-gray-600">
<!-- 购买人数取后端实时统计的**已付款订单数**buyerCount),不是 saleCount
后者在下单时就 +quantity 且从不回退含未付款与已取消的单一直是假数 -->
<p>购买人数{{ tender.buyerCount || 0 }}</p>
<p>售卖方式{{ sellingMethodText }}</p>
</div>
<!--
收藏**不需要企业资质**资质只卡购买),所以这里不看 authStatus / canBuy
用内联 SVG 而不是图标库这个模板是纯 Tailwind 手写的没有引入 a-* 组件
-->
<button
type="button"
class="mt-6 flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 py-2.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="favBusy"
@click="toggleFavorite"
>
<svg
viewBox="0 0 24 24"
class="h-4 w-4"
:class="favorited ? 'text-red-500' : 'text-gray-400'"
:fill="favorited ? 'currentColor' : 'none'"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M12 20.5 4.6 13.4a4.6 4.6 0 0 1 0-6.6 4.9 4.9 0 0 1 6.9 0l.5.5.5-.5a4.9 4.9 0 0 1 6.9 0 4.6 4.6 0 0 1 0 6.6Z" />
</svg>
<span>{{ favorited ? '已收藏' : '收藏' }}</span>
</button>
<p v-if="favError" class="mt-2 text-center text-xs text-red-500">{{ favError }}</p>
<button
class="mt-3 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!buyable"
@click="goBuy"
>
{{ buyable ? '立即购买' : '已截止' }}
</button>
<p class="mt-3 text-xs text-gray-400 text-center">购买需通过企业资质审核</p>
</aside>
</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'
const route = useRoute()
const id = route.params.id as string
const tender = shallowRef<Tender | null>(null)
const notFound = ref(false)
/** 是否已收藏:由详情接口**内联**返回(未登录恒 false),不额外发请求 */
const favorited = ref(false)
const favBusy = ref(false)
const favError = ref('')
/** 本次挂载是否已处理过「登录回跳补收藏」——保证只补一次 */
const favPendingHandled = ref(false)
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/**
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
*
* 详情响应里内联了 `favorited`,而站内代理只有在 **Cookie 被转发** 时才认得出登录态:
* Nuxt 只在 `useRequestFetch` / `useFetch` 里做转发,裸 `$fetch` 在 SSR 期间不转发 Cookie
* 代理就是匿名调用,后端按契约返回 `favorited = false` —— 已收藏的项目首屏会显示成「未收藏」。
* (同一个坑的另一种表现见 app/pages/tender/orders/index.vue:101-110:那次是刷新把自己登出。)
*
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
*/
const requestFetch = useRequestFetch()
const buyable = computed(() => {
if (!tender.value) return false
if (tender.value.status !== undefined && tender.value.status !== 1) return false
if (tender.value.needSell !== undefined && tender.value.needSell === 0) return false
if (tender.value.offsaleTime) {
const t = new Date(tender.value.offsaleTime.replace('T', ' ')).getTime()
if (!Number.isNaN(t) && t < Date.now()) return false
}
return true
})
const sellingMethodText = computed(() => {
const m = tender.value?.sellingMethod
if (m === 2) return '公众号'
if (m === 3) return '交易中心'
if (m === 4) return '政采云'
return '公司财务'
})
function fmt(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-'
}
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
function goBuy() {
if (!tender.value) return
return navigateTo(`/buy?id=${tender.value.id}`)
}
/** 补收藏:登录回跳后静默补一次,失败不打扰(用户自己再点一次即可) */
async function addFavoriteQuietly() {
try {
const res: any = await requestFetch('/api/tender/favorites', {
method: 'POST',
body: { projectId: Number(id) }
})
if (res?.code === 0) {
favorited.value = !!res.data?.favorited
}
} catch {
// 静默:补收藏失败就退化成「未收藏」
}
}
/**
* 收藏 / 取消收藏。
*
* **不判资质**:未认证 / 审核中 / 已驳回的企业都能收藏(资质只卡购买,
* 右侧「立即购买」仍然照旧拦)。**也不弹二次确认**:详情页点「已收藏」是明确意图,
* 弹窗只是摩擦——列表页的取消才弹确认(见 app/pages/tender/favorites.vue)。
*/
async function toggleFavorite() {
if (!isLoggedIn()) {
// 与 h5 一致:跳登录并带回跳地址,登录成功后自动补上这次收藏
return navigateTo(`/login?redirect=${encodeURIComponent(`/tender/${id}?favorite=1`)}`)
}
if (favBusy.value) return
favBusy.value = true
favError.value = ''
try {
const res: any = favorited.value
? await requestFetch(`/api/tender/favorites?projectId=${id}`, { method: 'DELETE' })
: await requestFetch('/api/tender/favorites', {
method: 'POST',
body: { projectId: Number(id) }
})
const auth = await handleAuthCode(res, `/tender/${id}`)
if (auth.handled) return
if (res?.code === 0) {
// 以后端返回的状态为准:不做乐观更新,失败时状态不乱
favorited.value = !!res.data?.favorited
} else {
favError.value = res?.message || '操作失败,请稍后重试'
}
} catch (e: any) {
favError.value = e?.data?.message || e?.message || '操作失败,请稍后重试'
} finally {
favBusy.value = false
}
}
try {
const res: any = await requestFetch(`/api/tender/detail?id=${id}`)
tender.value = res && res.id ? res : null
favorited.value = !!res?.favorited
if (!res) notFound.value = true
} catch {
notFound.value = true
}
/**
* 未登录点收藏 → 登录 → 回跳带上 `favorite=1`,这里补一次。
*
* 三条约束:
* 1. **只在客户端做**onMounted):SSR 期间发写请求是服务端副作用,且可能被执行两次;
* 2. **不改写 URL 去掉 `favorite=1`**:详情页壳 `app/pages/tender/[id].vue` 用
* `:key="route.fullPath"`,改 query 会让模板组件整体重挂载并重新取数,
* 那次重取会与刚发出的收藏请求赛跑;组件内的标记做「每次挂载只补一次」就够了;
* 3. 失败**静默**退化成「未收藏」,让用户自己再点一次,不弹二次打扰(与 h5 一致)。
*/
onMounted(async () => {
if (favPendingHandled.value) return
if (route.query.favorite !== '1') return
favPendingHandled.value = true
if (!isLoggedIn()) return
await addFavoriteQuietly()
})
</script>