2026-02-04 11:36:19 +08:00
|
|
|
|
/**
|
2026-02-04 21:36:26 +08:00
|
|
|
|
* Soul创业派对 - 阅读页(标准流程版)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
* 开发: 卡若
|
|
|
|
|
|
* 技术支持: 存客宝
|
2026-02-04 21:36:26 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 更新: 2026-02-04
|
|
|
|
|
|
* - 引入权限管理器(chapterAccessManager)统一权限判断
|
|
|
|
|
|
* - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完
|
|
|
|
|
|
* - 使用状态机(accessState)规范权限流转
|
|
|
|
|
|
* - 异常统一保守处理,避免误解锁
|
2026-02-04 11:36:19 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
import accessManager from '../../utils/chapterAccessManager'
|
|
|
|
|
|
import readingTracker from '../../utils/readingTracker'
|
|
|
|
|
|
|
2026-02-04 11:36:19 +08:00
|
|
|
|
const app = getApp()
|
|
|
|
|
|
|
|
|
|
|
|
Page({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
// 系统信息
|
|
|
|
|
|
statusBarHeight: 44,
|
|
|
|
|
|
navBarHeight: 88,
|
|
|
|
|
|
|
|
|
|
|
|
// 章节信息
|
|
|
|
|
|
sectionId: '',
|
|
|
|
|
|
section: null,
|
|
|
|
|
|
partTitle: '',
|
|
|
|
|
|
chapterTitle: '',
|
|
|
|
|
|
|
|
|
|
|
|
// 内容
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
previewContent: '',
|
|
|
|
|
|
contentParagraphs: [],
|
|
|
|
|
|
previewParagraphs: [],
|
|
|
|
|
|
loading: true,
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 【新增】权限状态机(替代 canAccess)
|
|
|
|
|
|
// unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误
|
|
|
|
|
|
accessState: 'unknown',
|
|
|
|
|
|
|
2026-02-04 11:36:19 +08:00
|
|
|
|
// 用户状态
|
|
|
|
|
|
isLoggedIn: false,
|
|
|
|
|
|
hasFullBook: false,
|
2026-02-04 21:36:26 +08:00
|
|
|
|
canAccess: false, // 保留兼容性,从 accessState 派生
|
2026-02-04 11:36:19 +08:00
|
|
|
|
purchasedCount: 0,
|
|
|
|
|
|
|
|
|
|
|
|
// 阅读进度
|
|
|
|
|
|
readingProgress: 0,
|
|
|
|
|
|
showPaywall: false,
|
|
|
|
|
|
|
|
|
|
|
|
// 上一篇/下一篇
|
|
|
|
|
|
prevSection: null,
|
|
|
|
|
|
nextSection: null,
|
|
|
|
|
|
|
|
|
|
|
|
// 价格
|
|
|
|
|
|
sectionPrice: 1,
|
|
|
|
|
|
fullBookPrice: 9.9,
|
|
|
|
|
|
totalSections: 62,
|
|
|
|
|
|
|
|
|
|
|
|
// 弹窗
|
|
|
|
|
|
showShareModal: false,
|
|
|
|
|
|
showLoginModal: false,
|
2026-02-10 15:03:31 +08:00
|
|
|
|
agreeProtocol: false,
|
2026-02-04 11:36:19 +08:00
|
|
|
|
showPosterModal: false,
|
|
|
|
|
|
isPaying: false,
|
|
|
|
|
|
isGeneratingPoster: false,
|
|
|
|
|
|
|
|
|
|
|
|
// 免费章节
|
|
|
|
|
|
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
async onLoad(options) {
|
2026-02-04 11:36:19 +08:00
|
|
|
|
const { id, ref } = options
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
statusBarHeight: app.globalData.statusBarHeight,
|
|
|
|
|
|
navBarHeight: app.globalData.navBarHeight,
|
2026-02-04 21:36:26 +08:00
|
|
|
|
sectionId: id,
|
|
|
|
|
|
loading: true,
|
|
|
|
|
|
accessState: 'unknown'
|
2026-02-04 11:36:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 处理推荐码绑定(异步不阻塞)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
if (ref) {
|
|
|
|
|
|
console.log('[Read] 检测到推荐码:', ref)
|
|
|
|
|
|
wx.setStorageSync('referral_code', ref)
|
|
|
|
|
|
app.handleReferralCode({ query: { ref } })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 【标准流程】1. 拉取最新配置(免费列表、价格)
|
|
|
|
|
|
const config = await accessManager.fetchLatestConfig()
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
freeIds: config.freeChapters,
|
2026-02-05 11:35:57 +08:00
|
|
|
|
sectionPrice: config.prices?.section ?? 1,
|
|
|
|
|
|
fullBookPrice: config.prices?.fullbook ?? 9.9
|
2026-02-04 21:36:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 【标准流程】2. 确定权限状态
|
|
|
|
|
|
const accessState = await accessManager.determineAccessState(id, config.freeChapters)
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(accessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
isLoggedIn: !!app.globalData.userInfo?.id,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 【标准流程】3. 加载内容
|
|
|
|
|
|
await this.loadContent(id, accessState)
|
|
|
|
|
|
|
|
|
|
|
|
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
|
|
|
|
|
if (canAccess) {
|
|
|
|
|
|
readingTracker.init(id)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
2026-02-04 21:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 5. 加载导航
|
|
|
|
|
|
this.loadNavigation(id)
|
|
|
|
|
|
|
2026-02-04 11:36:19 +08:00
|
|
|
|
} catch (e) {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
console.error('[Read] 初始化失败:', e)
|
|
|
|
|
|
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
|
|
|
|
|
|
this.setData({ accessState: 'error', loading: false })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.setData({ loading: false })
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-02-04 21:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 从后端加载免费章节配置
|
2026-02-04 11:36:19 +08:00
|
|
|
|
onPageScroll(e) {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 只在有权限时追踪阅读进度
|
|
|
|
|
|
if (!accessManager.canAccessFullContent(this.data.accessState)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取滚动信息并更新追踪器
|
2026-02-04 11:36:19 +08:00
|
|
|
|
const query = wx.createSelectorQuery()
|
|
|
|
|
|
query.select('.page').boundingClientRect()
|
2026-02-04 21:36:26 +08:00
|
|
|
|
query.selectViewport().scrollOffset()
|
2026-02-04 11:36:19 +08:00
|
|
|
|
query.exec((res) => {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
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
|
2026-02-04 11:36:19 +08:00
|
|
|
|
this.setData({ readingProgress: progress })
|
2026-02-04 21:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新阅读追踪器(记录最大进度、判断是否读完)
|
|
|
|
|
|
readingTracker.updateProgress(scrollInfo)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
|
|
|
|
|
|
async loadContent(id, accessState) {
|
2026-02-04 11:36:19 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const section = this.getSectionInfo(id)
|
2026-02-05 11:35:57 +08:00
|
|
|
|
const sectionPrice = this.data.sectionPrice ?? 1
|
|
|
|
|
|
if (section.price === undefined || section.price === null) {
|
|
|
|
|
|
section.price = sectionPrice
|
|
|
|
|
|
}
|
2026-02-04 21:36:26 +08:00
|
|
|
|
this.setData({ section })
|
2026-02-04 11:36:19 +08:00
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 从 API 获取内容
|
2026-02-09 18:19:12 +08:00
|
|
|
|
const res = await app.request(`/api/miniprogram/book/chapter/${id}`)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
if (res && res.content) {
|
|
|
|
|
|
const lines = res.content.split('\n').filter(line => line.trim())
|
|
|
|
|
|
const previewCount = Math.ceil(lines.length * 0.2)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
content: res.content,
|
|
|
|
|
|
contentParagraphs: lines,
|
|
|
|
|
|
previewParagraphs: lines.slice(0, previewCount),
|
|
|
|
|
|
partTitle: res.partTitle || '',
|
|
|
|
|
|
chapterTitle: res.chapterTitle || ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有权限,标记为已读
|
|
|
|
|
|
if (accessManager.canAccessFullContent(accessState)) {
|
|
|
|
|
|
app.markSectionAsRead(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-04 11:36:19 +08:00
|
|
|
|
} catch (e) {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
console.error('[Read] 加载内容失败:', e)
|
|
|
|
|
|
// 尝试从本地缓存加载
|
|
|
|
|
|
const cacheKey = `chapter_${id}`
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cached = wx.getStorageSync(cacheKey)
|
|
|
|
|
|
if (cached && cached.content) {
|
|
|
|
|
|
const lines = cached.content.split('\n').filter(line => line.trim())
|
|
|
|
|
|
const previewCount = Math.ceil(lines.length * 0.2)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
content: cached.content,
|
|
|
|
|
|
contentParagraphs: lines,
|
|
|
|
|
|
previewParagraphs: lines.slice(0, previewCount)
|
|
|
|
|
|
})
|
|
|
|
|
|
console.log('[Read] 从本地缓存加载成功')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (cacheErr) {
|
|
|
|
|
|
console.warn('[Read] 本地缓存也失败:', cacheErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
throw e
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 获取章节信息
|
|
|
|
|
|
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}`
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 加载内容 - 三级降级方案:API → 本地缓存 → 备用API
|
|
|
|
|
|
async loadContent(id) {
|
|
|
|
|
|
const cacheKey = `chapter_${id}`
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 优先从API获取
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await this.fetchChapterWithTimeout(id, 5000)
|
|
|
|
|
|
if (res && res.content) {
|
|
|
|
|
|
this.setChapterContent(res)
|
|
|
|
|
|
// 成功后缓存到本地
|
|
|
|
|
|
wx.setStorageSync(cacheKey, res)
|
|
|
|
|
|
console.log('[Read] 从API加载成功:', id)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Read] API加载失败,尝试本地缓存:', e.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. API失败,尝试从本地缓存读取
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cached = wx.getStorageSync(cacheKey)
|
|
|
|
|
|
if (cached && cached.content) {
|
|
|
|
|
|
this.setChapterContent(cached)
|
|
|
|
|
|
console.log('[Read] 从本地缓存加载成功:', id)
|
|
|
|
|
|
// 后台静默刷新
|
|
|
|
|
|
this.silentRefresh(id)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Read] 本地缓存读取失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 都失败,显示加载中并持续重试
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'],
|
|
|
|
|
|
previewParagraphs: ['章节内容加载中...']
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 延迟重试(最多3次)
|
|
|
|
|
|
this.retryLoadContent(id, 3)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 带超时的章节请求
|
|
|
|
|
|
fetchChapterWithTimeout(id, timeout = 5000) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
|
reject(new Error('请求超时'))
|
|
|
|
|
|
}, timeout)
|
|
|
|
|
|
|
2026-02-09 18:19:12 +08:00
|
|
|
|
app.request(`/api/miniprogram/book/chapter/${id}`)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
.then(res => {
|
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
|
resolve(res)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
|
reject(err)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 设置章节内容
|
|
|
|
|
|
setChapterContent(res) {
|
|
|
|
|
|
const lines = res.content.split('\n').filter(line => line.trim())
|
|
|
|
|
|
const previewCount = Math.ceil(lines.length * 0.2)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
content: res.content,
|
|
|
|
|
|
previewContent: lines.slice(0, previewCount).join('\n'),
|
|
|
|
|
|
contentParagraphs: lines,
|
|
|
|
|
|
previewParagraphs: lines.slice(0, previewCount),
|
|
|
|
|
|
partTitle: res.partTitle || '',
|
|
|
|
|
|
chapterTitle: res.chapterTitle || ''
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 静默刷新(后台更新缓存)
|
|
|
|
|
|
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: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
|
|
|
|
|
|
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))
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 加载导航
|
|
|
|
|
|
loadNavigation(id) {
|
|
|
|
|
|
const sectionOrder = [
|
|
|
|
|
|
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
|
|
|
|
|
|
'2.1', '2.2', '2.3', '2.4', '2.5',
|
|
|
|
|
|
'3.1', '3.2', '3.3', '3.4',
|
|
|
|
|
|
'4.1', '4.2', '4.3', '4.4', '4.5',
|
|
|
|
|
|
'5.1', '5.2', '5.3', '5.4', '5.5',
|
|
|
|
|
|
'6.1', '6.2', '6.3', '6.4',
|
|
|
|
|
|
'7.1', '7.2', '7.3', '7.4', '7.5',
|
|
|
|
|
|
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
|
|
|
|
|
|
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
|
|
|
|
|
|
'10.1', '10.2', '10.3', '10.4',
|
|
|
|
|
|
'11.1', '11.2', '11.3', '11.4', '11.5',
|
|
|
|
|
|
'epilogue'
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const currentIndex = sectionOrder.indexOf(id)
|
|
|
|
|
|
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
|
|
|
|
|
|
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
|
|
|
|
|
|
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 返回
|
|
|
|
|
|
goBack() {
|
|
|
|
|
|
wx.navigateBack({
|
|
|
|
|
|
fail: () => wx.switchTab({ url: '/pages/chapters/chapters' })
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 分享弹窗
|
|
|
|
|
|
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
|
|
|
|
|
|
onShareAppMessage() {
|
|
|
|
|
|
const { section, sectionId } = this.data
|
|
|
|
|
|
const userInfo = app.globalData.userInfo
|
|
|
|
|
|
const referralCode = userInfo?.referralCode || wx.getStorageSync('referralCode') || ''
|
|
|
|
|
|
|
|
|
|
|
|
// 分享标题优化
|
|
|
|
|
|
const shareTitle = section?.title
|
|
|
|
|
|
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
|
|
|
|
|
|
: '📚 Soul创业派对 - 真实商业故事'
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: shareTitle,
|
|
|
|
|
|
path: `/pages/read/read?id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`,
|
|
|
|
|
|
imageUrl: '/assets/share-cover.png' // 可配置分享封面图
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 分享到朋友圈
|
|
|
|
|
|
onShareTimeline() {
|
|
|
|
|
|
const { section, sectionId } = this.data
|
|
|
|
|
|
const userInfo = app.globalData.userInfo
|
|
|
|
|
|
const referralCode = userInfo?.referralCode || ''
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: `${section?.title || 'Soul创业派对'} - 来自派对房的真实故事`,
|
|
|
|
|
|
query: `id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-10 15:03:31 +08:00
|
|
|
|
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
showLoginModal() {
|
2026-02-10 15:03:31 +08:00
|
|
|
|
try {
|
|
|
|
|
|
this.setData({ showLoginModal: true, agreeProtocol: false })
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[Read] showLoginModal error:', e)
|
|
|
|
|
|
this.setData({ showLoginModal: true })
|
|
|
|
|
|
}
|
2026-02-04 11:36:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
closeLoginModal() {
|
|
|
|
|
|
this.setData({ showLoginModal: false })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-10 15:03:31 +08:00
|
|
|
|
toggleAgree() {
|
|
|
|
|
|
this.setData({ agreeProtocol: !this.data.agreeProtocol })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
openUserProtocol() {
|
|
|
|
|
|
wx.navigateTo({ url: '/pages/agreement/agreement' })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
openPrivacy() {
|
|
|
|
|
|
wx.navigateTo({ url: '/pages/privacy/privacy' })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 从服务端刷新购买状态,避免登录后误用旧数据导致误解锁
|
2026-02-10 15:03:31 +08:00
|
|
|
|
// 【重构】微信登录(须先勾选同意协议,符合审核要求)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
async handleWechatLogin() {
|
2026-02-10 15:03:31 +08:00
|
|
|
|
if (!this.data.agreeProtocol) {
|
|
|
|
|
|
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-04 11:36:19 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const result = await app.login()
|
2026-02-04 21:36:26 +08:00
|
|
|
|
if (!result) return
|
|
|
|
|
|
|
2026-02-10 15:03:31 +08:00
|
|
|
|
this.setData({ showLoginModal: false, agreeProtocol: false })
|
2026-02-04 21:36:26 +08:00
|
|
|
|
await this.onLoginSuccess()
|
|
|
|
|
|
wx.showToast({ title: '登录成功', icon: 'success' })
|
|
|
|
|
|
|
2026-02-04 11:36:19 +08:00
|
|
|
|
} catch (e) {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
console.error('[Read] 登录失败:', e)
|
2026-02-10 15:03:31 +08:00
|
|
|
|
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 【重构】手机号登录(标准流程)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
async handlePhoneLogin(e) {
|
|
|
|
|
|
if (!e.detail.code) {
|
|
|
|
|
|
return this.handleWechatLogin()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await app.loginWithPhone(e.detail.code)
|
2026-02-04 21:36:26 +08:00
|
|
|
|
if (!result) return
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({ showLoginModal: false })
|
|
|
|
|
|
await this.onLoginSuccess()
|
|
|
|
|
|
wx.showToast({ title: '登录成功', icon: 'success' })
|
|
|
|
|
|
|
2026-02-04 11:36:19 +08:00
|
|
|
|
} catch (e) {
|
2026-02-04 21:36:26 +08:00
|
|
|
|
console.error('[Read] 手机号登录失败:', e)
|
2026-02-04 11:36:19 +08:00
|
|
|
|
wx.showToast({ title: '登录失败', icon: 'none' })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 【新增】登录成功后的标准处理流程
|
|
|
|
|
|
async onLoginSuccess() {
|
|
|
|
|
|
wx.showLoading({ title: '更新状态中...', mask: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 1. 刷新用户购买状态(从 orders 表拉取最新)
|
|
|
|
|
|
await accessManager.refreshUserPurchaseStatus()
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 重新拉取免费列表(极端情况:刚登录时当前章节可能改免费了)
|
|
|
|
|
|
const config = await accessManager.fetchLatestConfig()
|
|
|
|
|
|
this.setData({ freeIds: config.freeChapters })
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 重新判断当前章节权限
|
|
|
|
|
|
const newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
|
|
|
|
|
config.freeChapters
|
|
|
|
|
|
)
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState: newAccessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
isLoggedIn: true,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 如果已解锁,重新加载内容并初始化阅读追踪
|
|
|
|
|
|
if (canAccess) {
|
|
|
|
|
|
await this.loadContent(this.data.sectionId, newAccessState)
|
|
|
|
|
|
readingTracker.init(this.data.sectionId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
console.error('[Read] 登录后更新状态失败:', e)
|
|
|
|
|
|
wx.showToast({ title: '状态更新失败,请重试', icon: 'none' })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 11:36:19 +08:00
|
|
|
|
// 购买章节 - 直接调起支付
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// ✅ 从服务器查询是否已购买(基于 orders 表)
|
|
|
|
|
|
try {
|
|
|
|
|
|
wx.showLoading({ title: '检查购买状态...', mask: true })
|
|
|
|
|
|
const userId = app.globalData.userInfo?.id
|
|
|
|
|
|
|
|
|
|
|
|
if (userId) {
|
2026-02-09 18:19:12 +08:00
|
|
|
|
const checkRes = await app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`)
|
2026-02-04 21:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
2026-02-04 21:36:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Pay] 查询购买状态失败,继续支付流程:', e)
|
|
|
|
|
|
// 查询失败不影响支付
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
|
2026-02-05 11:35:57 +08:00
|
|
|
|
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
|
2026-02-04 21:36:26 +08:00
|
|
|
|
const referralCode = wx.getStorageSync('referral_code') || ''
|
2026-02-04 11:36:19 +08:00
|
|
|
|
const res = await app.request('/api/miniprogram/pay', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
openId,
|
|
|
|
|
|
productType: type,
|
|
|
|
|
|
productId: sectionId,
|
|
|
|
|
|
amount,
|
|
|
|
|
|
description,
|
2026-02-04 21:36:26 +08:00
|
|
|
|
userId: app.globalData.userInfo?.id || '',
|
|
|
|
|
|
referralCode: referralCode || undefined
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Pay] 创建订单响应:', res)
|
|
|
|
|
|
|
|
|
|
|
|
if (res.success && res.data?.payParams) {
|
|
|
|
|
|
paymentData = res.data.payParams
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 4. 【标准流程】支付成功后刷新权限并解锁内容
|
2026-02-04 11:36:19 +08:00
|
|
|
|
console.log('[Pay] 微信支付成功!')
|
2026-02-04 21:36:26 +08:00
|
|
|
|
await this.onPaymentSuccess()
|
2026-02-04 11:36:19 +08:00
|
|
|
|
|
|
|
|
|
|
} 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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 【新增】支付成功后的标准处理流程
|
|
|
|
|
|
async onPaymentSuccess() {
|
|
|
|
|
|
wx.showLoading({ title: '确认购买中...', mask: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 1. 等待服务端处理支付回调(1-2秒)
|
|
|
|
|
|
await this.sleep(2000)
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 刷新用户购买状态
|
|
|
|
|
|
await accessManager.refreshUserPurchaseStatus()
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 重新判断当前章节权限(应为 unlocked_purchased)
|
|
|
|
|
|
let newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
|
|
|
|
|
this.data.freeIds
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果权限未生效,再重试一次(可能回调延迟)
|
|
|
|
|
|
if (newAccessState !== 'unlocked_purchased') {
|
|
|
|
|
|
console.log('[Pay] 权限未生效,1秒后重试...')
|
|
|
|
|
|
await this.sleep(1000)
|
|
|
|
|
|
newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
|
|
|
|
|
this.data.freeIds
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState: newAccessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 重新加载全文
|
|
|
|
|
|
await this.loadContent(this.data.sectionId, newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 调用专门的购买状态查询接口
|
2026-02-09 18:19:12 +08:00
|
|
|
|
const res = await app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`)
|
2026-02-04 21:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
if (res.success && res.data) {
|
|
|
|
|
|
// 更新全局购买状态
|
|
|
|
|
|
app.globalData.hasFullBook = res.data.hasFullBook
|
|
|
|
|
|
app.globalData.purchasedSections = res.data.purchasedSections || []
|
2026-02-04 11:36:19 +08:00
|
|
|
|
|
2026-02-04 21:36:26 +08:00
|
|
|
|
// 更新用户信息中的购买记录
|
2026-02-04 11:36:19 +08:00
|
|
|
|
const userInfo = app.globalData.userInfo || {}
|
2026-02-04 21:36:26 +08:00
|
|
|
|
userInfo.hasFullBook = res.data.hasFullBook
|
|
|
|
|
|
userInfo.purchasedSections = res.data.purchasedSections || []
|
2026-02-04 11:36:19 +08:00
|
|
|
|
app.globalData.userInfo = userInfo
|
|
|
|
|
|
wx.setStorageSync('userInfo', userInfo)
|
2026-02-04 21:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
console.log('[Pay] ✅ 购买状态已刷新:', {
|
|
|
|
|
|
hasFullBook: res.data.hasFullBook,
|
|
|
|
|
|
purchasedCount: res.data.purchasedSections.length
|
|
|
|
|
|
})
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
2026-02-04 21:36:26 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[Pay] 刷新购买状态失败:', e)
|
|
|
|
|
|
// 刷新失败时不影响用户体验,只是记录日志
|
2026-02-04 11:36:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 调用微信支付
|
|
|
|
|
|
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) {
|
|
|
|
|
|
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到下一篇
|
|
|
|
|
|
goToNext() {
|
|
|
|
|
|
if (this.data.nextSection) {
|
|
|
|
|
|
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到推广中心
|
|
|
|
|
|
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)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 阻止冒泡
|
2026-02-04 21:36:26 +08:00
|
|
|
|
stopPropagation() {},
|
|
|
|
|
|
|
|
|
|
|
|
// 【新增】页面隐藏时上报阅读进度
|
|
|
|
|
|
onHide() {
|
|
|
|
|
|
readingTracker.onPageHide()
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 【新增】页面卸载时清理追踪器
|
|
|
|
|
|
onUnload() {
|
|
|
|
|
|
readingTracker.cleanup()
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 【新增】重试加载(当 accessState 为 error 时)
|
|
|
|
|
|
async handleRetry() {
|
|
|
|
|
|
wx.showLoading({ title: '重试中...', mask: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 重新拉取配置
|
|
|
|
|
|
const config = await accessManager.fetchLatestConfig()
|
|
|
|
|
|
this.setData({ freeIds: config.freeChapters })
|
|
|
|
|
|
|
|
|
|
|
|
// 重新判断权限
|
|
|
|
|
|
const newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
|
|
|
|
|
config.freeChapters
|
|
|
|
|
|
)
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState: newAccessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 重新加载内容
|
|
|
|
|
|
await this.loadContent(this.data.sectionId, newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有权限,初始化阅读追踪
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
2026-02-04 11:36:19 +08:00
|
|
|
|
})
|