hookehuyr

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>
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(/&nbsp;/g, ' ') // 空格
73 + .replace(/&amp;/g, '&') // &
74 + .replace(/&lt;/g, '<') // <
75 + .replace(/&gt;/g, '>') // >
76 + .replace(/&quot;/g, '"') // "
77 + .replace(/&apos;/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>
This diff is collapsed. Click to expand it.