hookehuyr

feat(ui): RichTextRenderer 新增链接长按复制功能

- 新增 @link-copy 事件,长按链接时复制 URL 到剪贴板
- 使用 class="rich-text-link" 替代 data-is-link 属性(Taro v-html 过滤 data-*)
- 支持纯文本链接和 PDF 文件链接的长按复制
- 添加蓝色下划线样式,让链接更易识别
- 测试页新增纯文本链接测试用例

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
7 * - <a> 标签自动替换为 <div data-href=""> 7 * - <a> 标签自动替换为 <div data-href="">
8 * - 图片长按预览 8 * - 图片长按预览
9 * - 文件链接点击处理 9 * - 文件链接点击处理
10 + * - 链接长按复制
10 * - 可选的 transformElement 图片自动处理 11 * - 可选的 transformElement 图片自动处理
11 * 12 *
12 * @example 13 * @example
...@@ -15,6 +16,7 @@ ...@@ -15,6 +16,7 @@
15 * :enable-transform="true" 16 * :enable-transform="true"
16 * @image-preview="handlePreview" 17 * @image-preview="handlePreview"
17 * @file-click="handleFileClick" 18 * @file-click="handleFileClick"
19 + * @link-copy="handleLinkCopy"
18 * /> 20 * />
19 --> 21 -->
20 <template> 22 <template>
...@@ -54,7 +56,7 @@ const props = defineProps({ ...@@ -54,7 +56,7 @@ const props = defineProps({
54 /** 56 /**
55 * 组件事件 57 * 组件事件
56 */ 58 */
57 -const emit = defineEmits(['image-preview', 'file-click']) 59 +const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
58 60
59 // 文件操作 61 // 文件操作
60 const { viewFile } = useFileOperation() 62 const { viewFile } = useFileOperation()
...@@ -83,10 +85,21 @@ const decodeHtmlEntities = (html) => { ...@@ -83,10 +85,21 @@ const decodeHtmlEntities = (html) => {
83 */ 85 */
84 const replaceAnchorTags = (html) => { 86 const replaceAnchorTags = (html) => {
85 let content = html 87 let content = html
86 - // 替换 <a ... href="..."> 为 <div ... data-href="..." data-is-link="true"> 88 +
87 - content = content.replace(/<a\s+/g, '<div data-is-link="true" ') 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"
94 + // 使用 class 而不是 data-is-link,因为 Taro v-html 可能过滤 data-* 属性
95 + content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
88 content = content.replace(/href=/g, 'data-href=') 96 content = content.replace(/href=/g, 'data-href=')
89 content = content.replace(/<\/a>/g, '</div>') 97 content = content.replace(/<\/a>/g, '</div>')
98 +
99 + // 统计替换后 class 数量
100 + const linkClassCount = (content.match(/class="rich-text-link"/g) || []).length
101 + console.log('[RichTextRenderer] replaceAnchorTags: 替换后 rich-text-link class 数量:', linkClassCount)
102 +
90 return content 103 return content
91 } 104 }
92 105
...@@ -107,6 +120,14 @@ const processContent = (raw) => { ...@@ -107,6 +120,14 @@ const processContent = (raw) => {
107 // 2. 替换 <a> 标签 120 // 2. 替换 <a> 标签
108 processed = replaceAnchorTags(processed) 121 processed = replaceAnchorTags(processed)
109 122
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 +
110 processedContent.value = processed 131 processedContent.value = processed
111 } 132 }
112 133
...@@ -136,8 +157,8 @@ const setupTransformElement = () => { ...@@ -136,8 +157,8 @@ const setupTransformElement = () => {
136 const pDataIsLink = parent.getAttribute?.('data-is-link') 157 const pDataIsLink = parent.getAttribute?.('data-is-link')
137 const pClassList = parent.classList?.value || '' 158 const pClassList = parent.classList?.value || ''
138 159
139 - // 检查是否在 <a> 或带有 data-is-link 或 _file_list class 的元素内 160 + // 检查是否在 <a> 或带有 data-is-link 或 rich-text-link 或 _file_list class 的元素内
140 - if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) { 161 + if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('rich-text-link') || pClassList.includes('_file_list')) {
141 isInsideLink = true 162 isInsideLink = true
142 break 163 break
143 } 164 }
...@@ -199,12 +220,17 @@ const bindImageEvents = () => { ...@@ -199,12 +220,17 @@ const bindImageEvents = () => {
199 const bindFileLinkEvents = () => { 220 const bindFileLinkEvents = () => {
200 nextTick(() => { 221 nextTick(() => {
201 const container = $(CONTAINER_ID) 222 const container = $(CONTAINER_ID)
202 - const fileLinks = container.find('div[data-is-link="true"]')
203 223
204 - // 如果找不到 data-is-link,尝试用 class 查找 224 + // 查找所有链接元素
205 - const targetLinks = fileLinks.length > 0 ? fileLinks : container.find('._file_list') 225 + const richTextLinks = container.find('.rich-text-link')
226 + const fileLinks = container.find('._file_list')
227 +
228 + // 合并所有链接
229 + let allLinks = []
230 + if (richTextLinks.length > 0) allLinks = allLinks.concat(richTextLinks.toArray())
231 + if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray())
206 232
207 - targetLinks.each(function (idx, el) { 233 + allLinks.forEach((el) => {
208 const $el = $(el) 234 const $el = $(el)
209 const dataHref = $el.attr('data-href') 235 const dataHref = $el.attr('data-href')
210 236
...@@ -225,6 +251,90 @@ const bindFileLinkEvents = () => { ...@@ -225,6 +251,90 @@ const bindFileLinkEvents = () => {
225 } 251 }
226 252
227 /** 253 /**
254 + * 绑定链接长按复制事件
255 + */
256 +const bindLinkLongPressEvents = () => {
257 + nextTick(() => {
258 + const container = $(CONTAINER_ID)
259 +
260 + console.log('[RichTextRenderer] bindLinkLongPressEvents 开始')
261 +
262 + // 1. 查找 rich-text-link class(替换后的 a 标签)- 改用 class 因为 data-* 属性可能被过滤
263 + const richTextLinks = container.find('.rich-text-link')
264 + console.log('[RichTextRenderer] .rich-text-link 长度:', richTextLinks.length)
265 +
266 + // 2. 查找原始 a 标签(可能没有被替换)
267 + const anchorLinks = container.find('a[href]')
268 + console.log('[RichTextRenderer] a[href] 长度:', anchorLinks.length)
269 +
270 + // 3. 查找 _file_list class(PDF 文件链接)
271 + const fileLinks = container.find('._file_list')
272 + console.log('[RichTextRenderer] ._file_list 长度:', fileLinks.length)
273 +
274 + // 合并所有链接
275 + let allLinks = []
276 + if (richTextLinks.length > 0) allLinks = allLinks.concat(richTextLinks.toArray())
277 + if (anchorLinks.length > 0) allLinks = allLinks.concat(anchorLinks.toArray())
278 + if (fileLinks.length > 0) allLinks = allLinks.concat(fileLinks.toArray())
279 +
280 + console.log('[RichTextRenderer] 总链接数:', allLinks.length)
281 +
282 + allLinks.forEach((el, idx) => {
283 + const $el = $(el)
284 + const dataHref = $el.attr('data-href') || $el.attr('href')
285 +
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) {
295 + $el.off('longpress') // 先解绑,避免重复
296 + $el.on('longpress', function () {
297 + console.log('[RichTextRenderer] longpress 事件触发!dataHref:', dataHref)
298 +
299 + // 长按复制链接
300 + Taro.setClipboardData({
301 + data: dataHref,
302 + success: () => {
303 + console.log('[RichTextRenderer] 复制成功')
304 +
305 + // 尝试提取文件名用于提示
306 + const fileName = $el.find('span span span').first().text() ||
307 + $el.text().trim().substring(0, 30) ||
308 + '链接'
309 +
310 + Taro.showToast({
311 + title: '链接已复制',
312 + icon: 'success',
313 + duration: 2000
314 + })
315 +
316 + // 触发父组件事件
317 + emit('link-copy', { url: dataHref, fileName })
318 + },
319 + fail: (err) => {
320 + console.error('[RichTextRenderer] 复制链接失败:', err)
321 + Taro.showToast({
322 + title: '复制失败',
323 + icon: 'error'
324 + })
325 + }
326 + })
327 + })
328 +
329 + console.log(`[RichTextRenderer] 已为链接 ${idx} 绑定 longpress 事件`)
330 + }
331 + })
332 +
333 + console.log('[RichTextRenderer] bindLinkLongPressEvents 完成')
334 + })
335 +}
336 +
337 +/**
228 * 处理内容变化 338 * 处理内容变化
229 */ 339 */
230 const handleContentChange = () => { 340 const handleContentChange = () => {
...@@ -234,6 +344,7 @@ const handleContentChange = () => { ...@@ -234,6 +344,7 @@ const handleContentChange = () => {
234 nextTick(() => { 344 nextTick(() => {
235 bindImageEvents() 345 bindImageEvents()
236 bindFileLinkEvents() 346 bindFileLinkEvents()
347 + bindLinkLongPressEvents()
237 }) 348 })
238 } 349 }
239 350
...@@ -551,7 +662,8 @@ watch(() => props.enableTransform, () => { ...@@ -551,7 +662,8 @@ watch(() => props.enableTransform, () => {
551 } 662 }
552 663
553 // 文件链接交互样式 664 // 文件链接交互样式
554 - div[data-is-link="true"] { 665 + div[data-is-link="true"],
666 + .rich-text-link {
555 cursor: pointer; 667 cursor: pointer;
556 transition: opacity 0.2s; 668 transition: opacity 0.2s;
557 669
...@@ -559,5 +671,12 @@ watch(() => props.enableTransform, () => { ...@@ -559,5 +671,12 @@ watch(() => props.enableTransform, () => {
559 opacity: 0.7; 671 opacity: 0.7;
560 } 672 }
561 } 673 }
674 +
675 + // 纯文本链接样式(让链接看起来像可点击的)
676 + .rich-text-link {
677 + color: #1989fa;
678 + text-decoration: underline;
679 + display: inline;
680 + }
562 } 681 }
563 </style> 682 </style>
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
12 * 3. HTML 实体解析 12 * 3. HTML 实体解析
13 * 4. 图片长按预览 13 * 4. 图片长按预览
14 * 5. 文件链接点击 14 * 5. 文件链接点击
15 + * 6. 链接长按复制
15 --> 16 -->
16 <template> 17 <template>
17 <view class="rich-text-test-page"> 18 <view class="rich-text-test-page">
...@@ -26,6 +27,7 @@ ...@@ -26,6 +27,7 @@
26 <text class="test-item">2. 内容切换 {{ contentSwitchTest ? '✅' : '❌' }}</text> 27 <text class="test-item">2. 内容切换 {{ contentSwitchTest ? '✅' : '❌' }}</text>
27 <text class="test-item">3. 图片预览 {{ imagePreviewTest ? '✅' : '❌' }}</text> 28 <text class="test-item">3. 图片预览 {{ imagePreviewTest ? '✅' : '❌' }}</text>
28 <text class="test-item">4. 文件链接 {{ fileLinkTest ? '✅' : '❌' }}</text> 29 <text class="test-item">4. 文件链接 {{ fileLinkTest ? '✅' : '❌' }}</text>
30 + <text class="test-item">5. 长按复制 {{ linkCopyTest ? '✅' : '❌' }}</text>
29 </view> 31 </view>
30 32
31 <!-- RichTextRenderer 渲染区域 --> 33 <!-- RichTextRenderer 渲染区域 -->
...@@ -37,6 +39,7 @@ ...@@ -37,6 +39,7 @@
37 :enable-transform="transformEnabled" 39 :enable-transform="transformEnabled"
38 @image-preview="handleImagePreview" 40 @image-preview="handleImagePreview"
39 @file-click="handleFileClick" 41 @file-click="handleFileClick"
42 + @link-copy="handleLinkCopy"
40 /> 43 />
41 </view> 44 </view>
42 </view> 45 </view>
...@@ -69,7 +72,7 @@ ...@@ -69,7 +72,7 @@
69 <text class="note-item">• RichTextRenderer 基于 Taro v-html</text> 72 <text class="note-item">• RichTextRenderer 基于 Taro v-html</text>
70 <text class="note-item">• 自动解析 HTML 实体(&nbsp; &amp; 等)</text> 73 <text class="note-item">• 自动解析 HTML 实体(&nbsp; &amp; 等)</text>
71 <text class="note-item">• 自动替换 &lt;a&gt; 为 &lt;div data-href=""&gt;</text> 74 <text class="note-item">• 自动替换 &lt;a&gt; 为 &lt;div data-href=""&gt;</text>
72 - <text class="note-item">• 支持图片长按预览和文件链接点击</text> 75 + <text class="note-item">• 支持图片长按预览、文件链接点击、链接长按复制</text>
73 </view> 76 </view>
74 </view> 77 </view>
75 </view> 78 </view>
...@@ -91,6 +94,7 @@ const renderTest = ref(false) ...@@ -91,6 +94,7 @@ const renderTest = ref(false)
91 const contentSwitchTest = ref(false) 94 const contentSwitchTest = ref(false)
92 const imagePreviewTest = ref(false) 95 const imagePreviewTest = ref(false)
93 const fileLinkTest = ref(false) 96 const fileLinkTest = ref(false)
97 +const linkCopyTest = ref(false)
94 const switchCount = ref(0) 98 const switchCount = ref(0)
95 const currentContentType = ref('未加载') 99 const currentContentType = ref('未加载')
96 const contentLength = ref(0) 100 const contentLength = ref(0)
...@@ -180,6 +184,15 @@ const realApiData = { ...@@ -180,6 +184,15 @@ const realApiData = {
180 <p>🕹️签署合约后,每月需交给公司200元/月行政费!</p> 184 <p>🕹️签署合约后,每月需交给公司200元/月行政费!</p>
181 <hr /> 185 <hr />
182 <p>&nbsp;</p> 186 <p>&nbsp;</p>
187 +<p>🔗相关链接(长按可复制):</p>
188 +<p>• <a href="https://www.manulife.com" target="_blank" rel="noopener">宏利官网</a> - 了解公司最新动态</p>
189 +<p>• <a href="https://www.hkma.org.hk" target="_blank" rel="noopener">香港保险业监管局</a> - 查询保险中介人资质</p>
190 +<p>• <a href="https://www.ia.org.hk" target="_blank" rel="noopener">保监局一站通</a> - 在线申请挂牌</p>
191 +<p>• <a href="https://www.example.com/courses" target="_blank" rel="noopener">培训课程报名</a> - 新人课和财富管家课程</p>
192 +<p>&nbsp;</p>
193 +<p>💡提示:长按上方链接可以复制链接地址</p>
194 +<hr />
195 +<p>&nbsp;</p>
183 <p>入职资料Sample</p> 196 <p>入职资料Sample</p>
184 <p><a class="_file_list" style="display: inline-block; padding: 12px; border: 1px solid #e1e5e9; border-radius: 8px; margin: 8px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); max-width: 400px; transition: all 0.3s ease; text-decoration: none; color: inherit; cursor: pointer; font-family: -apple-system,BlinkMacSystemFont,;" href="https://cdn.ipadbiz.cn/space_3079606/需提供的部分个人资料SAMPLE(不含流水SAMPLE)_扫描版_lkAqOTlyK-jZ7RbBN43oiG5QPfr-.pdf" target="_blank" rel="noopener"><span style="display: inline-block; font-size: 24px; margin-right: 12px; color: #6c757d; vertical-align: middle;"><img style="width: 30px; height: 30px; vertical-align: text-bottom;" src="https://cdn.ipadbiz.cn/img%2Ftinymce_icon%2Foffice-pdf.png" /></span><span style="display: inline-block; vertical-align: middle;"><span style="display: block; font-weight: 600; font-size: 14px; color: #2c3e50; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;">需提供的部分个人资料SAMPLE(不含流水S....pdf</span><span style="display: block; font-size: 12px; color: #6c757d; margin-top: 4px; line-height: 1.2;">101.91 MB</span></span></a></p> 197 <p><a class="_file_list" style="display: inline-block; padding: 12px; border: 1px solid #e1e5e9; border-radius: 8px; margin: 8px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); max-width: 400px; transition: all 0.3s ease; text-decoration: none; color: inherit; cursor: pointer; font-family: -apple-system,BlinkMacSystemFont,;" href="https://cdn.ipadbiz.cn/space_3079606/需提供的部分个人资料SAMPLE(不含流水SAMPLE)_扫描版_lkAqOTlyK-jZ7RbBN43oiG5QPfr-.pdf" target="_blank" rel="noopener"><span style="display: inline-block; font-size: 24px; margin-right: 12px; color: #6c757d; vertical-align: middle;"><img style="width: 30px; height: 30px; vertical-align: text-bottom;" src="https://cdn.ipadbiz.cn/img%2Ftinymce_icon%2Foffice-pdf.png" /></span><span style="display: inline-block; vertical-align: middle;"><span style="display: block; font-weight: 600; font-size: 14px; color: #2c3e50; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;">需提供的部分个人资料SAMPLE(不含流水S....pdf</span><span style="display: block; font-size: 12px; color: #6c757d; margin-top: 4px; line-height: 1.2;">101.91 MB</span></span></a></p>
185 <p>&nbsp;</p>` 198 <p>&nbsp;</p>`
...@@ -233,6 +246,12 @@ const handleFileClick = ({ fileName }) => { ...@@ -233,6 +246,12 @@ const handleFileClick = ({ fileName }) => {
233 lastAction.value = `文件点击: ${fileName}` 246 lastAction.value = `文件点击: ${fileName}`
234 } 247 }
235 248
249 +// 处理链接复制事件
250 +const handleLinkCopy = ({ url, fileName }) => {
251 + linkCopyTest.value = true
252 + lastAction.value = `长按复制: ${fileName}`
253 +}
254 +
236 // 切换测试内容 255 // 切换测试内容
237 const switchTestContent = (index) => { 256 const switchTestContent = (index) => {
238 const template = testContents[index] 257 const template = testContents[index]
......