feat: enhance link handling in reading page
- Added functionality to open article links in a mini program web-view, allowing users to preview external links. - Implemented mobile hints for external links to improve user experience on mobile devices. - Updated content parser to support new link types, including regular hyperlinks. - Enhanced styling for article links to improve visibility and interaction. Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,35 @@
|
||||
const app = getApp()
|
||||
|
||||
function safeDecode(value) {
|
||||
try {
|
||||
return decodeURIComponent(value || '')
|
||||
} catch (_) {
|
||||
return String(value || '')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(raw) {
|
||||
let s = String(raw || '').trim()
|
||||
if (!s) return ''
|
||||
if (/^\/\//.test(s)) return 'https:' + s
|
||||
if (!/^https?:\/\//i.test(s)) s = 'https://' + s.replace(/^\/+/, '')
|
||||
return s
|
||||
}
|
||||
|
||||
// 为外链补充移动端提示参数:目标站支持时按手机网页输出
|
||||
function appendMobileHints(rawUrl) {
|
||||
const url = normalizeHttpUrl(rawUrl)
|
||||
if (!url) return ''
|
||||
const hasQuery = url.indexOf('?') >= 0
|
||||
const hasFrom = /(?:\?|&)from=/i.test(url)
|
||||
const hasView = /(?:\?|&)view=/i.test(url)
|
||||
const extras = []
|
||||
if (!hasFrom) extras.push('from=miniprogram')
|
||||
if (!hasView) extras.push('view=mobile')
|
||||
if (!extras.length) return url
|
||||
return url + (hasQuery ? '&' : '?') + extras.join('&')
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
url: '',
|
||||
@@ -10,8 +40,9 @@ Page({
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const url = decodeURIComponent(options.url || '')
|
||||
const title = options.title ? decodeURIComponent(options.title) : '链接预览'
|
||||
const rawUrl = safeDecode(options.url || '')
|
||||
const url = appendMobileHints(rawUrl)
|
||||
const title = options.title ? safeDecode(options.title) : '链接预览'
|
||||
this.setData({
|
||||
url,
|
||||
title,
|
||||
|
||||
@@ -1257,6 +1257,22 @@ Page({
|
||||
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
|
||||
},
|
||||
|
||||
// 点击正文普通超链接:用小程序 web-view 打开(微信内置浏览器)
|
||||
onArticleLinkTap(e) {
|
||||
const url = String((e.currentTarget.dataset.url || '')).trim()
|
||||
if (!url) {
|
||||
wx.showToast({ title: '链接地址为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const text = String((e.currentTarget.dataset.text || '链接预览')).trim() || '链接预览'
|
||||
wx.navigateTo({
|
||||
url: `/pages/link-preview/link-preview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(text)}`,
|
||||
fail: (err) => {
|
||||
wx.showToast({ title: err.errMsg || '打开失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 点击正文图片 → 全屏预览
|
||||
onImageTap(e) {
|
||||
const src = e.currentTarget.dataset.src
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
|
||||
</view>
|
||||
<view wx:else class="paragraph">
|
||||
<text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
|
||||
<text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text><text wx:elif="{{seg.type === 'link'}}" class="article-link" bindtap="onArticleLinkTap" data-url="{{seg.url}}" data-text="{{seg.text}}">{{seg.text}}</text></block></text>
|
||||
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
|
||||
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
|
||||
<video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
|
||||
@@ -158,7 +158,7 @@
|
||||
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
|
||||
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
|
||||
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
|
||||
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
|
||||
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text><text wx:elif="{{seg.type === 'link'}}" class="article-link" bindtap="onArticleLinkTap" data-url="{{seg.url}}" data-text="{{seg.text}}">{{seg.text}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
|
||||
</block>
|
||||
</view>
|
||||
<view class="fade-mask"></view>
|
||||
@@ -251,7 +251,7 @@
|
||||
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
|
||||
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
|
||||
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
|
||||
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
|
||||
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text><text wx:elif="{{seg.type === 'link'}}" class="article-link" bindtap="onArticleLinkTap" data-url="{{seg.url}}" data-text="{{seg.text}}">{{seg.text}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
|
||||
</block>
|
||||
</view>
|
||||
<view class="fade-mask"></view>
|
||||
|
||||
@@ -282,6 +282,14 @@
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
|
||||
/* 正文内普通超链接(管理端插入 a 标签) */
|
||||
.paragraph .article-link {
|
||||
color: #67e8f9;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 正文内图片 */
|
||||
.content-image {
|
||||
width: 100%;
|
||||
|
||||
@@ -21,6 +21,22 @@ function buildH5ReadUrl(articleId) {
|
||||
|
||||
function renderArticleHtml(text) {
|
||||
const src = String(text || '')
|
||||
const isHtml = /<[a-z][\s\S]*>/i.test(src)
|
||||
|
||||
// 富文本:保留原 HTML,仅统一补充 a 标签样式(可点击 + 高亮)
|
||||
if (isHtml) {
|
||||
return src.replace(/<a\b([^>]*)>/gi, (full, attrs) => {
|
||||
if (!/href\s*=/.test(attrs)) return full
|
||||
const hasStyle = /\sstyle\s*=/.test(attrs)
|
||||
const styleText = 'color:#22d3ee;text-decoration:underline;word-break:break-all;'
|
||||
if (hasStyle) {
|
||||
return `<a${attrs.replace(/\sstyle\s*=\s*(['"])(.*?)\1/i, (_m, q, s) => ` style=${q}${s};${styleText}${q}`)}>`
|
||||
}
|
||||
return `<a${attrs} style="${styleText}">`
|
||||
})
|
||||
}
|
||||
|
||||
// 纯文本:高亮 @/# 并自动识别 URL
|
||||
const escaped = src
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -30,7 +46,7 @@ function renderArticleHtml(text) {
|
||||
const withTags = withMentions.replace(/(^|[\s])(#[^\s#]+)/g, '$1<span style="color:#5eead4;">$2</span>')
|
||||
const withLinks = withTags.replace(
|
||||
/(https?:\/\/[^\s<]+)/g,
|
||||
'<a href="$1" style="color:#22d3ee;text-decoration:underline;">$1</a>'
|
||||
'<a href="$1" style="color:#22d3ee;text-decoration:underline;word-break:break-all;">$1</a>'
|
||||
)
|
||||
return withLinks.replace(/\n/g, '<br/>')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* { type: 'text', text }
|
||||
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead)
|
||||
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap:外链→link-preview、小程序→navigateToMiniProgram)
|
||||
* { type: 'link', text, url } — 普通超链接,点击外链预览(阅读页 onArticleLinkTap)
|
||||
* { type: 'image', src, alt } — 图片
|
||||
* { type: 'video', src } — 内嵌视频(管理端 rich-video-wrap / <video>)
|
||||
*/
|
||||
@@ -100,7 +101,7 @@ function stripTrailingAtForMention(before) {
|
||||
|
||||
/**
|
||||
* 将一个 HTML block 字符串解析为 segments 数组
|
||||
* 处理内联元素:mention / linkTag(span) / linkTag(a) / img / video
|
||||
* 处理内联元素:mention / linkTag(span) / linkTag(a) / link(a) / img / video
|
||||
*/
|
||||
function parseBlockToSegments(block, config) {
|
||||
const segs = []
|
||||
@@ -111,7 +112,7 @@ function parseBlockToSegments(block, config) {
|
||||
if (token) personTokenSet.add(token)
|
||||
}
|
||||
// 合并匹配所有内联元素
|
||||
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<video[^>]*>[\s\S]*?<\/video>|<video[^>]*\/>|<img[^>]*\/?>/gi
|
||||
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href=(?:"[^"]*"|'[^']*')[^>]*>[\s\S]*?<\/a>|<video[^>]*>[\s\S]*?<\/video>|<video[^>]*\/>|<img[^>]*\/?>/gi
|
||||
let lastEnd = 0
|
||||
let m
|
||||
|
||||
@@ -162,12 +163,21 @@ function parseBlockToSegments(block, config) {
|
||||
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey, passPhone, phoneParamName })
|
||||
|
||||
} else if (/^<a /i.test(tag)) {
|
||||
// #linkTag — 旧格式 <a href>(insertLinkTag 旧版产生,url 可能为空)
|
||||
// m[1] = href, m[2] = innerText(以 # 开头)
|
||||
const url = m[1] || ''
|
||||
const label = cleanSingleLineField((m[2] || '').replace(/^#/, ''))
|
||||
// 旧格式没有 tagType,在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
|
||||
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
|
||||
// <a href>:兼容旧 #linkTag 与普通超链接
|
||||
const hrefMatch =
|
||||
tag.match(/href\s*=\s*"([^"]*)"/i) ||
|
||||
tag.match(/href\s*=\s*'([^']*)'/i)
|
||||
const url = hrefMatch ? decodeEntities((hrefMatch[1] || '').trim()) : ''
|
||||
const anchorText = decodeEntities((tag.replace(/<[^>]+>/g, '') || '').trim())
|
||||
|
||||
// 旧格式:#标签仍走 linkTag 逻辑,复用现有 onLinkTagTap
|
||||
if (/^[##]/u.test(anchorText)) {
|
||||
const label = cleanSingleLineField(anchorText.replace(/^[##]/u, ''))
|
||||
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
|
||||
} else {
|
||||
// 普通链接:展示锚文本;无锚文本则回退展示 url
|
||||
segs.push({ type: 'link', text: anchorText || url, url })
|
||||
}
|
||||
|
||||
} else if (/^<video/i.test(tag)) {
|
||||
const rawSrc = extractVideoSrcFromTag(tag)
|
||||
|
||||
Reference in New Issue
Block a user