/** * Soul创业派对 - 阅读页(标准流程版) * 开发: 卡若 * 技术支持: 存客宝 * * 更新: 2026-02-04 * - 引入权限管理器(chapterAccessManager)统一权限判断 * - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完 * - 使用状态机(accessState)规范权限流转 * - 异常统一保守处理,避免误解锁 * * 更新: 正文 @某人(TipTap HTML ) * - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead */ import accessManager from '../../utils/chapterAccessManager' import readingTracker from '../../utils/readingTracker' const { parseScene } = require('../../utils/scene.js') const contentParser = require('../../utils/contentParser.js') const { trackClick } = require('../../utils/trackClick') const app = getApp() Page({ data: { // 系统信息 statusBarHeight: 44, navBarHeight: 88, // 章节信息 sectionId: '', section: null, partTitle: '', chapterTitle: '', // 内容 content: '', previewContent: '', contentParagraphs: [], contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}] previewParagraphs: [], loading: true, // 【新增】权限状态机(替代 canAccess) // unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误 accessState: 'unknown', // 用户状态 isLoggedIn: false, hasFullBook: false, canAccess: false, // 保留兼容性,从 accessState 派生 purchasedCount: 0, // 阅读进度 readingProgress: 0, showPaywall: false, // 上一篇/下一篇 prevSection: null, nextSection: null, // 价格 sectionPrice: 1, fullBookPrice: 9.9, totalSections: 62, // 弹窗 showShareModal: false, showGiftModal: false, giftQuantity: 6, giftUnitPrice: 0, giftTotalPrice: '0.00', giftPaying: false, giftPaid: false, giftRequestSn: '', showLoginModal: false, agreeProtocol: false, showPosterModal: false, isPaying: false, isGeneratingPoster: false, // 章节 mid(扫码/海报分享用,便于分享 path 带 mid) sectionMid: null, // 余额(用于余额支付) walletBalance: 0, // 审核模式:隐藏购买按钮 auditMode: false, // 好友从代付分享进入:待自动领取的 requestSn pendingGiftRequestSn: '', }, onShow() { this.setData({ auditMode: app.globalData.auditMode || false }) }, async onLoad(options) { wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] }) // 预加载:core+auditMode(getConfig)+ read-extras 懒加载(linkTags、linkedMiniprograms) Promise.all([ app.getConfig(), app.getReadExtras() ]).then(([cfg, extras]) => { if (cfg) { const mp = (cfg && cfg.mpConfig) || {} const auditMode = !!mp.auditMode app.globalData.auditMode = auditMode if (typeof this.setData === 'function') this.setData({ auditMode }) } if (extras && Array.isArray(extras.linkTags)) { app.globalData.linkTagsConfig = extras.linkTags app.globalData.linkedMiniprograms = extras.linkedMiniprograms || [] } }).catch(() => {}) // 支持 scene(扫码)、mid、id、ref、gift(代付) const sceneStr = (options && options.scene) || '' const parsed = parseScene(sceneStr) const isGift = options.gift === '1' || options.gift === 'true' // 代付:分享链路使用 requestSn(优先 options.requestSn;兼容旧链路 gift=1&ref=requestSn) const giftRequestSn = (options.requestSn || (isGift ? (options.ref || parsed.ref) : '') || '').trim() // 推荐码:仅在非代付链路使用 ref const ref = (!isGift ? (options.ref || parsed.ref) : '') || '' const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0) let id = options.id || parsed.id || app.globalData.initialSectionId if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid if (app.globalData.initialSectionId) delete app.globalData.initialSectionId console.log("页面:",mid); // 兼容:mid 有值但无 id 时,用 by-mid 解析 id;有 id 无 mid 时,后续用 by-id 请求 if (mid && !id) { try { const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}` const uid = app.globalData.userInfo?.id const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true }) if (chRes && chRes.id) id = chRes.id } catch (e) { console.warn('[Read] by-mid 解析失败:', e) } } if (!id) { wx.showToast({ title: '章节参数缺失', icon: 'none' }) this.setData({ accessState: 'error', loading: false }) return } this.setData({ statusBarHeight: app.globalData.statusBarHeight, navBarHeight: app.globalData.navBarHeight, sectionId: id, sectionMid: mid || null, loading: true, accessState: 'unknown', pendingGiftRequestSn: giftRequestSn || '' }) if (ref) { console.log('[Read] 检测到推荐码:', ref) wx.setStorageSync('referral_code', ref) app.handleReferralCode({ query: { ref } }) } try { const config = await accessManager.fetchLatestConfig() this.setData({ sectionPrice: config.prices?.section ?? 1, fullBookPrice: config.prices?.fullbook ?? 9.9 }) // 统一:先拉章节数据,用 isFree/price===0 判断免费 const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true }) let accessState = await accessManager.determineAccessState(id, chapterRes) let canAccess = accessManager.canAccessFullContent(accessState) this.setData({ accessState, canAccess, isLoggedIn: !!app.globalData.userInfo?.id, showPaywall: !canAccess }) // 加载内容(复用已拉取的章节数据,避免二次请求) await this.loadContent(id, accessState, chapterRes) // 代付自动领取:好友打开阅读页时自动领取并解锁 if (this.data.pendingGiftRequestSn) { const redeemed = await this._tryAutoRedeemGift(this.data.pendingGiftRequestSn) if (redeemed) { // 领取成功后刷新章节与权限(保守:重新拉章节数据 + 重新判断权限) await accessManager.refreshUserPurchaseStatus() const freshChapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true }) accessState = await accessManager.determineAccessState(id, freshChapterRes) canAccess = accessManager.canAccessFullContent(accessState) this.setData({ accessState, canAccess, showPaywall: !canAccess, pendingGiftRequestSn: '' }) if (canAccess) { await this.loadContent(id, accessState, freshChapterRes) readingTracker.init(id) } } } // 【标准流程】4. 如果有权限,初始化阅读追踪 if (canAccess) { readingTracker.init(id) } // 5. 导航:文章详情已带 prev/next this._applyPrevNext(chapterRes) } catch (e) { console.error('[Read] 初始化失败:', e) wx.showToast({ title: '加载失败,请重试', icon: 'none' }) this.setData({ accessState: 'error', loading: false }) } finally { this.setData({ loading: false }) } }, _getGiftUnitPrice() { const p = this.data.section?.price const cfg = this.data.sectionPrice const v = (p != null && p !== '') ? Number(p) : Number(cfg || 0) return isNaN(v) ? 0 : v }, _updateGiftTotalPrice() { const unit = this.data.giftUnitPrice || this._getGiftUnitPrice() const q = parseInt(this.data.giftQuantity, 10) || 0 const total = unit * q this.setData({ giftUnitPrice: unit, giftTotalPrice: (isNaN(total) ? 0 : total).toFixed(2) }) }, async _tryAutoRedeemGift(requestSn) { // 单页模式(朋友圈)不做自动领取,避免隐式登录/支付能力限制 try { const sys = wx.getSystemInfoSync() const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode if (isSinglePage) return false } catch (e) {} const userId = app.globalData.userInfo?.id if (!userId) { // 记住 requestSn,登录后自动领取 this.setData({ pendingGiftRequestSn: requestSn }) wx.showToast({ title: '登录后将自动领取并解锁', icon: 'none', duration: 2500 }) this.showLoginModal() return false } try { const res = await app.request({ url: '/api/miniprogram/gift-pay/redeem', method: 'POST', data: { requestSn, userId } }) if (res && res.success) return true // 已领取/已无名额等都视为无需再重试 if (res && (res.error || res.message)) { wx.showToast({ title: res.error || res.message || '领取失败', icon: 'none' }) } this.setData({ pendingGiftRequestSn: '' }) return false } catch (e) { console.warn('[Read][Gift] 自动领取失败:', e) return false } }, // 从后端加载免费章节配置 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] && 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) } }) }, // 加载章节内容:优先复用 prefetchedChapter 避免二次请求,失败时降级本地缓存 async loadContent(id, accessState, prefetchedChapter) { const cacheKey = `chapter_${id}` try { const sectionPrice = this.data.sectionPrice ?? 1 let res = prefetchedChapter if (!res || !res.content) { res = await app.request({ url: this._getChapterUrl({ id }), silent: true }) } const section = { id: res.id || id, title: res.sectionTitle || res.title || this.getSectionTitle(id), isFree: res.isFree === true || (res.price !== undefined && res.price === 0), price: res.price ?? sectionPrice } this.setData({ section }) // 已解锁用 data.content(完整内容),未解锁用 content(预览);先 determineAccessState 再 loadContent 保证顺序正确 const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content if (res && displayContent) { const { lines, segments } = contentParser.parseContent(displayContent) // 预览内容由后端统一截取比例,这里展示全部预览内容 const previewCount = lines.length const updates = { content: displayContent, contentParagraphs: lines, contentSegments: segments, previewParagraphs: lines.slice(0, previewCount), partTitle: res.partTitle || '', chapterTitle: res.chapterTitle || '' } if (res.mid) updates.sectionMid = res.mid this.setData(updates) // 写入本地缓存(存 displayContent,供离线/重试降级使用) try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {} if (accessManager.canAccessFullContent(accessState)) { app.markSectionAsRead(id) } } } catch (e) { console.error('[Read] 加载内容失败,尝试本地缓存:', e) try { const cached = wx.getStorageSync(cacheKey) if (cached && cached.content) { const { lines, segments } = contentParser.parseContent(cached.content) // 预览内容由后端统一截取比例,这里展示全部预览内容 const previewCount = lines.length this.setData({ content: cached.content, contentParagraphs: lines, contentSegments: segments, previewParagraphs: lines.slice(0, previewCount), partTitle: cached.partTitle || '', chapterTitle: cached.chapterTitle || '' }) console.log('[Read] 从本地缓存加载成功') return } } catch (cacheErr) { console.warn('[Read] 本地缓存也失败:', cacheErr) } throw e } }, // 获取章节信息 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}` }, // 根据 id/mid 构造章节接口路径:优先 mid(by-mid),否则用 id(by-id,兼容旧链接) _getChapterUrl(params = {}) { const { id, mid } = params const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid let url if (finalMid) { url = `/api/miniprogram/book/chapter/by-mid/${finalMid}` } else { const finalId = id || this.data.sectionId url = `/api/miniprogram/book/chapter/by-id/${encodeURIComponent(finalId)}` } const userId = app.globalData.userInfo?.id if (userId) url += (url.includes('?') ? '&' : '?') + 'userId=' + encodeURIComponent(userId) return url }, // 带超时的章节请求 fetchChapterWithTimeout(id, timeout = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error('请求超时')) }, timeout) app.request(this._getChapterUrl({ id })) .then(res => { clearTimeout(timer) resolve(res) }) .catch(err => { clearTimeout(timer) reject(err) }) }) }, // 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML) setChapterContent(res) { const { lines, segments } = contentParser.parseContent(res.content) // 预览内容由后端统一截取比例,这里展示全部预览内容 const previewCount = lines.length const sectionPrice = this.data.sectionPrice ?? 1 const sectionTitle = (res.sectionTitle || res.title || '').trim() this.setData({ // 文章详情标题:只使用后端提供的 sectionTitle,不再拼接其他本地标题信息 section: { id: res.id || this.data.sectionId, title: sectionTitle, isFree: res.isFree === true || (res.price !== undefined && res.price === 0), price: res.price ?? sectionPrice }, content: res.content, previewContent: lines.slice(0, previewCount).join('\n'), contentParagraphs: lines, contentSegments: segments, previewParagraphs: lines.slice(0, previewCount), partTitle: res.partTitle || '', // 导航栏、分享等使用的文章标题,同样统一为 sectionTitle chapterTitle: sectionTitle }) }, // 静默刷新(后台更新缓存) 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: ['内容加载失败', '请检查网络连接后下拉刷新重试'], contentSegments: contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments, 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)) }, _applyPrevNext(res) { const prev = res?.prev const next = res?.next this.setData({ prevSection: prev ? { id: prev.id, mid: prev.mid ?? null, title: prev.title || this.getSectionTitle(prev.id), } : null, nextSection: next ? { id: next.id, mid: next.mid ?? null, title: next.title || this.getSectionTitle(next.id), } : null, }) }, // 返回(从分享进入无栈时回首页) goBack() { getApp().goBackOrToHome() }, // 点击正文中的 #链接标签:小程序内页/预览页/唤醒其他小程序 onLinkTagTap(e) { let url = (e.currentTarget.dataset.url || '').trim() const label = (e.currentTarget.dataset.label || '').trim() let tagType = (e.currentTarget.dataset.tagType || '').trim() let pagePath = (e.currentTarget.dataset.pagePath || '').trim() let mpKey = (e.currentTarget.dataset.mpKey || '').trim() // 旧格式()tagType 为空 → 按 label 从缓存 linkTags 补充类型信息 if (!tagType && label) { const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label) if (cached) { tagType = cached.type || 'url' pagePath = cached.pagePath || '' if (!url) url = cached.url || '' if (cached.mpKey) mpKey = cached.mpKey } } // CKB 类型:复用 @mention 加好友流程,弹出留资表单 if (tagType === 'ckb') { // 触发通用加好友(无特定 personId,使用全局 CKB Key) this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } }) return } // 小程序类型:用密钥查 linkedMiniprograms 得 appId,再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置) if (tagType === 'miniprogram') { if (!mpKey && label) { const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label) if (cached) mpKey = cached.mpKey || '' } const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey) if (linked && linked.appId) { wx.navigateToMiniProgram({ appId: linked.appId, path: pagePath || linked.path || '', envVersion: 'release', success: () => {}, fail: (err) => { wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' }) }, }) return } if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' }) } // 小程序内部路径(pagePath 或 url 以 /pages/ 开头) const internalPath = pagePath || (url.startsWith('/pages/') ? url : '') if (internalPath) { wx.navigateTo({ url: internalPath, fail: () => wx.switchTab({ url: internalPath }) }) return } // 外部 URL:跳转到内置预览页,由 web-view 打开 if (url) { const encodedUrl = encodeURIComponent(url) const encodedTitle = encodeURIComponent(label || '链接预览') wx.navigateTo({ url: `/pages/link-preview/link-preview?url=${encodedUrl}&title=${encodedTitle}`, }) return } wx.showToast({ title: '暂无跳转地址', icon: 'none' }) }, // 点击正文图片 → 全屏预览 onImageTap(e) { const src = e.currentTarget.dataset.src if (!src) return wx.previewImage({ current: src, urls: [src] }) }, // 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资 onMentionTap(e) { const userId = e.currentTarget.dataset.userId const nickname = (e.currentTarget.dataset.nickname || '').trim() || 'TA' if (!userId) return wx.showModal({ title: '添加好友', content: `是否添加 @${nickname} ?`, confirmText: '确定', cancelText: '取消', success: (res) => { if (!res.confirm) return this._doMentionAddFriend(userId, nickname) } }) }, // 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重 async _doMentionAddFriend(targetUserId, targetNickname) { const app = getApp() if (!app.globalData.isLoggedIn || !app.globalData.userInfo) { wx.showModal({ title: '提示', content: '请先登录后再添加好友', confirmText: '去登录', cancelText: '取消', success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) } }) return } const myUserId = app.globalData.userInfo.id let phone = (app.globalData.userInfo.phone || '').trim() let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim() if (!phone && !wechatId) { try { const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true }) if (profileRes?.success && profileRes.data) { phone = (profileRes.data.phone || '').trim() wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim() } } catch (e) {} } if (!phone && !wechatId) { wx.showModal({ title: '完善资料', content: '请先填写手机号或微信号,以便对方联系您', confirmText: '去填写', cancelText: '取消', success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) } }) return } // 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用) const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0 if (Date.now() - leadLastTs < 2 * 60 * 1000) { wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' }) return } wx.showLoading({ title: '提交中...', mask: true }) try { const res = await app.request({ url: '/api/miniprogram/ckb/lead', method: 'POST', data: { userId: myUserId, phone: phone || undefined, wechatId: wechatId || undefined, name: (app.globalData.userInfo.nickname || '').trim() || undefined, targetUserId, targetNickname: targetNickname || undefined, source: 'article_mention' } }) wx.hideLoading() if (res && res.success) { wx.setStorageSync('lead_last_submit_ts', Date.now()) wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' }) } else { wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' }) } } catch (e) { wx.hideLoading() wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' }) } }, // 分享弹窗 showShare() { this.setData({ showShareModal: true }) }, closeShareModal() { this.setData({ showShareModal: false }) }, // 代付分享:直接跳转代付页,在代付页输入数量并支付(简化流程) showGiftShareModal() { if (!app.globalData.userInfo?.id) { wx.showToast({ title: '请先登录', icon: 'none' }) return } const { sectionId } = this.data if (!sectionId) { wx.showToast({ title: '章节信息异常', icon: 'none' }) return } this.setData({ showGiftModal: true, giftPaid: false, giftRequestSn: '', giftPaying: false, giftQuantity: 6 }) this._updateGiftTotalPrice() }, closeGiftModal() { this.setData({ showGiftModal: false }) }, selectGiftQuantity(e) { const q = parseInt(e.currentTarget.dataset.q, 10) if (!q || q < 1) return this.setData({ giftQuantity: q }) this._updateGiftTotalPrice() }, async confirmGiftPay() { if (this.data.giftPaying) return // 朋友圈单页模式禁止支付 try { const sys = wx.getSystemInfoSync() const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode if (isSinglePage) { wx.showModal({ title: '朋友圈单页', content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。', showCancel: false }) return } } catch (e) {} const userId = app.globalData.userInfo?.id if (!userId) { wx.showToast({ title: '请先登录', icon: 'none' }) return } const sectionId = this.data.sectionId const quantity = parseInt(this.data.giftQuantity, 10) if (!sectionId || !quantity) { wx.showToast({ title: '参数异常', icon: 'none' }) return } let openId = app.globalData.openId || wx.getStorageSync('openId') if (!openId) { wx.showLoading({ title: '获取支付凭证...', mask: true }) openId = await app.getOpenId() wx.hideLoading() } if (!openId) { wx.showToast({ title: '请先登录', icon: 'none' }) return } this.setData({ giftPaying: true }) wx.showLoading({ title: '创建订单中...', mask: true }) try { // 1) 创建代付请求 const createRes = await app.request({ url: '/api/miniprogram/gift-pay/create', method: 'POST', data: { userId, productType: 'section', productId: sectionId, quantity } }) if (!createRes?.success || !createRes.requestSn) { throw new Error(createRes?.error || '创建失败') } const requestSn = createRes.requestSn // 2) 发起人支付(微信支付) const payRes = await app.request({ url: '/api/miniprogram/gift-pay/initiator-pay', method: 'POST', data: { requestSn, openId, userId } }) wx.hideLoading() if (!payRes || !payRes.success || !payRes.data?.payParams) { throw new Error(payRes?.error || '创建订单失败') } const payParams = payRes.data.payParams const orderSn = payRes.data.orderSn await new Promise((resolve, reject) => { wx.requestPayment({ timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType || 'RSA', paySign: payParams.paySign, success: resolve, fail: reject }) }) // 3) 主动同步(与其他支付流程一致) if (orderSn) { try { await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true }) } catch (e) {} } wx.showToast({ title: '支付成功', icon: 'success' }) this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false }) } catch (e) { wx.hideLoading() const msg = e?.message || e?.error || e?.errMsg || '支付失败' if (e?.errMsg && String(e.errMsg).includes('cancel')) { wx.showToast({ title: '已取消支付', icon: 'none' }) } else { wx.showToast({ title: msg, icon: 'none', duration: 2500 }) } this.setData({ giftPaying: 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(e) { trackClick('read', 'btn_click', '分享_' + this.data.sectionId) const { section, sectionId, sectionMid } = this.data const ref = app.getMyReferralCode() const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` // 代付分享按钮(支付后):好友打开阅读页自动领取解锁 const isGiftShare = e?.from === 'button' && e?.target?.dataset?.gift === '1' const requestSn = (e?.target?.dataset?.requestSn || '').trim() if (isGiftShare && requestSn) { let path = `/pages/read/read?${q}&gift=1&requestSn=${encodeURIComponent(requestSn)}` if (ref) path += `&ref=${encodeURIComponent(ref)}` const t = section?.title || 'Soul创业派对' const title = `我已为你买单:${t.length > 18 ? t.slice(0, 18) + '...' : t}` return { title, path } } const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}` const title = section?.title ? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}` : '📚 Soul创业派对 - 真实商业故事' return { title, path } }, // 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户 onShareTimelineTap() { wx.showToast({ title: '请点击右上角「...」→ 分享到朋友圈', icon: 'none', duration: 2500 }) }, // 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角) shareToMoments() { const title = this.data.section?.title || this.data.chapterTitle || '好文推荐' const raw = (this.data.content || '') .replace(/<[^>]+>/g, '\n') .replace(/ /g, ' ') .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"') .replace(/[#@]\S+/g, '') const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4) const picked = sentences.slice(0, 5) const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#Soul创业派对 #真实商业故事` wx.setClipboardData({ data: copyText, success: () => { wx.showModal({ title: '文案已复制', content: '请点击右上角「···」菜单,选择「分享到朋友圈」即可发布', showCancel: false, confirmText: '知道了' }) }, fail: () => { wx.showToast({ title: '复制失败,请手动复制', icon: 'none' }) } }) }, // 分享到朋友圈:带文章标题,过长时截断 onShareTimeline() { const { section, sectionId, sectionMid, chapterTitle } = this.data const ref = app.getMyReferralCode() const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` const query = ref ? `${q}&ref=${ref}` : q const articleTitle = (section?.title || chapterTitle || '').trim() const title = articleTitle ? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle) : 'Soul创业派对 - 真实商业故事' return { title, query } }, // 显示登录弹窗(每次打开协议未勾选,符合审核要求) showLoginModal() { // 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」 try { const sys = wx.getSystemInfoSync() const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode if (isSinglePage) { wx.showModal({ title: '请前往完整小程序', content: '当前为朋友圈单页,仅支持部分浏览。想登录继续阅读,请点击底部「前往小程序」后再操作。', showCancel: false, confirmText: '我知道了', }) return } } catch (e) { console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e) } 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) return this.setData({ showLoginModal: false, agreeProtocol: false }) await this.onLoginSuccess() wx.showToast({ title: '登录成功', icon: 'success' }) } catch (e) { console.error('[Read] 登录失败:', e) wx.showToast({ title: '登录失败,请重试', icon: 'none' }) } }, // 【重构】手机号登录(标准流程) async handlePhoneLogin(e) { if (!e.detail.code) { return this.handleWechatLogin() } try { const result = await app.loginWithPhone(e.detail.code) 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 { // 0. 若有代付待领取,先领取再刷新购买状态 if (this.data.pendingGiftRequestSn) { try { const userId = app.globalData.userInfo?.id const requestSn = this.data.pendingGiftRequestSn if (userId && requestSn) { const res = await app.request({ url: '/api/miniprogram/gift-pay/redeem', method: 'POST', data: { requestSn, userId } }) if (res && res.success) { this.setData({ pendingGiftRequestSn: '' }) } } } catch (e) { console.warn('[Read][Gift] 登录后自动领取失败:', e) } } // 1. 刷新用户购买状态(从 orders 表拉取最新) await accessManager.refreshUserPurchaseStatus() // 2. 重新拉取章节数据,用 isFree/price 判断免费 const chapterRes = await app.request({ url: this._getChapterUrl({}), silent: true }) const newAccessState = await accessManager.determineAccessState( this.data.sectionId, chapterRes ) const canAccess = accessManager.canAccessFullContent(newAccessState) this.setData({ accessState: newAccessState, canAccess, isLoggedIn: true, showPaywall: !canAccess }) // 3. 如果已解锁,重新加载内容并初始化阅读追踪 if (canAccess) { await this.loadContent(this.data.sectionId, newAccessState, chapterRes) readingTracker.init(this.data.sectionId) } wx.hideLoading() } catch (e) { wx.hideLoading() console.error('[Read] 登录后更新状态失败:', e) wx.showToast({ title: '状态更新失败,请重试', icon: 'none' }) } }, // 购买章节 - 直接调起支付 async handlePurchaseSection() { trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId) 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 } // ✅ 从服务器查询是否已购买(基于 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 } } } } catch (e) { console.warn('[Pay] 查询购买状态失败,继续支付流程:', e) // 查询失败不影响支付 } this.setData({ isPaying: true }) wx.showLoading({ title: '正在发起支付...', mask: true }) try { // 0. 尝试余额支付(若余额足够) const userId = app.globalData.userInfo?.id const referralCode = wx.getStorageSync('referral_code') || '' if (userId) { try { const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }) const balance = balanceRes?.data?.balance || 0 if (balance >= amount) { const productId = type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : '') const consumeRes = await app.request({ url: '/api/miniprogram/balance/consume', method: 'POST', data: { userId, productType: type, productId: type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : 'vip_annual'), amount, referralCode: referralCode || undefined } }) if (consumeRes?.success) { wx.hideLoading() this.setData({ isPaying: false }) wx.showToast({ title: '购买成功', icon: 'success' }) await this.onPaymentSuccess() return } } } catch (e) { console.warn('[Pay] 余额支付失败,改用微信支付:', e) } } // 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}` // 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账 const referralCode = wx.getStorageSync('referral_code') || '' const res = await app.request('/api/miniprogram/pay', { method: 'POST', data: { openId, productType: type, productId: sectionId, amount, description, userId: app.globalData.userInfo?.id || '', referralCode: referralCode || undefined } }) console.log('[Pay] 创建订单响应:', res) if (res.success && res.data?.payParams) { paymentData = res.data.payParams paymentData._orderSn = res.data.orderSn // 保存订单号,支付成功后用于主动同步 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) // 4. 【关键】主动向微信查询订单状态并同步到本地(不依赖回调,解决订单一直 created 的问题) const orderSn = paymentData._orderSn || paymentData.orderSn if (orderSn) { try { await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true }) console.log('[Pay] 已主动同步订单状态:', orderSn) } catch (e) { console.warn('[Pay] 主动同步订单失败,继续刷新购买状态:', e) } } // 5. 【标准流程】刷新权限并解锁内容 console.log('[Pay] 微信支付成功!') await this.onPaymentSuccess() } 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 }) } }, // 【新增】支付成功后的标准处理流程 async onPaymentSuccess() { wx.showLoading({ title: '确认购买中...', mask: true }) try { // 1. 等待服务端处理支付回调(1-2秒) await this.sleep(2000) // 2. 刷新用户购买状态 await accessManager.refreshUserPurchaseStatus() // 3. 重新拉取章节并判断权限(应为 unlocked_purchased) const chapterRes = await app.request({ url: this._getChapterUrl({}), silent: true }) let newAccessState = await accessManager.determineAccessState( this.data.sectionId, chapterRes ) if (newAccessState !== 'unlocked_purchased') { console.log('[Pay] 权限未生效,1秒后重试...') await this.sleep(1000) newAccessState = await accessManager.determineAccessState( this.data.sectionId, chapterRes ) } const canAccess = accessManager.canAccessFullContent(newAccessState) this.setData({ accessState: newAccessState, canAccess, showPaywall: !canAccess }) // 4. 重新加载全文 await this.loadContent(this.data.sectionId, newAccessState, chapterRes) // 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.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) // 刷新失败时不影响用户体验,只是记录日志 } }, // 调用微信支付 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) { const { id, mid } = this.data.prevSection const query = mid ? `mid=${mid}` : `id=${id}` wx.redirectTo({ url: `/pages/read/read?${query}` }) } }, // 跳转到下一篇 goToNext() { if (this.data.nextSection) { const { id, mid } = this.data.nextSection const query = mid ? `mid=${mid}` : `id=${id}` wx.redirectTo({ url: `/pages/read/read?${query}` }) } }, // 跳转到推广中心 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, sectionMid } = this.data const userInfo = app.globalData.userInfo const userId = userInfo?.id || '' // 获取小程序码(带推荐人参数,优先 mid 与新链接一致) let qrcodeImage = null try { const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}` const scene = userId ? `${q}&ref=${userId.slice(0,10)}` : q 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) }, // 阻止冒泡 stopPropagation() {}, // 【新增】页面隐藏时上报阅读进度 onHide() { readingTracker.onPageHide() }, // 【新增】页面卸载时清理追踪器 onUnload() { readingTracker.cleanup() }, // 【新增】重试加载(当 accessState 为 error 时) async handleRetry() { wx.showLoading({ title: '重试中...', mask: true }) try { const config = await accessManager.fetchLatestConfig() this.setData({ sectionPrice: config.prices?.section ?? 1, fullBookPrice: config.prices?.fullbook ?? 9.9 }) // 重新拉取章节,用 isFree/price 判断免费 const chapterRes = await app.request({ url: this._getChapterUrl({}), silent: true }) const newAccessState = await accessManager.determineAccessState( this.data.sectionId, chapterRes ) const canAccess = accessManager.canAccessFullContent(newAccessState) this.setData({ accessState: newAccessState, canAccess, showPaywall: !canAccess }) await this.loadContent(this.data.sectionId, newAccessState, chapterRes) // 如果有权限,初始化阅读追踪 if (canAccess) { readingTracker.init(this.data.sectionId) } this._applyPrevNext(chapterRes) 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)) } })