2026-03-07 22:58:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 阅读进度追踪器
|
|
|
|
|
|
* 记录阅读进度、时长、是否读完,支持断点续读
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-03-08 08:00:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
|
|
|
|
|
|
setTimeout(() => this.reportProgressToServer(false), 0)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 恢复上次阅读位置(断点续读)
|
|
|
|
|
|
*/
|
|
|
|
|
|
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
|