feat: 阅读页与章节预览 API;管理端内容页;book/h5_read;脚本与文档

- miniprogram: read 页与 member-detail/my;SOP 文档
- soul-api: chapter_preview、book/h5_read 调整;VIP 订单回填 SQL
- soul-admin: ContentPage、dist
- scripts: pull_from_baota;content_upload、gitignore、对话规则

Made-with: Cursor
This commit is contained in:
卡若
2026-03-26 20:08:43 +08:00
parent d6c8aabbe8
commit 6aa0d27da1
19 changed files with 1825 additions and 130 deletions

View File

@@ -14,6 +14,9 @@ const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 从「我的」登录成功后 reLaunch 回本页并自动继续链接流程 */
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false },
@@ -22,10 +25,48 @@ Page({
const sb = app.globalData.statusBarHeight || 44
const myId = app.globalData.userInfo?.id
const isOwnProfile = !!(options.id && myId && String(options.id) === String(myId))
if (options.resumeLink === '1') this._resumeLinkAfterLoad = true
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44, isOwnProfile })
if (options.id) this.loadMember(options.id)
},
/** 朋友圈单页等受限环境 */
_isSinglePageMode() {
try {
if (app.globalData.isSinglePageMode) return true
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') return true
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
if (launch && Number(launch.scene) === 1154) return true
} catch (e) {}
return false
},
/** 资料加载完成后:① resumeLink=1我的页登录回流② 本地存了待链接 memberId单页→完整版且已登录 */
_scheduleResumeLinkFlowIfNeeded() {
if (!this.data.member || !app.globalData.isLoggedIn) return
let trigger = false
if (this._resumeLinkAfterLoad) {
trigger = true
this._resumeLinkAfterLoad = false
} else {
try {
const rid = wx.getStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
if (rid != null && String(rid).trim() !== '' && String(rid) === String(this.data.member.id)) {
wx.removeStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
trigger = true
}
} catch (_) {}
}
if (!trigger) return
if (this._resumeLinkScheduled) return
this._resumeLinkScheduled = true
setTimeout(() => {
this._resumeLinkScheduled = false
this.startLinkFlow()
}, 400)
},
/** 本人名片:去完整编辑资料(单页) */
goMyProfileEdit() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
@@ -92,6 +133,7 @@ Page({
}),
loading: false
})
this._scheduleResumeLinkFlowIfNeeded()
return
}
} catch (e) {}
@@ -101,7 +143,11 @@ Page({
const res = await app.request({ url: `/api/miniprogram/vip/members?id=${id}`, silent: true })
if (res?.success && res.data) {
const d = Array.isArray(res.data) ? res.data[0] : res.data
if (d) { this.setData({ member: this.enrichAndFormat(d), loading: false }); return }
if (d) {
this.setData({ member: this.enrichAndFormat(d), loading: false })
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
@@ -125,11 +171,13 @@ Page({
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
this.setData({ loading: false })
this._resumeLinkAfterLoad = false
},
// 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
@@ -315,12 +363,41 @@ Page({
trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || ''))
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
// 第一步:仅说明原因;第二步:引导登录(完整版去「我的」;单页引导底部「前往小程序」)
wx.showModal({
title: `链接「${nickname}`,
content: '请先登录后再发起链接。',
confirmText: '去登录',
title: '提示',
content: `发起与「${nickname}」的链接前,需要先登录小程序账号。登录后智能助手与人工可协同协助您对接。`,
confirmText: '下一步',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
success: (r1) => {
if (!r1.confirm) return
const isSp = this._isSinglePageMode()
if (isSp) {
try {
wx.setStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY, String(member.id))
} catch (_) {}
wx.showModal({
title: '前往完整小程序',
content: '当前为预览模式。请轻触屏幕底部「前往小程序」进入完整版,在「我的」中登录;登录成功后将自动回到本页并继续链接流程。',
showCancel: false,
confirmText: '我知道了'
})
return
}
wx.showModal({
title: `链接「${nickname}`,
content: '是否前往「我的」页登录?',
confirmText: '去登录',
cancelText: '取消',
success: (r2) => {
if (!r2.confirm) return
try {
wx.setStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY, String(member.id))
} catch (_) {}
wx.switchTab({ url: '/pages/my/my' })
}
})
}
})
return
}

View File

@@ -871,6 +871,17 @@ Page({
this.initUserStatus()
this.setData({ showLoginModal: false })
wx.showToast({ title: '登录成功', icon: 'success' })
// 超级个体详情:未登录点链接 → 去登录 → 回详情页自动继续链接流程(与 member-detail LOGIN_RESUME_MEMBER_DETAIL_KEY 一致)
try {
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
const rid = wx.getStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
if (rid != null && String(rid).trim() !== '') {
wx.removeStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
wx.reLaunch({
url: '/pages/member-detail/member-detail?id=' + encodeURIComponent(String(rid).trim()) + '&resumeLink=1'
})
}
} catch (_) {}
},
// 点击菜单

View File

@@ -24,6 +24,28 @@ const soulBridge = require('../../utils/soulBridge.js')
const app = getApp()
const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 与后端 defaultReadPreviewUI / read_preview_ui 配置键一致;占位符 {percent} {price} 在拉章节后替换 */
const READ_UI_DEFAULTS = {
singlePageUnlockTitle: '解锁完整内容',
singlePagePayButtonText: '支付 ¥{price} 解锁全文',
singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。',
payTapModalTitle: '解锁说明',
payTapModalContent:
'全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
fullUnlockTitle: '解锁完整内容',
fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明',
fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读',
fullPaywallTip: '转发给需要的人,一起学习还能赚佣金',
notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文',
notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金',
shareTipLine: '好友经你分享购买,你可获得约 90% 收益',
momentsModalTitle: '分享到朋友圈',
momentsModalContent:
'已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
momentsClipboardFooter: '\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——',
timelineTitleSuffix: '(预览{percent}%',
}
/** 阅读页解析正文用:人物字典 + #标签(与 /config/read-extras 一致) */
function getContentParseConfig() {
const g = getApp().globalData || {}
@@ -186,6 +208,11 @@ Page({
readSinglePageMode: false,
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal
momentsPaywallExpanded: false,
// 未付费预览比例(与后端 unpaid_preview_percent / 章节 preview_percent 一致)
effectivePreviewPercent: 20,
// 付费墙 / 朋友圈文案(后端 read_preview_ui + 占位符已替换)
readUi: {},
},
_isLockedState(state) {
@@ -268,15 +295,61 @@ Page({
return !!app.globalData.isSinglePageMode
},
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
_formatPriceToken(n) {
const x = Number(n)
if (!isFinite(x)) return '1'
if (Math.abs(x - Math.round(x)) < 1e-9) return String(Math.round(x))
return String(Number(x.toFixed(2)))
},
/** 章节接口 meta预览比例 + 付费墙/朋友圈文案模板 */
_applyChapterMetaFromResponse(res) {
if (!res || typeof res !== 'object') return
const pct =
typeof res.unpaidPreviewPercent === 'number' ? res.unpaidPreviewPercent : 20
const priceNum =
res.price != null
? Number(res.price)
: this.data.section?.price != null
? Number(this.data.section.price)
: Number(this.data.sectionPrice || 1)
const priceTok = this._formatPriceToken(priceNum)
const base = { ...READ_UI_DEFAULTS }
if (res.readPreviewUi && typeof res.readPreviewUi === 'object') {
Object.keys(res.readPreviewUi).forEach((k) => {
const v = res.readPreviewUi[k]
if (typeof v === 'string' && v.trim()) base[k] = v.trim()
})
}
const filled = {}
Object.keys(base).forEach((k) => {
filled[k] = String(base[k] || '')
.replace(/\{percent\}/g, String(pct))
.replace(/\{price\}/g, priceTok)
})
this.setData({ effectivePreviewPercent: pct, readUi: filled })
},
/** 单页模式:点支付解锁 → 弹窗说明 → 展开底栏引导与箭头 */
onUnlockTapInSinglePage() {
trackClick('read', 'btn_click', '单页_解锁引导')
try {
wx.vibrateShort({ type: 'light' })
} catch (e) {}
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
const ui = this.data.readUi || {}
wx.showModal({
title: ui.payTapModalTitle || '解锁说明',
content:
ui.payTapModalContent ||
'请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
showCancel: false,
confirmText: '知道了',
success: () => {
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
},
})
},
onShow() {
@@ -529,6 +602,7 @@ Page({
if (!res || !res.content) {
res = await app.request({ url: this._getChapterUrl({ id }), silent: true })
}
this._applyChapterMetaFromResponse(res)
const section = {
id: res.id || id,
title: res.sectionTitle || res.title || this.getSectionTitle(id),
@@ -680,6 +754,7 @@ Page({
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML
async setChapterContent(res) {
this._applyChapterMetaFromResponse(res)
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
@@ -1068,14 +1143,12 @@ Page({
shareToMoments() {
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"')
.replace(/[#@]\S+/g, '')
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
const picked = sentences.slice(0, 5)
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#卡若创业派对 #真实商业故事`
const ui = this.data.readUi || {}
const paras = (this.data.previewParagraphs || []).filter(Boolean).join('\n\n')
const footer = ui.momentsClipboardFooter || ''
const copyText = paras
? `${title}\n\n${paras}${footer}`
: `${title}${footer || `\n\n—— 预览约 ${this.data.effectivePreviewPercent ?? 20}% ,搜「卡若创业派对」小程序阅读全文 ——`}`
wx.setClipboardData({
data: copyText,
success: () => {
@@ -1084,8 +1157,10 @@ Page({
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
title: ui.momentsModalTitle || '分享到朋友圈',
content:
ui.momentsModalContent ||
'已复制发圈文案。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -1099,15 +1174,18 @@ Page({
// 分享到朋友圈:带文章标题,过长时截断
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data
const { section, sectionId, sectionMid, chapterTitle, readUi } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const query = ref ? `${q}&ref=${ref}` : q
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: '卡若创业派对 - 真实商业故事'
return { title, query }
const base = articleTitle || '卡若创业派对 - 真实商业故事'
const suffix = (readUi && readUi.timelineTitleSuffix) || ''
let full = base + suffix
if (full.length > 30) {
full = full.slice(0, 30) + '…'
}
return { title: full, query }
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)

View File

@@ -106,7 +106,7 @@
</button>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 {{shareRate || 90}}% 收益</text>
<text class="share-tip-text">{{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}}</text>
</view>
</view>
@@ -132,27 +132,30 @@
<view class="fade-mask"></view>
</view>
<view class="preview-percent-row" wx:if="{{effectivePreviewPercent > 0}}">
<text class="preview-percent-badge">预览 {{effectivePreviewPercent}}%</text>
</view>
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」点后再展开极简说明 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">{{readSinglePageTitle}}</text>
<text class="paywall-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</text>
<text class="paywall-title">{{readUi.singlePageUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc" wx:if="{{readUi.fullUnlockDesc}}">{{readUi.fullUnlockDesc}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
<view class="purchase-btn purchase-section purchase-btn--fulltext" bindtap="onUnlockTapInSinglePage">
<text class="btn-label btn-label--block">{{readUi.singlePagePayButtonText || '支付解锁'}}</text>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readUi.singlePageExpandedHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
</block>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
<text class="paywall-desc">已阅读{{previewPercent}}%,登录并支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 后阅读全文</text>
<text class="paywall-desc">{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
@@ -162,7 +165,7 @@
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
</block>
</view>
@@ -216,26 +219,30 @@
<view class="fade-mask"></view>
</view>
<view class="preview-percent-row" wx:if="{{effectivePreviewPercent > 0}}">
<text class="preview-percent-badge">预览 {{effectivePreviewPercent}}%</text>
</view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">{{readSinglePageTitle}}</text>
<text class="paywall-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</text>
<text class="paywall-title">{{readUi.singlePageUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc" wx:if="{{readUi.fullUnlockDesc}}">{{readUi.fullUnlockDesc}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
<view class="purchase-btn purchase-section purchase-btn--fulltext" bindtap="onUnlockTapInSinglePage">
<text class="btn-label btn-label--block">{{readUi.singlePagePayButtonText || '支付解锁'}}</text>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readUi.singlePageExpandedHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
</block>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读{{previewPercent}}%,购买后继续阅读</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc">{{readUi.fullUnlockDesc || '可先上滑阅读预览'}}</text>
<text class="paywall-subdesc" wx:if="{{readUi.fullLockedProgressText}}">{{readUi.fullLockedProgressText}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
@@ -253,7 +260,7 @@
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>

View File

@@ -327,6 +327,41 @@
margin-bottom: 48rpx;
}
.paywall-subdesc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.52);
text-align: center;
display: block;
margin-bottom: 36rpx;
line-height: 1.5;
padding: 0 16rpx;
}
.preview-percent-row {
margin: 16rpx 0 8rpx;
display: flex;
justify-content: center;
}
.preview-percent-badge {
font-size: 24rpx;
color: #00CED1;
border: 2rpx solid rgba(0, 206, 209, 0.35);
border-radius: 999rpx;
padding: 8rpx 24rpx;
background: rgba(0, 206, 209, 0.08);
}
.purchase-btn--fulltext {
justify-content: center;
}
.purchase-btn--fulltext .btn-label--block {
flex: 1;
text-align: center;
width: 100%;
}
/* ===== 购买选项 ===== */
.purchase-options {
display: flex;