ParseNodeRender.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. <template>
  2. <!-- 节点渲染 -->
  3. <!-- 图片 -->
  4. <Image
  5. v-if="node.tag === 'img'"
  6. :id="node.attrs?.id"
  7. mode="widthFix"
  8. :class="'_img ' + (node.attrs?.class || '')"
  9. :style="node.attrs?.style || {}"
  10. :src="node.attrs?.src as string || ''"
  11. :touchable="true"
  12. @click="preview(node.attrs?.src as string)"
  13. />
  14. <!-- 换行 -->
  15. <!-- #ifdef H5 -->
  16. <br v-else-if="node.tag === 'br'" />
  17. <!-- #endif -->
  18. <!-- #ifndef H5 -->
  19. <text v-else-if="node.tag === 'br'">\n</text>
  20. <!-- #endif -->
  21. <!-- 链接 -->
  22. <view
  23. v-else-if="node.tag === 'a'"
  24. :id="node.attrs?.id"
  25. :class="(node.attrs?.href ? '_a ' : '') + (node.attrs?.class || '')"
  26. hover-class="_hover"
  27. :style="'display:inline;' + (node.attrs?.style || '')"
  28. @tap.stop="linkTap"
  29. >
  30. <ParseNodeRender
  31. v-for="(child, index) in node.children"
  32. :key="index"
  33. :node="child"
  34. />
  35. </view>
  36. <!-- 视频 -->
  37. <video
  38. v-else-if="node.tag === 'video'"
  39. :id="node.attrs?.id"
  40. :class="node.attrs?.class || ''"
  41. :style="node.attrs?.style || {}"
  42. :autoplay="Boolean(node.attrs?.autoplay || false)"
  43. :controls="Boolean(node.attrs?.controls || true)"
  44. :loop="Boolean(node.attrs?.loop || false)"
  45. :muted="Boolean(node.attrs?.muted || false)"
  46. :object-fit="node.attrs?.['object-fit'] || 'contain'"
  47. :poster="node.attrs?.poster as string || ''"
  48. :src="node.attrs?.src as string || ''"
  49. />
  50. <!-- 音频 -->
  51. <!-- #ifndef H5 -->
  52. <audio
  53. v-else-if="node.tag === 'audio'"
  54. :id="node.attrs?.id"
  55. :class="node.attrs?.class || ''"
  56. :style="node.attrs?.style || {}"
  57. :author="node.attrs?.author || ''"
  58. :controls="Boolean(node.attrs?.controls || true)"
  59. :loop="Boolean(node.attrs?.loop || false)"
  60. :name="node.attrs?.name || ''"
  61. :poster="node.attrs?.poster || ''"
  62. :src="node.attrs?.src as string || ''"
  63. />
  64. <!-- #endif -->
  65. <!-- 嵌入小程序内容 -->
  66. <view v-else-if="node.tag === 'inject-mp'">
  67. <InjectMPRender :type="node.attrs?.type as string || ''" v-bind="node.attrs" />
  68. </view>
  69. <!-- 表格 -->
  70. <view
  71. v-else-if="node.tag === 'table'"
  72. :id="node.attrs?.id"
  73. :class="'_parse_table ' + (node.attrs?.class || '')"
  74. :style="style"
  75. >
  76. <ParseNodeRender
  77. v-for="(child, index) in node.children"
  78. :key="index"
  79. :node="child"
  80. />
  81. </view>
  82. <view
  83. v-else-if="node.tag === 'thead' || node.tag === 'tbody'"
  84. :class="node.tag === 'thead' ? '_parse_thead' : '_parse_tbody'"
  85. :style="style"
  86. >
  87. <ParseNodeRender
  88. v-for="(child, index) in node.children"
  89. :key="index"
  90. :node="child"
  91. />
  92. </view>
  93. <view
  94. v-else-if="node.tag === 'tr'"
  95. :class="'_parse_tr ' + (node.attrs?.class || '')"
  96. :style="style"
  97. >
  98. <ParseNodeRender
  99. v-for="(child, index) in node.children"
  100. :key="index"
  101. :node="child"
  102. />
  103. </view>
  104. <view
  105. v-else-if="node.tag === 'th'"
  106. :class="'_parse_th ' + (node.attrs?.class || '')"
  107. :style="style"
  108. >
  109. <ParseNodeRender
  110. v-for="(child, index) in node.children"
  111. :key="index"
  112. :node="child"
  113. />
  114. </view>
  115. <view
  116. v-else-if="node.tag === 'td'"
  117. :class="'_parse_td ' + (node.attrs?.class || '')"
  118. :style="style"
  119. >
  120. <ParseNodeRender
  121. v-for="(child, index) in node.children"
  122. :key="index"
  123. :node="child"
  124. />
  125. </view>
  126. <!-- 特殊标签,例如 ol ul 需要在前缀加序号 -->
  127. <view v-else-if="node.tag === 'li'"
  128. class="_ol_ul_container"
  129. :style="style"
  130. :class="node.attrs?.class || ''"
  131. >
  132. <text v-if="node.parentTag === 'ol'" class="_ol_prefix">·</text>
  133. <text v-else class="_ul_prefix">{{ (node.index || 0) + 1 }}.</text>
  134. <ParseNodeRender
  135. v-for="(child, index) in node.children"
  136. :key="index"
  137. :node="child"
  138. />
  139. </view>
  140. <!-- 其他标签 -->
  141. <view
  142. v-else-if="node.tag !== 'text'"
  143. :id="node.attrs?.id"
  144. :data-tag="node.tag"
  145. :class="node.attrs?.class || ''"
  146. :style="style"
  147. @click="viewTap(node.attrs?.id)"
  148. >
  149. <ParseNodeRender
  150. v-for="(child, index) in node.children"
  151. :key="index"
  152. :node="child"
  153. />
  154. </view>
  155. <!-- 文本 -->
  156. <text
  157. v-else
  158. :style="style"
  159. :class="node.attrs?.class || ''"
  160. >
  161. {{ node.attrs?.content }}
  162. </text>
  163. </template>
  164. <script setup lang="ts">
  165. import { computed, inject, ref, type Ref } from 'vue';
  166. import ParseNodeRender from './ParseNodeRender.vue';
  167. import type { ParseNode } from './Parse';
  168. import InjectMPRender from '@/common/components/rich/InjectMPRender.vue';
  169. import Image from '@/components/basic/Image.vue';
  170. const props = withDefaults(defineProps<{
  171. node: ParseNode;
  172. }>(), {
  173. });
  174. const emit = defineEmits(['linkTap', 'viewTap']);
  175. const tagStyle = inject<Ref<Record<string, string>>>('tagStyle', ref({}));
  176. const classStyle = inject<Ref<Record<string, string>>>('classStyle', ref({}));
  177. const praseImages = inject<Ref<string[]>>('praseImages', ref([]));
  178. // 与 HTML 标准默认样式(UA 样式)一致,参考 CSS 2.1 / HTML5 规范
  179. const builtInStyles = {
  180. // 标题标签 (CSS 2.1 suggested defaults)
  181. 'h1': 'font-size: 2em; font-weight: bold; margin: 0.67em 0;',
  182. 'h2': 'font-size: 1.5em; font-weight: bold; margin: 0.75em 0;',
  183. 'h3': 'font-size: 1.17em; font-weight: bold; margin: 0.83em 0;',
  184. 'h4': 'font-size: 1em; font-weight: bold; margin: 1.12em 0;',
  185. 'h5': 'font-size: 0.83em; font-weight: bold; margin: 1.5em 0;',
  186. 'h6': 'font-size: 0.67em; font-weight: bold; margin: 2.33em 0;',
  187. // 段落和引用
  188. 'p': 'margin: 1em 0;',
  189. 'blockquote': 'margin: 1em 0; border-left: 4px solid #ddd; padding-left: 1em; color: #666;',
  190. // 列表
  191. 'ul': 'margin: 1em 0; padding-left: 40px;',
  192. 'ol': 'margin: 1em 0; padding-left: 40px;',
  193. 'li': 'margin: 0.5em 0;',
  194. // 强调标签
  195. 'b': 'font-weight: bold;',
  196. 'strong': 'font-weight: bold;',
  197. 'i': 'font-style: italic;',
  198. 'em': 'font-style: italic;',
  199. 'u': 'text-decoration: underline;',
  200. 'del': 'text-decoration: line-through;',
  201. // 代码相关 (UA 仅 monospace,无背景)
  202. 'code': 'font-family: monospace;',
  203. 'pre': 'font-family: monospace; white-space: pre; margin: 1em 0;',
  204. // 其他内联标签
  205. 'mark': 'background-color: yellow; color: black;',
  206. 'sup': 'font-size: smaller; vertical-align: super;',
  207. 'sub': 'font-size: smaller; vertical-align: sub;',
  208. 'small': 'font-size: smaller;',
  209. 'large': 'font-size: larger;',
  210. // 分隔线 (CSS 2.1: margin 0.5em 0,inset 在小程序端用 solid 兼容)
  211. 'hr': 'border: none; border-top: 1px solid #ccc; margin: 0.5em 0;',
  212. // 表格(简单布局,无 table-layout)
  213. 'table': 'display: block; width: 100%; margin: 1em 0; overflow-x: auto;',
  214. 'thead': 'display: block;',
  215. 'tbody': 'display: block;',
  216. 'tr': 'display: flex; flex-direction: row;',
  217. 'th': 'font-weight: bold; padding: 8px 10px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; flex: 1; min-width: 0; box-sizing: border-box;',
  218. 'td': 'padding: 8px 10px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; flex: 1; min-width: 0; box-sizing: border-box;'
  219. } as Record<string, string>;
  220. const style = computed(() =>
  221. [
  222. (builtInStyles[props.node.tag] || ''),
  223. (tagStyle.value[props.node.tag] || ''),
  224. (classStyle.value[props.node.attrs?.class as string] || ''),
  225. isInline.value ? 'display:inline' : '',
  226. (props.node.attrs?.style || ''),
  227. ].join(';'),
  228. );
  229. const isInline = computed(() => [
  230. 'span', 'a', 'large','small',
  231. 'i', 'b', 'em', 'strong', 'u', 'del',
  232. 'code', 'sup', 'sub', 'mark'
  233. ].includes(props.node.tag));
  234. // 链接点击事件
  235. const linkTap = (e: any) => {
  236. const href = props.node.attrs?.href as string;
  237. emit('linkTap', href);
  238. if (href) {
  239. if (href[0] === '#') {
  240. // 跳转锚点
  241. // 实现锚点跳转逻辑
  242. } else if (href.includes('://')) {
  243. // 外部链接
  244. uni.showModal({
  245. title: '打开链接',
  246. content: href,
  247. success: (res) => {
  248. if (res.confirm) {
  249. // #ifdef H5
  250. window.open(href);
  251. // #endif
  252. // #ifdef MP
  253. uni.setClipboardData({
  254. data: href,
  255. success: () => {
  256. uni.showToast({
  257. title: '链接已复制',
  258. duration: 2000
  259. });
  260. }
  261. });
  262. // #endif
  263. // #ifdef APP-PLUS
  264. plus.runtime.openWeb(href);
  265. // #endif
  266. }
  267. }
  268. });
  269. } else {
  270. // 跳转页面
  271. uni.navigateTo({
  272. url: href,
  273. fail: () => {
  274. uni.switchTab({
  275. url: href,
  276. fail: () => {}
  277. });
  278. }
  279. });
  280. }
  281. }
  282. };
  283. const viewTap = (e: any) => {
  284. emit('viewTap', e);
  285. };
  286. function preview(url: string) {
  287. if (url) {
  288. if (praseImages.value.includes(url)) {
  289. uni.previewImage({
  290. urls: praseImages.value,
  291. current: praseImages.value.indexOf(url),
  292. })
  293. } else {
  294. uni.previewImage({
  295. urls: [url],
  296. })
  297. }
  298. }
  299. }
  300. defineOptions({
  301. options: {
  302. inheritAttrs: false,
  303. virtualHost: true,
  304. }
  305. })
  306. </script>
  307. <style scoped>
  308. /* a 标签默认效果 */
  309. ._a {
  310. padding: 1.5px 0;
  311. color: #366092;
  312. word-break: break-all;
  313. }
  314. /* a 标签点击态效果 */
  315. ._hover {
  316. text-decoration: underline;
  317. opacity: 0.7;
  318. }
  319. /* 图片默认效果 */
  320. ._img {
  321. max-width: 100%;
  322. -webkit-touch-callout: none;
  323. }
  324. /* ol ul 容器 */
  325. ._ol_ul_container {
  326. display: block;
  327. }
  328. /* ol/ul 前缀 */
  329. ._ol_prefix,
  330. ._ul_prefix {
  331. display: inline-block;
  332. padding-right: 10px;
  333. }
  334. /* 表格:用 view + flex 模拟,无 table 标签;仅画右、下边框,首列补左边框,首行补上边框 */
  335. ._parse_table {
  336. border: 1px solid #ddd;
  337. border-radius: 4px;
  338. }
  339. ._parse_thead ._parse_tr {
  340. background-color: #f5f5f5;
  341. }
  342. ._parse_thead ._parse_tr ._parse_th,
  343. ._parse_thead ._parse_tr ._parse_td {
  344. border-top: 1px solid #ddd;
  345. }
  346. ._parse_tbody ._parse_tr:first-child ._parse_th,
  347. ._parse_tbody ._parse_tr:first-child ._parse_td {
  348. border-top: 1px solid #ddd;
  349. }
  350. ._parse_tr ._parse_th:first-child,
  351. ._parse_tr ._parse_td:first-child {
  352. border-left: 1px solid #ddd;
  353. }
  354. ._parse_th,
  355. ._parse_td {
  356. word-break: break-all;
  357. }
  358. /* 视频默认效果 */
  359. ._video {
  360. width: 300px;
  361. height: 225px;
  362. }
  363. </style>