更新开发文档,强调接口路径必须按使用方区分,禁止通用路径混用。新增小程序分享功能,统一使用推荐码,确保用户体验一致性。
This commit is contained in:
206
miniprogram2/utils/chapterAccessManager.js
Normal file
206
miniprogram2/utils/chapterAccessManager.js
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 章节权限管理器
|
||||
* 统一管理章节权限判断、状态流转、异常处理
|
||||
*/
|
||||
|
||||
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('/api/miniprogram/config', { timeout: 3000 })
|
||||
if (res.success && res.freeChapters) {
|
||||
return {
|
||||
freeChapters: res.freeChapters,
|
||||
prices: res.prices || { section: 1, fullbook: 9.9 },
|
||||
userDiscount: (typeof res.userDiscount === 'number' ? res.userDiscount : 5)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
|
||||
}
|
||||
|
||||
return {
|
||||
freeChapters: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
|
||||
prices: { section: 1, fullbook: 9.9 },
|
||||
userDiscount: 5
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断章节是否免费
|
||||
*/
|
||||
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 || []
|
||||
app.globalData.sectionMidMap = res.data.sectionMidMap || {}
|
||||
app.globalData.matchCount = res.data.matchCount ?? 0
|
||||
app.globalData.matchQuota = res.data.matchQuota || null
|
||||
|
||||
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,
|
||||
matchCount: res.data.matchCount
|
||||
})
|
||||
}
|
||||
} 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
|
||||
211
miniprogram2/utils/payment.js
Normal file
211
miniprogram2/utils/payment.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// miniprogram/utils/payment.js
|
||||
// 微信支付工具类
|
||||
|
||||
const app = getApp()
|
||||
|
||||
/**
|
||||
* 发起微信支付
|
||||
* @param {Object} options - 支付选项
|
||||
* @param {String} options.orderId - 订单ID
|
||||
* @param {Number} options.amount - 支付金额(元)
|
||||
* @param {String} options.description - 商品描述
|
||||
* @param {Function} options.success - 成功回调
|
||||
* @param {Function} options.fail - 失败回调
|
||||
*/
|
||||
function wxPay(options) {
|
||||
const { orderId, amount, description, success, fail } = options
|
||||
|
||||
wx.showLoading({
|
||||
title: '正在支付...',
|
||||
mask: true
|
||||
})
|
||||
|
||||
// 1. 调用后端创建支付订单
|
||||
wx.request({
|
||||
url: `${app.globalData.apiBase}/payment/create`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${wx.getStorageSync('token')}`
|
||||
},
|
||||
data: {
|
||||
orderId,
|
||||
amount,
|
||||
description,
|
||||
paymentMethod: 'wechat'
|
||||
},
|
||||
success: (res) => {
|
||||
wx.hideLoading()
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const paymentData = res.data
|
||||
|
||||
// 2. 调起微信支付
|
||||
wx.requestPayment({
|
||||
timeStamp: paymentData.timeStamp,
|
||||
nonceStr: paymentData.nonceStr,
|
||||
package: paymentData.package,
|
||||
signType: paymentData.signType || 'RSA',
|
||||
paySign: paymentData.paySign,
|
||||
success: (payRes) => {
|
||||
console.log('支付成功', payRes)
|
||||
|
||||
// 3. 通知后端支付成功
|
||||
notifyPaymentSuccess(orderId, paymentData.prepayId)
|
||||
|
||||
wx.showToast({
|
||||
title: '支付成功',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
|
||||
success && success(payRes)
|
||||
},
|
||||
fail: (payErr) => {
|
||||
console.error('支付失败', payErr)
|
||||
|
||||
if (payErr.errMsg.indexOf('cancel') !== -1) {
|
||||
wx.showToast({
|
||||
title: '支付已取消',
|
||||
icon: 'none'
|
||||
})
|
||||
} else {
|
||||
wx.showToast({
|
||||
title: '支付失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
fail && fail(payErr)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
wx.showToast({
|
||||
title: res.data.message || '创建订单失败',
|
||||
icon: 'none'
|
||||
})
|
||||
fail && fail(res)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
wx.hideLoading()
|
||||
console.error('请求失败', err)
|
||||
|
||||
wx.showToast({
|
||||
title: '网络请求失败',
|
||||
icon: 'none'
|
||||
})
|
||||
|
||||
fail && fail(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知后端支付成功
|
||||
* @param {String} orderId
|
||||
* @param {String} prepayId
|
||||
*/
|
||||
function notifyPaymentSuccess(orderId, prepayId) {
|
||||
wx.request({
|
||||
url: `${app.globalData.apiBase}/payment/notify`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${wx.getStorageSync('token')}`
|
||||
},
|
||||
data: {
|
||||
orderId,
|
||||
prepayId,
|
||||
status: 'success'
|
||||
},
|
||||
success: (res) => {
|
||||
console.log('支付通知成功', res)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('支付通知失败', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单状态
|
||||
* @param {String} orderId
|
||||
* @param {Function} callback
|
||||
*/
|
||||
function queryOrderStatus(orderId, callback) {
|
||||
wx.request({
|
||||
url: `${app.globalData.apiBase}/payment/query`,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': `Bearer ${wx.getStorageSync('token')}`
|
||||
},
|
||||
data: { orderId },
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
callback && callback(true, res.data)
|
||||
} else {
|
||||
callback && callback(false, null)
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
callback && callback(false, null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买完整电子书
|
||||
* @param {Function} success
|
||||
* @param {Function} fail
|
||||
*/
|
||||
function purchaseFullBook(success, fail) {
|
||||
// 计算动态价格:9.9 + (天数 * 1元)
|
||||
const basePrice = 9.9
|
||||
const startDate = new Date('2025-01-01') // 书籍上架日期
|
||||
const today = new Date()
|
||||
const daysPassed = Math.floor((today - startDate) / (1000 * 60 * 60 * 24))
|
||||
const currentPrice = basePrice + daysPassed
|
||||
|
||||
const orderId = `ORDER_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
wxPay({
|
||||
orderId,
|
||||
amount: currentPrice,
|
||||
description: 'Soul派对·创业实验 完整版',
|
||||
success: (res) => {
|
||||
// 更新本地购买状态
|
||||
updatePurchaseStatus(true)
|
||||
success && success(res)
|
||||
},
|
||||
fail
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新购买状态
|
||||
* @param {Boolean} isPurchased
|
||||
*/
|
||||
function updatePurchaseStatus(isPurchased) {
|
||||
const userInfo = app.getUserInfo()
|
||||
if (userInfo) {
|
||||
userInfo.isPurchased = isPurchased
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
app.globalData.userInfo = userInfo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已购买
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function checkPurchaseStatus() {
|
||||
const userInfo = app.getUserInfo()
|
||||
return userInfo ? userInfo.isPurchased : false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
wxPay,
|
||||
queryOrderStatus,
|
||||
purchaseFullBook,
|
||||
checkPurchaseStatus,
|
||||
updatePurchaseStatus
|
||||
}
|
||||
246
miniprogram2/utils/readingTracker.js
Normal file
246
miniprogram2/utils/readingTracker.js
Normal 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
|
||||
45
miniprogram2/utils/scene.js
Normal file
45
miniprogram2/utils/scene.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 小程序码 scene 参数统一编解码(海报生成 ↔ 扫码解析闭环)
|
||||
* 官方以 options.scene 接收扫码参数;后端生成码时会把 & 转为 _,故解析时同时支持 & 和 _
|
||||
* scene 同时可带两个参数:章节标识(mid/id) + 推荐人(ref)
|
||||
*/
|
||||
|
||||
const SEP = '_' // 生成时统一用 _,与微信实际存储一致,且不占 32 字符限制
|
||||
|
||||
/**
|
||||
* 编码:生成海报/分享时组 scene 字符串(同时带 mid或id + ref)
|
||||
* @param {{ mid?: number, id?: string, ref?: string }} opts
|
||||
* @returns {string} 如 "mid=1_ref=ogpTW5fmXR" 或 "id=1.1_ref=xxx"
|
||||
*/
|
||||
function buildScene(opts) {
|
||||
const parts = []
|
||||
if (opts.mid != null && opts.mid !== '') parts.push(`mid=${opts.mid}`)
|
||||
if (opts.id) parts.push(`id=${opts.id}`)
|
||||
if (opts.ref) parts.push(`ref=${opts.ref}`)
|
||||
return parts.join(SEP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码:从 options.scene 解析出 mid、id、ref(支持 & 或 _ 分隔)
|
||||
* @param {string} sceneStr 原始 scene(可能未 decodeURIComponent)
|
||||
* @returns {{ mid: number, id: string, ref: string }}
|
||||
*/
|
||||
function parseScene(sceneStr) {
|
||||
const res = { mid: 0, id: '', ref: '' }
|
||||
if (!sceneStr || typeof sceneStr !== 'string') return res
|
||||
const decoded = decodeURIComponent(String(sceneStr)).trim()
|
||||
const parts = decoded.split(/[&_]/)
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf('=')
|
||||
if (eq > 0) {
|
||||
const k = part.slice(0, eq)
|
||||
const v = part.slice(eq + 1)
|
||||
if (k === 'mid') res.mid = parseInt(v, 10) || 0
|
||||
if (k === 'id' && v) res.id = v
|
||||
if (k === 'ref' && v) res.ref = v
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
module.exports = { buildScene, parseScene }
|
||||
182
miniprogram2/utils/util.js
Normal file
182
miniprogram2/utils/util.js
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Soul创业实验 - 工具函数
|
||||
*/
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = date => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const second = date.getSeconds()
|
||||
|
||||
return `${[year, month, day].map(formatNumber).join('/')} ${[hour, minute, second].map(formatNumber).join(':')}`
|
||||
}
|
||||
|
||||
const formatNumber = n => {
|
||||
n = n.toString()
|
||||
return n[1] ? n : `0${n}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = date => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
return `${year}-${formatNumber(month)}-${formatNumber(day)}`
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (amount, decimals = 2) => {
|
||||
return Number(amount).toFixed(decimals)
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (fn, delay = 300) => {
|
||||
let timer = null
|
||||
return function (...args) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(this, args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
const throttle = (fn, delay = 300) => {
|
||||
let last = 0
|
||||
return function (...args) {
|
||||
const now = Date.now()
|
||||
if (now - last >= delay) {
|
||||
fn.apply(this, args)
|
||||
last = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return 'id_' + Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
// 检查手机号格式
|
||||
const isValidPhone = phone => {
|
||||
return /^1[3-9]\d{9}$/.test(phone)
|
||||
}
|
||||
|
||||
// 检查微信号格式
|
||||
const isValidWechat = wechat => {
|
||||
return wechat && wechat.length >= 6 && wechat.length <= 20
|
||||
}
|
||||
|
||||
// 深拷贝
|
||||
const deepClone = obj => {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
if (obj instanceof Date) return new Date(obj)
|
||||
if (obj instanceof Array) return obj.map(item => deepClone(item))
|
||||
if (obj instanceof Object) {
|
||||
const copy = {}
|
||||
Object.keys(obj).forEach(key => {
|
||||
copy[key] = deepClone(obj[key])
|
||||
})
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URL参数
|
||||
const getQueryParams = url => {
|
||||
const params = {}
|
||||
const queryString = url.split('?')[1]
|
||||
if (queryString) {
|
||||
queryString.split('&').forEach(pair => {
|
||||
const [key, value] = pair.split('=')
|
||||
params[decodeURIComponent(key)] = decodeURIComponent(value || '')
|
||||
})
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// 存储操作
|
||||
const storage = {
|
||||
get(key) {
|
||||
try {
|
||||
return wx.getStorageSync(key)
|
||||
} catch (e) {
|
||||
console.error('获取存储失败:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
try {
|
||||
wx.setStorageSync(key, value)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('设置存储失败:', e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
try {
|
||||
wx.removeStorageSync(key)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('删除存储失败:', e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
try {
|
||||
wx.clearStorageSync()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('清除存储失败:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示Toast
|
||||
const showToast = (title, icon = 'none', duration = 2000) => {
|
||||
wx.showToast({ title, icon, duration })
|
||||
}
|
||||
|
||||
// 显示Loading
|
||||
const showLoading = (title = '加载中...') => {
|
||||
wx.showLoading({ title, mask: true })
|
||||
}
|
||||
|
||||
// 隐藏Loading
|
||||
const hideLoading = () => {
|
||||
wx.hideLoading()
|
||||
}
|
||||
|
||||
// 显示确认框
|
||||
const showConfirm = (title, content) => {
|
||||
return new Promise((resolve) => {
|
||||
wx.showModal({
|
||||
title,
|
||||
content,
|
||||
success: res => resolve(res.confirm)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
debounce,
|
||||
throttle,
|
||||
generateId,
|
||||
isValidPhone,
|
||||
isValidWechat,
|
||||
deepClone,
|
||||
getQueryParams,
|
||||
storage,
|
||||
showToast,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
showConfirm
|
||||
}
|
||||
Reference in New Issue
Block a user