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>
<!--
* @Date: 2026-02-27
* @Description: 富文本渲染组件 - 基于 Taro v-html
*
* @features
* - HTML 实体自动解码
* - <a> 标签自动替换为 <div data-href="">
* - 图片长按预览
* - 文件链接点击处理
* - 可选的 transformElement 图片自动处理
*
* @example
* <RichTextRenderer
* :content="htmlContent"
* :enable-transform="true"
* @image-preview="handlePreview"
* @file-click="handleFileClick"
* />
-->
<template>
<view id="rich-text-renderer" v-html="processedContent" class="rich-text-content"></view>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import { $ } from '@tarojs/extend'
import { useFileOperation } from '@/composables/useFileOperation'
import '@tarojs/taro/html.css'
/**
* 组件属性
*/
const props = defineProps({
/**
* HTML 内容字符串
*/
content: {
type: String,
default: ''
},
/**
* 是否启用 transformElement 自动处理图片
* @description 开启后会为非链接内的图片添加 mode="widthFix" 和 style="width: 100%"
* @default true
*/
enableTransform: {
type: Boolean,
default: true
}
})
/**
* 组件事件
*/
const emit = defineEmits(['image-preview', 'file-click'])
// 文件操作
const { viewFile } = useFileOperation()
// 处理后的内容
const processedContent = ref('')
// 容器 ID(用于 jQuery 选择器)
const CONTAINER_ID = '#rich-text-renderer'
/**
* HTML 实体解码
*/
const decodeHtmlEntities = (html) => {
return html
.replace(/&nbsp;/g, ' ') // 空格
.replace(/&amp;/g, '&') // &
.replace(/&lt;/g, '<') // <
.replace(/&gt;/g, '>') // >
.replace(/&quot;/g, '"') // "
.replace(/&apos;/g, "'") // '
}
/**
* 替换 <a> 标签为 <div data-href="">
*/
const replaceAnchorTags = (html) => {
let content = html
// 替换 <a ... href="..."> 为 <div ... data-href="..." data-is-link="true">
content = content.replace(/<a\s+/g, '<div data-is-link="true" ')
content = content.replace(/href=/g, 'data-href=')
content = content.replace(/<\/a>/g, '</div>')
return content
}
/**
* 处理 HTML 内容
*/
const processContent = (raw) => {
if (!raw) {
processedContent.value = ''
return
}
let processed = raw
// 1. HTML 实体解码
processed = decodeHtmlEntities(processed)
// 2. 替换 <a> 标签
processed = replaceAnchorTags(processed)
processedContent.value = processed
}
/**
* 配置 transformElement
*/
const setupTransformElement = () => {
if (props.enableTransform) {
Taro.options.html.transformElement = (element) => {
const el = element
// 获取标签名(兼容大小写)
const nodeName = el.nodeName?.toLowerCase() || ''
const tagName = el.tagName?.toLowerCase() || ''
// 只处理 img/image 标签
const isImg = nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
if (!isImg) {
return el
}
// 检查是否在链接内(遍历整个父元素链)
let parent = el.parentElement
let isInsideLink = false
while (parent) {
const pName = parent.nodeName?.toLowerCase() || ''
const pDataIsLink = parent.getAttribute?.('data-is-link')
const pClassList = parent.classList?.value || ''
// 检查是否在 <a> 或带有 data-is-link 或 _file_list class 的元素内
if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) {
isInsideLink = true
break
}
parent = parent.parentElement
}
if (isInsideLink) {
// 在链接内的 img(图标),不处理但必须返回元素
return el
}
// 在链接外的 img(内容图片),添加 mode="widthFix" 和 style
if (el.setAttribute) {
el.setAttribute('mode', 'widthFix')
el.setAttribute('style', 'width: 100%;')
}
return el
}
} else {
Taro.options.html.transformElement = undefined
}
}
/**
* 绑定图片长按预览事件
*/
const bindImageEvents = () => {
nextTick(() => {
const imgs = $(CONTAINER_ID).children('.h5-p').children('.h5-img')
imgs.forEach(function (img) {
$(img).off('longpress') // 先解绑,避免重复
$(img).on('longpress', function () {
const src = $(img).attr('src')
Taro.previewImage({
urls: [src],
current: src,
indicator: 'default',
loop: false,
success: () => {
emit('image-preview', { src })
},
fail: () => {
// 预览失败
}
})
})
})
})
}
/**
* 绑定文件链接点击事件
*/
const bindFileLinkEvents = () => {
nextTick(() => {
const container = $(CONTAINER_ID)
const fileLinks = container.find('div[data-is-link="true"]')
// 如果找不到 data-is-link,尝试用 class 查找
const targetLinks = fileLinks.length > 0 ? fileLinks : container.find('._file_list')
targetLinks.each(function (idx, el) {
const $el = $(el)
const dataHref = $el.attr('data-href')
if (dataHref) {
$el.off('tap') // 先解绑,避免重复
$el.on('tap', function () {
// 尝试提取文件名
const fileName = $el.find('span span span').first().text() ||
$el.text().trim().substring(0, 50) ||
'document.pdf'
emit('file-click', { url: dataHref, fileName })
viewFile({ downloadUrl: dataHref, fileName })
})
}
})
})
}
/**
* 处理内容变化
*/
const handleContentChange = () => {
processContent(props.content)
// 等待 DOM 更新后绑定事件
nextTick(() => {
bindImageEvents()
bindFileLinkEvents()
})
}
/**
* 监听 props 变化
*/
watch(() => props.content, handleContentChange, { immediate: true })
watch(() => props.enableTransform, () => {
setupTransformElement()
// 重新渲染当前内容
if (processedContent.value) {
const current = processedContent.value
processedContent.value = ''
nextTick(() => {
processedContent.value = current
handleContentChange()
})
}
})
/**
* 组件挂载
*/
onMounted(() => {
setupTransformElement()
handleContentChange()
})
</script>
<style lang="less" scoped>
.rich-text-content {
// 继承 Taro HTML 样式
:deep(img) {
max-width: 100%;
height: auto;
}
// 文件链接样式
:deep(div[data-is-link="true"]) {
cursor: pointer;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
}
</style>
This diff is collapsed. Click to expand it.