Files
hjc-web/app/utils/richText.ts
T
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

77 lines
3.5 KiB
TypeScript
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.
/**
* 「公告正文」富文本处理(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)
}