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:
@@ -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') {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 + path,path 上带 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 }
|
||||
|
||||
@@ -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="编辑资料">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 管理端 TipTap:div.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
|
||||
|
||||
191
miniprogram/utils/linkedMiniprogramNavigate.js
Normal file
191
miniprogram/utils/linkedMiniprogramNavigate.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user