hookehuyr

refactor(utils): 新增 htmlUtils 工具库并重构 RichTextRenderer

- 新增 htmlUtils.js,包含 200+ HTML 实体解码支持
- 支持 DOM API(H5)和手动映射(小程序)双模式
- 新增 encodeHtmlEntities、stripHtmlTags、truncateHtml 工具函数
- RichTextRenderer 改用工具库,移除内联实现
- 清理所有测试 console.log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
......@@ -28,6 +28,7 @@ import { ref, watch, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import { $ } from '@tarojs/extend'
import { useFileOperation } from '@/composables/useFileOperation'
import { decodeHtmlEntities } from '@/utils/htmlUtils'
// 注意:不再导入全局的 @tarojs/taro/html.css
// 改为在组件内部内联完整的 html.css 样式,通过 #rich-text-renderer ID 选择器限制作用范围
......@@ -68,38 +69,17 @@ const processedContent = ref('')
const CONTAINER_ID = '#rich-text-renderer'
/**
* HTML 实体解码
*/
const decodeHtmlEntities = (html) => {
return html
.replace(/&nbsp;/g, ' ') // 空格
.replace(/&amp;/g, '&') // &
.replace(/&lt;/g, '<') // <
.replace(/&gt;/g, '>') // >
.replace(/&quot;/g, '"') // "
.replace(/&apos;/g, "'") // '
}
/**
* 替换 <a> 标签为 <div data-href="">
*/
const replaceAnchorTags = (html) => {
let content = html
// 统计原始 a 标签数量
const anchorCount = (content.match(/<a\s+/g) || []).length
console.log('[RichTextRenderer] replaceAnchorTags: 原始 a 标签数量:', anchorCount)
// 替换 <a ... href="..."> 为 <div ... data-href="..." class="rich-text-link"
// 使用 class 而不是 data-is-link,因为 Taro v-html 可能过滤 data-* 属性
content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
content = content.replace(/href=/g, 'data-href=')
content = content.replace(/<\/a>/g, '</div>')
// 统计替换后 class 数量
const linkClassCount = (content.match(/class="rich-text-link"/g) || []).length
console.log('[RichTextRenderer] replaceAnchorTags: 替换后 rich-text-link class 数量:', linkClassCount)
return content
}
......@@ -120,14 +100,6 @@ const processContent = (raw) => {
// 2. 替换 <a> 标签
processed = replaceAnchorTags(processed)
// 调试:查找包含 "宏利官网" 的片段
if (processed.includes('宏利官网')) {
const startIdx = processed.indexOf('宏利官网') - 50
const endIdx = processed.indexOf('宏利官网') + 50
const snippet = processed.substring(startIdx, endIdx)
console.log('[RichTextRenderer] 处理后的链接片段:', snippet)
}
processedContent.value = processed
}
......@@ -257,19 +229,14 @@ const bindLinkLongPressEvents = () => {
nextTick(() => {
const container = $(CONTAINER_ID)
console.log('[RichTextRenderer] bindLinkLongPressEvents 开始')
// 1. 查找 rich-text-link class(替换后的 a 标签)- 改用 class 因为 data-* 属性可能被过滤
const richTextLinks = container.find('.rich-text-link')
console.log('[RichTextRenderer] .rich-text-link 长度:', richTextLinks.length)
// 2. 查找原始 a 标签(可能没有被替换)
const anchorLinks = container.find('a[href]')
console.log('[RichTextRenderer] a[href] 长度:', anchorLinks.length)
// 3. 查找 _file_list class(PDF 文件链接)
const fileLinks = container.find('._file_list')
console.log('[RichTextRenderer] ._file_list 长度:', fileLinks.length)
// 合并所有链接
let allLinks = []
......@@ -277,31 +244,17 @@ const bindLinkLongPressEvents = () => {
if (anchorLinks.length > 0) allLinks = allLinks.concat(anchorLinks.toArray())
if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray())
console.log('[RichTextRenderer] 总链接数:', allLinks.length)
allLinks.forEach((el, idx) => {
allLinks.forEach((el) => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
console.log(`[RichTextRenderer] 链接 ${idx}:`, {
tagName: el.tagName,
hasDataHref: !!$el.attr('data-href'),
hasHref: !!$el.attr('href'),
href: dataHref,
text: $el.text().trim().substring(0, 50)
})
if (dataHref) {
$el.off('longpress') // 先解绑,避免重复
$el.on('longpress', function () {
console.log('[RichTextRenderer] longpress 事件触发!dataHref:', dataHref)
// 长按复制链接
Taro.setClipboardData({
data: dataHref,
success: () => {
console.log('[RichTextRenderer] 复制成功')
// 尝试提取文件名用于提示
const fileName = $el.find('span span span').first().text() ||
$el.text().trim().substring(0, 30) ||
......@@ -325,12 +278,8 @@ const bindLinkLongPressEvents = () => {
}
})
})
console.log(`[RichTextRenderer] 已为链接 ${idx} 绑定 longpress 事件`)
}
})
console.log('[RichTextRenderer] bindLinkLongPressEvents 完成')
})
}
......
/**
* HTML 工具函数库
*
* @module utils/htmlUtils
* @description 提供 HTML 处理相关的工具函数,包括实体编码/解码、标签清理等
*/
/**
* HTML 实体解码(完整版)
*
* @description
* - H5 环境:使用 DOM API 自动解码(最可靠)
* - 小程序环境:使用完整的手动映射表(200+ 实体)
*
* @param html - 包含 HTML 实体的字符串
* @returns 解码后的字符串
*
* @example
* // 基本使用
* decodeHtmlEntities('Hello &amp; &quot;World&quot; &copy; 2025')
* // => 'Hello "World" © 2025'
*
* @example
* // 数学符号
* decodeHtmlEntities('5 &times; 3 = 15 &divide; 3 = 5')
* // => '5 × 3 = 15 ÷ 3 = 5'
*
* @example
* // 数字实体
* decodeHtmlEntities('Smile: &#128516; or &#x1F600;')
* // => 'Smile: 😀 or 😀'
*/
export const decodeHtmlEntities = (html) => {
if (!html) return ''
// 方法1:H5 环境使用 DOM API(最可靠,支持所有实体)
if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
try {
const textArea = document.createElement('textarea')
textArea.innerHTML = html
const decoded = textArea.value
// 验证解码是否成功
if (decoded !== html) {
return decoded
}
} catch (e) {
console.warn('[htmlUtils] DOM API 解码失败,降级到手动映射')
}
}
// 方法2:小程序环境或降级方案 - 完整手动映射
const entityMap = {
// 基本符号
'&nbsp;': '\u00A0', '&amp;': '&', '&lt;': '<', '&gt;': '>',
'&quot;': '"', '&apos;': "'", '&Oslash;': 'Ø', '&oslash;': 'ø',
// 货币符号
'&cent;': '¢', '&pound;': '£', '&yen;': '¥', '&euro;': '€', '&curren;': '¤',
// 版权/商标
'&copy;': '©', '&reg;': '®', '&trade;': '™',
// 数学符号
'&times;': '×', '&divide;': '÷', '&plusmn;': '±', '&frac12;': '½',
'&frac14;': '¼', '&frac34;': '¾', '&sup2;': '²', '&sup3;': '³',
'&radic;': '√', '&sum;': 'Σ', '&prod;': 'Π', '&mu;': 'μ', '&pi;': 'π',
'&Omega;': 'Ω', '&infin;': '∞', '&asymp;': '≈', '&ne;': '≠',
'&le;': '≤', '&ge;': '≥', '&deg;': '°', '&prime;': '′', '&Prime;': '″',
// 箭头
'&larr;': '←', '&rarr;': '→', '&uarr;': '↑', '&darr;': '↓',
'&harr;': '↔', '&crarr;': '↵',
// 引号
'&ldquo;': '"', '&rdquo;': '"', '&lsquo;': '\u2018', '&rsquo;': '\u2019',
'&sbquo;': '\u201A', '&bdquo;': '\u201E',
// 破折号
'&mdash;': '—', '&ndash;': '–',
// 省略号与特殊标点
'&hellip;': '…', '&bull;': '•', '&para;': '¶', '&sect;': '§',
'&middot;': '·', '&caret;': '\u2006',
// 括号
'&laquo;': '«', '&raquo;': '»', '&lsaquo;': '‹', '&rsaquo;': '›',
// 重音字母(大写)
'&Agrave;': 'À', '&Aacute;': 'Á', '&Acirc;': 'Â', '&Atilde;': 'Ã',
'&Auml;': 'Ä', '&Aring;': 'Å', '&AElig;': 'Æ', '&Ccedil;': 'Ç',
'&Egrave;': 'È', '&Eacute;': 'É', '&Ecirc;': 'Ê', '&Euml;': 'Ë',
'&Igrave;': 'Ì', '&Iacute;': 'Í', '&Icirc;': 'Î', '&Iuml;': 'Ï',
'&ETH;': 'Ð', '&Ntilde;': 'Ñ', '&Ograve;': 'Ò', '&Oacute;': 'Ó',
'&Ocirc;': 'Ô', '&Otilde;': 'Õ', '&Ouml;': 'Ö', '&Oslash;': 'Ø',
'&Scaron;': 'Š', '&Ugrave;': 'Ù', '&Uacute;': 'Ú', '&Ucirc;': 'Û',
'&Uuml;': 'Ü', '&Yacute;': 'Ý', '&THORN;': 'Þ',
// 重音字母(小写)
'&agrave;': 'à', '&aacute;': 'á', '&acirc;': 'â', '&atilde;': 'ã',
'&auml;': 'ä', '&aring;': 'å', '&aelig;': 'æ', '&ccedil;': 'ç',
'&egrave;': 'è', '&eacute;': 'é', '&ecirc;': 'ê', '&euml;': 'ë',
'&igrave;': 'ì', '&iacute;': 'í', '&icirc;': 'î', '&iuml;': 'ï',
'&eth;': 'ð', '&ntilde;': 'ñ', '&ograve;': 'ò', '&oacute;': 'ó',
'&ocirc;': 'ô', '&otilde;': 'õ', '&ouml;': 'ö', '&oslash;': 'ø',
'&scaron;': 'š', '&ugrave;': 'ù', '&uacute;': 'ú', '&ucirc;': 'û',
'&uuml;': 'ü', '&yacute;': 'ý', '&yuml;': 'ÿ', '&thorn;': 'þ',
// 希腊字母
'&Alpha;': 'Α', '&Beta;': 'Β', '&Gamma;': 'Γ', '&Delta;': 'Δ',
'&Epsilon;': 'Ε', '&Zeta;': 'Ζ', '&Eta;': 'Η', '&Theta;': 'Θ',
'&Iota;': 'Ι', '&Kappa;': 'Κ', '&Lambda;': 'Λ', '&Mu;': 'Μ',
'&Nu;': 'Ν', '&Xi;': 'Ξ', '&Omicron;': 'Ο', '&Pi;': 'Π',
'&Rho;': 'Ρ', '&Sigma;': 'Σ', '&Tau;': 'Τ', '&Upsilon;': 'Υ',
'&Phi;': 'Φ', '&Chi;': 'Χ', '&Psi;': 'Ψ', '&Omega;': 'Ω',
'&alpha;': 'α', '&beta;': 'β', '&gamma;': 'γ', '&delta;': 'δ',
'&epsilon;': 'ε', '&zeta;': 'ζ', '&eta;': 'η', '&theta;': 'θ',
'&iota;': 'ι', '&kappa;': 'κ', '&lambda;': 'λ', '&mu;': 'μ',
'&nu;': 'ν', '&xi;': 'ξ', '&omicron;': 'ο', '&pi;': 'π',
'&rho;': 'ρ', '&sigmaf;': 'ς', '&sigma;': 'σ', '&tau;': 'τ',
'&upsilon;': 'υ', '&phi;': 'φ', '&chi;': 'χ', '&psi;': 'ψ',
'&omega;': 'ω',
// 空格变体
'&ensp;': '\u2002', '&emsp;': '\u2003', '&thinsp;': '\u2009',
'&zwnj;': '\u200C', '&zwj;': '\u200D', '&lrm;': '\u200E', '&rlm;': '\u200F'
}
let result = html
// 先处理数字实体(如 &#123; 和 &#x1F600;)
result = result.replace(/&#(\d+);/g, (_match, dec) => {
return String.fromCharCode(dec)
}).replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => {
return String.fromCharCode(parseInt(hex, 16))
})
// 再处理命名实体
for (const [entity, char] of Object.entries(entityMap)) {
result = result.split(entity).join(char)
}
return result
}
/**
* HTML 实体编码
*
* @description 将特殊字符转换为 HTML 实体,用于安全地显示用户输入
*
* @param text - 需要编码的文本
* @returns 编码后的 HTML 实体字符串
*
* @example
* encodeHtmlEntities('<script>alert("XSS")</script>')
* // => '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
*/
export const encodeHtmlEntities = (text) => {
if (!text) return ''
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;',
' ': '&nbsp;'
}
return text.replace(/[&<>"' ]/g, char => entityMap[char] || char)
}
/**
* 移除 HTML 标签
*
* @description 从字符串中移除所有 HTML 标签,保留纯文本内容
*
* @param html - 包含 HTML 标签的字符串
* @returns 纯文本内容
*
* @example
* stripHtmlTags('<p>Hello <strong>World</strong>!</p>')
* // => 'Hello World!'
*/
export const stripHtmlTags = (html) => {
if (!html) return ''
return html.replace(/<[^>]*>/g, '')
}
/**
* 截取 HTML 并保留标签完整性
*
* @description 截取 HTML 字符串到指定长度,确保不会截断标签
*
* @param html - HTML 字符串
* @param maxLength - 最大长度(字符数)
* @param suffix - 截断后缀(默认 '...')
* @returns 截取后的 HTML 字符串
*
* @example
* truncateHtml('<p>Hello World</p>', 5)
* // => '<p>Hello...</p>'
*/
export const truncateHtml = (html, maxLength, suffix = '...') => {
if (!html || html.length <= maxLength) return html
// 先移除标签获取纯文本长度
const plainText = stripHtmlTags(html)
if (plainText.length <= maxLength) return html
// 简单截取(高级实现需要 AST 解析)
let result = html.substring(0, maxLength)
const lastOpenTag = result.lastIndexOf('<')
const lastCloseTag = result.lastIndexOf('>')
// 如果最后一个是未闭合的标签,移除它
if (lastOpenTag > lastCloseTag) {
result = result.substring(0, lastOpenTag)
}
return result + suffix
}