Files
weicw1996 a322b2eed4 feat(hjc-web): 标书详情支持公告正文富文本与公告附件下载
中标公告区域两块改动:

- 公告正文改按富文本渲染。一站式推的是富文本编辑器出的 HTML,原来当纯文本渲染
  (whitespace-pre-line),会把 <p>、&nbsp;、style="…" 原样显示在页面上。
  判定(HTML / 纯文本)、净化、纯文本换行抽到 app/utils/richText.ts,
  与 hjc-h5 的 src/utils/richText.js 同一套口径。
- 公告附件:渲染后端下发的 bulletinFiles(后端已解析成 {name,url} 并拼好完整
  下载地址,含 URL 编码),复用 HjcTenderFiles 组件。公告附件是公开资料
  (不判登录 / 资质 / 购买),与标书附件(付费内容、仅已支付订单下发)是两条线,
  所以这里不做权限判断。
- types/tender.ts:补 bulletinFiles 字段。

验证:npm run build 通过
2026-09-18 08:48:40 +08:00

280 lines
12 KiB
Vue
Raw Permalink 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 || bulletinHtml" 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 v-if="tender.bulletinTitle" class="mb-2 font-semibold">{{ tender.bulletinTitle }}</h3>
<!--
公告正文是一站式富文本编辑器出的 HTML**不能再当纯文本渲染**
原来那个 `whitespace-pre-line` 会把 `<p>`、`&nbsp;`、`style="…"`
原样显示在页面上。判定与净化在 app/utils/richText.ts
(纯文本内容仍按老样子保留换行)。
-->
<RichText v-if="bulletinHtml" :content="bulletinHtml" />
<p v-else class="text-gray-400">暂无公告正文</p>
</div>
</div>
<!--
公告附件:**公开资料**(公告正文本来就公开下发,附件跟着正文一起给),
与标书附件(`tenderFiles`,付费内容、只在已支付订单上出现)不是一回事,
所以这里不做任何权限判断——登录/资质/购买都不看。
唯一会「没有」的情况是项目已下架:后端对 status != 1 的项目不下发 `bulletinFiles`。
-->
<div v-if="bulletinFiles.length" class="mt-8">
<h2 class="mb-3 text-lg font-semibold text-gray-900">公告附件</h2>
<HjcTenderFiles :files="bulletinFiles" />
</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, HjcTenderFile } from '~/types/tender'
import { toRichTextHtml } from '~/utils/richText'
const route = useRoute()
const id = route.params.id as string
const tender = shallowRef<Tender | null>(null)
const notFound = ref(false)
/**
* 公告附件:后端已解析成 `{name, url}` 列表(`bulletinFiles`)。
*
* 后端做了两件事:把 JSON/逗号分隔的原始串解析成列表、把相对路径拼成完整下载地址。
* 前端只渲染,不自己解析 `bulletinFileList`——三端各解一份必然分叉。
*/
const bulletinFiles = computed<HjcTenderFile[]>(() =>
Array.isArray(tender.value?.bulletinFiles) ? tender.value!.bulletinFiles! : []
)
/**
* 公告正文:一站式推的是富文本编辑器出的 HTML,直接插进页面会把标签露出来。
*
* 判定(HTML / 纯文本)、净化、纯文本换行都在 `app/utils/richText.ts`
* 与 h5 的 `src/utils/richText.js` 同一套口径。
*/
const bulletinHtml = computed(() => toRichTextHtml(tender.value?.bulletinContent))
/** 是否已收藏:由详情接口**内联**返回(未登录恒 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>