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

@@ -0,0 +1,201 @@
/**
* 章节权限管理器
* 统一管理章节权限判断、状态流转、异常处理
*/
const app = getApp()
class ChapterAccessManager {
constructor() {
this.accessStates = {
UNKNOWN: 'unknown',
FREE: 'free',
LOCKED_NOT_LOGIN: 'locked_not_login',
LOCKED_NOT_PURCHASED: 'locked_not_purchased',
UNLOCKED_PURCHASED: 'unlocked_purchased',
ERROR: 'error'
}
}
/**
* 拉取最新配置(免费章节列表、价格等)
*/
async fetchLatestConfig() {
try {
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
if (res.success && res.freeChapters) {
return {
freeChapters: res.freeChapters,
prices: res.prices || { section: 1, fullbook: 9.9 }
}
}
} catch (e) {
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
}
// 默认配置
return {
freeChapters: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
prices: { section: 1, fullbook: 9.9 }
}
}
/**
* 判断章节是否免费
*/
isFreeChapter(sectionId, freeList) {
return freeList.includes(sectionId)
}
/**
* 【核心方法】确定章节权限状态
* @param {string} sectionId - 章节ID
* @param {Array} freeList - 免费章节列表
* @returns {Promise<string>} accessState
*/
async determineAccessState(sectionId, freeList) {
try {
// 1. 检查是否免费
if (this.isFreeChapter(sectionId, freeList)) {
console.log('[AccessManager] 免费章节:', sectionId)
return this.accessStates.FREE
}
// 2. 检查是否登录
const userId = app.globalData.userInfo?.id
if (!userId) {
console.log('[AccessManager] 未登录,需要登录:', sectionId)
return this.accessStates.LOCKED_NOT_LOGIN
}
// 3. 请求服务端校验是否已购买(带重试)
const res = await this.requestWithRetry(
`/api/miniprogram/user/check-purchased?userId=${encodeURIComponent(userId)}&type=section&productId=${encodeURIComponent(sectionId)}`,
{ timeout: 5000 },
2 // 最多重试2次
)
if (res.success && res.data?.isPurchased) {
console.log('[AccessManager] 已购买:', sectionId, res.data.reason)
// 同步更新本地缓存(仅用于展示,不作权限依据)
this.syncLocalCache(sectionId, res.data)
return this.accessStates.UNLOCKED_PURCHASED
}
console.log('[AccessManager] 未购买:', sectionId)
return this.accessStates.LOCKED_NOT_PURCHASED
} catch (error) {
console.error('[AccessManager] 权限判断失败:', error)
// 网络/服务端错误 → 保守策略:返回错误状态
return this.accessStates.ERROR
}
}
/**
* 带重试的请求
*/
async requestWithRetry(url, options = {}, maxRetries = 3) {
let lastError = null
for (let i = 0; i < maxRetries; i++) {
try {
const res = await app.request(url, options)
return res
} catch (e) {
lastError = e
console.warn(`[AccessManager] 第 ${i+1} 次请求失败:`, url, e.message)
// 如果不是最后一次,等待后重试(指数退避)
if (i < maxRetries - 1) {
await this.sleep(1000 * (i + 1))
}
}
}
throw lastError
}
/**
* 同步更新本地购买缓存(仅用于展示,不作权限依据)
*/
syncLocalCache(sectionId, purchaseData) {
if (purchaseData.reason === 'has_full_book') {
app.globalData.hasFullBook = true
}
if (!app.globalData.purchasedSections.includes(sectionId)) {
app.globalData.purchasedSections = [...app.globalData.purchasedSections, sectionId]
}
// 更新 storage
const userInfo = app.globalData.userInfo || {}
userInfo.hasFullBook = app.globalData.hasFullBook
userInfo.purchasedSections = app.globalData.purchasedSections
wx.setStorageSync('userInfo', userInfo)
}
/**
* 刷新用户购买状态(从 orders 表拉取最新)
*/
async refreshUserPurchaseStatus() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request(`/api/miniprogram/user/purchase-status?userId=${encodeURIComponent(userId)}`)
if (res.success && res.data) {
app.globalData.hasFullBook = res.data.hasFullBook || false
app.globalData.purchasedSections = res.data.purchasedSections || []
const userInfo = app.globalData.userInfo || {}
userInfo.hasFullBook = res.data.hasFullBook
userInfo.purchasedSections = res.data.purchasedSections
wx.setStorageSync('userInfo', userInfo)
console.log('[AccessManager] 购买状态已刷新:', {
hasFullBook: res.data.hasFullBook,
purchasedCount: res.data.purchasedSections.length
})
}
} catch (e) {
console.error('[AccessManager] 刷新购买状态失败:', e)
}
}
/**
* 获取状态对应的用户提示文案
*/
getStateMessage(accessState) {
const messages = {
[this.accessStates.UNKNOWN]: '加载中...',
[this.accessStates.FREE]: '免费阅读',
[this.accessStates.LOCKED_NOT_LOGIN]: '登录后继续阅读',
[this.accessStates.LOCKED_NOT_PURCHASED]: '购买后继续阅读',
[this.accessStates.UNLOCKED_PURCHASED]: '已解锁',
[this.accessStates.ERROR]: '网络异常,请重试'
}
return messages[accessState] || '未知状态'
}
/**
* 判断是否可访问全文
*/
canAccessFullContent(accessState) {
return [this.accessStates.FREE, this.accessStates.UNLOCKED_PURCHASED].includes(accessState)
}
/**
* 工具:延迟
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
// 导出单例
const accessManager = new ChapterAccessManager()
export default accessManager

View File

@@ -0,0 +1,246 @@
/**
* 阅读进度追踪器
* 记录阅读进度、时长、是否读完,支持断点续读
*/
const app = getApp()
class ReadingTracker {
constructor() {
this.activeTracker = null
this.reportInterval = null
}
/**
* 初始化阅读追踪
*/
init(sectionId) {
// 清理旧的追踪器
this.cleanup()
this.activeTracker = {
sectionId,
startTime: Date.now(),
lastScrollTime: Date.now(),
totalDuration: 0,
maxProgress: 0,
lastPosition: 0,
isCompleted: false,
completedAt: null,
scrollTimer: null
}
console.log('[ReadingTracker] 初始化追踪:', sectionId)
// 恢复上次阅读位置
this.restoreLastPosition(sectionId)
// 开始定期上报每30秒
this.startProgressReport()
}
/**
* 恢复上次阅读位置(断点续读)
*/
restoreLastPosition(sectionId) {
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const lastProgress = progressData[sectionId]
if (lastProgress && lastProgress.lastPosition > 100) {
setTimeout(() => {
wx.pageScrollTo({
scrollTop: lastProgress.lastPosition,
duration: 300
})
wx.showToast({
title: `继续阅读 (${lastProgress.progress}%)`,
icon: 'none',
duration: 2000
})
}, 500)
}
} catch (e) {
console.warn('[ReadingTracker] 恢复位置失败:', e)
}
}
/**
* 更新阅读进度(由页面滚动事件调用)
*/
updateProgress(scrollInfo) {
if (!this.activeTracker) return
const { scrollTop, scrollHeight, clientHeight } = scrollInfo
const totalScrollable = scrollHeight - clientHeight
if (totalScrollable <= 0) return
const progress = Math.min(100, Math.round((scrollTop / totalScrollable) * 100))
// 更新最大进度
if (progress > this.activeTracker.maxProgress) {
this.activeTracker.maxProgress = progress
this.activeTracker.lastPosition = scrollTop
this.saveProgressLocal()
console.log('[ReadingTracker] 进度更新:', progress + '%')
}
// 检查是否读完≥90%
if (progress >= 90 && !this.activeTracker.isCompleted) {
this.checkCompletion()
}
}
/**
* 检查是否读完需要停留3秒
*/
async checkCompletion() {
if (!this.activeTracker || this.activeTracker.isCompleted) return
// 等待3秒确认用户真的读到底部
await this.sleep(3000)
if (this.activeTracker && this.activeTracker.maxProgress >= 90 && !this.activeTracker.isCompleted) {
this.activeTracker.isCompleted = true
this.activeTracker.completedAt = Date.now()
console.log('[ReadingTracker] 阅读完成:', this.activeTracker.sectionId)
// 标记已读app.js 里的已读章节列表)
app.markSectionAsRead(this.activeTracker.sectionId)
// 立即上报完成状态
await this.reportProgressToServer(true)
// 触发埋点
this.trackEvent('chapter_completed', {
sectionId: this.activeTracker.sectionId,
duration: this.activeTracker.totalDuration
})
wx.showToast({
title: '已完成阅读',
icon: 'success',
duration: 1500
})
}
}
/**
* 保存进度到本地
*/
saveProgressLocal() {
if (!this.activeTracker) return
try {
const progressData = wx.getStorageSync('reading_progress') || {}
progressData[this.activeTracker.sectionId] = {
progress: this.activeTracker.maxProgress,
lastPosition: this.activeTracker.lastPosition,
lastOpenAt: Date.now()
}
wx.setStorageSync('reading_progress', progressData)
} catch (e) {
console.warn('[ReadingTracker] 保存本地进度失败:', e)
}
}
/**
* 开始定期上报
*/
startProgressReport() {
// 每30秒上报一次
this.reportInterval = setInterval(() => {
this.reportProgressToServer(false)
}, 30000)
}
/**
* 上报进度到服务端
*/
async reportProgressToServer(isCompletion = false) {
if (!this.activeTracker) return
const userId = app.globalData.userInfo?.id
if (!userId) return
// 计算本次上报的时长
const now = Date.now()
const duration = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += duration
this.activeTracker.lastScrollTime = now
try {
await app.request('/api/miniprogram/user/reading-progress', {
method: 'POST',
data: {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: this.activeTracker.totalDuration,
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}
})
if (isCompletion) {
console.log('[ReadingTracker] 完成状态已上报')
}
} catch (e) {
console.warn('[ReadingTracker] 上报进度失败,下次重试:', e)
}
}
/**
* 页面隐藏/卸载时调用(立即上报)
*/
onPageHide() {
if (this.activeTracker) {
this.reportProgressToServer(false)
}
}
/**
* 清理追踪器
*/
cleanup() {
if (this.reportInterval) {
clearInterval(this.reportInterval)
this.reportInterval = null
}
if (this.activeTracker) {
this.reportProgressToServer(false)
this.activeTracker = null
}
}
/**
* 获取当前章节的阅读进度(用于展示)
*/
getCurrentProgress() {
return this.activeTracker ? this.activeTracker.maxProgress : 0
}
/**
* 数据埋点(可对接统计平台)
*/
trackEvent(eventName, eventData) {
console.log('[Analytics]', eventName, eventData)
// TODO: 接入微信小程序数据助手 / 第三方统计
}
/**
* 工具:延迟
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
// 导出单例
const readingTracker = new ReadingTracker()
export default readingTracker