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>
Showing
2 changed files
with
149 additions
and
11 deletions
| ... | @@ -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 实体( & 等)</text> | 73 | <text class="note-item">• 自动解析 HTML 实体( & 等)</text> |
| 71 | <text class="note-item">• 自动替换 <a> 为 <div data-href=""></text> | 74 | <text class="note-item">• 自动替换 <a> 为 <div data-href=""></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> </p> | 186 | <p> </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> </p> | ||
| 193 | +<p>💡提示:长按上方链接可以复制链接地址</p> | ||
| 194 | +<hr /> | ||
| 195 | +<p> </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> </p>` | 198 | <p> </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] | ... | ... |
-
Please register or login to post a comment