feat(hjc-web): 标书详情支持公告正文富文本与公告附件下载
中标公告区域两块改动:
- 公告正文改按富文本渲染。一站式推的是富文本编辑器出的 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 通过
This commit is contained in:
@@ -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>`、` `、`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
@@ -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字符串) */
|
||||||
|
|||||||
@@ -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>`、` `、`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;` 在页面上露出来),只有裸 `&` 才补成 `&`。换行转 `<br>`,
|
||||||
|
* 顶替原来的 `whitespace-pre-line`。
|
||||||
|
*/
|
||||||
|
function escapeText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&(?![a-z][a-z0-9]*;|#\d+;|#x[0-9a-f]+;)/gi, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user