320 行
10 KiB
Plaintext
320 行
10 KiB
Plaintext
<template>
|
||
<view class="rich-text-msg-root">
|
||
<template v-for="(node, i) in nodes" :key="i">
|
||
<!-- 纯文本节点 type: 'text';鸿蒙不支持组合选择器,用 inlineClassName 单类 -->
|
||
<text v-if="isTextNode(node)" :class="'rich-text-inline' + (inlineClassName.length > 0 ? ' ' + inlineClassName : '')">{{ getText(node) }}</text>
|
||
<!-- 换行 -->
|
||
<view v-else-if="isBr(node)" class="rich-text-br"></view>
|
||
<!-- 无序列表 ul:每项前加 • -->
|
||
<view v-else-if="isUl(node)" :style="getNodeStyle(node)" class="rich-text-ul">
|
||
<view v-for="(li, liIdx) in getChildren(node)" :key="liIdx" class="rich-text-li">
|
||
<text class="rich-text-li-marker">• </text>
|
||
<rich-text-msg :nodes="getChildren(li)" :inline-class-name="inlineClassName" />
|
||
</view>
|
||
</view>
|
||
<!-- 有序列表 ol:每项前加 1. 2. 3. ... -->
|
||
<view v-else-if="isOl(node)" :style="getNodeStyle(node)" class="rich-text-ol">
|
||
<view v-for="(li, liIdx) in getChildren(node)" :key="liIdx" class="rich-text-li">
|
||
<text class="rich-text-li-marker">{{ liIdx + 1 }}. </text>
|
||
<rich-text-msg :nodes="getChildren(li)" :inline-class-name="inlineClassName" />
|
||
</view>
|
||
</view>
|
||
<!-- 块级标签:p, div, h1-h6, li, blockquote, pre 等;子节点用 getBlockInlineClass 单类 -->
|
||
<view v-else-if="isBlockTag(node)" :style="getNodeStyle(node)" class="rich-text-block" :class="getBlockTagClass(node)">
|
||
<rich-text-msg v-if="hasChildren(node)" :nodes="getChildren(node)" :inline-class-name="getBlockInlineClass(node)" />
|
||
</view>
|
||
<!-- 链接 a:单类 rich-text-link-inline -->
|
||
<view v-else-if="isLink(node)" :style="getNodeStyle(node)" class="rich-text-inline-wrap rich-text-link"
|
||
@click.stop="onLinkClick(node)">
|
||
<rich-text-msg v-if="hasChildren(node)" :nodes="getChildren(node)" inline-class-name="rich-text-link-inline" />
|
||
</view>
|
||
<!-- 行内代码 code:单类 rich-text-code-inline -->
|
||
<view v-else-if="isCode(node)" :style="getNodeStyle(node)" class="rich-text-inline-wrap rich-text-code">
|
||
<rich-text-msg v-if="hasChildren(node)" :nodes="getChildren(node)" inline-class-name="rich-text-code-inline" />
|
||
</view>
|
||
<!-- 行内标签:span, strong, i 等;子节点用 getInlineWrapInlineClass 单类 -->
|
||
<view v-else :style="getNodeStyle(node)" class="rich-text-inline-wrap" :class="getInlineTagClass(node)">
|
||
<rich-text-msg v-if="hasChildren(node)" :nodes="getChildren(node)" :inline-class-name="getInlineWrapInlineClass(node)" />
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
/**
|
||
* @description: 作为聊天页的富文本渲染子组件,支持按节点类型递归渲染文本、标题、列表、链接和代码块。
|
||
* @logic: 组件根据传入节点类型判断使用不同模板分支,并在需要时递归渲染子节点,同时统一处理链接点击和样式映射。
|
||
* @optimization: 通过把节点判断和样式映射集中到当前组件内递归复用,避免在聊天页主逻辑中重复处理富文本节点结构。
|
||
*/
|
||
import type { TextNode, ElementNode, RichNode, RichNodes } from './types.uts'
|
||
|
||
const props = defineProps<{
|
||
nodes : RichNodes
|
||
/** 鸿蒙等端不支持组合选择器,由父传入作用于 .rich-text-inline 的单类名 */
|
||
inlineClassName? : string
|
||
}>()
|
||
|
||
const nodes = computed(() => props.nodes ?? [])
|
||
const inlineClassName = computed(() => props.inlineClassName ?? '')
|
||
|
||
/** 类型守卫:判断是否为 TextNode */
|
||
function isTextNodeType(node : RichNode) : boolean {
|
||
if (typeof node === 'string') return false
|
||
try {
|
||
const tn = node as TextNode
|
||
return tn.type == 'text' && (tn.text != null)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 类型守卫:判断是否为 ElementNode */
|
||
function isElementNodeType(node : RichNode) : boolean {
|
||
if (typeof node === 'string') return false
|
||
try {
|
||
const en = node as ElementNode
|
||
return en.name != null
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function isTextNode(node : RichNode) : boolean {
|
||
if (typeof node === 'string') return true
|
||
return isTextNodeType(node)
|
||
}
|
||
|
||
function getText(node : RichNode) : string {
|
||
if (typeof node === 'string') return node
|
||
if (isTextNodeType(node)) {
|
||
const tn = node as TextNode
|
||
return tn.text ?? ''
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function isBr(node : RichNode) : boolean {
|
||
if (typeof node === 'string') return false
|
||
if (isElementNodeType(node)) {
|
||
const en = node as ElementNode
|
||
return en.name == 'br'
|
||
}
|
||
return false
|
||
}
|
||
|
||
function getName(node : RichNode) : string {
|
||
if (typeof node === 'string') return ''
|
||
if (isTextNodeType(node)) return ''
|
||
if (isElementNodeType(node)) {
|
||
const en = node as ElementNode
|
||
return en.name ?? ''
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function isUl(node : RichNode) : boolean {
|
||
return getName(node) == 'ul'
|
||
}
|
||
|
||
function isOl(node : RichNode) : boolean {
|
||
return getName(node) == 'ol'
|
||
}
|
||
|
||
function isLink(node : RichNode) : boolean {
|
||
return getName(node) == 'a'
|
||
}
|
||
|
||
function isCode(node : RichNode) : boolean {
|
||
return getName(node) == 'code'
|
||
}
|
||
|
||
function getInlineTagClass(node : RichNode) : string {
|
||
const name = getName(node)
|
||
if (name == 'strong' || name == 'b') return 'rt-strong'
|
||
if (name == 'i' || name == 'em') return 'rt-italic'
|
||
if (name == 'u') return 'rt-underline'
|
||
if (name == 'del' || name == 's' || name == 'strike') return 'rt-line-through'
|
||
return ''
|
||
}
|
||
|
||
function getHref(node : RichNode) : string {
|
||
if (typeof node === 'string') return ''
|
||
if (isElementNodeType(node)) {
|
||
const en = node as ElementNode
|
||
const attrs = en.attrs
|
||
if (attrs == null) return ''
|
||
return attrs.get('href') ?? ''
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function onLinkClick(node : RichNode) {
|
||
const href = getHref(node)
|
||
if (href.length == 0) return
|
||
uni.setClipboardData({
|
||
data: href,
|
||
success: () => {
|
||
uni.showToast({ title: '复制成功', icon: 'none' })
|
||
},
|
||
fail: () => {
|
||
uni.showToast({ title: '复制失败', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
const blockTags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'pre']
|
||
|
||
function getBlockTagClass(node : RichNode) : string {
|
||
const name = getName(node)
|
||
if (name == 'h1') return 'rt-h1'
|
||
if (name == 'h2') return 'rt-h2'
|
||
if (name == 'h3') return 'rt-h3'
|
||
if (name == 'h4') return 'rt-h4'
|
||
if (name == 'h5') return 'rt-h5'
|
||
if (name == 'h6') return 'rt-h6'
|
||
if (name == 'blockquote') return 'rt-blockquote'
|
||
return ''
|
||
}
|
||
|
||
/** 块级子节点用的单类名,鸿蒙等端不支持组合选择器 */
|
||
function getBlockInlineClass(node : RichNode) : string {
|
||
const name = getName(node)
|
||
if (name == 'h1') return 'rt-h1-inline'
|
||
if (name == 'h2') return 'rt-h2-inline'
|
||
if (name == 'h3') return 'rt-h3-inline'
|
||
if (name == 'h4') return 'rt-h4-inline'
|
||
if (name == 'h5') return 'rt-h5-inline'
|
||
if (name == 'h6') return 'rt-h6-inline'
|
||
if (name == 'blockquote') return 'rt-blockquote-inline'
|
||
return ''
|
||
}
|
||
|
||
/** 行内包裹子节点用的单类名 */
|
||
function getInlineWrapInlineClass(node : RichNode) : string {
|
||
const name = getName(node)
|
||
if (name == 'strong' || name == 'b') return 'rt-strong-inline'
|
||
if (name == 'i' || name == 'em') return 'rt-italic-inline'
|
||
if (name == 'u') return 'rt-underline-inline'
|
||
if (name == 'del' || name == 's' || name == 'strike') return 'rt-line-through-inline'
|
||
return ''
|
||
}
|
||
|
||
function isBlockTag(node : RichNode) : boolean {
|
||
const name = getName(node)
|
||
return blockTags.indexOf(name) >= 0
|
||
}
|
||
|
||
function getNodeStyle(node : RichNode) : string {
|
||
if (typeof node === 'string') return ''
|
||
if (isElementNodeType(node)) {
|
||
const en = node as ElementNode
|
||
const attrs = en.attrs
|
||
if (attrs == null) return ''
|
||
return attrs.get('style') ?? ''
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function hasChildren(node : RichNode) : boolean {
|
||
if (typeof node === 'string') return false
|
||
if (isElementNodeType(node)) {
|
||
const en = node as ElementNode
|
||
const children = en.children
|
||
if (children == null) return false
|
||
return children.length > 0
|
||
}
|
||
return false
|
||
}
|
||
|
||
function getChildren(node : RichNode) : RichNodes {
|
||
const empty : RichNode[] = [] as RichNode[]
|
||
if (typeof node === 'string') return empty
|
||
if (isElementNodeType(node)) {
|
||
const en = node as ElementNode
|
||
const children = en.children
|
||
return children ?? empty
|
||
}
|
||
return empty
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
/* 鸿蒙等端:max-width 仅支持 number|rpx;限制宽度防溢出 */
|
||
.rich-text-msg-root {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
max-width: 540rpx;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
.rich-text-inline {
|
||
font-size: 13px;
|
||
color: #666;
|
||
max-width: 540rpx;
|
||
flex-shrink: 1;
|
||
}
|
||
.rich-text-br {
|
||
width: 100%;
|
||
height: 6px;
|
||
}
|
||
.rich-text-block {
|
||
flex-direction: column;
|
||
width: 100%;
|
||
}
|
||
.rt-h1-inline { font-size: 18px; font-weight: bold; color: #333;}
|
||
.rt-h2-inline { font-size: 16px; font-weight: bold; color: #333; }
|
||
.rt-h3-inline { font-size: 15px; font-weight: bold; color: #444; }
|
||
.rt-h4-inline { font-size: 14px; font-weight: bold; color: #444; }
|
||
.rt-h5-inline { font-size: 13px; font-weight: bold; color: #555; }
|
||
.rt-h6-inline { font-size: 12px; font-weight: bold; color: #555; }
|
||
.rt-blockquote {
|
||
border-left: 4px solid #ccc;
|
||
padding-left: 8px;
|
||
margin: 4px 0;
|
||
}
|
||
.rt-blockquote-inline { color: #555; font-style: italic; }
|
||
.rich-text-ul,
|
||
.rich-text-ol {
|
||
flex-direction: column;
|
||
width: 100%;
|
||
padding-left: 8px;
|
||
}
|
||
.rich-text-li {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 2px;
|
||
}
|
||
.rich-text-li-marker {
|
||
font-size: 13px;
|
||
line-height: 20px;
|
||
margin-right: 4px;
|
||
color: #999;
|
||
}
|
||
.rich-text-inline-wrap {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
flex-shrink: 1;
|
||
max-width: 540rpx;
|
||
min-width: 0;
|
||
}
|
||
.rich-text-link-inline {
|
||
color: #007aff;
|
||
}
|
||
.rt-strong-inline { font-weight: bold; }
|
||
.rt-italic-inline { font-style: italic; }
|
||
/* 预留:部分端不支持时可在此补充 text-decoration */
|
||
.rt-underline-inline { }
|
||
.rt-line-through-inline { }
|
||
.rich-text-code {
|
||
background-color: #333;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
flex-shrink: 1;
|
||
margin-left: 4px;
|
||
}
|
||
.rich-text-code-inline {
|
||
color: #e0e0e0;
|
||
}
|
||
</style>
|