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>
<!--
* @Date: 2026-02-27
* @Description: v-html 测试页面 - 验证 v-html 在 Taro 中是否可用
* @Description: v-html 测试页面 - 使用 RichTextRenderer 组件
*
* @purpose
* 验证 v-html 指令在 Taro + Vue3 小程序中是否能正常运行
* 测试 RichTextRenderer 组件的富文本渲染功能
* 对比 index1.vue 中 mp-html 组件的绑定方式
*
* @test-cases
* 1. v-html 渲染响应式变量
* 1. 组件渲染响应式变量
* 2. 内容切换响应性
* 3. HTML 标签解析
* 3. HTML 实体解析
* 4. 图片长按预览
* 5. 文件链接点击
-->
<template>
<view class="rich-text-test-page">
<!-- 导航栏 -->
<NavHeader title="v-html 测试" />
<NavHeader title="RichTextRenderer 测试" />
<view class="test-container">
<!-- 测试说明 -->
<view class="test-info">
<text class="test-title">测试项目:</text>
<text class="test-item">1. v-html 渲染 {{ renderTest ? '✅' : '❌' }}</text>
<text class="test-item">1. 组件渲染 {{ renderTest ? '✅' : '❌' }}</text>
<text class="test-item">2. 内容切换 {{ contentSwitchTest ? '✅' : '❌' }}</text>
<text class="test-item">3. HTML 解析 {{ htmlParseTest ? '✅' : '❌' }}</text>
<text class="test-item">3. 图片预览 {{ imagePreviewTest ? '✅' : '❌' }}</text>
<text class="test-item">4. 文件链接 {{ fileLinkTest ? '✅' : '❌' }}</text>
</view>
<!-- v-html 渲染区域 -->
<view class="v-html-container">
<text class="container-title">v-html 绑定区域:</text>
<view id="taro_html" v-html="testHtml" class="taro_html"></view>
<!-- RichTextRenderer 渲染区域 -->
<view class="renderer-container">
<text class="container-title">RichTextRenderer 渲染区域:</text>
<view class="renderer-wrapper">
<RichTextRenderer
:content="testHtml"
:enable-transform="transformEnabled"
@image-preview="handleImagePreview"
@file-click="handleFileClick"
/>
</view>
</view>
<!-- 测试结果 -->
......@@ -45,11 +55,9 @@
<view class="action-btn primary" @tap="switchTestContent(1)">测试1: 纯文本</view>
<view class="action-btn primary" @tap="switchTestContent(2)">测试2: HTML标签</view>
<view class="action-btn primary" @tap="switchTestContent(3)">测试3: 图片</view>
<view class="action-btn primary" @tap="switchTestContent(4)">测试4: 链接</view>
<view class="action-btn warning" @tap="clearContent">清空内容</view>
<view class="action-btn success" @tap="loadFromAPI">模拟API加载</view>
<view class="action-btn danger" @tap="loadRealApiData">📄 真实API</view>
<view class="action-btn info" @tap="loadRealApiDataWithDiv">📄 真实API(a→div)</view>
<view class="action-btn" :class="transformEnabled ? 'enabled' : ''" @tap="toggleTransform">
{{ transformEnabled ? '✅' : '⚪' }} 图片自动处理
</view>
......@@ -57,41 +65,37 @@
<!-- 对比说明 -->
<view class="compare-note">
<text class="note-title">与 index1.vue 对比:</text>
<text class="note-item">• index1.vue 使用 &lt;mp-html&gt; 组件 + :content="testHtml"</text>
<text class="note-item">• 本页使用 v-html="testHtml" 指令</text>
<text class="note-item">• 两者都绑定响应式变量 testHtml</text>
<text class="note-title">组件说明:</text>
<text class="note-item">• RichTextRenderer 基于 Taro v-html</text>
<text class="note-item">• 自动解析 HTML 实体(&nbsp; &amp; 等)</text>
<text class="note-item">• 自动替换 &lt;a&gt; 为 &lt;div data-href=""&gt;</text>
<text class="note-item">• 支持图片长按预览和文件链接点击</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick, watch } from 'vue'
import { ref, onMounted } from 'vue'
import Taro, { definePageConfig } from '@tarojs/taro'
import NavHeader from '@/components/navigation/NavHeader.vue'
import { $ } from '@tarojs/extend'
import { useFileOperation } from '@/composables/useFileOperation'
// v-html 需要导入 Taro 的 HTML 样式
import '@tarojs/taro/html.css'
// 文件操作
const { viewFile } = useFileOperation()
import RichTextRenderer from '@/components/RichTextRenderer.vue'
// 配置页面
definePageConfig({
navigationBarTitleText: 'v-html 测试'
navigationBarTitleText: 'RichTextRenderer 测试'
})
// 测试状态
const renderTest = ref(false)
const contentSwitchTest = ref(false)
const htmlParseTest = ref(false)
const imagePreviewTest = ref(false)
const fileLinkTest = ref(false)
const switchCount = ref(0)
const currentContentType = ref('未加载')
const contentLength = ref(0)
const lastAction = ref('页面初始化')
const transformEnabled = ref(false) // 是否启用 transformElement
const transformEnabled = ref(true)
// 测试 HTML 内容
const testHtml = ref('')
......@@ -187,7 +191,7 @@ const testContents = {
name: '纯文本',
content: `
<h1>测试1:纯文本渲染</h1>
<p>这是一段纯文本内容,测试 v-html 是否能正确渲染基本的 HTML 结构。</p>
<p>这是一段纯文本内容,测试 RichTextRenderer 是否能正确渲染基本的 HTML 结构。</p>
<p><strong>加粗文本</strong>和<em>斜体文本</em>测试。</p>
`
},
......@@ -214,23 +218,21 @@ const testContents = {
<img class="h5-img" src="https://cdn.ipadbiz.cn/space_3079606/微信图片_20260227170749_FhZ6SHYxM9yVKOPo1LeqVO25I35q.jpg" alt="测试图片" style="max-width: 100%;" />
</p>
`
},
4: {
name: '链接',
content: `
<h1>测试4:链接渲染</h1>
<p>测试链接是否正确渲染:</p>
<p>
<a href="https://www.baidu.com">百度链接</a> |
<a href="https://www.github.com">GitHub 链接</a>
</p>
<p>PDF 链接测试:
<a href="https://cdn.ipadbiz.cn/space_3079606/需提供的部分个人资料SAMPLE(不含流水SAMPLE)_扫描版_lkAqOTlyK-jZ7RbBN43oiG5QPfr-.pdf">PDF 文档</a>
</p>
`
}
}
// 处理图片预览事件
const handleImagePreview = ({ src }) => {
imagePreviewTest.value = true
lastAction.value = `图片预览: ${src.substring(0, 50)}...`
}
// 处理文件点击事件
const handleFileClick = ({ fileName }) => {
fileLinkTest.value = true
lastAction.value = `文件点击: ${fileName}`
}
// 切换测试内容
const switchTestContent = (index) => {
const template = testContents[index]
......@@ -241,11 +243,7 @@ const switchTestContent = (index) => {
switchCount.value++
lastAction.value = `切换到测试${index}: ${template.name}`
contentSwitchTest.value = true
// 绑定图片长按事件
nextTick(() => {
bindImageEvents()
})
renderTest.value = true
}
}
......@@ -263,7 +261,6 @@ const loadFromAPI = () => {
Taro.showLoading({ title: '加载中...' })
setTimeout(() => {
// 模拟 API 返回的数据
const apiData = `
<div class="api-content">
<h1>从 API 加载的内容</h1>
......@@ -278,34 +275,28 @@ const loadFromAPI = () => {
currentContentType.value = 'API 数据'
contentLength.value = apiData.length
lastAction.value = 'API 加载完成'
renderTest.value = true
Taro.hideLoading()
nextTick(() => {
bindImageEvents()
})
}, 1000)
}
// 加载真实 API 数据(原始版本,包含 <a> 标签)
// 加载真实 API 数据
const loadRealApiData = () => {
lastAction.value = '加载真实 API 数据(原始)...'
lastAction.value = '加载真实 API 数据...'
Taro.showLoading({ title: '加载中...' })
setTimeout(() => {
testHtml.value = realApiData.post_content
currentContentType.value = `真实文章(原始<a>): ${realApiData.post_title}`
currentContentType.value = `真实文章: ${realApiData.post_title}`
contentLength.value = realApiData.post_content.length
switchCount.value++
lastAction.value = `加载文章 ID: ${realApiData.id} (原始<a>标签)`
lastAction.value = `加载文章 ID: ${realApiData.id}`
renderTest.value = true
Taro.hideLoading()
nextTick(() => {
bindImageEvents()
})
Taro.showToast({
title: '原始 <a> 标签',
icon: 'none'
title: '组件自动处理',
icon: 'success'
})
}, 500)
}
......@@ -313,220 +304,15 @@ const loadRealApiData = () => {
// 切换 transformElement
const toggleTransform = () => {
transformEnabled.value = !transformEnabled.value
if (transformEnabled.value) {
// 启用 transformElement
Taro.options.html.transformElement = (el) => {
const nodeName = el.nodeName?.toLowerCase() || ''
const tagName = el.tagName?.toLowerCase() || ''
// 处理 img/image 标签
if (nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image') {
const src = el.getAttribute('src') || ''
// 检查父元素链
let parent = el.parentElement
let isInsideLink = false
let parentChain = []
while (parent) {
const pName = parent.nodeName?.toLowerCase() || ''
const pDataIsLink = parent.getAttribute?.('data-is-link')
const pClassList = parent.classList?.value || ''
parentChain.push(pName)
if (pName === 'a' || pDataIsLink === 'true' || pClassList.includes('_file_list')) {
isInsideLink = true
break
}
parent = parent.parentElement
}
if (isInsideLink) {
// 在链接内,不处理(图标),但必须返回元素让它显示
return el // 直接返回,不做任何修改
}
// 不在链接内,处理(内容图片)
el.setAttribute('mode', 'widthFix')
el.setAttribute('style', 'width: 100%;')
}
// 所有元素都必须返回,否则会被过滤掉不显示
return el
}
lastAction.value = '已启用 transformElement(外部图片自动 widthFix)'
Taro.showToast({
title: '已启用图片自动处理',
icon: 'success'
})
// 重新加载当前内容以应用 transform
if (testHtml.value) {
const currentContent = testHtml.value
testHtml.value = ''
nextTick(() => {
testHtml.value = currentContent
})
}
} else {
// 禁用 transformElement
Taro.options.html.transformElement = undefined
lastAction.value = '已禁用 transformElement'
Taro.showToast({
title: '已禁用图片自动处理',
icon: 'none'
})
// 重新加载当前内容
if (testHtml.value) {
const currentContent = testHtml.value
testHtml.value = ''
nextTick(() => {
testHtml.value = currentContent
})
}
}
}
// 加载真实 API 数据(<a> 替换为 <div> 版本)
const loadRealApiDataWithDiv = () => {
lastAction.value = '加载真实 API 数据(a→div)...'
Taro.showLoading({ title: '加载中...' })
setTimeout(() => {
// 将 <a> 标签替换为 <div> 标签,保留 href 为 data-href
let content = realApiData.post_content
// 替换 HTML 实体
content = content.replace(/&nbsp;/g, ' ') // 空格
content = content.replace(/&amp;/g, '&') // &
content = content.replace(/&lt;/g, '<') // <
content = content.replace(/&gt;/g, '>') // >
content = content.replace(/&quot;/g, '"') // "
content = content.replace(/&apos;/g, "'") // '
// 替换 <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>')
// 统计 data-href 数量
const hrefCount = (content.match(/data-href=/g) || []).length
testHtml.value = content
currentContentType.value = `真实文章(<a>→<div>): ${realApiData.post_title}`
contentLength.value = content.length
switchCount.value++
lastAction.value = `加载文章 ID: ${realApiData.id} (a标签已替换为div, href=${hrefCount})`
Taro.hideLoading()
nextTick(() => {
bindImageEvents()
// 绑定文件链接点击事件
bindFileLinkEvents()
})
Taro.showToast({
title: `已绑定 ${hrefCount} 个文件链接`,
icon: 'success'
})
}, 500)
}
// 绑定图片长按预览事件(类似 activityDetail 页面的实现)
const bindImageEvents = () => {
const imgs = $('#taro_html').children('.h5-p').children('.h5-img')
imgs.forEach(function (img) {
$(img).on('longpress', function () {
const src = $(img).attr('src')
Taro.previewImage({
urls: [src],
current: src,
indicator: 'default',
loop: false,
success: res => {
lastAction.value = '图片预览触发'
},
fail: err => {
// 预览失败
}
})
})
})
htmlParseTest.value = imgs.length > 0
}
// 绑定文件链接点击事件
const bindFileLinkEvents = () => {
// 先检查 #taro_html 容器是否存在
const container = $('#taro_html')
// 检查带 data-is-link 属性的 div
const fileLinks = container.find('div[data-is-link="true"]')
// 如果找不到,尝试用 class 查找
if (fileLinks.length === 0) {
const classLinks = container.find('._file_list')
// 如果用 class 找到了,绑定到这些元素上
classLinks.each(function (idx, el) {
const $el = $(el)
const dataHref = $el.attr('data-href')
if (dataHref) {
$el.on('tap', function () {
const fileName = $el.find('span span span').first().text() || 'document.pdf'
lastAction.value = `打开文件: ${fileName}`
viewFile({
downloadUrl: dataHref,
fileName: fileName
})
})
}
})
return classLinks.length
}
// 原始逻辑:使用 data-is-link 查找
fileLinks.forEach(function (link) {
const $link = $(link)
const href = $link.attr('data-href')
$link.on('tap', function () {
const fileName = $link.find('span span span').first().text() || 'document.pdf'
if (href) {
lastAction.value = `打开文件: ${fileName}`
// 使用 useFileOperation 打开文件
viewFile({
downloadUrl: href,
fileName: fileName
})
}
})
lastAction.value = transformEnabled.value ? '已启用图片自动处理' : '已禁用图片自动处理'
Taro.showToast({
title: transformEnabled.value ? '已启用' : '已禁用',
icon: transformEnabled.value ? 'success' : 'none'
})
return fileLinks.length
}
// 监听 testHtml 变化
watch(testHtml, (_newVal, _oldVal) => {
renderTest.value = true
})
// 初始化
onMounted(() => {
// 加载默认测试内容
switchTestContent(1)
})
</script>
......@@ -562,7 +348,7 @@ onMounted(() => {
margin: 8rpx 0;
}
.v-html-container {
.renderer-container {
background: #fff;
padding: 24rpx;
border-radius: 12rpx;
......@@ -577,89 +363,6 @@ onMounted(() => {
margin-bottom: 16rpx;
}
.taro_html {
// Taro html.css 样式
:deep(.h5-p) {
margin: 16rpx 0;
}
:deep(.h5-img) {
max-width: 100%;
display: block;
}
:deep(h1) {
font-size: 48rpx;
font-weight: bold;
margin: 32rpx 0 16rpx;
}
:deep(h2) {
font-size: 40rpx;
font-weight: bold;
margin: 24rpx 0 12rpx;
}
:deep(h3) {
font-size: 36rpx;
font-weight: bold;
margin: 20rpx 0 12rpx;
}
:deep(p) {
font-size: 28rpx;
line-height: 1.6;
margin: 12rpx 0;
color: #333;
}
:deep(ul) {
padding-left: 48rpx;
margin: 16rpx 0;
}
:deep(li) {
font-size: 28rpx;
line-height: 1.6;
margin: 8rpx 0;
}
:deep(a) {
color: #1989fa;
text-decoration: underline;
}
:deep(strong) {
font-weight: bold;
}
:deep(em) {
font-style: italic;
}
:deep(span) {
font-size: 28rpx;
}
// 文件链接卡片样式
:deep(._file_list) {
display: inline-block !important;
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;
width: 100%;
box-sizing: border-box;
}
}
.test-results {
background: #fff;
padding: 24rpx;
......@@ -717,11 +420,6 @@ onMounted(() => {
font-weight: bold;
}
&.info {
background: #7232dd;
color: #fff;
}
&.enabled {
background: #07c160;
color: #fff;
......