Merge branch 'yongpxu-dev' into devlop

# Conflicts:
#	miniprogram/pages/read/read.wxml   resolved by yongpxu-dev version
#	miniprogram/utils/contentParser.js   resolved by yongpxu-dev version
This commit is contained in:
乘风
2026-04-20 11:27:42 +08:00
61 changed files with 1336 additions and 1548 deletions

View File

@@ -1026,8 +1026,11 @@ App({
},
success: (res) => {
const data = res.data
const rejectWithBody = (message) => {
reject(new Error(message))
/** 业务失败时挂 err.response供页面读取 needBindWechat / errorCode 等(与 miniprogram-dev Skill 一致) */
const rejectWithBody = (message, body) => {
const err = new Error(message)
if (body && typeof body === 'object') err.response = body
reject(err)
}
if (res.statusCode === 200) {
if (data && data.success === false) {
@@ -1036,7 +1039,7 @@ App({
this.logout()
}
showError(msg)
rejectWithBody(msg)
rejectWithBody(msg, data)
return
}
resolve(data)
@@ -1050,7 +1053,7 @@ App({
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
rejectWithBody(msg)
rejectWithBody(msg, data && typeof data === 'object' ? data : undefined)
},
fail: (err) => {
const msg = (err && err.errMsg)
@@ -1090,8 +1093,8 @@ App({
}
const promise = this._requestOnce(url, options, silent).catch((err) => {
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
if (err && err.message) throw err
throw new Error('网络异常,请重试')
})
if (method === 'GET') {

View File

@@ -692,7 +692,11 @@ Page({
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
const resp = e && e.response
wx.showToast({
title: (resp && (resp.message || resp.error)) || (e && e.message) || '网络异常,请重试',
icon: 'none',
})
} finally {
this.setData({ isJoining: false })
}

View File

@@ -3,6 +3,7 @@
* 联系导师按钮 → 弹出 v2 弹窗(选择咨询项目)
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge')
Page({
data: {
@@ -79,6 +80,11 @@ Page({
this.setData({ selectedType: item.type, selectedAmount: item.price })
},
_consultationTypeLabel(t) {
const m = { single: '单次咨询', half_year: '半年咨询', year: '年度咨询' }
return m[t] || '咨询'
},
async onConfirmConsult() {
const { mentor, selectedType } = this.data
const userId = app.globalData.userInfo?.id
@@ -94,22 +100,57 @@ Page({
method: 'POST',
data: { userId, consultationType: selectedType },
})
if (res?.success && res.data) {
this.setData({ showConsultModal: false, creating: false })
wx.showToast({ title: '预约创建成功', icon: 'success' })
// TODO: 调起支付 productType: mentor_consultation, productId: res.data.id
if (!res?.success || !res.data) {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
return
}
this.setData({ showConsultModal: false })
const consult = res.data
const openId = await app.ensurePayOpenId()
if (!openId) {
wx.showModal({
title: '预约成功',
content: '请联系客服完成后续对接',
title: '无法拉起支付',
content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。',
showCancel: false,
})
} else {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
return
}
wx.showLoading({ title: '正在拉起支付...', mask: true })
try {
const label = this._consultationTypeLabel(selectedType)
const payRes = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
openId,
userId,
productType: 'mentor_consultation',
productId: String(consult.id),
amount: Number(consult.amount),
description: `导师咨询 · ${(mentor && mentor.name) || '导师'}${label}`,
referralCode: soulBridge.getReferralCodeForPay(app) || undefined,
},
})
if (payRes?.success && payRes.data?.payParams) {
try {
await soulBridge.requestWxJsapiPayment(payRes.data.payParams)
await soulBridge.syncOrderStatusQuery(app, payRes.data.orderSn)
wx.showToast({ title: '支付成功', icon: 'success' })
} catch (pe) {
const msg = pe && pe.errMsg ? String(pe.errMsg) : ''
if (msg.indexOf('cancel') !== -1) wx.showToast({ title: '支付取消', icon: 'none' })
else wx.showToast({ title: '支付未完成', icon: 'none' })
}
} else {
wx.showToast({ title: payRes?.error || '获取支付参数失败', icon: 'none' })
}
} finally {
wx.hideLoading()
}
} catch (e) {
wx.showToast({ title: '创建失败', icon: 'none' })
wx.showToast({ title: (e && e.message) || '操作失败', icon: 'none' })
} finally {
this.setData({ creating: false })
}
this.setData({ creating: false })
},
goBack() {

View File

@@ -10,6 +10,11 @@ const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { appendQueryToPath, ensureLoggedInUserPhone } = require('../../utils/linkedMiniprogramNavigate.js')
/** MBTI 测试小程序(写死;须与 app.json navigateToMiniProgramAppIdList 一致) */
const MBTI_MP_APP_ID = 'wx3d15ed02e98b04e3'
const MBTI_MP_PATH = 'pages/index/index'
Page({
data: {
@@ -95,6 +100,8 @@ Page({
mpUiVipLabelGuest: '成为会员',
mpUiReadStatLabel: '已读章节',
mpUiRecentTitle: '最近阅读',
/** 资料区 MBTI 标签文案已测为四字母大写未测为「MBTI」 */
mbtiTagText: '',
},
onLoad() {
@@ -147,6 +154,12 @@ Page({
})
},
_formatMbtiTagText(user) {
const raw = String(user && user.mbti != null ? user.mbti : '').trim()
if (!raw) return 'MBTI'
return raw.toUpperCase()
},
async _refreshMyAvatarDisplay(safeUser) {
if (!safeUser || !app.globalData.isLoggedIn) return
try {
@@ -198,6 +211,7 @@ Page({
isLoggedIn: true,
userInfo: safeUser,
profileAvatarDisplay: '',
mbtiTagText: this._formatMbtiTagText(safeUser),
userIdShort,
userWechat,
readCount: 0,
@@ -227,6 +241,7 @@ Page({
isLoggedIn: false,
userInfo: null,
profileAvatarDisplay: '',
mbtiTagText: '',
userIdShort: '',
readCount: guestReadCount,
readCountText: formatStatNum(guestReadCount),
@@ -1065,6 +1080,26 @@ Page({
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` })
},
/** MBTI 测试小程序:写死 appId + pathpath 上带 phone有则传与阅读页一致 */
async onMbtiTagTap() {
trackClick('my', 'btn_click', 'MBTI标签')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const phone = await ensureLoggedInUserPhone()
let path = MBTI_MP_PATH.trim().replace(/^\/+/, '')
if (phone) path = appendQueryToPath(path, 'phone', phone)
wx.navigateToMiniProgram({
appId: MBTI_MP_APP_ID,
path,
envVersion: 'release',
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
},
goToProfileEdit() {
trackClick('my', 'nav_click', '资料编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }

View File

@@ -36,6 +36,7 @@
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
<view class="profile-mbti-tag" catchtap="onMbtiTagTap">{{mbtiTagText}}</view>
</view>
</view>
<view class="profile-settings-hit" catchtap="goToProfileEdit" hover-class="profile-settings-hit-active" aria-label="编辑资料">

View File

@@ -105,6 +105,26 @@
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
}
.profile-action-btn:active { opacity: 0.75; }
/* MBTI高亮胶囊实心青渐变 + 白字 + 外发光),与描边按钮区分 */
.profile-mbti-tag {
padding: 12rpx 26rpx;
border-radius: 40rpx;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 0.06em;
color: #fff;
background: linear-gradient(135deg, #5eead4 0%, #4FD1C5 45%, #2dd4bf 100%);
border: 1rpx solid rgba(255, 255, 255, 0.35);
box-shadow:
0 4rpx 16rpx rgba(79, 209, 197, 0.45),
0 0 0 1rpx rgba(79, 209, 197, 0.25) inset;
white-space: nowrap;
flex-shrink: 0;
}
.profile-mbti-tag:active {
opacity: 0.88;
transform: scale(0.98);
}
.user-wechat { font-size: 26rpx; color: #6B7280; }
.super-card-entry {
position: relative;

View File

@@ -79,13 +79,13 @@
<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 object-fit="contain"></video>
<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" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video>
<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>
@@ -157,8 +157,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<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 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" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video></block></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>
</block>
</view>
<view class="fade-mask"></view>
@@ -250,8 +250,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<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 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" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video></block></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>
</block>
</view>
<view class="fade-mask"></view>

View File

@@ -290,6 +290,22 @@
display: block;
}
.content-video-wrap {
width: 100%;
margin: 24rpx 0;
}
.content-video {
width: 100%;
display: block;
border-radius: 12rpx;
background: #111;
}
.content-video--inline {
margin: 24rpx 0;
}
/* 正文内表格 */
.table-scroll {
margin: 24rpx 0 36rpx;

View File

@@ -7,7 +7,7 @@
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap外链→link-preview、小程序→navigateToMiniProgram
* { type: 'image', src, alt } — 图片
* { type: 'video', src } — 正文内嵌视频(管理端 rich-video-wrap / <video>
* { type: 'video', src } — 内嵌视频(管理端 rich-video-wrap / <video>
*/
/** 判断内容是否为 HTML */
@@ -57,6 +57,24 @@ function extractImgSrcFromTag(tag) {
return ''
}
/** 从 <video ...> 或包含 video 的 HTML 片段取出首个 src */
function extractVideoSrcFromTag(tag) {
const vm = tag.match(/<video[^>]*>/i)
const chunk = vm ? vm[0] : tag
return extractImgSrcFromTag(chunk)
}
function pushResolvedVideoSrc(videos, rawSrc, config) {
if (!rawSrc || typeof rawSrc !== 'string') return
const decoded = decodeEntities(rawSrc.trim())
if (!decoded) return
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
videos.push({ src })
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
@@ -82,7 +100,7 @@ function stripTrailingAtForMention(before) {
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
* 处理内联元素mention / linkTag(span) / linkTag(a) / img / video
*/
function parseBlockToSegments(block, config) {
const segs = []
@@ -93,7 +111,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>|<img[^>]*\/?>/gi
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
let lastEnd = 0
let m
@@ -151,6 +169,16 @@ function parseBlockToSegments(block, config) {
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
} else if (/^<video/i.test(tag)) {
const rawSrc = extractVideoSrcFromTag(tag)
if (rawSrc) {
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
segs.push({ type: 'video', src })
}
} else if (/^<img /i.test(tag)) {
// 图片src 可能为相对路径,需结合 config.assetBase
const rawSrc = extractImgSrcFromTag(tag)
@@ -234,6 +262,33 @@ function parseHtmlToSegments(html, config) {
return '\n__TABLE_' + idx + '__\n'
})
// 0.5 管理端 TipTapdiv.rich-video-wrap > video +可选caption须在剥离 div 前整体替换
const videos = []
const videoWrapRe =
/<div[^>]*(?:class="[^"]*rich-video-wrap[^"]*"|class='[^']*rich-video-wrap[^']*')[^>]*>\s*<video[^>]*>\s*<\/video>\s*(?:<div[^>]*(?:class="[^"]*rich-video-caption[^"]*"|class='[^']*rich-video-caption[^']*')[^>]*>[\s\S]*?<\/div>)?\s*<\/div>/gi
text = text.replace(videoWrapRe, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
// 未包在 rich-video-wrap 内的裸 <video>(兼容粘贴或其它导出)
text = text.replace(/<video[^>]*\/>/gi, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
text = text.replace(/<video[^>]*>\s*<\/video>/gi, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
// 1. 提取 <h2>/<h3> → heading 占位
const headings = []
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
@@ -250,24 +305,6 @@ function parseHtmlToSegments(html, config) {
return '\n__Q_' + idx + '__\n'
})
// 2.5 内嵌视频去掉管理端「rich-video-caption」说明仅编辑器用整段 <video> 抽成占位
// 须在列表拆解之前执行,否则 li/段落内 video 会在 parseBlockToSegments 里被 strip 成纯文本
const videos = []
text = text.replace(/<div[^>]*\brich-video-caption\b[^>]*>[\s\S]*?<\/div>/gi, '')
text = text.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, function (match) {
const sm = match.match(/\bsrc\s*=\s*"([^"]*)"/i) || match.match(/\bsrc\s*=\s*'([^']*)'/i)
const rawSrc = sm ? String(sm[1] || '').trim() : ''
if (!rawSrc) return match
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
const idx = videos.length
videos.push({ src })
return '\n__VIDEO_' + idx + '__\n'
})
// 3. 列表 → listItem 占位(保留 ordered 标记)
var olDepth = 0
var olCounter = 0
@@ -278,11 +315,6 @@ function parseHtmlToSegments(html, config) {
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
var cleaned = inner.replace(/<p[^>]*>/gi, '').replace(/<\/p>/gi, '').trim()
if (!cleaned) return '\n'
// 列表项内仅有视频占位时,提升为独立视频块(避免 __VIDEO_n__ 当正文展示)
var onlyVid = cleaned.match(/^__VIDEO_(\d+)__$/)
if (onlyVid) {
return '\n__VIDEO_' + onlyVid[1] + '__\n'
}
if (olDepth > 0) {
olCounter++
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
@@ -316,13 +348,13 @@ function parseHtmlToSegments(html, config) {
continue
}
// video
// video(整块占位)
var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
if (vidM) {
var vid = videos[parseInt(vidM[1], 10)]
if (vid && vid.src) {
var v = videos[parseInt(vidM[1], 10)]
if (v && v.src) {
lines.push('')
segments.push([{ type: 'video', src: vid.src }])
segments.push([{ type: 'video', src: v.src }])
}
continue
}
@@ -380,7 +412,10 @@ function parseHtmlToSegments(html, config) {
var blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
if (
blockSegs.length === 1 &&
(blockSegs[0].type === 'image' || blockSegs[0].type === 'video')
) {
lines.push('')
segments.push(blockSegs)
continue

View File

@@ -0,0 +1,191 @@
/**
* Soul 创业派对 - 关联小程序跳转(与阅读页 onLinkTagTap miniprogram 分支一致)
*/
const app = getApp()
function normalizeLinkTagLabel(raw) {
return String(raw || '')
.replace(/^[#\s\u00a0\u200b\u3000]+/u, '')
.replace(/[\s\u00a0\u200b\u3000]+$/u, '')
.trim()
.toLowerCase()
}
function resolveLinkTagByLabel(label) {
const normalized = normalizeLinkTagLabel(label)
if (!normalized) return null
const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
for (const t of tags) {
if (!t) continue
const candidates = [t.label]
if (typeof t.aliases === 'string' && t.aliases.trim()) {
candidates.push(...t.aliases.split(','))
}
for (const c of candidates) {
if (normalizeLinkTagLabel(c) === normalized) return t
}
}
return null
}
function pickLinkTagField(tag, keys, defaultValue = '') {
if (!tag || typeof tag !== 'object') return defaultValue
for (const key of keys) {
const v = tag[key]
if (v == null) continue
const s = String(v).trim()
if (s) return s
}
return defaultValue
}
function parseDatasetBool(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
return s === '1' || s === 'true' || s === 'yes' || s === 'on'
}
function normalizeQueryKey(raw, defaultKey = 'phone') {
const s = String(raw || '').trim()
if (!s) return defaultKey
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(s)) return defaultKey
return s
}
/** 跳转其他小程序时在 path 上追加/覆盖 query保留已有 ?…&…,避免重复同名 key */
function appendQueryToPath(path, key, value) {
if (value == null || value === '') return (path || '').trim()
const base = (path || '').trim()
const hashIdx = base.indexOf('#')
const hashPart = hashIdx >= 0 ? base.slice(hashIdx) : ''
const noHash = hashIdx >= 0 ? base.slice(0, hashIdx) : base
const qIdx = noHash.indexOf('?')
const pathname = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash
const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : ''
const params = []
if (query) {
query.split('&').forEach((pair) => {
if (!pair) return
const [k = '', v = ''] = pair.split('=')
const dk = decodeURIComponent(k || '')
if (dk && dk !== key) params.push(`${encodeURIComponent(dk)}=${v}`)
})
}
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
return `${pathname}${params.length ? '?' + params.join('&') : ''}${hashPart}`
}
function getLoggedInUserPhone() {
const u = app.globalData.userInfo || {}
return (u.phone || u.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
}
async function ensureLoggedInUserPhone() {
let phone = getLoggedInUserPhone()
if (phone) return phone
try {
const profileRes = await app.request({ url: '/api/miniprogram/user/profile', silent: true, timeout: 3000 })
const p = (profileRes && profileRes.data) ? profileRes.data : {}
phone = String(p.phone || p.phoneNumber || '').trim().replace(/\s/g, '')
if (phone) {
const merged = { ...(app.globalData.userInfo || {}), ...p, phone }
app.globalData.userInfo = merged
try {
wx.setStorageSync('userInfo', merged)
wx.setStorageSync('user_phone', phone)
} catch (_) {}
}
} catch (e) {}
return phone
}
function resolveMiniprogramLinkFromTag(tag) {
if (!tag || typeof tag !== 'object') return null
const tagType = pickLinkTagField(tag, ['type', 'tagType'], '').toLowerCase()
if (tagType !== 'miniprogram') return null
const mpKey = pickLinkTagField(tag, ['mpKey', 'mp_key', 'appId', 'app_id'], '')
const pagePath = pickLinkTagField(tag, ['pagePath', 'page_path'], '')
let passPhone = true
const hasCachedPassPhone = !(tag.passPhone == null || tag.passPhone === '')
if (hasCachedPassPhone) passPhone = parseDatasetBool(tag.passPhone)
const phoneParamName = normalizeQueryKey(tag.phoneParamName, 'phone')
return { mpKey, pagePath, passPhone, phoneParamName }
}
/**
* @param {{ mpKey: string, pagePath?: string, passPhone?: boolean, phoneParamName?: string }} opts
*/
async function navigateLinkedMiniprogram(opts) {
const mpKey = String(opts.mpKey || '').trim()
if (!mpKey) {
wx.showToast({ title: '未配置小程序', icon: 'none' })
return
}
await app.getReadExtras()
const linked = (app.globalData.linkedMiniprograms || []).find((m) => m.key === mpKey)
if (!linked || !linked.appId) {
wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
return
}
let targetPath = (opts.pagePath != null && String(opts.pagePath).trim())
? String(opts.pagePath).trim()
: (linked.path || '')
const phone = await ensureLoggedInUserPhone()
const passPhone = opts.passPhone !== false
const phoneParamName = normalizeQueryKey(opts.phoneParamName, 'phone')
if (passPhone && phone) {
targetPath = appendQueryToPath(targetPath, phoneParamName, phone)
}
wx.navigateToMiniProgram({
appId: linked.appId,
path: targetPath || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
}
async function navigateMiniprogramFromLinkTagConfig(tag) {
const r = resolveMiniprogramLinkFromTag(tag)
if (!r || !r.mpKey) return false
await navigateLinkedMiniprogram(r)
return true
}
/**
* @param {Record<string, unknown>} myPageUi mp_config.mpUi.myPage
*/
function findMbtiLinkTagInConfig(myPageUi) {
const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
const preferred = myPageUi && String(myPageUi.mbtiLinkLabel || '').trim()
if (preferred) {
const t = resolveLinkTagByLabel(preferred)
if (t) {
const r = resolveMiniprogramLinkFromTag(t)
if (r && r.mpKey) return t
}
}
for (const t of tags) {
const r = resolveMiniprogramLinkFromTag(t)
if (!r || !r.mpKey) continue
const lbl = pickLinkTagField(t, ['label'], '').toLowerCase()
const key = String(r.mpKey || '').toLowerCase()
const path = String(r.pagePath || '').toLowerCase()
if (lbl.includes('mbti') || key.includes('mbti') || path.includes('mbti')) return t
}
return null
}
module.exports = {
appendQueryToPath,
getLoggedInUserPhone,
ensureLoggedInUserPhone,
navigateLinkedMiniprogram,
navigateMiniprogramFromLinkTagConfig,
findMbtiLinkTagInConfig,
resolveLinkTagByLabel,
resolveMiniprogramLinkFromTag,
normalizeQueryKey,
}