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 通过
This commit is contained in:
2026-09-18 08:48:40 +08:00
parent 2e847b5e09
commit a322b2eed4
3 changed files with 128 additions and 5 deletions
@@ -36,13 +36,31 @@
</div> </div>
</div> </div>
<div v-if="tender.bulletinTitle || tender.bulletinContent" class="mt-8"> <div v-if="tender.bulletinTitle || bulletinHtml" class="mt-8">
<h2 class="mb-3 text-lg font-semibold text-gray-900">中标公告</h2> <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"> <div class="rounded-md border border-gray-200 p-5 text-sm text-gray-700">
<h3 class="mb-2 font-semibold">{{ tender.bulletinTitle }}</h3> <h3 v-if="tender.bulletinTitle" class="mb-2 font-semibold">{{ tender.bulletinTitle }}</h3>
<p class="whitespace-pre-line leading-relaxed">{{ tender.bulletinContent }}</p> <!--
公告正文是一站式富文本编辑器出的 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>
</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> </div>
<!-- 右侧:购买卡片 --> <!-- 右侧:购买卡片 -->
@@ -102,13 +120,32 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Tender } from '~/types/tender' import type { Tender, HjcTenderFile } from '~/types/tender'
import { toRichTextHtml } from '~/utils/richText'
const route = useRoute() const route = useRoute()
const id = route.params.id as string const id = route.params.id as string
const tender = shallowRef<Tender | null>(null) const tender = shallowRef<Tender | null>(null)
const notFound = ref(false) 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),不额外发请求 */ /** 是否已收藏:由详情接口**内联**返回(未登录恒 false),不额外发请求 */
const favorited = ref(false) const favorited = ref(false)
const favBusy = ref(false) const favBusy = ref(false)
+11 -1
View File
@@ -21,8 +21,18 @@ export interface Tender {
bulletinTitle?: string bulletinTitle?: string
/** 公告正文 */ /** 公告正文 */
bulletinContent?: string bulletinContent?: string
/** 公告附件(JSON字符串) */ /** 公告附件(JSON字符串,一站式推送的原始值) */
bulletinFileList?: string bulletinFileList?: string
/**
* 公告附件(后端解析好的可下载列表)。
*
* **门槛与 `tenderFiles` 不同**:公告附件是公开资料(公告正文本来就公开下发),
* 匿名也能拿到——不判登录、不判资质、不判购买;`tenderFiles`(标书附件)才是付费内容、
* 只在已支付订单上出现。
*
* **唯一没有的情况是项目已下架**:后端对 `status != 1` 的项目不下发本字段(前端不自己判)。
*/
bulletinFiles?: HjcTenderFile[] | null
/** 标书价格(信息服务费) */ /** 标书价格(信息服务费) */
tenderPrice?: number tenderPrice?: number
/** 标书附件(JSON字符串) */ /** 标书附件(JSON字符串) */
+76
View File
@@ -0,0 +1,76 @@
/**
* 「公告正文」富文本处理(hjc-web)。
*
* **必须与 `hjc-h5/src/utils/richText.js` 保持一致**——两端是不同仓库、不同技术栈
* Nuxt4 + TS / uni-app + JS),代码无法真正共享,只能靠"两处同一套判定"来对齐。
* 改这里的时候,另一边也要改。
*
* 为什么需要它:一站式平台推来的公告正文(`content` → `bulletin_content`)是**富文本编辑器
* 出的 HTML**,后端原样落库(见 `HjcBizServiceImpl#upsertFromPush`),而标书详情页此前把它
* 当纯文本渲染(`whitespace-pre-line`)——页面上会直接露出 `<p>`、`&nbsp;`、`style="..."`
* 这些标记。
*
* 两条路都要留着:
* - 是 HTML → 净化后交给 `RichText` 组件走 `v-html`
* - 不是 HTML(推送历史数据 / 后台手录)→ 按纯文本转义、换行转 `<br>`,保住改造前
* `whitespace-pre-line` 的观感,不让这次改动把纯文本那条路弄塌。
*/
/**
* 是否「看起来是 HTML」。
*
* 判据只有一条:出现 `<字母…>` 这样的标签。纯文本里几乎不会自然出现它
* (正文写「工期 < 30 天」也不会命中——`<` 后面必须紧跟字母),
* 所以不做更聪明的解析:正文格式只有「编辑器 HTML」和「纯文本」两种。
*/
const HTML_TAG_RE = /<\/?[a-z][a-z0-9-]*(\s[^<>]*)?\/?>/i
/** 整段丢掉的标签(连内容一起):能跑脚本、能引外部资源、能改整页样式的一律不留 */
const DROP_TAGS = ['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'link', 'meta', 'base', 'form']
/** 事件属性:`v-html` 不执行 `<script>`,但**会**执行 `onerror` / `onload` 这类事件属性 */
const EVENT_ATTR_RE = /\son[a-z-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi
/** `href` / `src` 里的伪协议(`data:` 只挡 text/html`data:image/*` 是合法图片) */
const JS_URL_RE = /\s(href|src)\s*=\s*(["'])\s*(?:javascript|vbscript|data:text\/html)[^"']*\2/gi
/**
* 净化 HTML。
*
* 内容来自**站外**(一站式平台,虽然带 appKey/sign 鉴权),而这条链路是 `v-html`,
* 摘掉脚本与事件属性是这一层该负的责任(`RichText` 组件里那条正则只挡 `<script>`)。
*/
function sanitize(html: string): string {
let out = html
for (const tag of DROP_TAGS) {
out = out.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}\\s*>`, 'gi'), '')
out = out.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'), '')
}
return out.replace(EVENT_ATTR_RE, '').replace(JS_URL_RE, ' $1="#"')
}
/**
* 纯文本 → 可直接塞进 HTML 的文本。
*
* 只转义 `<` `>`**已成的实体不动**`&nbsp;`、`&amp;` 保持原样,否则会二次转义成
* `&amp;nbsp;` 在页面上露出来),只有裸 `&` 才补成 `&amp;`。换行转 `<br>`
* 顶替原来的 `whitespace-pre-line`。
*/
function escapeText(text: string): string {
return text
.replace(/&(?![a-z][a-z0-9]*;|#\d+;|#x[0-9a-f]+;)/gi, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\r\n|\r|\n/g, '<br />')
}
/**
* 公告正文 → 可交给 `RichText` 渲染的 HTML。
*
* 空内容返回空串,由调用方决定兜底文案(不在这里塞「暂无」——那是页面的事)。
*/
export function toRichTextHtml(raw?: string | null): string {
const content = (raw || '').trim()
if (!content) return ''
return HTML_TAG_RE.test(content) ? sanitize(content) : escapeText(content)
}