Files
soul-yongping/miniprogram/pages/read/read.js
Alex-larget db4b4b8b87 Add linked mini program functionality and enhance link tag handling
- Introduced `navigateToMiniProgramAppIdList` in app.json for mini program navigation.
- Updated link tag handling in the read page to support mini program keys and app IDs.
- Enhanced content parsing to include app ID and mini program key in link tags.
- Added linked mini programs management in the admin panel with API endpoints for CRUD operations.
- Improved UI for selecting linked mini programs in the content creation page.
2026-03-12 16:51:12 +08:00

1459 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Soul创业派对 - 阅读页(标准流程版)
* 开发: 卡若
* 技术支持: 存客宝
*
* 更新: 2026-02-04
* - 引入权限管理器chapterAccessManager统一权限判断
* - 引入阅读追踪器readingTracker记录阅读进度、时长、是否读完
* - 使用状态机accessState规范权限流转
* - 异常统一保守处理,避免误解锁
*
* 更新: 正文 @某人TipTap HTML <span data-type="mention">
* - contentSegments 解析每行mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
*/
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const app = getApp()
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 章节信息
sectionId: '',
section: null,
partTitle: '',
chapterTitle: '',
// 内容
content: '',
previewContent: '',
contentParagraphs: [],
contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}]
previewParagraphs: [],
loading: true,
// 【新增】权限状态机(替代 canAccess
// unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误
accessState: 'unknown',
// 用户状态
isLoggedIn: false,
hasFullBook: false,
canAccess: false, // 保留兼容性,从 accessState 派生
purchasedCount: 0,
// 阅读进度
readingProgress: 0,
showPaywall: false,
// 上一篇/下一篇
prevSection: null,
nextSection: null,
// 价格
sectionPrice: 1,
fullBookPrice: 9.9,
totalSections: 62,
// 弹窗
showShareModal: false,
showLoginModal: false,
agreeProtocol: false,
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
// 章节 mid扫码/海报分享用,便于分享 path 带 mid
sectionMid: null
},
async onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
// 预加载 linkTags、linkedMiniprograms供 onLinkTagTap 用密钥查 appId
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms) {
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
if (cfg) {
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
}
}).catch(() => {})
}
// 支持 scene扫码、mid、id、ref
const sceneStr = (options && options.scene) || ''
const parsed = parseScene(sceneStr)
const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0)
let id = options.id || parsed.id || app.globalData.initialSectionId
const ref = options.ref || parsed.ref
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
console.log("页面:",mid);
// mid 有值但无 id 时,从 bookData 或 API 解析 id
if (mid && !id) {
const bookData = app.globalData.bookData || []
const ch = bookData.find(c => c.mid == mid || (c.mid && Number(c.mid) === Number(mid)))
if (ch?.id) {
id = ch.id
} else {
try {
const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}`
const uid = app.globalData.userInfo?.id
const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true })
if (chRes && chRes.id) id = chRes.id
} catch (e) {
console.warn('[Read] by-mid 解析失败:', e)
}
}
}
if (!id) {
wx.showToast({ title: '章节参数缺失', icon: 'none' })
this.setData({ accessState: 'error', loading: false })
return
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
sectionId: id,
sectionMid: mid || null,
loading: true,
accessState: 'unknown'
})
if (ref) {
console.log('[Read] 检测到推荐码:', ref)
wx.setStorageSync('referral_code', ref)
app.handleReferralCode({ query: { ref } })
}
try {
const config = await accessManager.fetchLatestConfig()
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 统一:先拉章节数据,用 isFree/price===0 判断免费
const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true })
const accessState = await accessManager.determineAccessState(id, chapterRes)
const canAccess = accessManager.canAccessFullContent(accessState)
this.setData({
accessState,
canAccess,
isLoggedIn: !!app.globalData.userInfo?.id,
showPaywall: !canAccess
})
// 加载内容(复用已拉取的章节数据,避免二次请求)
await this.loadContent(id, accessState, chapterRes)
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
}
// 5. 加载导航
this.loadNavigation(id)
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
this.setData({ accessState: 'error', loading: false })
} finally {
this.setData({ loading: false })
}
},
// 从后端加载免费章节配置
onPageScroll(e) {
// 只在有权限时追踪阅读进度
if (!accessManager.canAccessFullContent(this.data.accessState)) {
return
}
// 获取滚动信息并更新追踪器
const query = wx.createSelectorQuery()
query.select('.page').boundingClientRect()
query.selectViewport().scrollOffset()
query.exec((res) => {
if (res[0] && res[1]) {
const scrollInfo = {
scrollTop: res[1].scrollTop,
scrollHeight: res[0].height,
clientHeight: res[1].height
}
// 计算进度条显示(用于 UI
const totalScrollable = scrollInfo.scrollHeight - scrollInfo.clientHeight
const progress = totalScrollable > 0
? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100)
: 0
this.setData({ readingProgress: progress })
// 更新阅读追踪器(记录最大进度、判断是否读完)
readingTracker.updateProgress(scrollInfo)
}
})
},
// 加载章节内容:优先复用 prefetchedChapter 避免二次请求,失败时降级本地缓存
async loadContent(id, accessState, prefetchedChapter) {
const cacheKey = `chapter_${id}`
try {
const sectionPrice = this.data.sectionPrice ?? 1
let res = prefetchedChapter
if (!res || !res.content) {
res = await app.request({ url: this._getChapterUrl({ id }), silent: true })
}
const section = {
id: res.id || id,
title: res.sectionTitle || res.title || this.getSectionTitle(id),
isFree: res.isFree === true || (res.price !== undefined && res.price === 0),
price: res.price ?? sectionPrice
}
this.setData({ section })
// 已解锁用 data.content完整内容未解锁用 content预览先 determineAccessState 再 loadContent 保证顺序正确
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
if (res && displayContent) {
const { lines, segments } = contentParser.parseContent(displayContent)
const previewCount = Math.ceil(lines.length * 0.2)
const updates = {
content: displayContent,
contentParagraphs: lines,
contentSegments: segments,
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
}
if (res.mid) updates.sectionMid = res.mid
this.setData(updates)
// 写入本地缓存(存 displayContent供离线/重试降级使用)
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
}
} catch (e) {
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
const { lines, segments } = contentParser.parseContent(cached.content)
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
content: cached.content,
contentParagraphs: lines,
contentSegments: segments,
previewParagraphs: lines.slice(0, previewCount),
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
console.log('[Read] 从本地缓存加载成功')
return
}
} catch (cacheErr) {
console.warn('[Read] 本地缓存也失败:', cacheErr)
}
throw e
}
},
// 获取章节信息
getSectionInfo(id) {
// 特殊章节
if (id === 'preface') {
return { id: 'preface', title: '为什么我每天早上6点在Soul开播?', isFree: true, price: 0 }
}
if (id === 'epilogue') {
return { id: 'epilogue', title: '这本书的真实目的', isFree: true, price: 0 }
}
if (id.startsWith('appendix')) {
const appendixTitles = {
'appendix-1': 'Soul派对房精选对话',
'appendix-2': '创业者自检清单',
'appendix-3': '本书提到的工具和资源'
}
return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 }
}
// 普通章节
return {
id: id,
title: this.getSectionTitle(id),
isFree: id === '1.1',
price: 1
}
},
// 获取章节标题
getSectionTitle(id) {
const titles = {
'1.1': '荷包:电动车出租的被动收入模式',
'1.2': '老墨:资源整合高手的社交方法',
'1.3': '笑声背后的MBTI',
'1.4': '人性的三角结构:利益、情感、价值观',
'1.5': '沟通差的问题:为什么你说的别人听不懂',
'2.1': '相亲故事:你以为找的是人,实际是在找模式',
'2.2': '找工作迷茫者:为什么简历解决不了人生',
'2.3': '撸运费险:小钱困住大脑的真实心理',
'2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力',
'2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒',
'3.1': '3000万流水如何跑出来(退税模式解析)',
'8.1': '流量杠杆:抖音、Soul、飞书',
'9.14': '大健康私域一个月150万的70后'
}
return titles[id] || `章节 ${id}`
},
// 根据 id/mid 构造章节接口路径(优先使用 mid。必须带 userId 才能让后端正确判断付费用户并返回完整内容
_getChapterUrl(params = {}) {
const { id, mid } = params
const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid
let url
if (finalMid) {
url = `/api/miniprogram/book/chapter/by-mid/${finalMid}`
} else {
const finalId = id || this.data.sectionId
url = `/api/miniprogram/book/chapter/${finalId}`
}
const userId = app.globalData.userInfo?.id
if (userId) url += (url.includes('?') ? '&' : '?') + 'userId=' + encodeURIComponent(userId)
return url
},
// 带超时的章节请求
fetchChapterWithTimeout(id, timeout = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('请求超时'))
}, timeout)
app.request(this._getChapterUrl({ id }))
.then(res => {
clearTimeout(timer)
resolve(res)
})
.catch(err => {
clearTimeout(timer)
reject(err)
})
})
},
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML
setChapterContent(res) {
const { lines, segments } = contentParser.parseContent(res.content)
const previewCount = Math.ceil(lines.length * 0.2)
const sectionPrice = this.data.sectionPrice ?? 1
const sectionTitle = (res.sectionTitle || res.title || '').trim()
this.setData({
// 文章详情标题:只使用后端提供的 sectionTitle不再拼接其他本地标题信息
section: {
id: res.id || this.data.sectionId,
title: sectionTitle,
isFree: res.isFree === true || (res.price !== undefined && res.price === 0),
price: res.price ?? sectionPrice
},
content: res.content,
previewContent: lines.slice(0, previewCount).join('\n'),
contentParagraphs: lines,
contentSegments: segments,
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
chapterTitle: sectionTitle
})
},
// 静默刷新(后台更新缓存)
async silentRefresh(id) {
try {
const res = await this.fetchChapterWithTimeout(id, 10000)
if (res && res.content) {
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 后台缓存更新成功:', id)
}
} catch (e) {
// 静默失败不处理
}
},
// 重试加载
retryLoadContent(id, maxRetries, currentRetry = 0) {
if (currentRetry >= maxRetries) {
this.setData({
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
contentSegments: contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments,
previewParagraphs: ['内容加载失败']
})
return
}
setTimeout(async () => {
try {
const res = await this.fetchChapterWithTimeout(id, 8000)
if (res && res.content) {
this.setChapterContent(res)
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
return
}
} catch (e) {
console.warn('[Read] 重试失败,继续重试:', currentRetry + 1)
}
this.retryLoadContent(id, maxRetries, currentRetry + 1)
}, 2000 * (currentRetry + 1))
},
// 加载导航:基于后端章节顺序计算上一篇/下一篇
async loadNavigation(id) {
try {
// 优先使用全局缓存的 bookData
let chapters = app.globalData.bookData || []
if (!chapters || !Array.isArray(chapters) || chapters.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
chapters = (res && (res.data || res.chapters)) || []
}
if (!chapters || chapters.length === 0) {
this.setData({ prevSection: null, nextSection: null })
return
}
// 过滤掉没有 id 的记录,并按 sort_order + id 排序
const ordered = chapters
.filter(c => c.id)
.sort((a, b) => {
const soA = typeof a.sort_order === 'number' ? a.sort_order : (typeof a.sortOrder === 'number' ? a.sortOrder : 0)
const soB = typeof b.sort_order === 'number' ? b.sort_order : (typeof b.sortOrder === 'number' ? b.sortOrder : 0)
if (soA !== soB) return soA - soB
return String(a.id).localeCompare(String(b.id), 'zh-Hans-CN')
})
const index = ordered.findIndex(c => String(c.id) === String(id))
const prev = index > 0 ? ordered[index - 1] : null
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null
this.setData({
prevSection: prev ? {
id: prev.id,
mid: prev.mid ?? prev.MID ?? null,
title: prev.section_title || prev.sectionTitle || prev.title || this.getSectionTitle(prev.id),
} : null,
nextSection: next ? {
id: next.id,
mid: next.mid ?? next.MID ?? null,
title: next.section_title || next.sectionTitle || next.title || this.getSectionTitle(next.id),
} : null,
})
} catch (e) {
console.warn('[Read] loadNavigation failed:', e)
this.setData({ prevSection: null, nextSection: null })
}
},
// 返回(从分享进入无栈时回首页)
goBack() {
getApp().goBackOrToHome()
},
// 点击正文中的 #链接标签:小程序内页/预览页/唤醒其他小程序
onLinkTagTap(e) {
let url = (e.currentTarget.dataset.url || '').trim()
const label = (e.currentTarget.dataset.label || '').trim()
let tagType = (e.currentTarget.dataset.tagType || '').trim()
let pagePath = (e.currentTarget.dataset.pagePath || '').trim()
let mpKey = (e.currentTarget.dataset.mpKey || '').trim() || (e.currentTarget.dataset.appId || '').trim()
// 旧格式(<a href>tagType 为空 → 按 label 从缓存 linkTags 补充类型信息
if (!tagType && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) {
tagType = cached.type || 'url'
pagePath = cached.pagePath || ''
if (!url) url = cached.url || ''
if (cached.mpKey) mpKey = cached.mpKey
}
}
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
if (tagType === 'ckb') {
// 触发通用加好友(无特定 personId使用全局 CKB Key
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
return
}
// 小程序类型:用密钥查 linkedMiniprograms 得 appId再唤醒需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
if (tagType === 'miniprogram') {
if (!mpKey && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) mpKey = cached.mpKey || cached.appId || ''
}
const linked = (app.globalData.linkedMiniprograms || []).find(m => (m.key || m.id) === mpKey)
if (linked && linked.appId) {
wx.navigateToMiniProgram({
appId: linked.appId,
path: pagePath || linked.path || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
return
}
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
}
// 小程序内部路径pagePath 或 url 以 /pages/ 开头)
const internalPath = pagePath || (url.startsWith('/pages/') ? url : '')
if (internalPath) {
wx.navigateTo({ url: internalPath, fail: () => wx.switchTab({ url: internalPath }) })
return
}
// 外部 URL跳转到内置预览页由 web-view 打开
if (url) {
const encodedUrl = encodeURIComponent(url)
const encodedTitle = encodeURIComponent(label || '链接预览')
wx.navigateTo({
url: `/pages/link-preview/link-preview?url=${encodedUrl}&title=${encodedTitle}`,
})
return
}
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
},
// 点击正文图片 → 全屏预览
onImageTap(e) {
const src = e.currentTarget.dataset.src
if (!src) return
wx.previewImage({ current: src, urls: [src] })
},
// 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资
onMentionTap(e) {
const userId = e.currentTarget.dataset.userId
const nickname = (e.currentTarget.dataset.nickname || '').trim() || 'TA'
if (!userId) return
wx.showModal({
title: '添加好友',
content: `是否添加 @${nickname} `,
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (!res.confirm) return
this._doMentionAddFriend(userId, nickname)
}
})
},
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
async _doMentionAddFriend(targetUserId, targetNickname) {
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
}
} catch (e) {}
}
if (!phone && !wechatId) {
wx.showModal({
title: '完善资料',
content: '请先填写手机号或微信号,以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
source: 'article_mention'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
// 分享弹窗
showShare() {
this.setData({ showShareModal: true })
},
closeShareModal() {
this.setData({ showShareModal: false })
},
// 复制链接
copyLink() {
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || ''
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
wx.setClipboardData({
data: shareUrl,
success: () => {
wx.showToast({ title: '链接已复制', icon: 'success' })
this.setData({ showShareModal: false })
}
})
},
// 复制分享文案(朋友圈风格)
copyShareText() {
const { section } = this.data
const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了!
62个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
推荐给正在创业或想创业的朋友,搜"Soul创业派对"小程序就能看!
#创业派对 #私域运营 #商业案例`
wx.setClipboardData({
data: shareText,
success: () => {
wx.showToast({ title: '文案已复制', icon: 'success' })
}
})
},
// 分享到微信 - 自动带分享人ID优先用 mid扫码/海报闭环),无则用 id
onShareAppMessage() {
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
return {
title: shareTitle,
path: ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
// 不设置 imageUrl使用当前阅读页截图作为分享卡片中间图片
}
},
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: 'Soul创业派对 - 真实商业故事'
return { title, query: ref ? `${q}&ref=${ref}` : q }
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '请前往完整小程序',
content: '当前为朋友圈单页,仅支持部分浏览。想登录继续阅读,请点击底部「前往小程序」后再操作。',
showCancel: false,
confirmText: '我知道了',
})
return
}
} catch (e) {
console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e)
}
try {
this.setData({ showLoginModal: true, agreeProtocol: false })
} catch (e) {
console.error('[Read] showLoginModal error:', e)
this.setData({ showLoginModal: true })
}
},
closeLoginModal() {
this.setData({ showLoginModal: false })
},
toggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
},
openUserProtocol() {
wx.navigateTo({ url: '/pages/agreement/agreement' })
},
openPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' })
},
// 从服务端刷新购买状态,避免登录后误用旧数据导致误解锁
// 【重构】微信登录(须先勾选同意协议,符合审核要求)
async handleWechatLogin() {
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
}
try {
const result = await app.login()
if (!result) return
this.setData({ showLoginModal: false, agreeProtocol: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
console.error('[Read] 登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
},
// 【重构】手机号登录(标准流程)
async handlePhoneLogin(e) {
if (!e.detail.code) {
return this.handleWechatLogin()
}
try {
const result = await app.loginWithPhone(e.detail.code)
if (!result) return
this.setData({ showLoginModal: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
console.error('[Read] 手机号登录失败:', e)
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
// 【新增】登录成功后的标准处理流程
async onLoginSuccess() {
wx.showLoading({ title: '更新状态中...', mask: true })
try {
// 1. 刷新用户购买状态(从 orders 表拉取最新)
await accessManager.refreshUserPurchaseStatus()
// 2. 重新拉取章节数据,用 isFree/price 判断免费
const chapterRes = await app.request({
url: this._getChapterUrl({}),
silent: true
})
const newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
chapterRes
)
const canAccess = accessManager.canAccessFullContent(newAccessState)
this.setData({
accessState: newAccessState,
canAccess,
isLoggedIn: true,
showPaywall: !canAccess
})
// 3. 如果已解锁,重新加载内容并初始化阅读追踪
if (canAccess) {
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
readingTracker.init(this.data.sectionId)
}
wx.hideLoading()
} catch (e) {
wx.hideLoading()
console.error('[Read] 登录后更新状态失败:', e)
wx.showToast({ title: '状态更新失败,请重试', icon: 'none' })
}
},
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
console.log('[Pay] 点击购买章节按钮')
wx.showLoading({ title: '处理中...', mask: true })
if (!this.data.isLoggedIn) {
wx.hideLoading()
console.log('[Pay] 用户未登录,显示登录弹窗')
this.setData({ showLoginModal: true })
return
}
const price = this.data.section?.price || 1
console.log('[Pay] 开始支付流程:', { sectionId: this.data.sectionId, price })
wx.hideLoading()
await this.processPayment('section', this.data.sectionId, price)
},
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
if (!this.data.isLoggedIn) {
wx.hideLoading()
console.log('[Pay] 用户未登录,显示登录弹窗')
this.setData({ showLoginModal: true })
return
}
console.log('[Pay] 开始支付流程: 全书', { price: this.data.fullBookPrice })
wx.hideLoading()
await this.processPayment('fullbook', null, this.data.fullBookPrice)
},
// 处理支付 - 调用真实微信支付接口
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
// 检查金额是否有效
if (!amount || amount <= 0) {
console.error('[Pay] 金额无效:', amount)
wx.showToast({ title: '价格信息错误', icon: 'none' })
return
}
// ✅ 从服务器查询是否已购买(基于 orders 表)
try {
wx.showLoading({ title: '检查购买状态...', mask: true })
const userId = app.globalData.userInfo?.id
if (userId) {
const checkRes = await app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`)
if (checkRes.success && checkRes.data) {
// 更新本地购买状态
app.globalData.hasFullBook = checkRes.data.hasFullBook
app.globalData.purchasedSections = checkRes.data.purchasedSections || []
// 检查是否已购买
if (type === 'section' && sectionId) {
if (checkRes.data.purchasedSections.includes(sectionId)) {
wx.hideLoading()
wx.showToast({ title: '已购买过此章节', icon: 'none' })
return
}
}
if (type === 'fullbook' && checkRes.data.hasFullBook) {
wx.hideLoading()
wx.showToast({ title: '已购买全书', icon: 'none' })
return
}
}
}
} catch (e) {
console.warn('[Pay] 查询购买状态失败,继续支付流程:', e)
// 查询失败不影响支付
}
this.setData({ isPaying: true })
wx.showLoading({ title: '正在发起支付...', mask: true })
try {
// 1. 先获取openId (支付必需)
let openId = app.globalData.openId || wx.getStorageSync('openId')
if (!openId) {
console.log('[Pay] 需要先获取openId尝试静默获取')
wx.showLoading({ title: '获取支付凭证...', mask: true })
openId = await app.getOpenId()
if (!openId) {
// openId获取失败但已登录用户可以使用用户ID替代
if (app.globalData.isLoggedIn && app.globalData.userInfo?.id) {
console.log('[Pay] 使用用户ID作为替代')
openId = app.globalData.userInfo.id
} else {
wx.hideLoading()
wx.showModal({
title: '提示',
content: '需要登录后才能支付,请先登录',
showCancel: false
})
this.setData({ showLoginModal: true, isPaying: false })
return
}
}
}
console.log('[Pay] 开始创建订单:', { type, sectionId, amount, openId: openId.slice(0, 10) + '...' })
wx.showLoading({ title: '创建订单中...', mask: true })
// 2. 调用后端创建预支付订单
let paymentData = null
try {
// 获取章节完整名称用于支付描述
const sectionTitle = this.data.section?.title || sectionId
const description = type === 'fullbook'
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
const referralCode = wx.getStorageSync('referral_code') || ''
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
openId,
productType: type,
productId: sectionId,
amount,
description,
userId: app.globalData.userInfo?.id || '',
referralCode: referralCode || undefined
}
})
console.log('[Pay] 创建订单响应:', res)
if (res.success && res.data?.payParams) {
paymentData = res.data.payParams
paymentData._orderSn = res.data.orderSn // 保存订单号,支付成功后用于主动同步
console.log('[Pay] 获取支付参数成功:', paymentData)
} else {
throw new Error(res.error || res.message || '创建订单失败')
}
} catch (apiError) {
console.error('[Pay] API创建订单失败:', apiError)
wx.hideLoading()
// 支付接口失败时,显示客服联系方式
wx.showModal({
title: '支付通道维护中',
content: '微信支付正在审核中请添加客服微信28533368手动购买感谢理解',
confirmText: '复制微信号',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
})
}
}
})
this.setData({ isPaying: false })
return
}
// 3. 调用微信支付
wx.hideLoading()
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
try {
await this.callWechatPay(paymentData)
// 4. 【关键】主动向微信查询订单状态并同步到本地(不依赖回调,解决订单一直 created 的问题)
const orderSn = paymentData._orderSn || paymentData.orderSn
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
console.log('[Pay] 已主动同步订单状态:', orderSn)
} catch (e) {
console.warn('[Pay] 主动同步订单失败,继续刷新购买状态:', e)
}
}
// 5. 【标准流程】刷新权限并解锁内容
console.log('[Pay] 微信支付成功!')
await this.onPaymentSuccess()
} catch (payErr) {
console.error('[Pay] 微信支付调起失败:', payErr)
if (payErr.errMsg && payErr.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消支付', icon: 'none' })
} else if (payErr.errMsg && payErr.errMsg.includes('requestPayment:fail')) {
// 支付失败,可能是参数错误或权限问题
wx.showModal({
title: '支付失败',
content: '微信支付暂不可用请添加客服微信28533368手动购买',
confirmText: '复制微信号',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
})
}
}
})
} else {
wx.showToast({ title: payErr.errMsg || '支付失败', icon: 'none' })
}
}
} catch (e) {
console.error('[Pay] 支付流程异常:', e)
wx.hideLoading()
wx.showToast({ title: '支付出错,请重试', icon: 'none' })
} finally {
this.setData({ isPaying: false })
}
},
// 【新增】支付成功后的标准处理流程
async onPaymentSuccess() {
wx.showLoading({ title: '确认购买中...', mask: true })
try {
// 1. 等待服务端处理支付回调1-2秒
await this.sleep(2000)
// 2. 刷新用户购买状态
await accessManager.refreshUserPurchaseStatus()
// 3. 重新拉取章节并判断权限(应为 unlocked_purchased
const chapterRes = await app.request({
url: this._getChapterUrl({}),
silent: true
})
let newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
chapterRes
)
if (newAccessState !== 'unlocked_purchased') {
console.log('[Pay] 权限未生效1秒后重试...')
await this.sleep(1000)
newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
chapterRes
)
}
const canAccess = accessManager.canAccessFullContent(newAccessState)
this.setData({
accessState: newAccessState,
canAccess,
showPaywall: !canAccess
})
// 4. 重新加载全文
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
// 5. 初始化阅读追踪
if (canAccess) {
readingTracker.init(this.data.sectionId)
}
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[Pay] 支付后更新失败:', e)
wx.showModal({
title: '提示',
content: '购买成功,但内容加载失败,请返回重新进入',
showCancel: false
})
}
},
// ✅ 刷新用户购买状态(从服务器获取最新数据)
async refreshUserPurchaseStatus() {
try {
const userId = app.globalData.userInfo?.id
if (!userId) {
console.warn('[Pay] 用户未登录,无法刷新购买状态')
return
}
// 调用专门的购买状态查询接口
const res = await app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`)
if (res.success && res.data) {
// 更新全局购买状态
app.globalData.hasFullBook = res.data.hasFullBook
app.globalData.purchasedSections = res.data.purchasedSections || []
// 更新用户信息中的购买记录
const userInfo = app.globalData.userInfo || {}
userInfo.hasFullBook = res.data.hasFullBook
userInfo.purchasedSections = res.data.purchasedSections || []
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
console.log('[Pay] ✅ 购买状态已刷新:', {
hasFullBook: res.data.hasFullBook,
purchasedCount: res.data.purchasedSections.length
})
}
} catch (e) {
console.error('[Pay] 刷新购买状态失败:', e)
// 刷新失败时不影响用户体验,只是记录日志
}
},
// 调用微信支付
callWechatPay(paymentData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'MD5',
paySign: paymentData.paySign,
success: resolve,
fail: reject
})
})
},
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
const { id, mid } = this.data.prevSection
const query = mid ? `mid=${mid}` : `id=${id}`
wx.redirectTo({ url: `/pages/read/read?${query}` })
}
},
// 跳转到下一篇
goToNext() {
if (this.data.nextSection) {
const { id, mid } = this.data.nextSection
const query = mid ? `mid=${mid}` : `id=${id}`
wx.redirectTo({ url: `/pages/read/read?${query}` })
}
},
// 跳转到推广中心
goToReferral() {
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 生成海报
async generatePoster() {
wx.showLoading({ title: '生成中...' })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
try {
const ctx = wx.createCanvasContext('posterCanvas', this)
const { section, contentParagraphs, sectionId } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
// 获取小程序码(带推荐人参数)
let qrcodeImage = null
try {
const scene = userId ? `id=${sectionId}&ref=${userId.slice(0,10)}` : `id=${sectionId}`
const qrRes = await app.request('/api/miniprogram/qrcode', {
method: 'POST',
data: { scene, page: 'pages/read/read', width: 280 }
})
if (qrRes.success && qrRes.image) {
qrcodeImage = qrRes.image
}
} catch (e) {
console.log('[Poster] 获取小程序码失败,使用占位符')
}
// 海报尺寸 300x450
const width = 300
const height = 450
// 背景渐变
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, width, height)
// 顶部装饰条
ctx.setFillStyle('#00CED1')
ctx.fillRect(0, 0, width, 4)
// 标题区域
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillText('📚 Soul创业派对', 20, 35)
// 章节标题
ctx.setFontSize(18)
ctx.setFillStyle('#ffffff')
const title = section?.title || '精彩内容'
const titleLines = this.wrapText(ctx, title, width - 40, 18)
let y = 70
titleLines.forEach(line => {
ctx.fillText(line, 20, y)
y += 26
})
// 分隔线
ctx.setStrokeStyle('rgba(255,255,255,0.1)')
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
// 内容摘要
ctx.setFontSize(12)
ctx.setFillStyle('rgba(255,255,255,0.8)')
y += 30
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
const summaryLines = this.wrapText(ctx, summary, width - 40, 12)
summaryLines.slice(0, 6).forEach(line => {
ctx.fillText(line, 20, y)
y += 20
})
// 底部区域背景
ctx.setFillStyle('rgba(0,206,209,0.1)')
ctx.fillRect(0, height - 100, width, 100)
// 左侧提示文字
ctx.setFillStyle('#ffffff')
ctx.setFontSize(13)
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.setFillStyle('rgba(255,255,255,0.6)')
ctx.setFontSize(11)
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
// 绘制小程序码或占位符
const drawQRCode = () => {
return new Promise((resolve) => {
if (qrcodeImage) {
// 下载base64图片并绘制
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFile({
filePath,
data: base64Data,
encoding: 'base64',
success: () => {
ctx.drawImage(filePath, width - 85, height - 85, 70, 70)
resolve()
},
fail: () => {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
} else {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
}
await drawQRCode()
ctx.draw(true, () => {
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
})
} catch (e) {
console.error('生成海报失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false })
}
},
// 绘制小程序码占位符
drawQRPlaceholder(ctx, width, height) {
ctx.setFillStyle('#ffffff')
ctx.beginPath()
ctx.arc(width - 50, height - 50, 35, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#00CED1')
ctx.setFontSize(9)
ctx.fillText('扫码', width - 57, height - 52)
ctx.fillText('阅读', width - 57, height - 40)
},
// 文字换行处理
wrapText(ctx, text, maxWidth, fontSize) {
const lines = []
let line = ''
for (let i = 0; i < text.length; i++) {
const testLine = line + text[i]
const metrics = ctx.measureText(testLine)
if (metrics.width > maxWidth && line) {
lines.push(line)
line = text[i]
} else {
line = testLine
}
}
if (line) lines.push(line)
return lines
},
// 关闭海报弹窗
closePosterModal() {
this.setData({ showPosterModal: false })
},
// 保存海报到相册
savePoster() {
wx.canvasToTempFilePath({
canvasId: 'posterCanvas',
success: (res) => {
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
this.setData({ showPosterModal: false })
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
wx.openSetting()
}
}
})
} else {
wx.showToast({ title: '保存失败', icon: 'none' })
}
}
})
},
fail: () => {
wx.showToast({ title: '生成图片失败', icon: 'none' })
}
}, this)
},
// 阻止冒泡
stopPropagation() {},
// 【新增】页面隐藏时上报阅读进度
onHide() {
readingTracker.onPageHide()
},
// 【新增】页面卸载时清理追踪器
onUnload() {
readingTracker.cleanup()
},
// 【新增】重试加载(当 accessState 为 error 时)
async handleRetry() {
wx.showLoading({ title: '重试中...', mask: true })
try {
const config = await accessManager.fetchLatestConfig()
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 重新拉取章节,用 isFree/price 判断免费
const chapterRes = await app.request({
url: this._getChapterUrl({}),
silent: true
})
const newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
chapterRes
)
const canAccess = accessManager.canAccessFullContent(newAccessState)
this.setData({
accessState: newAccessState,
canAccess,
showPaywall: !canAccess
})
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
// 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(this.data.sectionId)
}
// 加载导航
this.loadNavigation(this.data.sectionId)
wx.hideLoading()
wx.showToast({ title: '加载成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[Read] 重试失败:', e)
wx.showToast({ title: '重试失败,请检查网络', icon: 'none' })
}
},
// 工具:延迟
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
})