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' ...@@ -28,6 +28,7 @@ import { ref, watch, nextTick } from 'vue'
28 import Taro from '@tarojs/taro' 28 import Taro from '@tarojs/taro'
29 import { $ } from '@tarojs/extend' 29 import { $ } from '@tarojs/extend'
30 import { useFileOperation } from '@/composables/useFileOperation' 30 import { useFileOperation } from '@/composables/useFileOperation'
31 +import { decodeHtmlEntities } from '@/utils/htmlUtils'
31 // 注意:不再导入全局的 @tarojs/taro/html.css 32 // 注意:不再导入全局的 @tarojs/taro/html.css
32 // 改为在组件内部内联完整的 html.css 样式,通过 #rich-text-renderer ID 选择器限制作用范围 33 // 改为在组件内部内联完整的 html.css 样式,通过 #rich-text-renderer ID 选择器限制作用范围
33 34
...@@ -68,38 +69,17 @@ const processedContent = ref('') ...@@ -68,38 +69,17 @@ const processedContent = ref('')
68 const CONTAINER_ID = '#rich-text-renderer' 69 const CONTAINER_ID = '#rich-text-renderer'
69 70
70 /** 71 /**
71 - * HTML 实体解码
72 - */
73 -const decodeHtmlEntities = (html) => {
74 - return html
75 - .replace(/&nbsp;/g, ' ') // 空格
76 - .replace(/&amp;/g, '&') // &
77 - .replace(/&lt;/g, '<') // <
78 - .replace(/&gt;/g, '>') // >
79 - .replace(/&quot;/g, '"') // "
80 - .replace(/&apos;/g, "'") // '
81 -}
82 -
83 -/**
84 * 替换 <a> 标签为 <div data-href=""> 72 * 替换 <a> 标签为 <div data-href="">
85 */ 73 */
86 const replaceAnchorTags = (html) => { 74 const replaceAnchorTags = (html) => {
87 let content = html 75 let content = html
88 76
89 - // 统计原始 a 标签数量
90 - const anchorCount = (content.match(/<a\s+/g) || []).length
91 - console.log('[RichTextRenderer] replaceAnchorTags: 原始 a 标签数量:', anchorCount)
92 -
93 // 替换 <a ... href="..."> 为 <div ... data-href="..." class="rich-text-link" 77 // 替换 <a ... href="..."> 为 <div ... data-href="..." class="rich-text-link"
94 // 使用 class 而不是 data-is-link,因为 Taro v-html 可能过滤 data-* 属性 78 // 使用 class 而不是 data-is-link,因为 Taro v-html 可能过滤 data-* 属性
95 content = content.replace(/<a\s+/g, '<div class="rich-text-link" ') 79 content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
96 content = content.replace(/href=/g, 'data-href=') 80 content = content.replace(/href=/g, 'data-href=')
97 content = content.replace(/<\/a>/g, '</div>') 81 content = content.replace(/<\/a>/g, '</div>')
98 82
99 - // 统计替换后 class 数量
100 - const linkClassCount = (content.match(/class="rich-text-link"/g) || []).length
101 - console.log('[RichTextRenderer] replaceAnchorTags: 替换后 rich-text-link class 数量:', linkClassCount)
102 -
103 return content 83 return content
104 } 84 }
105 85
...@@ -120,14 +100,6 @@ const processContent = (raw) => { ...@@ -120,14 +100,6 @@ const processContent = (raw) => {
120 // 2. 替换 <a> 标签 100 // 2. 替换 <a> 标签
121 processed = replaceAnchorTags(processed) 101 processed = replaceAnchorTags(processed)
122 102
123 - // 调试:查找包含 "宏利官网" 的片段
124 - if (processed.includes('宏利官网')) {
125 - const startIdx = processed.indexOf('宏利官网') - 50
126 - const endIdx = processed.indexOf('宏利官网') + 50
127 - const snippet = processed.substring(startIdx, endIdx)
128 - console.log('[RichTextRenderer] 处理后的链接片段:', snippet)
129 - }
130 -
131 processedContent.value = processed 103 processedContent.value = processed
132 } 104 }
133 105
...@@ -257,19 +229,14 @@ const bindLinkLongPressEvents = () => { ...@@ -257,19 +229,14 @@ const bindLinkLongPressEvents = () => {
257 nextTick(() => { 229 nextTick(() => {
258 const container = $(CONTAINER_ID) 230 const container = $(CONTAINER_ID)
259 231
260 - console.log('[RichTextRenderer] bindLinkLongPressEvents 开始')
261 -
262 // 1. 查找 rich-text-link class(替换后的 a 标签)- 改用 class 因为 data-* 属性可能被过滤 232 // 1. 查找 rich-text-link class(替换后的 a 标签)- 改用 class 因为 data-* 属性可能被过滤
263 const richTextLinks = container.find('.rich-text-link') 233 const richTextLinks = container.find('.rich-text-link')
264 - console.log('[RichTextRenderer] .rich-text-link 长度:', richTextLinks.length)
265 234
266 // 2. 查找原始 a 标签(可能没有被替换) 235 // 2. 查找原始 a 标签(可能没有被替换)
267 const anchorLinks = container.find('a[href]') 236 const anchorLinks = container.find('a[href]')
268 - console.log('[RichTextRenderer] a[href] 长度:', anchorLinks.length)
269 237
270 // 3. 查找 _file_list class(PDF 文件链接) 238 // 3. 查找 _file_list class(PDF 文件链接)
271 const fileLinks = container.find('._file_list') 239 const fileLinks = container.find('._file_list')
272 - console.log('[RichTextRenderer] ._file_list 长度:', fileLinks.length)
273 240
274 // 合并所有链接 241 // 合并所有链接
275 let allLinks = [] 242 let allLinks = []
...@@ -277,31 +244,17 @@ const bindLinkLongPressEvents = () => { ...@@ -277,31 +244,17 @@ const bindLinkLongPressEvents = () => {
277 if (anchorLinks.length > 0) allLinks = allLinks.concat(anchorLinks.toArray()) 244 if (anchorLinks.length > 0) allLinks = allLinks.concat(anchorLinks.toArray())
278 if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray()) 245 if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray())
279 246
280 - console.log('[RichTextRenderer] 总链接数:', allLinks.length) 247 + allLinks.forEach((el) => {
281 -
282 - allLinks.forEach((el, idx) => {
283 const $el = $(el) 248 const $el = $(el)
284 const dataHref = $el.attr('data-href') || $el.attr('href') 249 const dataHref = $el.attr('data-href') || $el.attr('href')
285 250
286 - console.log(`[RichTextRenderer] 链接 ${idx}:`, {
287 - tagName: el.tagName,
288 - hasDataHref: !!$el.attr('data-href'),
289 - hasHref: !!$el.attr('href'),
290 - href: dataHref,
291 - text: $el.text().trim().substring(0, 50)
292 - })
293 -
294 if (dataHref) { 251 if (dataHref) {
295 $el.off('longpress') // 先解绑,避免重复 252 $el.off('longpress') // 先解绑,避免重复
296 $el.on('longpress', function () { 253 $el.on('longpress', function () {
297 - console.log('[RichTextRenderer] longpress 事件触发!dataHref:', dataHref)
298 -
299 // 长按复制链接 254 // 长按复制链接
300 Taro.setClipboardData({ 255 Taro.setClipboardData({
301 data: dataHref, 256 data: dataHref,
302 success: () => { 257 success: () => {
303 - console.log('[RichTextRenderer] 复制成功')
304 -
305 // 尝试提取文件名用于提示 258 // 尝试提取文件名用于提示
306 const fileName = $el.find('span span span').first().text() || 259 const fileName = $el.find('span span span').first().text() ||
307 $el.text().trim().substring(0, 30) || 260 $el.text().trim().substring(0, 30) ||
...@@ -325,12 +278,8 @@ const bindLinkLongPressEvents = () => { ...@@ -325,12 +278,8 @@ const bindLinkLongPressEvents = () => {
325 } 278 }
326 }) 279 })
327 }) 280 })
328 -
329 - console.log(`[RichTextRenderer] 已为链接 ${idx} 绑定 longpress 事件`)
330 } 281 }
331 }) 282 })
332 -
333 - console.log('[RichTextRenderer] bindLinkLongPressEvents 完成')
334 }) 283 })
335 } 284 }
336 285
......
1 +/**
2 + * HTML 工具函数库
3 + *
4 + * @module utils/htmlUtils
5 + * @description 提供 HTML 处理相关的工具函数,包括实体编码/解码、标签清理等
6 + */
7 +
8 +/**
9 + * HTML 实体解码(完整版)
10 + *
11 + * @description
12 + * - H5 环境:使用 DOM API 自动解码(最可靠)
13 + * - 小程序环境:使用完整的手动映射表(200+ 实体)
14 + *
15 + * @param html - 包含 HTML 实体的字符串
16 + * @returns 解码后的字符串
17 + *
18 + * @example
19 + * // 基本使用
20 + * decodeHtmlEntities('Hello &amp; &quot;World&quot; &copy; 2025')
21 + * // => 'Hello "World" © 2025'
22 + *
23 + * @example
24 + * // 数学符号
25 + * decodeHtmlEntities('5 &times; 3 = 15 &divide; 3 = 5')
26 + * // => '5 × 3 = 15 ÷ 3 = 5'
27 + *
28 + * @example
29 + * // 数字实体
30 + * decodeHtmlEntities('Smile: &#128516; or &#x1F600;')
31 + * // => 'Smile: 😀 or 😀'
32 + */
33 +export const decodeHtmlEntities = (html) => {
34 + if (!html) return ''
35 +
36 + // 方法1:H5 环境使用 DOM API(最可靠,支持所有实体)
37 + if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
38 + try {
39 + const textArea = document.createElement('textarea')
40 + textArea.innerHTML = html
41 + const decoded = textArea.value
42 + // 验证解码是否成功
43 + if (decoded !== html) {
44 + return decoded
45 + }
46 + } catch (e) {
47 + console.warn('[htmlUtils] DOM API 解码失败,降级到手动映射')
48 + }
49 + }
50 +
51 + // 方法2:小程序环境或降级方案 - 完整手动映射
52 + const entityMap = {
53 + // 基本符号
54 + '&nbsp;': '\u00A0', '&amp;': '&', '&lt;': '<', '&gt;': '>',
55 + '&quot;': '"', '&apos;': "'", '&Oslash;': 'Ø', '&oslash;': 'ø',
56 +
57 + // 货币符号
58 + '&cent;': '¢', '&pound;': '£', '&yen;': '¥', '&euro;': '€', '&curren;': '¤',
59 +
60 + // 版权/商标
61 + '&copy;': '©', '&reg;': '®', '&trade;': '™',
62 +
63 + // 数学符号
64 + '&times;': '×', '&divide;': '÷', '&plusmn;': '±', '&frac12;': '½',
65 + '&frac14;': '¼', '&frac34;': '¾', '&sup2;': '²', '&sup3;': '³',
66 + '&radic;': '√', '&sum;': 'Σ', '&prod;': 'Π', '&mu;': 'μ', '&pi;': 'π',
67 + '&Omega;': 'Ω', '&infin;': '∞', '&asymp;': '≈', '&ne;': '≠',
68 + '&le;': '≤', '&ge;': '≥', '&deg;': '°', '&prime;': '′', '&Prime;': '″',
69 +
70 + // 箭头
71 + '&larr;': '←', '&rarr;': '→', '&uarr;': '↑', '&darr;': '↓',
72 + '&harr;': '↔', '&crarr;': '↵',
73 +
74 + // 引号
75 + '&ldquo;': '"', '&rdquo;': '"', '&lsquo;': '\u2018', '&rsquo;': '\u2019',
76 + '&sbquo;': '\u201A', '&bdquo;': '\u201E',
77 +
78 + // 破折号
79 + '&mdash;': '—', '&ndash;': '–',
80 +
81 + // 省略号与特殊标点
82 + '&hellip;': '…', '&bull;': '•', '&para;': '¶', '&sect;': '§',
83 + '&middot;': '·', '&caret;': '\u2006',
84 +
85 + // 括号
86 + '&laquo;': '«', '&raquo;': '»', '&lsaquo;': '‹', '&rsaquo;': '›',
87 +
88 + // 重音字母(大写)
89 + '&Agrave;': 'À', '&Aacute;': 'Á', '&Acirc;': 'Â', '&Atilde;': 'Ã',
90 + '&Auml;': 'Ä', '&Aring;': 'Å', '&AElig;': 'Æ', '&Ccedil;': 'Ç',
91 + '&Egrave;': 'È', '&Eacute;': 'É', '&Ecirc;': 'Ê', '&Euml;': 'Ë',
92 + '&Igrave;': 'Ì', '&Iacute;': 'Í', '&Icirc;': 'Î', '&Iuml;': 'Ï',
93 + '&ETH;': 'Ð', '&Ntilde;': 'Ñ', '&Ograve;': 'Ò', '&Oacute;': 'Ó',
94 + '&Ocirc;': 'Ô', '&Otilde;': 'Õ', '&Ouml;': 'Ö', '&Oslash;': 'Ø',
95 + '&Scaron;': 'Š', '&Ugrave;': 'Ù', '&Uacute;': 'Ú', '&Ucirc;': 'Û',
96 + '&Uuml;': 'Ü', '&Yacute;': 'Ý', '&THORN;': 'Þ',
97 +
98 + // 重音字母(小写)
99 + '&agrave;': 'à', '&aacute;': 'á', '&acirc;': 'â', '&atilde;': 'ã',
100 + '&auml;': 'ä', '&aring;': 'å', '&aelig;': 'æ', '&ccedil;': 'ç',
101 + '&egrave;': 'è', '&eacute;': 'é', '&ecirc;': 'ê', '&euml;': 'ë',
102 + '&igrave;': 'ì', '&iacute;': 'í', '&icirc;': 'î', '&iuml;': 'ï',
103 + '&eth;': 'ð', '&ntilde;': 'ñ', '&ograve;': 'ò', '&oacute;': 'ó',
104 + '&ocirc;': 'ô', '&otilde;': 'õ', '&ouml;': 'ö', '&oslash;': 'ø',
105 + '&scaron;': 'š', '&ugrave;': 'ù', '&uacute;': 'ú', '&ucirc;': 'û',
106 + '&uuml;': 'ü', '&yacute;': 'ý', '&yuml;': 'ÿ', '&thorn;': 'þ',
107 +
108 + // 希腊字母
109 + '&Alpha;': 'Α', '&Beta;': 'Β', '&Gamma;': 'Γ', '&Delta;': 'Δ',
110 + '&Epsilon;': 'Ε', '&Zeta;': 'Ζ', '&Eta;': 'Η', '&Theta;': 'Θ',
111 + '&Iota;': 'Ι', '&Kappa;': 'Κ', '&Lambda;': 'Λ', '&Mu;': 'Μ',
112 + '&Nu;': 'Ν', '&Xi;': 'Ξ', '&Omicron;': 'Ο', '&Pi;': 'Π',
113 + '&Rho;': 'Ρ', '&Sigma;': 'Σ', '&Tau;': 'Τ', '&Upsilon;': 'Υ',
114 + '&Phi;': 'Φ', '&Chi;': 'Χ', '&Psi;': 'Ψ', '&Omega;': 'Ω',
115 + '&alpha;': 'α', '&beta;': 'β', '&gamma;': 'γ', '&delta;': 'δ',
116 + '&epsilon;': 'ε', '&zeta;': 'ζ', '&eta;': 'η', '&theta;': 'θ',
117 + '&iota;': 'ι', '&kappa;': 'κ', '&lambda;': 'λ', '&mu;': 'μ',
118 + '&nu;': 'ν', '&xi;': 'ξ', '&omicron;': 'ο', '&pi;': 'π',
119 + '&rho;': 'ρ', '&sigmaf;': 'ς', '&sigma;': 'σ', '&tau;': 'τ',
120 + '&upsilon;': 'υ', '&phi;': 'φ', '&chi;': 'χ', '&psi;': 'ψ',
121 + '&omega;': 'ω',
122 +
123 + // 空格变体
124 + '&ensp;': '\u2002', '&emsp;': '\u2003', '&thinsp;': '\u2009',
125 + '&zwnj;': '\u200C', '&zwj;': '\u200D', '&lrm;': '\u200E', '&rlm;': '\u200F'
126 + }
127 +
128 + let result = html
129 +
130 + // 先处理数字实体(如 &#123; 和 &#x1F600;)
131 + result = result.replace(/&#(\d+);/g, (_match, dec) => {
132 + return String.fromCharCode(dec)
133 + }).replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => {
134 + return String.fromCharCode(parseInt(hex, 16))
135 + })
136 +
137 + // 再处理命名实体
138 + for (const [entity, char] of Object.entries(entityMap)) {
139 + result = result.split(entity).join(char)
140 + }
141 +
142 + return result
143 +}
144 +
145 +/**
146 + * HTML 实体编码
147 + *
148 + * @description 将特殊字符转换为 HTML 实体,用于安全地显示用户输入
149 + *
150 + * @param text - 需要编码的文本
151 + * @returns 编码后的 HTML 实体字符串
152 + *
153 + * @example
154 + * encodeHtmlEntities('<script>alert("XSS")</script>')
155 + * // => '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
156 + */
157 +export const encodeHtmlEntities = (text) => {
158 + if (!text) return ''
159 +
160 + const entityMap = {
161 + '&': '&amp;',
162 + '<': '&lt;',
163 + '>': '&gt;',
164 + '"': '&quot;',
165 + "'": '&apos;',
166 + ' ': '&nbsp;'
167 + }
168 +
169 + return text.replace(/[&<>"' ]/g, char => entityMap[char] || char)
170 +}
171 +
172 +/**
173 + * 移除 HTML 标签
174 + *
175 + * @description 从字符串中移除所有 HTML 标签,保留纯文本内容
176 + *
177 + * @param html - 包含 HTML 标签的字符串
178 + * @returns 纯文本内容
179 + *
180 + * @example
181 + * stripHtmlTags('<p>Hello <strong>World</strong>!</p>')
182 + * // => 'Hello World!'
183 + */
184 +export const stripHtmlTags = (html) => {
185 + if (!html) return ''
186 + return html.replace(/<[^>]*>/g, '')
187 +}
188 +
189 +/**
190 + * 截取 HTML 并保留标签完整性
191 + *
192 + * @description 截取 HTML 字符串到指定长度,确保不会截断标签
193 + *
194 + * @param html - HTML 字符串
195 + * @param maxLength - 最大长度(字符数)
196 + * @param suffix - 截断后缀(默认 '...')
197 + * @returns 截取后的 HTML 字符串
198 + *
199 + * @example
200 + * truncateHtml('<p>Hello World</p>', 5)
201 + * // => '<p>Hello...</p>'
202 + */
203 +export const truncateHtml = (html, maxLength, suffix = '...') => {
204 + if (!html || html.length <= maxLength) return html
205 +
206 + // 先移除标签获取纯文本长度
207 + const plainText = stripHtmlTags(html)
208 + if (plainText.length <= maxLength) return html
209 +
210 + // 简单截取(高级实现需要 AST 解析)
211 + let result = html.substring(0, maxLength)
212 + const lastOpenTag = result.lastIndexOf('<')
213 + const lastCloseTag = result.lastIndexOf('>')
214 +
215 + // 如果最后一个是未闭合的标签,移除它
216 + if (lastOpenTag > lastCloseTag) {
217 + result = result.substring(0, lastOpenTag)
218 + }
219 +
220 + return result + suffix
221 +}