fix(knowledge): render DB dynamic articles via markdown safe renderer
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
A: dynamic knowledge-articles content goes through marked instead of raw v-html (escape-first, no new deps). B: plain-text fallback normalize (bracket headings, circled numbers to ordered list, blank lines, key-number bold, idempotent). C: detail-content styles for h2/h3/pre/blockquote/paragraph spacing. D: dialog width 700 to 860, content padding, in-dialog scroll. Task: project-20260924-knowledge-dialog-render-fullstack
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 知识库文章渲染(2026-09-24 knowledge-dialog-render)
|
||||
*
|
||||
* 口径:
|
||||
* - md 单一来源(后端 /api/cma/docs 运行期读 md 目录)沿用页面既有渲染器:
|
||||
* 正文可能含内联 HTML(如 <span style="color:…">),**不得转义**,否则颜色会丢。
|
||||
* - DB 动态知识库文章(/knowledge-articles):先做纯文本规范化,再交给「原始 HTML 一律降级为纯文本」
|
||||
* 的 marked 实例渲染 ⇒ 任何 <script>/<img onerror> 只会以字面文本出现,不会被 v-html 当 HTML 执行。
|
||||
* - 全链路零新依赖(marked 已在项目中)。
|
||||
*/
|
||||
import { marked, Marked } from "marked"
|
||||
|
||||
/** HTML 实体转义(& 必须最先,避免二次转义) */
|
||||
export function escapeHtmlEntities(s: string): string {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
/** 属性值转义(在实体转义基础上补引号,防止从 href/title 属性里逃逸) */
|
||||
function escapeAttr(s: string): string {
|
||||
return escapeHtmlEntities(s).replace(/"/g, """).replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/** 只放行常规协议/站内锚点,挡住 javascript: / vbscript: / data: 等可执行协议 */
|
||||
const SAFE_URL_RE = /^(?:https?:|mailto:|tel:|\/|#|\.{1,2}\/)/i
|
||||
|
||||
/**
|
||||
* 安全渲染实例:覆盖 marked 的三处「原始输入直出」出口
|
||||
* - html :块级/行内原始 HTML 一律降级为纯文本
|
||||
* - link :非法协议(javascript: 等)不给 <a>,只留文本
|
||||
* - image :非法协议不给 <img>
|
||||
* ⇒ 不引入 DOMPurify 也能杜绝 v-html 执行注入(marked 其余节点自带实体转义)。
|
||||
*/
|
||||
const safeMarked = new Marked({
|
||||
renderer: {
|
||||
html: (token: any) => escapeHtmlEntities(token?.text ?? token?.raw ?? ""),
|
||||
link(this: any, token: any) {
|
||||
const text = token?.tokens ? this.parser.parseInline(token.tokens) : escapeHtmlEntities(token?.text ?? "")
|
||||
const href = String(token?.href ?? "")
|
||||
if (!SAFE_URL_RE.test(href.trim())) return text
|
||||
const title = token?.title ? ` title="${escapeAttr(String(token.title))}"` : ""
|
||||
return `<a href="${escapeAttr(href)}"${title}>${text}</a>`
|
||||
},
|
||||
image(this: any, token: any) {
|
||||
const alt = escapeAttr(String(token?.text ?? ""))
|
||||
const href = String(token?.href ?? "").trim()
|
||||
if (!SAFE_URL_RE.test(href)) return alt
|
||||
const title = token?.title ? ` title="${escapeAttr(String(token.title))}"` : ""
|
||||
return `<img src="${escapeAttr(href)}" alt="${alt}"${title}>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** 段首 ①…⑩ → markdown 有序列表标记 */
|
||||
const CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
|
||||
const KEY_NUM_RE = /(?<![\w.])-?\d+(?:,\d{3})*(?:\.\d+)?(?:%|亿|万|倍|元|tokens)/g
|
||||
/** 紧随其后的字符会破坏 CommonMark 强调闭合(闭合符后必须空白/标点)——汉字/字母/数字 */
|
||||
const CLOSE_BLOCKER_RE = /[0-9A-Za-z\u4e00-\u9fff]/
|
||||
/** 零宽空格:仅在「加粗数字紧跟汉字/字母」这一形态里补在闭合 `**` 之前,视觉上不可见 */
|
||||
const ZWSP = "\u200B"
|
||||
|
||||
function convertLeadingCircled(line: string): string {
|
||||
const idx = CIRCLED.indexOf(line.charAt(0))
|
||||
if (idx < 0) return line
|
||||
return `${idx + 1}. ${line.slice(1).replace(/^[\s::、]+/, "")}`
|
||||
}
|
||||
|
||||
/** 关键数字/百分比加粗;跳过既有 **强调** 与 `行内代码`,避免嵌套 */
|
||||
function boldKeyNumbers(line: string): string {
|
||||
return line
|
||||
.split(/(\*\*[^*]+\*\*|`[^`]*`)/g)
|
||||
.map((seg, i) => {
|
||||
if (i % 2 === 1) return seg
|
||||
let out = ""
|
||||
let last = 0
|
||||
for (const m of seg.matchAll(KEY_NUM_RE)) {
|
||||
const start = m.index ?? 0
|
||||
const end = start + m[0].length
|
||||
// 闭合 `**` 后紧跟汉字/字母/数字时 CommonMark 不认闭合(会原样显示 **)⇒ 闭合前补一个零宽空格
|
||||
const guard = CLOSE_BLOCKER_RE.test(seg.charAt(end)) ? ZWSP : ""
|
||||
out += seg.slice(last, start) + `**${m[0]}${guard}**`
|
||||
last = end
|
||||
}
|
||||
return out + seg.slice(last)
|
||||
})
|
||||
.join("")
|
||||
}
|
||||
|
||||
function isStructuralLine(line: string): boolean {
|
||||
return /^(?:[-*+]|\d+[.)])\s/.test(line) || /^\s*\|/.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本兜底规范化:仅当内容**不含** markdown 标题时启用(幂等——已含 `#` 标题的原样返回,
|
||||
* 不会套双层标题/双重加粗)。转换项:
|
||||
* 1) 行首 `【xxx】` → `#### xxx`;同一行剩余文字另起一段
|
||||
* 2) 段首 `①…⑩` → `1.` `2.` …(保留行内层级,只动段首)
|
||||
* 3) 段与段之间补空行(marked 才会真正分段);块内若全是列表/表格行则保持紧凑,避免把列表打散
|
||||
* 4) 关键数字/百分比(-62%、127.6%、101亿、470,301元…)加粗
|
||||
*/
|
||||
export function normalizePlainText(src: string): string {
|
||||
const text = String(src ?? "").replace(/\r\n?/g, "\n")
|
||||
if (/^[ \t]{0,3}#{1,6}[ \t]/m.test(text)) return text
|
||||
|
||||
const out: string[] = []
|
||||
for (const block of text.split(/\n[ \t]*\n+/)) {
|
||||
const raw = block
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => l.trim() !== "")
|
||||
if (raw.length === 0) continue
|
||||
|
||||
const conv: string[] = []
|
||||
let inFence = false
|
||||
for (const line of raw) {
|
||||
if (/^\s*```/.test(line)) inFence = !inFence
|
||||
if (inFence) {
|
||||
conv.push(line)
|
||||
continue
|
||||
}
|
||||
const h = /^[ \t]*【([^】]{1,30})】[ \t]*(.*)$/.exec(line)
|
||||
if (h) {
|
||||
conv.push(`#### ${h[1].trim()}`)
|
||||
if (h[2].trim()) conv.push(boldKeyNumbers(h[2].trim()))
|
||||
} else {
|
||||
conv.push(boldKeyNumbers(convertLeadingCircled(line.trim())))
|
||||
}
|
||||
}
|
||||
|
||||
const body = conv.filter((l) => !/^####\s/.test(l) && !l.startsWith("```"))
|
||||
const compact = body.length > 0 && body.every(isStructuralLine)
|
||||
for (const l of conv) {
|
||||
out.push(l)
|
||||
// 紧凑块:只保证标题后有空行;非紧凑块:每行都成为独立段落
|
||||
if (!compact || /^####\s/.test(l)) out.push("")
|
||||
}
|
||||
// 块尾必须留空行:否则下一块的标题/表格会被 list 的 lazy continuation 吞进上一条列表项
|
||||
out.push("")
|
||||
}
|
||||
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口:把知识库条目渲染成可直接 v-html 的 HTML 字符串。
|
||||
* @param item `kind === "md"` = 运行期 md 单一来源;其余 = DB 动态知识库文章
|
||||
*/
|
||||
export function renderKnowledgeContent(item: { content?: string; kind?: string } | null | undefined): string {
|
||||
const raw = String(item?.content ?? "")
|
||||
if (!raw) return ""
|
||||
if (item?.kind === "md") return marked.parse(raw) as string
|
||||
return safeMarked.parse(normalizePlainText(raw)) as string
|
||||
}
|
||||
@@ -41,8 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<MyDialog v-model="showDetail" :title="detailItem?.title || '文章详情'" :width="700">
|
||||
<!-- 详情弹窗(2026-09-24:宽度 700→860,长内容在弹窗内滚动) -->
|
||||
<MyDialog v-model="showDetail" :title="detailItem?.title || '文章详情'" :width="860">
|
||||
<div v-if="detailItem" class="detail-content" v-html="detailHtml"></div>
|
||||
<template #footer>
|
||||
<el-button @click="showDetail = false">关闭</el-button>
|
||||
@@ -170,7 +170,7 @@ import { ref, computed } from "vue"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { ethicsQuizApi } from "../api/index"
|
||||
import api from "../api/index"
|
||||
import { marked } from "marked"
|
||||
import { renderKnowledgeContent } from "../utils/knowledgeRender"
|
||||
|
||||
// ── Tab ──
|
||||
const activeTab = ref("knowledge")
|
||||
@@ -312,8 +312,9 @@ function catLabel(key: string) {
|
||||
|
||||
function openItem(item: any) {
|
||||
detailItem.value = item
|
||||
// md 单一来源的内容用页面同一渲染器渲染;DB 动态知识库文章保持既有 HTML 直出
|
||||
detailHtml.value = item?.kind === "md" ? (marked(item.content || "") as string) : (item?.content || "")
|
||||
// 统一走 knowledgeRender(2026-09-24):md 单一来源保持既有 marked 渲染(含内联样式,不转义);
|
||||
// DB 动态知识库文章先做纯文本规范化再渲染,且原始 HTML 只以纯文本出现、不当 HTML 执行。
|
||||
detailHtml.value = renderKnowledgeContent(item)
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
@@ -448,15 +449,25 @@ function backToIntro() {
|
||||
.empty-search { text-align: center; color: #999; padding: 40px; }
|
||||
.kb-hint { text-align: center; color: #999; padding: 24px; font-size: 14px; }
|
||||
.kb-hint-error { color: #f56c6c; }
|
||||
.detail-content { line-height: 1.8; font-size: 14px; }
|
||||
.detail-content h4 { margin: 16px 0 8px; }
|
||||
.detail-content h5 { margin: 12px 0 6px; color: #666; }
|
||||
.detail-content ul, .detail-content ol { padding-left: 20px; }
|
||||
.detail-content li { margin-bottom: 4px; }
|
||||
.detail-content code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
.detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
|
||||
.detail-content th { background: #f5f5f5; font-weight: 600; }
|
||||
/* ── 详情正文(v-html 内容不带 scoped 属性 ⇒ 元素级样式一律走 :deep()) ── */
|
||||
.detail-content { line-height: 1.8; font-size: 14px; color: #303133; padding: 4px 2px 28px; overflow-wrap: break-word; word-break: break-word; }
|
||||
.detail-content :deep(h1) { font-size: 20px; line-height: 1.4; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
|
||||
.detail-content :deep(h2) { font-size: 18px; line-height: 1.4; margin: 22px 0 12px; padding-left: 10px; border-left: 3px solid #409eff; }
|
||||
.detail-content :deep(h3) { font-size: 16px; line-height: 1.4; margin: 20px 0 10px; padding-left: 9px; border-left: 3px solid #dcdfe6; }
|
||||
.detail-content :deep(h4) { font-size: 15px; line-height: 1.45; margin: 18px 0 8px; color: #409eff; }
|
||||
.detail-content :deep(h5) { font-size: 14px; margin: 14px 0 6px; color: #666; }
|
||||
.detail-content :deep(p) { margin: 10px 0; line-height: 1.8; }
|
||||
.detail-content :deep(ul), .detail-content :deep(ol) { padding-left: 22px; margin: 10px 0; }
|
||||
.detail-content :deep(li) { margin-bottom: 6px; line-height: 1.8; }
|
||||
.detail-content :deep(li > p) { margin: 4px 0; }
|
||||
.detail-content :deep(strong) { color: #409eff; font-weight: 600; }
|
||||
.detail-content :deep(code) { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: "JetBrains Mono", Consolas, Monaco, "Courier New", monospace; }
|
||||
.detail-content :deep(pre) { background: #f6f8fa; border: 1px solid #e4e7ed; border-radius: 6px; padding: 12px 14px; margin: 12px 0; overflow-x: auto; }
|
||||
.detail-content :deep(pre code) { background: transparent; padding: 0; font-size: 13px; line-height: 1.7; white-space: pre; }
|
||||
.detail-content :deep(blockquote) { margin: 12px 0; padding: 8px 14px; border-left: 4px solid #409eff; background: #f5f7fa; border-radius: 0 6px 6px 0; color: #606266; }
|
||||
.detail-content :deep(blockquote > p) { margin: 4px 0; }
|
||||
.detail-content :deep(hr) { border: none; border-top: 1px dashed #dcdfe6; margin: 18px 0; }
|
||||
.detail-content :deep(img) { max-width: 100%; height: auto; }
|
||||
/* v-html 内容不带 scoped 属性(旧版靠 HTML 内联 border/cellpadding 出边框);
|
||||
md 单一来源的表格是纯 markdown,改用 :deep 让既有表格样式真正生效(2026-09-24) */
|
||||
.detail-content :deep(table) { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
|
||||
Reference in New Issue
Block a user