miniprogram: 用永平版本替换(含超级个体、会员详情、提现等)

- 来源: 一场soul的创业实验-永平/soul/miniprogram
- 新增: addresses/agreement/privacy/withdraw-records 等页面
- 新增: components/icon, utils/chapterAccessManager, readingTracker
- 删除: 上传脚本、部署说明等冗余文件
- 同步永平最新结构和功能

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-02-24 14:35:58 +08:00
parent b038a042c2
commit e5e6ffd7b1
99 changed files with 8370 additions and 3550 deletions

View File

@@ -1,9 +1,18 @@
/**
* Soul创业派对 - 阅读页
* Soul创业派对 - 阅读页(标准流程版)
* 开发: 卡若
* 技术支持: 存客宝
*
* 更新: 2026-02-04
* - 引入权限管理器chapterAccessManager统一权限判断
* - 引入阅读追踪器readingTracker记录阅读进度、时长、是否读完
* - 使用状态机accessState规范权限流转
* - 异常统一保守处理,避免误解锁
*/
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const app = getApp()
Page({
@@ -25,10 +34,14 @@ Page({
previewParagraphs: [],
loading: true,
// 【新增】权限状态机(替代 canAccess
// unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误
accessState: 'unknown',
// 用户状态
isLoggedIn: false,
hasFullBook: false,
canAccess: false,
canAccess: false, // 保留兼容性,从 accessState 派生
purchasedCount: 0,
// 阅读进度
@@ -47,6 +60,7 @@ Page({
// 弹窗
showShareModal: false,
showLoginModal: false,
agreeProtocol: false,
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
@@ -55,89 +69,147 @@ Page({
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
},
onLoad(options) {
async onLoad(options) {
const { id, ref } = options
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
sectionId: id
sectionId: id,
loading: true,
accessState: 'unknown'
})
// 处理推荐码绑定
// 处理推荐码绑定(异步不阻塞)
if (ref) {
console.log('[Read] 检测到推荐码:', ref)
wx.setStorageSync('referral_code', ref)
app.handleReferralCode({ query: { ref } })
}
// 加载免费章节配置
this.loadFreeChaptersConfig()
this.initSection(id)
try {
// 【标准流程】1. 拉取最新配置(免费列表、价格)
const config = await accessManager.fetchLatestConfig()
this.setData({
freeIds: config.freeChapters,
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 【标准流程】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)
}
// 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 })
}
},
// 从后端加载免费章节配置
async loadFreeChaptersConfig() {
try {
const res = await app.request('/api/db/config')
if (res.success && res.freeChapters) {
this.setData({ freeIds: res.freeChapters })
console.log('[Read] 加载免费章节配置:', res.freeChapters)
}
} catch (e) {
console.log('[Read] 使用默认免费章节配置')
}
},
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]) {
const scrollTop = e.scrollTop
const pageHeight = res[0].height - this.data.statusBarHeight - 200
const progress = pageHeight > 0 ? Math.min((scrollTop / pageHeight) * 100, 100) : 0
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)
}
})
},
// 初始化章节
async initSection(id) {
this.setData({ loading: true })
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
async loadContent(id, accessState) {
try {
// 模拟获取章节数据
const section = this.getSectionInfo(id)
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
const sectionPrice = this.data.sectionPrice ?? 1
if (section.price === undefined || section.price === null) {
section.price = sectionPrice
}
this.setData({ section })
const isFree = this.data.freeIds.includes(id)
const isPurchased = hasFullBook || (purchasedSections && purchasedSections.includes(id))
const canAccess = isFree || isPurchased
const purchasedCount = purchasedSections?.length || 0
this.setData({
section,
isLoggedIn,
hasFullBook,
canAccess,
purchasedCount,
showPaywall: !canAccess
})
// 加载内容
await this.loadContent(id)
// 获取上一篇/下一篇
this.loadNavigation(id)
// 从 API 获取内容
const res = await app.request({ url: `/api/miniprogram/book/chapter/${id}`, silent: true })
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)
}
}
} catch (e) {
console.error('初始化章节失败:', e)
wx.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
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
}
},
@@ -237,7 +309,7 @@ Page({
reject(new Error('请求超时'))
}, timeout)
app.request(`/api/book/chapter/${id}`)
app.request(`/api/miniprogram/book/chapter/${id}`)
.then(res => {
clearTimeout(timer)
resolve(res)
@@ -412,30 +484,54 @@ Page({
}
},
// 显示登录弹窗
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
this.setData({ showLoginModal: true })
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) {
this.setData({ showLoginModal: false })
this.initSection(this.data.sectionId)
wx.showToast({ title: '登录成功', icon: 'success' })
}
if (!result) return
this.setData({ showLoginModal: false, agreeProtocol: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
wx.showToast({ title: '登录失败', icon: 'none' })
console.error('[Read] 登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
},
// 手机号登录
// 【重构】手机号登录(标准流程)
async handlePhoneLogin(e) {
if (!e.detail.code) {
return this.handleWechatLogin()
@@ -443,16 +539,59 @@ Page({
try {
const result = await app.loginWithPhone(e.detail.code)
if (result) {
this.setData({ showLoginModal: false })
this.initSection(this.data.sectionId)
wx.showToast({ title: '登录成功', icon: 'success' })
}
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. 重新拉取免费列表(极端情况:刚登录时当前章节可能改免费了)
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' })
}
},
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
console.log('[Pay] 点击购买章节按钮')
@@ -499,18 +638,38 @@ Page({
return
}
// 检查是否已购买(避免重复购买
if (type === 'section' && sectionId) {
const purchasedSections = app.globalData.purchasedSections || []
if (purchasedSections.includes(sectionId)) {
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
}
}
}
}
if (type === 'fullbook' && app.globalData.hasFullBook) {
wx.showToast({ title: '已购买全书', icon: 'none' })
return
} catch (e) {
console.warn('[Pay] 查询购买状态失败,继续支付流程:', e)
// 查询失败不影响支付
}
this.setData({ isPaying: true })
@@ -556,6 +715,8 @@ Page({
? '《一场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: {
@@ -564,7 +725,8 @@ Page({
productId: sectionId,
amount,
description,
userId: app.globalData.userInfo?.id || ''
userId: app.globalData.userInfo?.id || '',
referralCode: referralCode || undefined
}
})
@@ -607,13 +769,10 @@ Page({
try {
await this.callWechatPay(paymentData)
// 4. 支付成功,更新本地数据
// 4. 【标准流程】支付成功后刷新权限并解锁内容
console.log('[Pay] 微信支付成功!')
this.mockPaymentSuccess(type, sectionId)
wx.showToast({ title: '购买成功', icon: 'success' })
await this.onPaymentSuccess()
// 5. 刷新页面
this.initSection(this.data.sectionId)
} catch (payErr) {
console.error('[Pay] 微信支付调起失败:', payErr)
if (payErr.errMsg && payErr.errMsg.includes('cancel')) {
@@ -648,25 +807,95 @@ Page({
}
},
// 模拟支付成功
mockPaymentSuccess(type, sectionId) {
if (type === 'fullbook') {
app.globalData.hasFullBook = true
const userInfo = app.globalData.userInfo || {}
userInfo.hasFullBook = true
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
} else if (sectionId) {
const purchasedSections = app.globalData.purchasedSections || []
if (!purchasedSections.includes(sectionId)) {
purchasedSections.push(sectionId)
app.globalData.purchasedSections = purchasedSections
// 【新增】支付成功后的标准处理流程
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
}
// 调用专门的购买状态查询接口
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.purchasedSections = purchasedSections
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)
// 刷新失败时不影响用户体验,只是记录日志
}
},
@@ -905,5 +1134,63 @@ Page({
},
// 阻止冒泡
stopPropagation() {}
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))
}
})