Files
hjc-web/app/templates/template-07/pages/TenderDetail.vue
T
weicw1996 9299485899 feat(hjc-web): 标书详情加「收藏」按钮,「我的收藏」页与顶栏入口
后端接口是端无关的,本轮 **mp-java 零改动**,纯前端 + Nitro 代理。

- 新增 favorites.get/post/delete 三个站内代理,原样透传 ApiResult
  (HTTP 恒 200,按 body.code 判登录态与业务失败)。delete 用查询串传 projectId。
- detail.get.ts 补发 Authorization:详情接口内联了 favorited,而它此前是匿名调用,
  已收藏的项目会显示成「未收藏」。未登录时不带这个头,行为与改动前完全一致
  (返回体仍整体返回 data,favorited 正是靠这一点透传的)。
- 「我的收藏」做成**模板无关的公共路由** app/pages/tender/favorites.vue(10 套模板共用),
  顶栏 HjcUserBar 在「我的订单」后加入口。列表按收藏时间倒序 + 加载更多;
  只渲染后端算好的 saleState/purchased(前端不再判一遍);失效项目**保留条目**、
  点击只提示不进详情、仍可取消;取消收藏弹二次确认(复用 HjcConfirmDialog)。
  取数用 useRequestFetch —— 裸 $fetch 在 SSR 不转发 cookie,会被代理当成匿名调用,
  拿到 401 后 handleAuthCode 会清凭据跳登录,等于刷新页面把自己登出。
- template-07 详情页加收藏按钮(heart SVG 两态,不引图标库),未登录点它跳登录、
  回跳带 favorite=1 后自动补一次收藏(只在客户端 onMounted 做,不改写 URL——
  详情壳是 :key="route.fullPath",改 query 会重挂载并与收藏请求赛跑)。
  取数同样改用 useRequestFetch,否则首屏 favorited 恒为 false。
- 只改 template-07:10 套模板里只有它有 TenderDetail.vue,其余按需再补。
2026-09-17 17:37:10 +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.vue:77-88:那次是刷新把自己登出。)
*
* 客户端侧 `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>