feat(rich-text): 新增 RichTextRenderer 富文本渲染组件
新增功能: - HTML 实体自动解码( , &, <, >, ", ') - <a> 标签自动替换为 <div data-href=""> - 图片长按预览功能 - PDF 文件链接点击处理 - transformElement 图片自动处理(默认启用) - 支持图片 mode="widthFix" 和 style="width: 100%" 组件 API: - content: HTML 内容(必需) - enable-transform: 是否启用图片处理(默认 true) - @image-preview: 图片预览事件 - @file-click: 文件点击事件 测试页面: - 简化为使用 RichTextRenderer 组件 - 保留 realApiData 测试数据 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Showing
2 changed files
with
341 additions
and
361 deletions
src/components/RichTextRenderer.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2026-02-27 | ||
| 3 | + * @Description: 富文本渲染组件 - 基于 Taro v-html | ||
| 4 | + * | ||
| 5 | + * @features | ||
| 6 | + * - HTML 实体自动解码 | ||
| 7 | + * - <a> 标签自动替换为 <div data-href=""> | ||
| 8 | + * - 图片长按预览 | ||
| 9 | + * - 文件链接点击处理 | ||
| 10 | + * - 可选的 transformElement 图片自动处理 | ||
| 11 | + * | ||
| 12 | + * @example | ||
| 13 | + * <RichTextRenderer | ||
| 14 | + * :content="htmlContent" | ||
| 15 | + * :enable-transform="true" | ||
| 16 | + * @image-preview="handlePreview" | ||
| 17 | + * @file-click="handleFileClick" | ||
| 18 | + * /> | ||
| 19 | +--> | ||
| 20 | +<template> | ||
| 21 | + <view id="rich-text-renderer" v-html="processedContent" class="rich-text-content"></view> | ||
| 22 | +</template> | ||
| 23 | + | ||
| 24 | +<script setup> | ||
| 25 | +import { ref, watch, onMounted, nextTick } from 'vue' | ||
| 26 | +import Taro from '@tarojs/taro' | ||
| 27 | +import { $ } from '@tarojs/extend' | ||
| 28 | +import { useFileOperation } from '@/composables/useFileOperation' | ||
| 29 | +import '@tarojs/taro/html.css' | ||
| 30 | + | ||
| 31 | +/** | ||
| 32 | + * 组件属性 | ||
| 33 | + */ | ||
| 34 | +const props = defineProps({ | ||
| 35 | + /** | ||
| 36 | + * HTML 内容字符串 | ||
| 37 | + */ | ||
| 38 | + content: { | ||
| 39 | + type: String, | ||
| 40 | + default: '' | ||
| 41 | + }, | ||
| 42 | + /** | ||
| 43 | + * 是否启用 transformElement 自动处理图片 | ||
| 44 | + * @description 开启后会为非链接内的图片添加 mode="widthFix" 和 style="width: 100%" | ||
| 45 | + * @default true | ||
| 46 | + */ | ||
| 47 | + enableTransform: { | ||
| 48 | + type: Boolean, | ||
| 49 | + default: true | ||
| 50 | + } | ||
| 51 | +}) | ||
| 52 | + | ||
| 53 | +/** | ||
| 54 | + * 组件事件 | ||
| 55 | + */ | ||
| 56 | +const emit = defineEmits(['image-preview', 'file-click']) | ||
| 57 | + | ||
| 58 | +// 文件操作 | ||
| 59 | +const { viewFile } = useFileOperation() | ||
| 60 | + | ||
| 61 | +// 处理后的内容 | ||
| 62 | +const processedContent = ref('') | ||
| 63 | + | ||
| 64 | +// 容器 ID(用于 jQuery 选择器) | ||
| 65 | +const CONTAINER_ID = '#rich-text-renderer' | ||
| 66 | + | ||
| 67 | +/** | ||
| 68 | + * HTML 实体解码 | ||
| 69 | + */ | ||
| 70 | +const decodeHtmlEntities = (html) => { | ||
| 71 | + return html | ||
| 72 | + .replace(/ /g, ' ') // 空格 | ||
| 73 | + .replace(/&/g, '&') // & | ||
| 74 | + .replace(/</g, '<') // < | ||
| 75 | + .replace(/>/g, '>') // > | ||
| 76 | + .replace(/"/g, '"') // " | ||
| 77 | + .replace(/'/g, "'") // ' | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +/** | ||
| 81 | + * 替换 <a> 标签为 <div data-href=""> | ||
| 82 | + */ | ||
| 83 | +const replaceAnchorTags = (html) => { | ||
| 84 | + let content = html | ||
| 85 | + // 替换 <a ... href="..."> 为 <div ... data-href="..." data-is-link="true"> | ||
| 86 | + content = content.replace(/<a\s+/g, '<div data-is-link="true" ') | ||
| 87 | + content = content.replace(/href=/g, 'data-href=') | ||
| 88 | + content = content.replace(/<\/a>/g, '</div>') | ||
| 89 | + return content | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +/** | ||
| 93 | + * 处理 HTML 内容 | ||
| 94 | + */ | ||
| 95 | +const processContent = (raw) => { | ||
| 96 | + if (!raw) { | ||
| 97 | + processedContent.value = '' | ||
| 98 | + return | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + let processed = raw | ||
| 102 | + | ||
| 103 | + // 1. HTML 实体解码 | ||
| 104 | + processed = decodeHtmlEntities(processed) | ||
| 105 | + | ||
| 106 | + // 2. 替换 <a> 标签 | ||
| 107 | + processed = replaceAnchorTags(processed) | ||
| 108 | + | ||
| 109 | + processedContent.value = processed | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +/** | ||
| 113 | + * 配置 transformElement | ||
| 114 | + */ | ||
| 115 | +const setupTransformElement = () => { | ||
| 116 | + if (props.enableTransform) { | ||
| 117 | + Taro.options.html.transformElement = (element) => { | ||
| 118 | + const el = element | ||
| 119 | + // 获取标签名(兼容大小写) | ||
| 120 | + const nodeName = el.nodeName?.toLowerCase() || '' | ||
| 121 | + const tagName = el.tagName?.toLowerCase() || '' | ||
| 122 | + | ||
| 123 | + // 只处理 img/image 标签 | ||
| 124 | + const isImg = nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image' | ||
| 125 | + if (!isImg) { | ||
| 126 | + return el | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + // 检查是否在链接内(遍历整个父元素链) | ||
| 130 | + let parent = el.parentElement | ||
| 131 | + let isInsideLink = false | ||
| 132 | + | ||
| 133 | + while (parent) { | ||
| 134 | + const pName = parent.nodeName?.toLowerCase() || '' | ||
| 135 | + const pDataIsLink = parent.getAttribute?.('data-is-link') | ||
| 136 | + const pClassList = parent.classList?.value || '' | ||
| 137 | + | ||
| 138 | + // 检查是否在 <a> 或带有 data-is-link 或 _file_list class 的元素内 | ||
| 139 | + if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) { | ||
| 140 | + isInsideLink = true | ||
| 141 | + break | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + parent = parent.parentElement | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + if (isInsideLink) { | ||
| 148 | + // 在链接内的 img(图标),不处理但必须返回元素 | ||
| 149 | + return el | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + // 在链接外的 img(内容图片),添加 mode="widthFix" 和 style | ||
| 153 | + if (el.setAttribute) { | ||
| 154 | + el.setAttribute('mode', 'widthFix') | ||
| 155 | + el.setAttribute('style', 'width: 100%;') | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + return el | ||
| 159 | + } | ||
| 160 | + } else { | ||
| 161 | + Taro.options.html.transformElement = undefined | ||
| 162 | + } | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +/** | ||
| 166 | + * 绑定图片长按预览事件 | ||
| 167 | + */ | ||
| 168 | +const bindImageEvents = () => { | ||
| 169 | + nextTick(() => { | ||
| 170 | + const imgs = $(CONTAINER_ID).children('.h5-p').children('.h5-img') | ||
| 171 | + | ||
| 172 | + imgs.forEach(function (img) { | ||
| 173 | + $(img).off('longpress') // 先解绑,避免重复 | ||
| 174 | + $(img).on('longpress', function () { | ||
| 175 | + const src = $(img).attr('src') | ||
| 176 | + | ||
| 177 | + Taro.previewImage({ | ||
| 178 | + urls: [src], | ||
| 179 | + current: src, | ||
| 180 | + indicator: 'default', | ||
| 181 | + loop: false, | ||
| 182 | + success: () => { | ||
| 183 | + emit('image-preview', { src }) | ||
| 184 | + }, | ||
| 185 | + fail: () => { | ||
| 186 | + // 预览失败 | ||
| 187 | + } | ||
| 188 | + }) | ||
| 189 | + }) | ||
| 190 | + }) | ||
| 191 | + }) | ||
| 192 | +} | ||
| 193 | + | ||
| 194 | +/** | ||
| 195 | + * 绑定文件链接点击事件 | ||
| 196 | + */ | ||
| 197 | +const bindFileLinkEvents = () => { | ||
| 198 | + nextTick(() => { | ||
| 199 | + const container = $(CONTAINER_ID) | ||
| 200 | + const fileLinks = container.find('div[data-is-link="true"]') | ||
| 201 | + | ||
| 202 | + // 如果找不到 data-is-link,尝试用 class 查找 | ||
| 203 | + const targetLinks = fileLinks.length > 0 ? fileLinks : container.find('._file_list') | ||
| 204 | + | ||
| 205 | + targetLinks.each(function (idx, el) { | ||
| 206 | + const $el = $(el) | ||
| 207 | + const dataHref = $el.attr('data-href') | ||
| 208 | + | ||
| 209 | + if (dataHref) { | ||
| 210 | + $el.off('tap') // 先解绑,避免重复 | ||
| 211 | + $el.on('tap', function () { | ||
| 212 | + // 尝试提取文件名 | ||
| 213 | + const fileName = $el.find('span span span').first().text() || | ||
| 214 | + $el.text().trim().substring(0, 50) || | ||
| 215 | + 'document.pdf' | ||
| 216 | + | ||
| 217 | + emit('file-click', { url: dataHref, fileName }) | ||
| 218 | + viewFile({ downloadUrl: dataHref, fileName }) | ||
| 219 | + }) | ||
| 220 | + } | ||
| 221 | + }) | ||
| 222 | + }) | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +/** | ||
| 226 | + * 处理内容变化 | ||
| 227 | + */ | ||
| 228 | +const handleContentChange = () => { | ||
| 229 | + processContent(props.content) | ||
| 230 | + | ||
| 231 | + // 等待 DOM 更新后绑定事件 | ||
| 232 | + nextTick(() => { | ||
| 233 | + bindImageEvents() | ||
| 234 | + bindFileLinkEvents() | ||
| 235 | + }) | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +/** | ||
| 239 | + * 监听 props 变化 | ||
| 240 | + */ | ||
| 241 | +watch(() => props.content, handleContentChange, { immediate: true }) | ||
| 242 | +watch(() => props.enableTransform, () => { | ||
| 243 | + setupTransformElement() | ||
| 244 | + // 重新渲染当前内容 | ||
| 245 | + if (processedContent.value) { | ||
| 246 | + const current = processedContent.value | ||
| 247 | + processedContent.value = '' | ||
| 248 | + nextTick(() => { | ||
| 249 | + processedContent.value = current | ||
| 250 | + handleContentChange() | ||
| 251 | + }) | ||
| 252 | + } | ||
| 253 | +}) | ||
| 254 | + | ||
| 255 | +/** | ||
| 256 | + * 组件挂载 | ||
| 257 | + */ | ||
| 258 | +onMounted(() => { | ||
| 259 | + setupTransformElement() | ||
| 260 | + handleContentChange() | ||
| 261 | +}) | ||
| 262 | +</script> | ||
| 263 | + | ||
| 264 | +<style lang="less" scoped> | ||
| 265 | +.rich-text-content { | ||
| 266 | + // 继承 Taro HTML 样式 | ||
| 267 | + :deep(img) { | ||
| 268 | + max-width: 100%; | ||
| 269 | + height: auto; | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + // 文件链接样式 | ||
| 273 | + :deep(div[data-is-link="true"]) { | ||
| 274 | + cursor: pointer; | ||
| 275 | + transition: opacity 0.2s; | ||
| 276 | + | ||
| 277 | + &:active { | ||
| 278 | + opacity: 0.7; | ||
| 279 | + } | ||
| 280 | + } | ||
| 281 | +} | ||
| 282 | +</style> |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2026-02-27 | 2 | * @Date: 2026-02-27 |
| 3 | - * @Description: v-html 测试页面 - 验证 v-html 在 Taro 中是否可用 | 3 | + * @Description: v-html 测试页面 - 使用 RichTextRenderer 组件 |
| 4 | * | 4 | * |
| 5 | * @purpose | 5 | * @purpose |
| 6 | - * 验证 v-html 指令在 Taro + Vue3 小程序中是否能正常运行 | 6 | + * 测试 RichTextRenderer 组件的富文本渲染功能 |
| 7 | * 对比 index1.vue 中 mp-html 组件的绑定方式 | 7 | * 对比 index1.vue 中 mp-html 组件的绑定方式 |
| 8 | * | 8 | * |
| 9 | * @test-cases | 9 | * @test-cases |
| 10 | - * 1. v-html 渲染响应式变量 | 10 | + * 1. 组件渲染响应式变量 |
| 11 | * 2. 内容切换响应性 | 11 | * 2. 内容切换响应性 |
| 12 | - * 3. HTML 标签解析 | 12 | + * 3. HTML 实体解析 |
| 13 | + * 4. 图片长按预览 | ||
| 14 | + * 5. 文件链接点击 | ||
| 13 | --> | 15 | --> |
| 14 | <template> | 16 | <template> |
| 15 | <view class="rich-text-test-page"> | 17 | <view class="rich-text-test-page"> |
| 16 | <!-- 导航栏 --> | 18 | <!-- 导航栏 --> |
| 17 | - <NavHeader title="v-html 测试" /> | 19 | + <NavHeader title="RichTextRenderer 测试" /> |
| 18 | 20 | ||
| 19 | <view class="test-container"> | 21 | <view class="test-container"> |
| 20 | <!-- 测试说明 --> | 22 | <!-- 测试说明 --> |
| 21 | <view class="test-info"> | 23 | <view class="test-info"> |
| 22 | <text class="test-title">测试项目:</text> | 24 | <text class="test-title">测试项目:</text> |
| 23 | - <text class="test-item">1. v-html 渲染 {{ renderTest ? '✅' : '❌' }}</text> | 25 | + <text class="test-item">1. 组件渲染 {{ renderTest ? '✅' : '❌' }}</text> |
| 24 | <text class="test-item">2. 内容切换 {{ contentSwitchTest ? '✅' : '❌' }}</text> | 26 | <text class="test-item">2. 内容切换 {{ contentSwitchTest ? '✅' : '❌' }}</text> |
| 25 | - <text class="test-item">3. HTML 解析 {{ htmlParseTest ? '✅' : '❌' }}</text> | 27 | + <text class="test-item">3. 图片预览 {{ imagePreviewTest ? '✅' : '❌' }}</text> |
| 28 | + <text class="test-item">4. 文件链接 {{ fileLinkTest ? '✅' : '❌' }}</text> | ||
| 26 | </view> | 29 | </view> |
| 27 | 30 | ||
| 28 | - <!-- v-html 渲染区域 --> | 31 | + <!-- RichTextRenderer 渲染区域 --> |
| 29 | - <view class="v-html-container"> | 32 | + <view class="renderer-container"> |
| 30 | - <text class="container-title">v-html 绑定区域:</text> | 33 | + <text class="container-title">RichTextRenderer 渲染区域:</text> |
| 31 | - <view id="taro_html" v-html="testHtml" class="taro_html"></view> | 34 | + <view class="renderer-wrapper"> |
| 35 | + <RichTextRenderer | ||
| 36 | + :content="testHtml" | ||
| 37 | + :enable-transform="transformEnabled" | ||
| 38 | + @image-preview="handleImagePreview" | ||
| 39 | + @file-click="handleFileClick" | ||
| 40 | + /> | ||
| 41 | + </view> | ||
| 32 | </view> | 42 | </view> |
| 33 | 43 | ||
| 34 | <!-- 测试结果 --> | 44 | <!-- 测试结果 --> |
| ... | @@ -45,11 +55,9 @@ | ... | @@ -45,11 +55,9 @@ |
| 45 | <view class="action-btn primary" @tap="switchTestContent(1)">测试1: 纯文本</view> | 55 | <view class="action-btn primary" @tap="switchTestContent(1)">测试1: 纯文本</view> |
| 46 | <view class="action-btn primary" @tap="switchTestContent(2)">测试2: HTML标签</view> | 56 | <view class="action-btn primary" @tap="switchTestContent(2)">测试2: HTML标签</view> |
| 47 | <view class="action-btn primary" @tap="switchTestContent(3)">测试3: 图片</view> | 57 | <view class="action-btn primary" @tap="switchTestContent(3)">测试3: 图片</view> |
| 48 | - <view class="action-btn primary" @tap="switchTestContent(4)">测试4: 链接</view> | ||
| 49 | <view class="action-btn warning" @tap="clearContent">清空内容</view> | 58 | <view class="action-btn warning" @tap="clearContent">清空内容</view> |
| 50 | <view class="action-btn success" @tap="loadFromAPI">模拟API加载</view> | 59 | <view class="action-btn success" @tap="loadFromAPI">模拟API加载</view> |
| 51 | <view class="action-btn danger" @tap="loadRealApiData">📄 真实API</view> | 60 | <view class="action-btn danger" @tap="loadRealApiData">📄 真实API</view> |
| 52 | - <view class="action-btn info" @tap="loadRealApiDataWithDiv">📄 真实API(a→div)</view> | ||
| 53 | <view class="action-btn" :class="transformEnabled ? 'enabled' : ''" @tap="toggleTransform"> | 61 | <view class="action-btn" :class="transformEnabled ? 'enabled' : ''" @tap="toggleTransform"> |
| 54 | {{ transformEnabled ? '✅' : '⚪' }} 图片自动处理 | 62 | {{ transformEnabled ? '✅' : '⚪' }} 图片自动处理 |
| 55 | </view> | 63 | </view> |
| ... | @@ -57,41 +65,37 @@ | ... | @@ -57,41 +65,37 @@ |
| 57 | 65 | ||
| 58 | <!-- 对比说明 --> | 66 | <!-- 对比说明 --> |
| 59 | <view class="compare-note"> | 67 | <view class="compare-note"> |
| 60 | - <text class="note-title">与 index1.vue 对比:</text> | 68 | + <text class="note-title">组件说明:</text> |
| 61 | - <text class="note-item">• index1.vue 使用 <mp-html> 组件 + :content="testHtml"</text> | 69 | + <text class="note-item">• RichTextRenderer 基于 Taro v-html</text> |
| 62 | - <text class="note-item">• 本页使用 v-html="testHtml" 指令</text> | 70 | + <text class="note-item">• 自动解析 HTML 实体( & 等)</text> |
| 63 | - <text class="note-item">• 两者都绑定响应式变量 testHtml</text> | 71 | + <text class="note-item">• 自动替换 <a> 为 <div data-href=""></text> |
| 72 | + <text class="note-item">• 支持图片长按预览和文件链接点击</text> | ||
| 64 | </view> | 73 | </view> |
| 65 | </view> | 74 | </view> |
| 66 | </view> | 75 | </view> |
| 67 | </template> | 76 | </template> |
| 68 | 77 | ||
| 69 | <script setup> | 78 | <script setup> |
| 70 | -import { ref, onMounted, nextTick, watch } from 'vue' | 79 | +import { ref, onMounted } from 'vue' |
| 71 | import Taro, { definePageConfig } from '@tarojs/taro' | 80 | import Taro, { definePageConfig } from '@tarojs/taro' |
| 72 | import NavHeader from '@/components/navigation/NavHeader.vue' | 81 | import NavHeader from '@/components/navigation/NavHeader.vue' |
| 73 | -import { $ } from '@tarojs/extend' | 82 | +import RichTextRenderer from '@/components/RichTextRenderer.vue' |
| 74 | -import { useFileOperation } from '@/composables/useFileOperation' | ||
| 75 | -// v-html 需要导入 Taro 的 HTML 样式 | ||
| 76 | -import '@tarojs/taro/html.css' | ||
| 77 | - | ||
| 78 | -// 文件操作 | ||
| 79 | -const { viewFile } = useFileOperation() | ||
| 80 | 83 | ||
| 81 | // 配置页面 | 84 | // 配置页面 |
| 82 | definePageConfig({ | 85 | definePageConfig({ |
| 83 | - navigationBarTitleText: 'v-html 测试' | 86 | + navigationBarTitleText: 'RichTextRenderer 测试' |
| 84 | }) | 87 | }) |
| 85 | 88 | ||
| 86 | // 测试状态 | 89 | // 测试状态 |
| 87 | const renderTest = ref(false) | 90 | const renderTest = ref(false) |
| 88 | const contentSwitchTest = ref(false) | 91 | const contentSwitchTest = ref(false) |
| 89 | -const htmlParseTest = ref(false) | 92 | +const imagePreviewTest = ref(false) |
| 93 | +const fileLinkTest = ref(false) | ||
| 90 | const switchCount = ref(0) | 94 | const switchCount = ref(0) |
| 91 | const currentContentType = ref('未加载') | 95 | const currentContentType = ref('未加载') |
| 92 | const contentLength = ref(0) | 96 | const contentLength = ref(0) |
| 93 | const lastAction = ref('页面初始化') | 97 | const lastAction = ref('页面初始化') |
| 94 | -const transformEnabled = ref(false) // 是否启用 transformElement | 98 | +const transformEnabled = ref(true) |
| 95 | 99 | ||
| 96 | // 测试 HTML 内容 | 100 | // 测试 HTML 内容 |
| 97 | const testHtml = ref('') | 101 | const testHtml = ref('') |
| ... | @@ -187,7 +191,7 @@ const testContents = { | ... | @@ -187,7 +191,7 @@ const testContents = { |
| 187 | name: '纯文本', | 191 | name: '纯文本', |
| 188 | content: ` | 192 | content: ` |
| 189 | <h1>测试1:纯文本渲染</h1> | 193 | <h1>测试1:纯文本渲染</h1> |
| 190 | - <p>这是一段纯文本内容,测试 v-html 是否能正确渲染基本的 HTML 结构。</p> | 194 | + <p>这是一段纯文本内容,测试 RichTextRenderer 是否能正确渲染基本的 HTML 结构。</p> |
| 191 | <p><strong>加粗文本</strong>和<em>斜体文本</em>测试。</p> | 195 | <p><strong>加粗文本</strong>和<em>斜体文本</em>测试。</p> |
| 192 | ` | 196 | ` |
| 193 | }, | 197 | }, |
| ... | @@ -214,23 +218,21 @@ const testContents = { | ... | @@ -214,23 +218,21 @@ const testContents = { |
| 214 | <img class="h5-img" src="https://cdn.ipadbiz.cn/space_3079606/微信图片_20260227170749_FhZ6SHYxM9yVKOPo1LeqVO25I35q.jpg" alt="测试图片" style="max-width: 100%;" /> | 218 | <img class="h5-img" src="https://cdn.ipadbiz.cn/space_3079606/微信图片_20260227170749_FhZ6SHYxM9yVKOPo1LeqVO25I35q.jpg" alt="测试图片" style="max-width: 100%;" /> |
| 215 | </p> | 219 | </p> |
| 216 | ` | 220 | ` |
| 217 | - }, | ||
| 218 | - 4: { | ||
| 219 | - name: '链接', | ||
| 220 | - content: ` | ||
| 221 | - <h1>测试4:链接渲染</h1> | ||
| 222 | - <p>测试链接是否正确渲染:</p> | ||
| 223 | - <p> | ||
| 224 | - <a href="https://www.baidu.com">百度链接</a> | | ||
| 225 | - <a href="https://www.github.com">GitHub 链接</a> | ||
| 226 | - </p> | ||
| 227 | - <p>PDF 链接测试: | ||
| 228 | - <a href="https://cdn.ipadbiz.cn/space_3079606/需提供的部分个人资料SAMPLE(不含流水SAMPLE)_扫描版_lkAqOTlyK-jZ7RbBN43oiG5QPfr-.pdf">PDF 文档</a> | ||
| 229 | - </p> | ||
| 230 | - ` | ||
| 231 | } | 221 | } |
| 232 | } | 222 | } |
| 233 | 223 | ||
| 224 | +// 处理图片预览事件 | ||
| 225 | +const handleImagePreview = ({ src }) => { | ||
| 226 | + imagePreviewTest.value = true | ||
| 227 | + lastAction.value = `图片预览: ${src.substring(0, 50)}...` | ||
| 228 | +} | ||
| 229 | + | ||
| 230 | +// 处理文件点击事件 | ||
| 231 | +const handleFileClick = ({ fileName }) => { | ||
| 232 | + fileLinkTest.value = true | ||
| 233 | + lastAction.value = `文件点击: ${fileName}` | ||
| 234 | +} | ||
| 235 | + | ||
| 234 | // 切换测试内容 | 236 | // 切换测试内容 |
| 235 | const switchTestContent = (index) => { | 237 | const switchTestContent = (index) => { |
| 236 | const template = testContents[index] | 238 | const template = testContents[index] |
| ... | @@ -241,11 +243,7 @@ const switchTestContent = (index) => { | ... | @@ -241,11 +243,7 @@ const switchTestContent = (index) => { |
| 241 | switchCount.value++ | 243 | switchCount.value++ |
| 242 | lastAction.value = `切换到测试${index}: ${template.name}` | 244 | lastAction.value = `切换到测试${index}: ${template.name}` |
| 243 | contentSwitchTest.value = true | 245 | contentSwitchTest.value = true |
| 244 | - | 246 | + renderTest.value = true |
| 245 | - // 绑定图片长按事件 | ||
| 246 | - nextTick(() => { | ||
| 247 | - bindImageEvents() | ||
| 248 | - }) | ||
| 249 | } | 247 | } |
| 250 | } | 248 | } |
| 251 | 249 | ||
| ... | @@ -263,7 +261,6 @@ const loadFromAPI = () => { | ... | @@ -263,7 +261,6 @@ const loadFromAPI = () => { |
| 263 | Taro.showLoading({ title: '加载中...' }) | 261 | Taro.showLoading({ title: '加载中...' }) |
| 264 | 262 | ||
| 265 | setTimeout(() => { | 263 | setTimeout(() => { |
| 266 | - // 模拟 API 返回的数据 | ||
| 267 | const apiData = ` | 264 | const apiData = ` |
| 268 | <div class="api-content"> | 265 | <div class="api-content"> |
| 269 | <h1>从 API 加载的内容</h1> | 266 | <h1>从 API 加载的内容</h1> |
| ... | @@ -278,34 +275,28 @@ const loadFromAPI = () => { | ... | @@ -278,34 +275,28 @@ const loadFromAPI = () => { |
| 278 | currentContentType.value = 'API 数据' | 275 | currentContentType.value = 'API 数据' |
| 279 | contentLength.value = apiData.length | 276 | contentLength.value = apiData.length |
| 280 | lastAction.value = 'API 加载完成' | 277 | lastAction.value = 'API 加载完成' |
| 278 | + renderTest.value = true | ||
| 281 | Taro.hideLoading() | 279 | Taro.hideLoading() |
| 282 | - | ||
| 283 | - nextTick(() => { | ||
| 284 | - bindImageEvents() | ||
| 285 | - }) | ||
| 286 | }, 1000) | 280 | }, 1000) |
| 287 | } | 281 | } |
| 288 | 282 | ||
| 289 | -// 加载真实 API 数据(原始版本,包含 <a> 标签) | 283 | +// 加载真实 API 数据 |
| 290 | const loadRealApiData = () => { | 284 | const loadRealApiData = () => { |
| 291 | - lastAction.value = '加载真实 API 数据(原始)...' | 285 | + lastAction.value = '加载真实 API 数据...' |
| 292 | Taro.showLoading({ title: '加载中...' }) | 286 | Taro.showLoading({ title: '加载中...' }) |
| 293 | 287 | ||
| 294 | setTimeout(() => { | 288 | setTimeout(() => { |
| 295 | testHtml.value = realApiData.post_content | 289 | testHtml.value = realApiData.post_content |
| 296 | - currentContentType.value = `真实文章(原始<a>): ${realApiData.post_title}` | 290 | + currentContentType.value = `真实文章: ${realApiData.post_title}` |
| 297 | contentLength.value = realApiData.post_content.length | 291 | contentLength.value = realApiData.post_content.length |
| 298 | switchCount.value++ | 292 | switchCount.value++ |
| 299 | - lastAction.value = `加载文章 ID: ${realApiData.id} (原始<a>标签)` | 293 | + lastAction.value = `加载文章 ID: ${realApiData.id}` |
| 294 | + renderTest.value = true | ||
| 300 | Taro.hideLoading() | 295 | Taro.hideLoading() |
| 301 | 296 | ||
| 302 | - nextTick(() => { | ||
| 303 | - bindImageEvents() | ||
| 304 | - }) | ||
| 305 | - | ||
| 306 | Taro.showToast({ | 297 | Taro.showToast({ |
| 307 | - title: '原始 <a> 标签', | 298 | + title: '组件自动处理', |
| 308 | - icon: 'none' | 299 | + icon: 'success' |
| 309 | }) | 300 | }) |
| 310 | }, 500) | 301 | }, 500) |
| 311 | } | 302 | } |
| ... | @@ -313,220 +304,15 @@ const loadRealApiData = () => { | ... | @@ -313,220 +304,15 @@ const loadRealApiData = () => { |
| 313 | // 切换 transformElement | 304 | // 切换 transformElement |
| 314 | const toggleTransform = () => { | 305 | const toggleTransform = () => { |
| 315 | transformEnabled.value = !transformEnabled.value | 306 | transformEnabled.value = !transformEnabled.value |
| 316 | - | 307 | + lastAction.value = transformEnabled.value ? '已启用图片自动处理' : '已禁用图片自动处理' |
| 317 | - if (transformEnabled.value) { | 308 | + Taro.showToast({ |
| 318 | - // 启用 transformElement | 309 | + title: transformEnabled.value ? '已启用' : '已禁用', |
| 319 | - Taro.options.html.transformElement = (el) => { | 310 | + icon: transformEnabled.value ? 'success' : 'none' |
| 320 | - const nodeName = el.nodeName?.toLowerCase() || '' | ||
| 321 | - const tagName = el.tagName?.toLowerCase() || '' | ||
| 322 | - | ||
| 323 | - // 处理 img/image 标签 | ||
| 324 | - if (nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image') { | ||
| 325 | - const src = el.getAttribute('src') || '' | ||
| 326 | - | ||
| 327 | - // 检查父元素链 | ||
| 328 | - let parent = el.parentElement | ||
| 329 | - let isInsideLink = false | ||
| 330 | - let parentChain = [] | ||
| 331 | - | ||
| 332 | - while (parent) { | ||
| 333 | - const pName = parent.nodeName?.toLowerCase() || '' | ||
| 334 | - const pDataIsLink = parent.getAttribute?.('data-is-link') | ||
| 335 | - const pClassList = parent.classList?.value || '' | ||
| 336 | - | ||
| 337 | - parentChain.push(pName) | ||
| 338 | - | ||
| 339 | - if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) { | ||
| 340 | - isInsideLink = true | ||
| 341 | - break | ||
| 342 | - } | ||
| 343 | - | ||
| 344 | - parent = parent.parentElement | ||
| 345 | - } | ||
| 346 | - | ||
| 347 | - if (isInsideLink) { | ||
| 348 | - // 在链接内,不处理(图标),但必须返回元素让它显示 | ||
| 349 | - return el // 直接返回,不做任何修改 | ||
| 350 | - } | ||
| 351 | - | ||
| 352 | - // 不在链接内,处理(内容图片) | ||
| 353 | - el.setAttribute('mode', 'widthFix') | ||
| 354 | - el.setAttribute('style', 'width: 100%;') | ||
| 355 | - } | ||
| 356 | - | ||
| 357 | - // 所有元素都必须返回,否则会被过滤掉不显示 | ||
| 358 | - return el | ||
| 359 | - } | ||
| 360 | - | ||
| 361 | - lastAction.value = '已启用 transformElement(外部图片自动 widthFix)' | ||
| 362 | - Taro.showToast({ | ||
| 363 | - title: '已启用图片自动处理', | ||
| 364 | - icon: 'success' | ||
| 365 | - }) | ||
| 366 | - | ||
| 367 | - // 重新加载当前内容以应用 transform | ||
| 368 | - if (testHtml.value) { | ||
| 369 | - const currentContent = testHtml.value | ||
| 370 | - testHtml.value = '' | ||
| 371 | - nextTick(() => { | ||
| 372 | - testHtml.value = currentContent | ||
| 373 | - }) | ||
| 374 | - } | ||
| 375 | - } else { | ||
| 376 | - // 禁用 transformElement | ||
| 377 | - Taro.options.html.transformElement = undefined | ||
| 378 | - | ||
| 379 | - lastAction.value = '已禁用 transformElement' | ||
| 380 | - Taro.showToast({ | ||
| 381 | - title: '已禁用图片自动处理', | ||
| 382 | - icon: 'none' | ||
| 383 | - }) | ||
| 384 | - | ||
| 385 | - // 重新加载当前内容 | ||
| 386 | - if (testHtml.value) { | ||
| 387 | - const currentContent = testHtml.value | ||
| 388 | - testHtml.value = '' | ||
| 389 | - nextTick(() => { | ||
| 390 | - testHtml.value = currentContent | ||
| 391 | - }) | ||
| 392 | - } | ||
| 393 | - } | ||
| 394 | -} | ||
| 395 | - | ||
| 396 | -// 加载真实 API 数据(<a> 替换为 <div> 版本) | ||
| 397 | -const loadRealApiDataWithDiv = () => { | ||
| 398 | - lastAction.value = '加载真实 API 数据(a→div)...' | ||
| 399 | - Taro.showLoading({ title: '加载中...' }) | ||
| 400 | - | ||
| 401 | - setTimeout(() => { | ||
| 402 | - // 将 <a> 标签替换为 <div> 标签,保留 href 为 data-href | ||
| 403 | - let content = realApiData.post_content | ||
| 404 | - | ||
| 405 | - // 替换 HTML 实体 | ||
| 406 | - content = content.replace(/ /g, ' ') // 空格 | ||
| 407 | - content = content.replace(/&/g, '&') // & | ||
| 408 | - content = content.replace(/</g, '<') // < | ||
| 409 | - content = content.replace(/>/g, '>') // > | ||
| 410 | - content = content.replace(/"/g, '"') // " | ||
| 411 | - content = content.replace(/'/g, "'") // ' | ||
| 412 | - | ||
| 413 | - // 替换 <a ... href="..."> 为 <div ... data-href="..." data-is-link="true"> | ||
| 414 | - content = content.replace(/<a\s+/g, '<div data-is-link="true" ') | ||
| 415 | - content = content.replace(/href=/g, 'data-href=') | ||
| 416 | - content = content.replace(/<\/a>/g, '</div>') | ||
| 417 | - | ||
| 418 | - // 统计 data-href 数量 | ||
| 419 | - const hrefCount = (content.match(/data-href=/g) || []).length | ||
| 420 | - | ||
| 421 | - testHtml.value = content | ||
| 422 | - currentContentType.value = `真实文章(<a>→<div>): ${realApiData.post_title}` | ||
| 423 | - contentLength.value = content.length | ||
| 424 | - switchCount.value++ | ||
| 425 | - lastAction.value = `加载文章 ID: ${realApiData.id} (a标签已替换为div, href=${hrefCount})` | ||
| 426 | - Taro.hideLoading() | ||
| 427 | - | ||
| 428 | - nextTick(() => { | ||
| 429 | - bindImageEvents() | ||
| 430 | - // 绑定文件链接点击事件 | ||
| 431 | - bindFileLinkEvents() | ||
| 432 | - }) | ||
| 433 | - | ||
| 434 | - Taro.showToast({ | ||
| 435 | - title: `已绑定 ${hrefCount} 个文件链接`, | ||
| 436 | - icon: 'success' | ||
| 437 | - }) | ||
| 438 | - }, 500) | ||
| 439 | -} | ||
| 440 | - | ||
| 441 | -// 绑定图片长按预览事件(类似 activityDetail 页面的实现) | ||
| 442 | -const bindImageEvents = () => { | ||
| 443 | - const imgs = $('#taro_html').children('.h5-p').children('.h5-img') | ||
| 444 | - | ||
| 445 | - imgs.forEach(function (img) { | ||
| 446 | - $(img).on('longpress', function () { | ||
| 447 | - const src = $(img).attr('src') | ||
| 448 | - | ||
| 449 | - Taro.previewImage({ | ||
| 450 | - urls: [src], | ||
| 451 | - current: src, | ||
| 452 | - indicator: 'default', | ||
| 453 | - loop: false, | ||
| 454 | - success: res => { | ||
| 455 | - lastAction.value = '图片预览触发' | ||
| 456 | - }, | ||
| 457 | - fail: err => { | ||
| 458 | - // 预览失败 | ||
| 459 | - } | ||
| 460 | - }) | ||
| 461 | - }) | ||
| 462 | - }) | ||
| 463 | - | ||
| 464 | - htmlParseTest.value = imgs.length > 0 | ||
| 465 | -} | ||
| 466 | - | ||
| 467 | -// 绑定文件链接点击事件 | ||
| 468 | -const bindFileLinkEvents = () => { | ||
| 469 | - // 先检查 #taro_html 容器是否存在 | ||
| 470 | - const container = $('#taro_html') | ||
| 471 | - | ||
| 472 | - // 检查带 data-is-link 属性的 div | ||
| 473 | - const fileLinks = container.find('div[data-is-link="true"]') | ||
| 474 | - | ||
| 475 | - // 如果找不到,尝试用 class 查找 | ||
| 476 | - if (fileLinks.length === 0) { | ||
| 477 | - const classLinks = container.find('._file_list') | ||
| 478 | - | ||
| 479 | - // 如果用 class 找到了,绑定到这些元素上 | ||
| 480 | - classLinks.each(function (idx, el) { | ||
| 481 | - const $el = $(el) | ||
| 482 | - const dataHref = $el.attr('data-href') | ||
| 483 | - | ||
| 484 | - if (dataHref) { | ||
| 485 | - $el.on('tap', function () { | ||
| 486 | - const fileName = $el.find('span span span').first().text() || 'document.pdf' | ||
| 487 | - lastAction.value = `打开文件: ${fileName}` | ||
| 488 | - viewFile({ | ||
| 489 | - downloadUrl: dataHref, | ||
| 490 | - fileName: fileName | ||
| 491 | - }) | ||
| 492 | - }) | ||
| 493 | - } | ||
| 494 | - }) | ||
| 495 | - | ||
| 496 | - return classLinks.length | ||
| 497 | - } | ||
| 498 | - | ||
| 499 | - // 原始逻辑:使用 data-is-link 查找 | ||
| 500 | - fileLinks.forEach(function (link) { | ||
| 501 | - const $link = $(link) | ||
| 502 | - const href = $link.attr('data-href') | ||
| 503 | - | ||
| 504 | - $link.on('tap', function () { | ||
| 505 | - const fileName = $link.find('span span span').first().text() || 'document.pdf' | ||
| 506 | - | ||
| 507 | - if (href) { | ||
| 508 | - lastAction.value = `打开文件: ${fileName}` | ||
| 509 | - | ||
| 510 | - // 使用 useFileOperation 打开文件 | ||
| 511 | - viewFile({ | ||
| 512 | - downloadUrl: href, | ||
| 513 | - fileName: fileName | ||
| 514 | - }) | ||
| 515 | - } | ||
| 516 | - }) | ||
| 517 | }) | 311 | }) |
| 518 | - | ||
| 519 | - return fileLinks.length | ||
| 520 | } | 312 | } |
| 521 | 313 | ||
| 522 | -// 监听 testHtml 变化 | ||
| 523 | -watch(testHtml, (_newVal, _oldVal) => { | ||
| 524 | - renderTest.value = true | ||
| 525 | -}) | ||
| 526 | - | ||
| 527 | // 初始化 | 314 | // 初始化 |
| 528 | onMounted(() => { | 315 | onMounted(() => { |
| 529 | - // 加载默认测试内容 | ||
| 530 | switchTestContent(1) | 316 | switchTestContent(1) |
| 531 | }) | 317 | }) |
| 532 | </script> | 318 | </script> |
| ... | @@ -562,7 +348,7 @@ onMounted(() => { | ... | @@ -562,7 +348,7 @@ onMounted(() => { |
| 562 | margin: 8rpx 0; | 348 | margin: 8rpx 0; |
| 563 | } | 349 | } |
| 564 | 350 | ||
| 565 | -.v-html-container { | 351 | +.renderer-container { |
| 566 | background: #fff; | 352 | background: #fff; |
| 567 | padding: 24rpx; | 353 | padding: 24rpx; |
| 568 | border-radius: 12rpx; | 354 | border-radius: 12rpx; |
| ... | @@ -577,89 +363,6 @@ onMounted(() => { | ... | @@ -577,89 +363,6 @@ onMounted(() => { |
| 577 | margin-bottom: 16rpx; | 363 | margin-bottom: 16rpx; |
| 578 | } | 364 | } |
| 579 | 365 | ||
| 580 | -.taro_html { | ||
| 581 | - // Taro html.css 样式 | ||
| 582 | - :deep(.h5-p) { | ||
| 583 | - margin: 16rpx 0; | ||
| 584 | - } | ||
| 585 | - | ||
| 586 | - :deep(.h5-img) { | ||
| 587 | - max-width: 100%; | ||
| 588 | - display: block; | ||
| 589 | - } | ||
| 590 | - | ||
| 591 | - :deep(h1) { | ||
| 592 | - font-size: 48rpx; | ||
| 593 | - font-weight: bold; | ||
| 594 | - margin: 32rpx 0 16rpx; | ||
| 595 | - } | ||
| 596 | - | ||
| 597 | - :deep(h2) { | ||
| 598 | - font-size: 40rpx; | ||
| 599 | - font-weight: bold; | ||
| 600 | - margin: 24rpx 0 12rpx; | ||
| 601 | - } | ||
| 602 | - | ||
| 603 | - :deep(h3) { | ||
| 604 | - font-size: 36rpx; | ||
| 605 | - font-weight: bold; | ||
| 606 | - margin: 20rpx 0 12rpx; | ||
| 607 | - } | ||
| 608 | - | ||
| 609 | - :deep(p) { | ||
| 610 | - font-size: 28rpx; | ||
| 611 | - line-height: 1.6; | ||
| 612 | - margin: 12rpx 0; | ||
| 613 | - color: #333; | ||
| 614 | - } | ||
| 615 | - | ||
| 616 | - :deep(ul) { | ||
| 617 | - padding-left: 48rpx; | ||
| 618 | - margin: 16rpx 0; | ||
| 619 | - } | ||
| 620 | - | ||
| 621 | - :deep(li) { | ||
| 622 | - font-size: 28rpx; | ||
| 623 | - line-height: 1.6; | ||
| 624 | - margin: 8rpx 0; | ||
| 625 | - } | ||
| 626 | - | ||
| 627 | - :deep(a) { | ||
| 628 | - color: #1989fa; | ||
| 629 | - text-decoration: underline; | ||
| 630 | - } | ||
| 631 | - | ||
| 632 | - :deep(strong) { | ||
| 633 | - font-weight: bold; | ||
| 634 | - } | ||
| 635 | - | ||
| 636 | - :deep(em) { | ||
| 637 | - font-style: italic; | ||
| 638 | - } | ||
| 639 | - | ||
| 640 | - :deep(span) { | ||
| 641 | - font-size: 28rpx; | ||
| 642 | - } | ||
| 643 | - | ||
| 644 | - // 文件链接卡片样式 | ||
| 645 | - :deep(._file_list) { | ||
| 646 | - display: inline-block !important; | ||
| 647 | - padding: 12px; | ||
| 648 | - border: 1px solid #e1e5e9; | ||
| 649 | - border-radius: 8px; | ||
| 650 | - margin: 8px; | ||
| 651 | - background: #f8f9fa; | ||
| 652 | - box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 653 | - max-width: 400px; | ||
| 654 | - transition: all 0.3s ease; | ||
| 655 | - text-decoration: none; | ||
| 656 | - color: inherit; | ||
| 657 | - cursor: pointer; | ||
| 658 | - width: 100%; | ||
| 659 | - box-sizing: border-box; | ||
| 660 | - } | ||
| 661 | -} | ||
| 662 | - | ||
| 663 | .test-results { | 366 | .test-results { |
| 664 | background: #fff; | 367 | background: #fff; |
| 665 | padding: 24rpx; | 368 | padding: 24rpx; |
| ... | @@ -717,11 +420,6 @@ onMounted(() => { | ... | @@ -717,11 +420,6 @@ onMounted(() => { |
| 717 | font-weight: bold; | 420 | font-weight: bold; |
| 718 | } | 421 | } |
| 719 | 422 | ||
| 720 | - &.info { | ||
| 721 | - background: #7232dd; | ||
| 722 | - color: #fff; | ||
| 723 | - } | ||
| 724 | - | ||
| 725 | &.enabled { | 423 | &.enabled { |
| 726 | background: #07c160; | 424 | background: #07c160; |
| 727 | color: #fff; | 425 | color: #fff; | ... | ... |
-
Please register or login to post a comment