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