feat(mp): 文章详情页改用 mp-html 原生富文本组件
- 引入 mp-html 替代 RichText,支持图文混排、表格等复杂内容 - 新建 RichArticle 组件封装 sanitizeHtml + mp-html - 通过 native-components 拷贝原生组件到 dist - 优化文章排版样式(字号、间距、分隔线、底栏安全区) - sanitize-html 扩展允许 style/data-w-e-type 属性
This commit is contained in:
32
apps/miniprogram/src/components/RichArticle/index.tsx
Normal file
32
apps/miniprogram/src/components/RichArticle/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { View } from '@tarojs/components';
|
||||
import { sanitizeHtml } from '@/utils/sanitize-html';
|
||||
|
||||
interface RichArticleProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function prepareHtml(raw: string): string {
|
||||
return sanitizeHtml(raw);
|
||||
}
|
||||
|
||||
function RichArticle({ html, className }: RichArticleProps) {
|
||||
const content = useMemo(() => prepareHtml(html), [html]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<View className={className}>
|
||||
<mp-html
|
||||
content={content}
|
||||
lazy-load
|
||||
selectable
|
||||
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
|
||||
tag-style='{"img":"max-width:100%;border-radius:8px;margin:12px auto;display:block","a":"color:#C4623A;text-decoration:none"}'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(RichArticle);
|
||||
8
apps/miniprogram/src/native-components/mp-html/index.js
Normal file
8
apps/miniprogram/src/native-components/mp-html/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
"use strict";function e(t){"@babel/helpers - typeof";return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t,o){return(t=n(t))in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e}function n(t){var n=o(t,"string");return"symbol"==e(n)?n:n+""}function o(t,n){if("object"!=e(t)||!t)return t;var o=t[Symbol.toPrimitive];if(void 0!==o){var i=o.call(t,n||"default");if("object"!=e(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}/*!
|
||||
* mp-html v2.5.2
|
||||
* https://github.com/jin-yufeng/mp-html
|
||||
*
|
||||
* Released under the MIT license
|
||||
* Author: Jin Yufeng
|
||||
*/
|
||||
var i=require("./parser"),r=[];Component({data:{nodes:[]},properties:{containerStyle:String,content:{type:String,value:"",observer:function(e){this.setContent(e)}},copyLink:{type:Boolean,value:!0},domain:String,errorImg:String,lazyLoad:Boolean,loadingImg:String,pauseVideo:{type:Boolean,value:!0},previewImg:{type:null,value:!0},scrollTable:Boolean,selectable:null,setTitle:{type:Boolean,value:!0},showImgMenu:{type:Boolean,value:!0},tagStyle:Object,useAnchor:null},created:function(){this.plugins=[];for(var e=r.length;e--;)this.plugins.push(new r[e](this))},detached:function(){this._hook("onDetached")},methods:{in:function(e,t,n){e&&t&&n&&(this._in={page:e,selector:t,scrollTop:n})},navigateTo:function(e,n){var o=this;return new Promise(function(i,r){if(!o.data.useAnchor)return void r(Error("Anchor is disabled"));var a=wx.createSelectorQuery().in(o._in?o._in.page:o).select((o._in?o._in.selector:"._root")+(e?"".concat(">>>","#").concat(e):"")).boundingClientRect();o._in?a.select(o._in.selector).scrollOffset().select(o._in.selector).boundingClientRect():a.selectViewport().scrollOffset(),a.exec(function(e){if(!e[0])return void r(Error("Label not found"));var a=e[1].scrollTop+e[0].top-(e[2]?e[2].top:0)+(n||parseInt(o.data.useAnchor)||0);o._in?o._in.page.setData(t({},o._in.scrollTop,a)):wx.pageScrollTo({scrollTop:a,duration:300}),i()})})},getText:function(e){var t="";return function e(n){for(var o=0;o<n.length;o++){var i=n[o];if("text"===i.type)t+=i.text.replace(/&/g,"&");else if("br"===i.name)t+="\n";else{var r="p"===i.name||"div"===i.name||"tr"===i.name||"li"===i.name||"h"===i.name[0]&&i.name[1]>"0"&&i.name[1]<"7";r&&t&&"\n"!==t[t.length-1]&&(t+="\n"),i.children&&e(i.children),r&&"\n"!==t[t.length-1]?t+="\n":"td"!==i.name&&"th"!==i.name||(t+="\t")}}}(e||this.data.nodes),t},getRect:function(){var e=this;return new Promise(function(t,n){wx.createSelectorQuery().in(e).select("._root").boundingClientRect().exec(function(e){return e[0]?t(e[0]):n(Error("Root label not found"))})})},pauseMedia:function(){for(var e=(this._videos||[]).length;e--;)this._videos[e].pause()},setPlaybackRate:function(e){this.playbackRate=e;for(var t=(this._videos||[]).length;t--;)this._videos[t].playbackRate(e)},setContent:function(e,t){var n=this;this.imgList&&t||(this.imgList=[]),this._videos=[];var o={},r=new i(this).parse(e);if(t)for(var a=this.data.nodes.length,s=r.length;s--;)o["nodes[".concat(a+s,"]")]=r[s];else o.nodes=r;if(this.setData(o,function(){n._hook("onLoad"),n.triggerEvent("load")}),this.data.lazyLoad||this.imgList._unloadimgs<this.imgList.length/2){var l=0,c=function(e){e&&e.height||(e={}),e.height===l?n.triggerEvent("ready",e):(l=e.height,setTimeout(function(){n.getRect().then(c).catch(c)},350))};this.getRect().then(c).catch(c)}else this.imgList._unloadimgs||this.getRect().then(function(e){n.triggerEvent("ready",e)}).catch(function(){n.triggerEvent("ready",{})})},_hook:function(e){for(var t=r.length;t--;)this.plugins[t][e]&&this.plugins[t][e]()},_add:function(e){e.detail.root=this}}});
|
||||
@@ -0,0 +1 @@
|
||||
{"component":true,"usingComponents":{"node":"./node/node"}}
|
||||
@@ -0,0 +1 @@
|
||||
<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot wx:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>
|
||||
@@ -0,0 +1 @@
|
||||
._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"component":true,"usingComponents":{"node":"./node"}}
|
||||
@@ -0,0 +1 @@
|
||||
<wxs module="isInline">var e={abbr:!0,b:!0,big:!0,code:!0,del:!0,em:!0,i:!0,ins:!0,label:!0,q:!0,small:!0,span:!0,strong:!0,sub:!0,sup:!0};module.exports=function(n,i){return e[n]||-1!==(i||"").indexOf("inline")};</wxs><template name="el"><block wx:if="{{n.name==='img'}}"><rich-text wx:if="{{n.t}}" style="display:{{n.t}}" nodes="<img class='_img' style='{{n.attrs.style}}' src='{{n.attrs.src}}'>" data-i="{{i}}" catchtap="imgTap"/><block wx:else><image wx:if="{{(opts[1]&&!ctrl[i])||ctrl[i]<0}}" class="_img" style="{{n.attrs.style}}" src="{{ctrl[i]<0?opts[2]:opts[1]}}" mode="widthFix"/><image id="{{n.attrs.id}}" class="_img {{n.attrs.class}}" style="{{ctrl[i]===-1?'display:none;':''}}width:{{ctrl[i]||1}}px;height:1px;{{n.attrs.style}}" src="{{n.attrs.src}}" mode="{{!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))}}" lazy-load="{{opts[0]}}" webp="{{n.webp}}" show-menu-by-longpress="{{opts[3]&&!n.attrs.ignore}}" data-i="{{i}}" bindload="imgLoad" binderror="mediaError" catchtap="imgTap" bindlongpress="noop"/></block></block><text wx:elif="{{n.text}}" user-select="{{opts[4]=='force'&&isiOS}}" decode>{{n.text}}</text><text wx:elif="{{n.name==='br'}}">{{'\n'}}</text><view wx:elif="{{n.name==='a'}}" id="{{n.attrs.id}}" class="{{n.attrs.href?'_a ':''}}{{n.attrs.class}}" hover-class="_hover" style="display:inline;{{n.attrs.style}}" data-i="{{i}}" catchtap="linkTap"><node childs="{{n.children}}" opts="{{opts}}" style="display:inherit"/></view><video wx:elif="{{n.name==='video'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" muted="{{n.attrs.muted}}" object-fit="{{n.attrs['object-fit']}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" bindfullscreenchange="mediaEvent" binderror="mediaError"/><audio wx:elif="{{n.name==='audio'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" binderror="mediaError"/><rich-text wx:else id="{{n.attrs.id}}" style="{{n.f}}" user-select="{{opts[4]}}" nodes="{{[n]}}"/></template><block wx:for="{{nodes}}" wx:for-item="n1" wx:for-index="i1" wx:key="i1"><template wx:if="{{!n1.c&&(!n1.children||n1.name==='a'||!isInline(n1.name,n1.attrs.style))}}" is="el" data="{{n:n1,i:''+i1,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n1.attrs.id}}" class="_{{n1.name}} {{n1.attrs.class}}" style="{{n1.attrs.style}}"><block wx:for="{{n1.children}}" wx:for-item="n2" wx:for-index="i2" wx:key="i2"><template wx:if="{{!n2.c&&(!n2.children||n2.name==='a'||!isInline(n2.name,n2.attrs.style))}}" is="el" data="{{n:n2,i:i1+'_'+i2,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n2.attrs.id}}" class="_{{n2.name}} {{n2.attrs.class}}" style="{{n2.attrs.style}}"><block wx:for="{{n2.children}}" wx:for-item="n3" wx:for-index="i3" wx:key="i3"><template wx:if="{{!n3.c&&(!n3.children||n3.name==='a'||!isInline(n3.name,n3.attrs.style))}}" is="el" data="{{n:n3,i:i1+'_'+i2+'_'+i3,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n3.attrs.id}}" class="_{{n3.name}} {{n3.attrs.class}}" style="{{n3.attrs.style}}"><block wx:for="{{n3.children}}" wx:for-item="n4" wx:for-index="i4" wx:key="i4"><template wx:if="{{!n4.c&&(!n4.children||n4.name==='a'||!isInline(n4.name,n4.attrs.style))}}" is="el" data="{{n:n4,i:i1+'_'+i2+'_'+i3+'_'+i4,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n4.attrs.id}}" class="_{{n4.name}} {{n4.attrs.class}}" style="{{n4.attrs.style}}"><block wx:for="{{n4.children}}" wx:for-item="n5" wx:for-index="i5" wx:key="i5"><template wx:if="{{!n5.c&&(!n5.children||n5.name==='a'||!isInline(n5.name,n5.attrs.style))}}" is="el" data="{{n:n5,i:i1+'_'+i2+'_'+i3+'_'+i4+'_'+i5,opts:opts,ctrl:ctrl}}"/><node wx:else id="{{n5.attrs.id}}" class="_{{n5.name}} {{n5.attrs.class}}" style="{{n5.attrs.style}}" childs="{{n5.children}}" opts="{{opts}}"/></block></view></block></view></block></view></block></view></block>
|
||||
@@ -0,0 +1 @@
|
||||
._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}
|
||||
1
apps/miniprogram/src/native-components/mp-html/parser.js
Normal file
1
apps/miniprogram/src/native-components/mp-html/parser.js
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '文章详情',
|
||||
usingComponents: {
|
||||
'mp-html': '../../../native-components/mp-html/index',
|
||||
},
|
||||
});
|
||||
@@ -1,19 +1,21 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
// 文章详情页 — 对齐原型 docs/design/mp-04-article-report.html → ArticleDetail
|
||||
// 文章详情页 — 阅读优化排版
|
||||
|
||||
.article-detail-page {
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: 100px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
line-height: 1.35;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
@@ -21,36 +23,38 @@
|
||||
gap: var(--tk-gap-md);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-gap-lg);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.article-divider {
|
||||
height: 1px;
|
||||
background: $bd-l;
|
||||
background: linear-gradient(90deg, $bd-l, $bd, $bd-l);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.article-body {
|
||||
font-size: 15px;
|
||||
// RichText 内部样式由 formatArticleHtml 内联注入
|
||||
// 这里只控制容器间距
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
line-height: 1.8;
|
||||
|
||||
// RichText 内部样式
|
||||
h1, h2, h3 {
|
||||
font-weight: bold;
|
||||
// 兜底:万一内联样式未生效的标签
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $tx;
|
||||
margin: var(--tk-gap-lg) 0 var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: $r-sm;
|
||||
margin: var(--tk-gap-sm) 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,31 +63,40 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
gap: 48px;
|
||||
padding: 0 var(--tk-page-padding);
|
||||
z-index: 10;
|
||||
// 安全区适配
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
|
||||
}
|
||||
|
||||
.article-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
min-width: $touch-min;
|
||||
min-height: $touch-min;
|
||||
justify-content: center;
|
||||
border-radius: $r-sm;
|
||||
transition: background var(--tk-duration-fast);
|
||||
|
||||
&:active {
|
||||
background: $surface-alt;
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.article-action-icon {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -105,3 +118,29 @@
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ─── 关怀模式覆盖 ───
|
||||
.elder-mode {
|
||||
.article-title {
|
||||
font-size: var(--tk-font-h1);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
font-size: var(--tk-font-body);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.article-bottom-bar {
|
||||
height: 72px;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.article-action-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.article-action-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, RichText } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import { sanitizeHtml } from '@/utils/sanitize-html';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import RichArticle from '@/components/RichArticle';
|
||||
import './index.scss';
|
||||
|
||||
export default function ArticleDetail() {
|
||||
@@ -77,14 +77,24 @@ export default function ArticleDetail() {
|
||||
<Text className='article-title'>{article.title}</Text>
|
||||
|
||||
<View className='article-meta'>
|
||||
{article.author && <Text>{article.author}</Text>}
|
||||
{article.published_at && <Text>{article.published_at.slice(0, 10)}</Text>}
|
||||
{article.author && (
|
||||
<View className='meta-item'>
|
||||
<Text className='meta-icon'>✍</Text>
|
||||
<Text>{article.author}</Text>
|
||||
</View>
|
||||
)}
|
||||
{article.published_at && (
|
||||
<View className='meta-item'>
|
||||
<Text className='meta-icon'>📅</Text>
|
||||
<Text>{article.published_at.slice(0, 10)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='article-divider' />
|
||||
|
||||
<View className='article-body'>
|
||||
<RichText nodes={sanitizeHtml(article.content || '')} />
|
||||
<RichArticle html={article.content || ''} />
|
||||
</View>
|
||||
|
||||
<View className='article-bottom-bar'>
|
||||
|
||||
15
apps/miniprogram/src/types/mp-html.d.ts
vendored
Normal file
15
apps/miniprogram/src/types/mp-html.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'mp-html': {
|
||||
content?: string;
|
||||
'lazy-load'?: boolean;
|
||||
selectable?: boolean;
|
||||
'show-img-menu'?: boolean;
|
||||
domain?: string;
|
||||
'tag-style'?: string;
|
||||
'link-style'?: string;
|
||||
'container-style'?: string;
|
||||
onReady?: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,12 @@ const ALLOWED_TAGS = new Set([
|
||||
]);
|
||||
|
||||
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
||||
'*': new Set(['class']),
|
||||
'*': new Set(['class', 'style', 'data-w-e-type']),
|
||||
a: new Set(['href', 'title']),
|
||||
img: new Set(['src', 'alt', 'width', 'height']),
|
||||
td: new Set(['colspan', 'rowspan']),
|
||||
th: new Set(['colspan', 'rowspan']),
|
||||
span: new Set(['style']),
|
||||
};
|
||||
|
||||
const URL_ATTRS = new Set(['href', 'src']);
|
||||
|
||||
Reference in New Issue
Block a user