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
+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)
}