2026-01-21 15:49:12 +08:00
|
|
|
|
/**
|
2026-03-20 11:31:04 +08:00
|
|
|
|
* 卡若创业派对 - 阅读页(标准流程版)
|
2026-01-21 15:49:12 +08:00
|
|
|
|
* 开发: 卡若
|
|
|
|
|
|
* 技术支持: 存客宝
|
2026-03-07 18:57:22 +08:00
|
|
|
|
*
|
2026-02-24 14:35:58 +08:00
|
|
|
|
* 更新: 2026-02-04
|
|
|
|
|
|
* - 引入权限管理器(chapterAccessManager)统一权限判断
|
|
|
|
|
|
* - 引入阅读追踪器(readingTracker)记录阅读进度、时长、是否读完
|
|
|
|
|
|
* - 使用状态机(accessState)规范权限流转
|
|
|
|
|
|
* - 异常统一保守处理,避免误解锁
|
2026-03-07 18:57:22 +08:00
|
|
|
|
*
|
2026-03-10 14:32:20 +08:00
|
|
|
|
* 更新: 正文 @某人(TipTap HTML <span data-type="mention">)
|
2026-03-07 18:57:22 +08:00
|
|
|
|
* - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
|
2026-01-21 15:49:12 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
const accessManager = require('../../utils/chapterAccessManager')
|
|
|
|
|
|
const readingTracker = require('../../utils/readingTracker')
|
2026-02-25 11:50:07 +08:00
|
|
|
|
const { parseScene } = require('../../utils/scene.js')
|
2026-03-10 14:32:20 +08:00
|
|
|
|
const contentParser = require('../../utils/contentParser.js')
|
2026-03-17 12:15:08 +08:00
|
|
|
|
const { trackClick } = require('../../utils/trackClick')
|
2026-03-23 18:38:23 +08:00
|
|
|
|
const { checkAndExecute } = require('../../utils/ruleEngine')
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
2026-01-14 12:50:00 +08:00
|
|
|
|
const app = getApp()
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
/** 阅读页解析正文用:人物字典 + #标签(与 /config/read-extras 一致) */
|
|
|
|
|
|
function getContentParseConfig() {
|
|
|
|
|
|
const g = getApp().globalData || {}
|
|
|
|
|
|
const raw = Array.isArray(g.mentionPersons) ? g.mentionPersons : []
|
|
|
|
|
|
const persons = raw.map((p) => ({
|
|
|
|
|
|
personId: p.personId || '',
|
|
|
|
|
|
token: p.token || '',
|
|
|
|
|
|
name: (p.name || '').trim(),
|
|
|
|
|
|
label: (p.label || '').trim(),
|
|
|
|
|
|
aliases: p.aliases != null ? String(p.aliases) : '',
|
|
|
|
|
|
}))
|
|
|
|
|
|
const linkTags = Array.isArray(g.linkTagsConfig) ? g.linkTagsConfig : []
|
|
|
|
|
|
return { persons, linkTags }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 补全 mentionDisplay,避免旧数据无字段;昵称去空白防「@ 名」 */
|
|
|
|
|
|
function normalizeMentionSegments(segments) {
|
|
|
|
|
|
if (!Array.isArray(segments)) return []
|
|
|
|
|
|
return segments.map((row) => {
|
|
|
|
|
|
if (!Array.isArray(row)) return row
|
|
|
|
|
|
return row.map((seg) => {
|
|
|
|
|
|
if (!seg || seg.type !== 'mention') return seg
|
|
|
|
|
|
const nick = String(seg.nickname || '')
|
|
|
|
|
|
.replace(/^[\s\u00a0\u200b\u3000]+/g, '')
|
|
|
|
|
|
.replace(/[\s\u00a0\u200b\u3000]+$/g, '')
|
|
|
|
|
|
return { ...seg, nickname: nick, mentionDisplay: '@' + nick }
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 12:50:00 +08:00
|
|
|
|
Page({
|
|
|
|
|
|
data: {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 系统信息
|
|
|
|
|
|
statusBarHeight: 44,
|
|
|
|
|
|
navBarHeight: 88,
|
|
|
|
|
|
|
|
|
|
|
|
// 章节信息
|
|
|
|
|
|
sectionId: '',
|
|
|
|
|
|
section: null,
|
|
|
|
|
|
partTitle: '',
|
|
|
|
|
|
chapterTitle: '',
|
|
|
|
|
|
|
|
|
|
|
|
// 内容
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
previewContent: '',
|
|
|
|
|
|
contentParagraphs: [],
|
2026-03-07 18:57:22 +08:00
|
|
|
|
contentSegments: [], // 每行解析为 [{type:'text'|'mention', text?, userId?, nickname?}]
|
2026-01-21 15:49:12 +08:00
|
|
|
|
previewParagraphs: [],
|
2026-01-14 12:50:00 +08:00
|
|
|
|
loading: true,
|
2026-01-21 15:49:12 +08:00
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 【新增】权限状态机(替代 canAccess)
|
|
|
|
|
|
// unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误
|
|
|
|
|
|
accessState: 'unknown',
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 用户状态
|
|
|
|
|
|
isLoggedIn: false,
|
|
|
|
|
|
hasFullBook: false,
|
2026-02-24 14:35:58 +08:00
|
|
|
|
canAccess: false, // 保留兼容性,从 accessState 派生
|
2026-01-21 15:49:12 +08:00
|
|
|
|
purchasedCount: 0,
|
|
|
|
|
|
|
|
|
|
|
|
// 阅读进度
|
|
|
|
|
|
readingProgress: 0,
|
|
|
|
|
|
showPaywall: false,
|
|
|
|
|
|
|
|
|
|
|
|
// 上一篇/下一篇
|
|
|
|
|
|
prevSection: null,
|
|
|
|
|
|
nextSection: null,
|
|
|
|
|
|
|
|
|
|
|
|
// 价格
|
|
|
|
|
|
sectionPrice: 1,
|
|
|
|
|
|
fullBookPrice: 9.9,
|
|
|
|
|
|
totalSections: 62,
|
|
|
|
|
|
|
|
|
|
|
|
// 弹窗
|
|
|
|
|
|
showShareModal: false,
|
2026-03-18 12:40:51 +08:00
|
|
|
|
showGiftModal: false,
|
2026-03-18 20:33:50 +08:00
|
|
|
|
giftQuantity: 6,
|
|
|
|
|
|
giftUnitPrice: 0,
|
|
|
|
|
|
giftTotalPrice: '0.00',
|
|
|
|
|
|
giftPaying: false,
|
|
|
|
|
|
giftPaid: false,
|
|
|
|
|
|
giftRequestSn: '',
|
2026-01-21 15:49:12 +08:00
|
|
|
|
showLoginModal: false,
|
2026-03-20 13:40:13 +08:00
|
|
|
|
showPrivacyModal: false,
|
2026-01-25 19:52:38 +08:00
|
|
|
|
showPosterModal: false,
|
2026-01-21 15:49:12 +08:00
|
|
|
|
isPaying: false,
|
2026-01-25 19:52:38 +08:00
|
|
|
|
isGeneratingPoster: false,
|
2026-02-25 11:50:07 +08:00
|
|
|
|
|
|
|
|
|
|
// 章节 mid(扫码/海报分享用,便于分享 path 带 mid)
|
2026-03-17 11:44:36 +08:00
|
|
|
|
sectionMid: null,
|
|
|
|
|
|
|
|
|
|
|
|
// 余额(用于余额支付)
|
|
|
|
|
|
walletBalance: 0,
|
2026-03-17 18:22:06 +08:00
|
|
|
|
|
|
|
|
|
|
// 审核模式:隐藏购买按钮
|
|
|
|
|
|
auditMode: false,
|
2026-03-18 20:33:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 好友从代付分享进入:待自动领取的 requestSn
|
|
|
|
|
|
pendingGiftRequestSn: '',
|
2026-03-23 18:38:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 朋友圈单页模式(scene 1154 / systemInfo.mode):无法登录与支付,仅引导「前往小程序」
|
|
|
|
|
|
readSinglePageMode: false,
|
|
|
|
|
|
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal)
|
|
|
|
|
|
momentsPaywallExpanded: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 是否处于朋友圈等「单页预览」环境。
|
|
|
|
|
|
* 兼容:部分机型/基础库首帧 getSystemInfoSync().mode 未就绪,需结合 launch/enter scene 1154、getWindowInfo。
|
|
|
|
|
|
* 命中时同步 app.globalData.isSinglePageMode,保证 ensureFullAppForAuth 与页内 wx:if 一致。
|
|
|
|
|
|
*/
|
|
|
|
|
|
_detectReadSinglePage() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
|
|
|
|
|
|
if (launch && Number(launch.scene) === 1154) {
|
|
|
|
|
|
app.globalData.isSinglePageMode = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const enter = typeof wx.getEnterOptionsSync === 'function' ? wx.getEnterOptionsSync() : null
|
|
|
|
|
|
if (enter && Number(enter.scene) === 1154) {
|
|
|
|
|
|
app.globalData.isSinglePageMode = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const win = typeof wx.getWindowInfo === 'function' ? wx.getWindowInfo() : null
|
|
|
|
|
|
if (win && win.mode === 'singlePage') {
|
|
|
|
|
|
app.globalData.isSinglePageMode = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const sys = wx.getSystemInfoSync()
|
|
|
|
|
|
if (sys && sys.mode === 'singlePage') {
|
|
|
|
|
|
app.globalData.isSinglePageMode = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
return !!app.globalData.isSinglePageMode
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
|
|
|
|
|
|
onUnlockTapInSinglePage() {
|
|
|
|
|
|
trackClick('read', 'btn_click', '单页_解锁引导')
|
|
|
|
|
|
try {
|
|
|
|
|
|
wx.vibrateShort({ type: 'light' })
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
|
|
|
|
|
|
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
|
|
|
|
|
|
}
|
2026-03-17 18:22:06 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
onShow() {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
const sp = this._detectReadSinglePage()
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
auditMode: app.globalData.auditMode || false,
|
|
|
|
|
|
readSinglePageMode: sp,
|
|
|
|
|
|
...(sp ? {} : { momentsPaywallExpanded: false }),
|
|
|
|
|
|
})
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
async onLoad(options) {
|
2026-03-14 18:04:05 +08:00
|
|
|
|
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
// 预加载:core+auditMode(getConfig)+ read-extras 懒加载(linkTags、linkedMiniprograms)
|
|
|
|
|
|
Promise.all([
|
|
|
|
|
|
app.getConfig(),
|
|
|
|
|
|
app.getReadExtras()
|
|
|
|
|
|
]).then(([cfg, extras]) => {
|
2026-03-17 18:22:06 +08:00
|
|
|
|
if (cfg) {
|
|
|
|
|
|
const mp = (cfg && cfg.mpConfig) || {}
|
|
|
|
|
|
const auditMode = !!mp.auditMode
|
|
|
|
|
|
app.globalData.auditMode = auditMode
|
|
|
|
|
|
if (typeof this.setData === 'function') this.setData({ auditMode })
|
|
|
|
|
|
}
|
2026-03-18 16:00:57 +08:00
|
|
|
|
if (extras && Array.isArray(extras.linkTags)) {
|
|
|
|
|
|
app.globalData.linkTagsConfig = extras.linkTags
|
|
|
|
|
|
app.globalData.linkedMiniprograms = extras.linkedMiniprograms || []
|
|
|
|
|
|
}
|
2026-03-17 18:22:06 +08:00
|
|
|
|
}).catch(() => {})
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
2026-03-17 11:44:36 +08:00
|
|
|
|
// 支持 scene(扫码)、mid、id、ref、gift(代付)
|
2026-02-25 11:50:07 +08:00
|
|
|
|
const sceneStr = (options && options.scene) || ''
|
|
|
|
|
|
const parsed = parseScene(sceneStr)
|
2026-03-17 11:44:36 +08:00
|
|
|
|
const isGift = options.gift === '1' || options.gift === 'true'
|
2026-03-18 20:33:50 +08:00
|
|
|
|
// 代付:分享链路使用 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) : '') || ''
|
2026-03-17 12:15:08 +08:00
|
|
|
|
const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0)
|
|
|
|
|
|
let id = options.id || parsed.id || app.globalData.initialSectionId
|
2026-02-25 11:50:07 +08:00
|
|
|
|
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
|
|
|
|
|
|
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
|
|
|
|
|
|
|
2026-03-06 12:12:13 +08:00
|
|
|
|
console.log("页面:",mid);
|
|
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
// 兼容:mid 有值但无 id 时,用 by-mid 解析 id;有 id 无 mid 时,后续用 by-id 请求
|
2026-02-25 11:50:07 +08:00
|
|
|
|
if (mid && !id) {
|
2026-03-18 16:00:57 +08:00
|
|
|
|
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)
|
2026-02-25 11:50:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!id) {
|
|
|
|
|
|
wx.showToast({ title: '章节参数缺失', icon: 'none' })
|
|
|
|
|
|
this.setData({ accessState: 'error', loading: false })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
this.setData({
|
|
|
|
|
|
statusBarHeight: app.globalData.statusBarHeight,
|
|
|
|
|
|
navBarHeight: app.globalData.navBarHeight,
|
2026-02-24 14:35:58 +08:00
|
|
|
|
sectionId: id,
|
2026-02-25 11:50:07 +08:00
|
|
|
|
sectionMid: mid || null,
|
2026-02-24 14:35:58 +08:00
|
|
|
|
loading: true,
|
2026-03-18 20:33:50 +08:00
|
|
|
|
accessState: 'unknown',
|
2026-03-23 18:38:23 +08:00
|
|
|
|
pendingGiftRequestSn: giftRequestSn || '',
|
|
|
|
|
|
readSinglePageMode: this._detectReadSinglePage(),
|
|
|
|
|
|
momentsPaywallExpanded: false,
|
2026-01-21 15:49:12 +08:00
|
|
|
|
})
|
2026-02-25 11:50:07 +08:00
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
if (ref) {
|
2026-01-25 19:37:59 +08:00
|
|
|
|
console.log('[Read] 检测到推荐码:', ref)
|
2026-01-21 15:49:12 +08:00
|
|
|
|
wx.setStorageSync('referral_code', ref)
|
2026-01-25 19:37:59 +08:00
|
|
|
|
app.handleReferralCode({ query: { ref } })
|
2026-01-14 12:50:00 +08:00
|
|
|
|
}
|
2026-02-25 11:50:07 +08:00
|
|
|
|
|
2026-01-25 21:09:20 +08:00
|
|
|
|
try {
|
2026-02-24 14:35:58 +08:00
|
|
|
|
const config = await accessManager.fetchLatestConfig()
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
sectionPrice: config.prices?.section ?? 1,
|
|
|
|
|
|
fullBookPrice: config.prices?.fullbook ?? 9.9
|
|
|
|
|
|
})
|
2026-02-25 11:50:07 +08:00
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
// 统一:先拉章节数据,用 isFree/price===0 判断免费
|
2026-03-06 12:12:13 +08:00
|
|
|
|
const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true })
|
2026-03-18 20:33:50 +08:00
|
|
|
|
let accessState = await accessManager.determineAccessState(id, chapterRes)
|
|
|
|
|
|
let canAccess = accessManager.canAccessFullContent(accessState)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
isLoggedIn: !!app.globalData.userInfo?.id,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
// 加载内容(复用已拉取的章节数据,避免二次请求)
|
|
|
|
|
|
await this.loadContent(id, accessState, chapterRes)
|
2026-03-18 20:33:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 代付自动领取:好友打开阅读页时自动领取并解锁
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
if (canAccess) {
|
|
|
|
|
|
readingTracker.init(id)
|
2026-03-23 18:38:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
app.touchRecentSection(id)
|
2026-01-25 21:09:20 +08:00
|
|
|
|
}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
// 5. 导航:文章详情已带 prev/next
|
|
|
|
|
|
this._applyPrevNext(chapterRes)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
2026-01-25 21:09:20 +08:00
|
|
|
|
} catch (e) {
|
2026-02-24 14:35:58 +08:00
|
|
|
|
console.error('[Read] 初始化失败:', e)
|
|
|
|
|
|
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
|
|
|
|
|
|
this.setData({ accessState: 'error', loading: false })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.setData({ loading: false })
|
2026-01-25 21:09:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-18 20:33:50 +08:00
|
|
|
|
|
|
|
|
|
|
_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
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 从后端加载免费章节配置
|
2026-01-21 15:49:12 +08:00
|
|
|
|
onPageScroll(e) {
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 只在有权限时追踪阅读进度
|
|
|
|
|
|
if (!accessManager.canAccessFullContent(this.data.accessState)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取滚动信息并更新追踪器
|
2026-01-21 15:49:12 +08:00
|
|
|
|
const query = wx.createSelectorQuery()
|
|
|
|
|
|
query.select('.page').boundingClientRect()
|
2026-02-24 14:35:58 +08:00
|
|
|
|
query.selectViewport().scrollOffset()
|
2026-01-21 15:49:12 +08:00
|
|
|
|
query.exec((res) => {
|
2026-02-24 14:35:58 +08:00
|
|
|
|
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
|
2026-01-21 15:49:12 +08:00
|
|
|
|
this.setData({ readingProgress: progress })
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新阅读追踪器(记录最大进度、判断是否读完)
|
|
|
|
|
|
readingTracker.updateProgress(scrollInfo)
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
// 加载章节内容:优先复用 prefetchedChapter 避免二次请求,失败时降级本地缓存
|
2026-03-04 19:06:06 +08:00
|
|
|
|
async loadContent(id, accessState, prefetchedChapter) {
|
2026-03-10 18:06:10 +08:00
|
|
|
|
const cacheKey = `chapter_${id}`
|
2026-01-21 15:49:12 +08:00
|
|
|
|
try {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
await app.getReadExtras()
|
|
|
|
|
|
const parseCfg = getContentParseConfig()
|
2026-02-24 14:35:58 +08:00
|
|
|
|
const sectionPrice = this.data.sectionPrice ?? 1
|
2026-03-04 19:06:06 +08:00
|
|
|
|
let res = prefetchedChapter
|
|
|
|
|
|
if (!res || !res.content) {
|
2026-03-06 12:12:13 +08:00
|
|
|
|
res = await app.request({ url: this._getChapterUrl({ id }), silent: true })
|
2026-03-04 19:06:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-02-24 14:35:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
this.setData({ section })
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
2026-03-10 20:20:03 +08:00
|
|
|
|
// 已解锁用 data.content(完整内容),未解锁用 content(预览);先 determineAccessState 再 loadContent 保证顺序正确
|
|
|
|
|
|
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
|
|
|
|
|
|
if (res && displayContent) {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
const { lines, segments } = contentParser.parseContent(displayContent, parseCfg)
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 预览内容由后端统一截取比例,这里展示全部预览内容
|
|
|
|
|
|
const previewCount = lines.length
|
2026-02-25 11:50:07 +08:00
|
|
|
|
const updates = {
|
2026-03-10 20:20:03 +08:00
|
|
|
|
content: displayContent,
|
2026-02-24 14:35:58 +08:00
|
|
|
|
contentParagraphs: lines,
|
2026-03-22 08:34:28 +08:00
|
|
|
|
contentSegments: normalizeMentionSegments(segments),
|
2026-02-24 14:35:58 +08:00
|
|
|
|
previewParagraphs: lines.slice(0, previewCount),
|
|
|
|
|
|
partTitle: res.partTitle || '',
|
|
|
|
|
|
chapterTitle: res.chapterTitle || ''
|
2026-02-25 11:50:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (res.mid) updates.sectionMid = res.mid
|
|
|
|
|
|
this.setData(updates)
|
2026-03-10 20:20:03 +08:00
|
|
|
|
// 写入本地缓存(存 displayContent,供离线/重试降级使用)
|
|
|
|
|
|
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
if (accessManager.canAccessFullContent(accessState)) {
|
|
|
|
|
|
app.markSectionAsRead(id)
|
|
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
app.touchRecentSection(id)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
}
|
2026-01-21 15:49:12 +08:00
|
|
|
|
} catch (e) {
|
2026-03-10 18:06:10 +08:00
|
|
|
|
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const cached = wx.getStorageSync(cacheKey)
|
|
|
|
|
|
if (cached && cached.content) {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
await app.getReadExtras()
|
|
|
|
|
|
const { lines, segments } = contentParser.parseContent(cached.content, getContentParseConfig())
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 预览内容由后端统一截取比例,这里展示全部预览内容
|
|
|
|
|
|
const previewCount = lines.length
|
2026-02-24 14:35:58 +08:00
|
|
|
|
this.setData({
|
|
|
|
|
|
content: cached.content,
|
|
|
|
|
|
contentParagraphs: lines,
|
2026-03-22 08:34:28 +08:00
|
|
|
|
contentSegments: normalizeMentionSegments(segments),
|
2026-03-10 18:06:10 +08:00
|
|
|
|
previewParagraphs: lines.slice(0, previewCount),
|
|
|
|
|
|
partTitle: cached.partTitle || '',
|
|
|
|
|
|
chapterTitle: cached.chapterTitle || ''
|
2026-02-24 14:35:58 +08:00
|
|
|
|
})
|
2026-03-23 18:38:23 +08:00
|
|
|
|
app.touchRecentSection(id)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
console.log('[Read] 从本地缓存加载成功')
|
2026-03-10 18:06:10 +08:00
|
|
|
|
return
|
2026-02-24 14:35:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (cacheErr) {
|
|
|
|
|
|
console.warn('[Read] 本地缓存也失败:', cacheErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
throw e
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 获取章节信息
|
|
|
|
|
|
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 }
|
|
|
|
|
|
}
|
2026-01-14 12:50:00 +08:00
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 普通章节
|
|
|
|
|
|
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}`
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
// 根据 id/mid 构造章节接口路径:优先 mid(by-mid),否则用 id(by-id,兼容旧链接)
|
2026-03-06 12:12:13 +08:00
|
|
|
|
_getChapterUrl(params = {}) {
|
|
|
|
|
|
const { id, mid } = params
|
|
|
|
|
|
const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid
|
2026-03-10 20:20:03 +08:00
|
|
|
|
let url
|
2026-03-06 12:12:13 +08:00
|
|
|
|
if (finalMid) {
|
2026-03-10 20:20:03 +08:00
|
|
|
|
url = `/api/miniprogram/book/chapter/by-mid/${finalMid}`
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const finalId = id || this.data.sectionId
|
2026-03-18 16:00:57 +08:00
|
|
|
|
url = `/api/miniprogram/book/chapter/by-id/${encodeURIComponent(finalId)}`
|
2026-03-06 12:12:13 +08:00
|
|
|
|
}
|
2026-03-10 20:20:03 +08:00
|
|
|
|
const userId = app.globalData.userInfo?.id
|
|
|
|
|
|
if (userId) url += (url.includes('?') ? '&' : '?') + 'userId=' + encodeURIComponent(userId)
|
|
|
|
|
|
return url
|
2026-03-06 12:12:13 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-25 21:29:04 +08:00
|
|
|
|
|
|
|
|
|
|
// 带超时的章节请求
|
|
|
|
|
|
fetchChapterWithTimeout(id, timeout = 5000) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
|
reject(new Error('请求超时'))
|
|
|
|
|
|
}, timeout)
|
|
|
|
|
|
|
2026-03-06 12:12:13 +08:00
|
|
|
|
app.request(this._getChapterUrl({ id }))
|
2026-01-25 21:29:04 +08:00
|
|
|
|
.then(res => {
|
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
|
resolve(res)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
|
reject(err)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-10 14:32:20 +08:00
|
|
|
|
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
async setChapterContent(res) {
|
|
|
|
|
|
await app.getReadExtras()
|
|
|
|
|
|
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 预览内容由后端统一截取比例,这里展示全部预览内容
|
|
|
|
|
|
const previewCount = lines.length
|
2026-03-06 12:12:13 +08:00
|
|
|
|
const sectionPrice = this.data.sectionPrice ?? 1
|
|
|
|
|
|
const sectionTitle = (res.sectionTitle || res.title || '').trim()
|
2026-01-25 21:29:04 +08:00
|
|
|
|
|
|
|
|
|
|
this.setData({
|
2026-03-06 12:12:13 +08:00
|
|
|
|
// 文章详情标题:只使用后端提供的 sectionTitle,不再拼接其他本地标题信息
|
|
|
|
|
|
section: {
|
|
|
|
|
|
id: res.id || this.data.sectionId,
|
|
|
|
|
|
title: sectionTitle,
|
|
|
|
|
|
isFree: res.isFree === true || (res.price !== undefined && res.price === 0),
|
|
|
|
|
|
price: res.price ?? sectionPrice
|
|
|
|
|
|
},
|
2026-01-25 21:29:04 +08:00
|
|
|
|
content: res.content,
|
|
|
|
|
|
previewContent: lines.slice(0, previewCount).join('\n'),
|
|
|
|
|
|
contentParagraphs: lines,
|
2026-03-22 08:34:28 +08:00
|
|
|
|
contentSegments: normalizeMentionSegments(segments),
|
2026-01-25 21:29:04 +08:00
|
|
|
|
previewParagraphs: lines.slice(0, previewCount),
|
|
|
|
|
|
partTitle: res.partTitle || '',
|
2026-03-06 12:12:13 +08:00
|
|
|
|
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
|
|
|
|
|
|
chapterTitle: sectionTitle
|
2026-01-25 21:29:04 +08:00
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 静默刷新(后台更新缓存)
|
|
|
|
|
|
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: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
|
2026-03-22 08:34:28 +08:00
|
|
|
|
contentSegments: normalizeMentionSegments(contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments),
|
2026-01-25 21:29:04 +08:00
|
|
|
|
previewParagraphs: ['内容加载失败']
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 22:08:30 +08:00
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
|
try {
|
2026-01-25 21:29:04 +08:00
|
|
|
|
const res = await this.fetchChapterWithTimeout(id, 8000)
|
2026-01-23 22:08:30 +08:00
|
|
|
|
if (res && res.content) {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
await this.setChapterContent(res)
|
2026-01-25 21:29:04 +08:00
|
|
|
|
wx.setStorageSync(`chapter_${id}`, res)
|
|
|
|
|
|
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
|
|
|
|
|
|
return
|
2026-01-23 22:08:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2026-01-25 21:29:04 +08:00
|
|
|
|
console.warn('[Read] 重试失败,继续重试:', currentRetry + 1)
|
2026-01-23 22:08:30 +08:00
|
|
|
|
}
|
2026-01-25 21:29:04 +08:00
|
|
|
|
this.retryLoadContent(id, maxRetries, currentRetry + 1)
|
|
|
|
|
|
}, 2000 * (currentRetry + 1))
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
_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,
|
|
|
|
|
|
})
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-06 15:16:19 +08:00
|
|
|
|
// 返回(从分享进入无栈时回首页)
|
2026-01-21 15:49:12 +08:00
|
|
|
|
goBack() {
|
2026-03-06 15:16:19 +08:00
|
|
|
|
getApp().goBackOrToHome()
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-12 16:51:12 +08:00
|
|
|
|
// 点击正文中的 #链接标签:小程序内页/预览页/唤醒其他小程序
|
2026-03-10 18:06:10 +08:00
|
|
|
|
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()
|
2026-03-14 14:37:17 +08:00
|
|
|
|
let mpKey = (e.currentTarget.dataset.mpKey || '').trim()
|
2026-03-10 18:06:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 旧格式(<a href>)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 || ''
|
2026-03-12 16:51:12 +08:00
|
|
|
|
if (cached.mpKey) mpKey = cached.mpKey
|
2026-03-10 18:06:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
|
|
|
|
|
|
if (tagType === 'ckb') {
|
|
|
|
|
|
// 触发通用加好友(无特定 personId,使用全局 CKB Key)
|
|
|
|
|
|
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 16:51:12 +08:00
|
|
|
|
// 小程序类型:用密钥查 linkedMiniprograms 得 appId,再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
|
|
|
|
|
|
if (tagType === 'miniprogram') {
|
|
|
|
|
|
if (!mpKey && label) {
|
|
|
|
|
|
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
|
2026-03-14 14:37:17 +08:00
|
|
|
|
if (cached) mpKey = cached.mpKey || ''
|
2026-03-12 16:51:12 +08:00
|
|
|
|
}
|
2026-03-14 14:37:17 +08:00
|
|
|
|
const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey)
|
2026-03-12 16:51:12 +08:00
|
|
|
|
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' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
// 小程序内部路径(pagePath 或 url 以 /pages/ 开头)
|
|
|
|
|
|
const internalPath = pagePath || (url.startsWith('/pages/') ? url : '')
|
|
|
|
|
|
if (internalPath) {
|
|
|
|
|
|
wx.navigateTo({ url: internalPath, fail: () => wx.switchTab({ url: internalPath }) })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 11:36:50 +08:00
|
|
|
|
// 外部 URL:跳转到内置预览页,由 web-view 打开
|
2026-03-10 18:06:10 +08:00
|
|
|
|
if (url) {
|
2026-03-12 11:36:50 +08:00
|
|
|
|
const encodedUrl = encodeURIComponent(url)
|
|
|
|
|
|
const encodedTitle = encodeURIComponent(label || '链接预览')
|
|
|
|
|
|
wx.navigateTo({
|
|
|
|
|
|
url: `/pages/link-preview/link-preview?url=${encodedUrl}&title=${encodedTitle}`,
|
|
|
|
|
|
})
|
2026-03-10 18:06:10 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 点击正文图片 → 全屏预览
|
|
|
|
|
|
onImageTap(e) {
|
|
|
|
|
|
const src = e.currentTarget.dataset.src
|
|
|
|
|
|
if (!src) return
|
|
|
|
|
|
wx.previewImage({ current: src, urls: [src] })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-07 18:57:22 +08:00
|
|
|
|
// 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 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
|
2026-03-19 18:26:45 +08:00
|
|
|
|
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
|
|
|
|
|
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
|
|
|
|
|
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
2026-03-07 18:57:22 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
|
|
|
|
|
|
if (profileRes?.success && profileRes.data) {
|
2026-03-19 18:26:45 +08:00
|
|
|
|
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
|
|
|
|
|
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
2026-03-07 18:57:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
2026-03-19 18:26:45 +08:00
|
|
|
|
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
2026-03-07 18:57:22 +08:00
|
|
|
|
wx.showModal({
|
2026-03-23 18:38:23 +08:00
|
|
|
|
title: '补全手机号',
|
|
|
|
|
|
content: '请填写手机号(必填),便于对方联系您。',
|
2026-03-07 18:57:22 +08:00
|
|
|
|
confirmText: '去填写',
|
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
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) {
|
2026-03-07 21:30:40 +08:00
|
|
|
|
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
2026-03-07 18:57:22 +08:00
|
|
|
|
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' })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 分享弹窗
|
|
|
|
|
|
showShare() {
|
|
|
|
|
|
this.setData({ showShareModal: true })
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
closeShareModal() {
|
|
|
|
|
|
this.setData({ showShareModal: false })
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-18 12:40:51 +08:00
|
|
|
|
// 代付分享:直接跳转代付页,在代付页输入数量并支付(简化流程)
|
|
|
|
|
|
showGiftShareModal() {
|
2026-03-17 11:44:36 +08:00
|
|
|
|
if (!app.globalData.userInfo?.id) {
|
|
|
|
|
|
wx.showToast({ title: '请先登录', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-18 12:40:51 +08:00
|
|
|
|
const { sectionId } = this.data
|
|
|
|
|
|
if (!sectionId) {
|
2026-03-17 11:44:36 +08:00
|
|
|
|
wx.showToast({ title: '章节信息异常', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
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) {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
this.onUnlockTapInSinglePage()
|
2026-03-18 20:33:50 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
2026-03-17 11:44:36 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 复制链接
|
|
|
|
|
|
copyLink() {
|
|
|
|
|
|
const userInfo = app.globalData.userInfo
|
|
|
|
|
|
const referralCode = userInfo?.referralCode || ''
|
2026-01-23 05:47:09 +08:00
|
|
|
|
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
|
2026-01-21 15:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
wx.setClipboardData({
|
|
|
|
|
|
data: shareUrl,
|
|
|
|
|
|
success: () => {
|
|
|
|
|
|
wx.showToast({ title: '链接已复制', icon: 'success' })
|
|
|
|
|
|
this.setData({ showShareModal: false })
|
2026-01-14 12:50:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-25 20:41:38 +08:00
|
|
|
|
// 复制分享文案(朋友圈风格)
|
2026-01-25 20:35:30 +08:00
|
|
|
|
copyShareText() {
|
|
|
|
|
|
const { section } = this.data
|
|
|
|
|
|
|
2026-03-20 11:31:04 +08:00
|
|
|
|
const shareText = `🔥 刚看完这篇《${section?.title || '卡若创业派对'}》,太上头了!
|
2026-01-25 20:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
62个真实商业案例,每个都是从0到1的实战经验。私域运营、资源整合、商业变现,干货满满。
|
|
|
|
|
|
|
2026-03-20 11:31:04 +08:00
|
|
|
|
推荐给正在创业或想创业的朋友,搜"卡若创业派对"小程序就能看!
|
2026-01-25 20:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
#创业派对 #私域运营 #商业案例`
|
2026-01-25 20:35:30 +08:00
|
|
|
|
|
|
|
|
|
|
wx.setClipboardData({
|
2026-03-22 08:34:28 +08:00
|
|
|
|
data: shareText
|
|
|
|
|
|
// 不额外 showToast:系统已有「内容已复制」,避免与自定义文案叠两层
|
2026-01-25 20:35:30 +08:00
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-17 12:15:08 +08:00
|
|
|
|
// 分享到微信 - 自动带分享人ID
|
2026-03-18 20:33:50 +08:00
|
|
|
|
onShareAppMessage(e) {
|
2026-03-17 12:15:08 +08:00
|
|
|
|
trackClick('read', 'btn_click', '分享_' + this.data.sectionId)
|
|
|
|
|
|
const { section, sectionId, sectionMid } = this.data
|
2026-02-25 11:47:36 +08:00
|
|
|
|
const ref = app.getMyReferralCode()
|
2026-02-25 11:50:07 +08:00
|
|
|
|
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
2026-03-18 20:33:50 +08:00
|
|
|
|
// 代付分享按钮(支付后):好友打开阅读页自动领取解锁
|
|
|
|
|
|
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)}`
|
2026-03-20 11:31:04 +08:00
|
|
|
|
const t = section?.title || '卡若创业派对'
|
2026-03-18 20:33:50 +08:00
|
|
|
|
const title = `我已为你买单:${t.length > 18 ? t.slice(0, 18) + '...' : t}`
|
|
|
|
|
|
return { title, path }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 12:15:08 +08:00
|
|
|
|
const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
|
|
|
|
|
|
const title = section?.title
|
2026-01-25 19:37:59 +08:00
|
|
|
|
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
|
2026-03-20 11:31:04 +08:00
|
|
|
|
: '📚 卡若创业派对 - 真实商业故事'
|
2026-03-17 12:15:08 +08:00
|
|
|
|
return { title, path }
|
2026-01-25 19:37:59 +08:00
|
|
|
|
},
|
2026-02-25 11:50:07 +08:00
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
// 分享到朋友圈:复制摘要 + 引导用户用右上角「···」发圈(无 open-type=shareTimeline)
|
2026-03-17 18:22:06 +08:00
|
|
|
|
shareToMoments() {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
|
2026-03-17 18:22:06 +08:00
|
|
|
|
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)
|
2026-03-20 11:31:04 +08:00
|
|
|
|
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#卡若创业派对 #真实商业故事`
|
2026-03-17 18:22:06 +08:00
|
|
|
|
wx.setClipboardData({
|
|
|
|
|
|
data: copyText,
|
|
|
|
|
|
success: () => {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 系统会在 setClipboardData 成功后自动 toast「内容已复制」,与下方引导弹窗重复,先关掉再弹窗
|
|
|
|
|
|
wx.hideToast()
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
wx.hideToast()
|
|
|
|
|
|
wx.showModal({
|
|
|
|
|
|
title: '分享到朋友圈',
|
2026-03-23 18:38:23 +08:00
|
|
|
|
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
|
2026-03-22 08:34:28 +08:00
|
|
|
|
showCancel: false,
|
|
|
|
|
|
confirmText: '知道了'
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 120)
|
2026-03-17 18:22:06 +08:00
|
|
|
|
},
|
|
|
|
|
|
fail: () => {
|
|
|
|
|
|
wx.showToast({ title: '复制失败,请手动复制', icon: 'none' })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-17 12:15:08 +08:00
|
|
|
|
// 分享到朋友圈:带文章标题,过长时截断
|
2026-01-25 19:37:59 +08:00
|
|
|
|
onShareTimeline() {
|
2026-03-17 12:15:08 +08:00
|
|
|
|
const { section, sectionId, sectionMid, chapterTitle } = this.data
|
2026-02-25 11:47:36 +08:00
|
|
|
|
const ref = app.getMyReferralCode()
|
2026-02-25 11:50:07 +08:00
|
|
|
|
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
2026-03-17 12:15:08 +08:00
|
|
|
|
const query = ref ? `${q}&ref=${ref}` : q
|
2026-03-06 12:12:13 +08:00
|
|
|
|
const articleTitle = (section?.title || chapterTitle || '').trim()
|
|
|
|
|
|
const title = articleTitle
|
|
|
|
|
|
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
|
2026-03-20 11:31:04 +08:00
|
|
|
|
: '卡若创业派对 - 真实商业故事'
|
2026-03-17 11:44:36 +08:00
|
|
|
|
return { title, query }
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
2026-01-21 15:49:12 +08:00
|
|
|
|
showLoginModal() {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
// 单页模式无法弹登录组件:页内已说明「前往小程序」,不再弹 Modal
|
|
|
|
|
|
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
|
|
|
|
|
this.onUnlockTapInSinglePage()
|
|
|
|
|
|
return
|
2026-03-10 20:20:03 +08:00
|
|
|
|
}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
try {
|
2026-03-20 14:38:51 +08:00
|
|
|
|
this.setData({ showLoginModal: true })
|
2026-02-24 14:35:58 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[Read] showLoginModal error:', e)
|
|
|
|
|
|
this.setData({ showLoginModal: true })
|
|
|
|
|
|
}
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-20 14:38:51 +08:00
|
|
|
|
onLoginModalClose() {
|
2026-03-20 13:40:13 +08:00
|
|
|
|
this.setData({ showLoginModal: false, showPrivacyModal: false })
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
2026-03-20 14:38:51 +08:00
|
|
|
|
onLoginModalPrivacyAgree() {
|
2026-03-20 13:40:13 +08:00
|
|
|
|
this.setData({ showPrivacyModal: false })
|
|
|
|
|
|
},
|
2026-03-20 14:38:51 +08:00
|
|
|
|
async onLoginModalSuccess() {
|
|
|
|
|
|
this.setData({ showLoginModal: false })
|
|
|
|
|
|
await this.onLoginSuccess()
|
|
|
|
|
|
wx.showToast({ title: '登录成功', icon: 'success' })
|
2026-01-21 15:49:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 【新增】登录成功后的标准处理流程
|
|
|
|
|
|
async onLoginSuccess() {
|
|
|
|
|
|
wx.showLoading({ title: '更新状态中...', mask: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-18 20:33:50 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 1. 刷新用户购买状态(从 orders 表拉取最新)
|
|
|
|
|
|
await accessManager.refreshUserPurchaseStatus()
|
|
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
// 2. 重新拉取章节数据,用 isFree/price 判断免费
|
|
|
|
|
|
const chapterRes = await app.request({
|
2026-03-06 12:12:13 +08:00
|
|
|
|
url: this._getChapterUrl({}),
|
2026-03-04 19:06:06 +08:00
|
|
|
|
silent: true
|
|
|
|
|
|
})
|
2026-02-24 14:35:58 +08:00
|
|
|
|
const newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
2026-03-04 19:06:06 +08:00
|
|
|
|
chapterRes
|
2026-02-24 14:35:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState: newAccessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
isLoggedIn: true,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
// 3. 如果已解锁,重新加载内容并初始化阅读追踪
|
2026-02-24 14:35:58 +08:00
|
|
|
|
if (canAccess) {
|
2026-03-04 19:06:06 +08:00
|
|
|
|
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
readingTracker.init(this.data.sectionId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
console.error('[Read] 登录后更新状态失败:', e)
|
|
|
|
|
|
wx.showToast({ title: '状态更新失败,请重试', icon: 'none' })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 购买章节 - 直接调起支付
|
|
|
|
|
|
async handlePurchaseSection() {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
|
|
|
|
|
this.onUnlockTapInSinglePage()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-17 12:15:08 +08:00
|
|
|
|
trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId)
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.log('[Pay] 点击购买章节按钮')
|
|
|
|
|
|
wx.showLoading({ title: '处理中...', mask: true })
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
if (!this.data.isLoggedIn) {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
console.log('[Pay] 用户未登录,显示登录弹窗')
|
2026-01-21 15:49:12 +08:00
|
|
|
|
this.setData({ showLoginModal: true })
|
|
|
|
|
|
return
|
2026-01-14 12:50:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 09:47:04 +08:00
|
|
|
|
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)
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 购买全书 - 直接调起支付
|
|
|
|
|
|
async handlePurchaseFullBook() {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
|
|
|
|
|
this.onUnlockTapInSinglePage()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.log('[Pay] 点击购买全书按钮')
|
|
|
|
|
|
wx.showLoading({ title: '处理中...', mask: true })
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
if (!this.data.isLoggedIn) {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
console.log('[Pay] 用户未登录,显示登录弹窗')
|
2026-01-21 15:49:12 +08:00
|
|
|
|
this.setData({ showLoginModal: true })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.log('[Pay] 开始支付流程: 全书', { price: this.data.fullBookPrice })
|
|
|
|
|
|
wx.hideLoading()
|
2026-01-21 15:49:12 +08:00
|
|
|
|
await this.processPayment('fullbook', null, this.data.fullBookPrice)
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-23 05:44:21 +08:00
|
|
|
|
// 处理支付 - 调用真实微信支付接口
|
2026-01-21 15:49:12 +08:00
|
|
|
|
async processPayment(type, sectionId, amount) {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
|
2026-03-23 18:38:23 +08:00
|
|
|
|
|
|
|
|
|
|
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
this.onUnlockTapInSinglePage()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-29 09:47:04 +08:00
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
const userInfo = app.globalData.userInfo
|
|
|
|
|
|
if (userInfo?.id) {
|
|
|
|
|
|
const avatar = userInfo.avatarUrl || ''
|
|
|
|
|
|
const nickname = userInfo.nickname || userInfo.nickName || ''
|
|
|
|
|
|
const needProfile = !avatar || avatar.includes('default') || avatar.includes('132') || !nickname || nickname === '微信用户'
|
|
|
|
|
|
if (needProfile) {
|
|
|
|
|
|
const res = await new Promise(resolve => {
|
|
|
|
|
|
wx.showModal({
|
2026-03-23 18:38:23 +08:00
|
|
|
|
title: '设置头像与昵称',
|
|
|
|
|
|
content: '支付订单会关联你的对外展示信息,请先设置头像与昵称,避免账单与对方看到默认占位。',
|
|
|
|
|
|
confirmText: '去设置',
|
|
|
|
|
|
cancelText: '关闭',
|
2026-03-22 08:34:28 +08:00
|
|
|
|
success: resolve
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
|
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 09:47:04 +08:00
|
|
|
|
if (!amount || amount <= 0) {
|
|
|
|
|
|
console.error('[Pay] 金额无效:', amount)
|
|
|
|
|
|
wx.showToast({ title: '价格信息错误', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// ✅ 从服务器查询是否已购买(基于 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-23 17:25:15 +08:00
|
|
|
|
}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Pay] 查询购买状态失败,继续支付流程:', e)
|
|
|
|
|
|
// 查询失败不影响支付
|
2026-01-23 17:25:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
this.setData({ isPaying: true })
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.showLoading({ title: '正在发起支付...', mask: true })
|
2026-01-21 15:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-17 11:44:36 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 05:44:21 +08:00
|
|
|
|
// 1. 先获取openId (支付必需)
|
|
|
|
|
|
let openId = app.globalData.openId || wx.getStorageSync('openId')
|
|
|
|
|
|
|
|
|
|
|
|
if (!openId) {
|
2026-01-23 17:25:15 +08:00
|
|
|
|
console.log('[Pay] 需要先获取openId,尝试静默获取')
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.showLoading({ title: '获取支付凭证...', mask: true })
|
2026-01-23 05:44:21 +08:00
|
|
|
|
openId = await app.getOpenId()
|
|
|
|
|
|
|
|
|
|
|
|
if (!openId) {
|
2026-01-23 17:25:15 +08:00
|
|
|
|
// openId获取失败,但已登录用户可以使用用户ID替代
|
|
|
|
|
|
if (app.globalData.isLoggedIn && app.globalData.userInfo?.id) {
|
|
|
|
|
|
console.log('[Pay] 使用用户ID作为替代')
|
|
|
|
|
|
openId = app.globalData.userInfo.id
|
|
|
|
|
|
} else {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.hideLoading()
|
2026-01-23 17:25:15 +08:00
|
|
|
|
wx.showModal({
|
|
|
|
|
|
title: '提示',
|
|
|
|
|
|
content: '需要登录后才能支付,请先登录',
|
|
|
|
|
|
showCancel: false
|
|
|
|
|
|
})
|
|
|
|
|
|
this.setData({ showLoginModal: true, isPaying: false })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-23 05:44:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Pay] 开始创建订单:', { type, sectionId, amount, openId: openId.slice(0, 10) + '...' })
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.showLoading({ title: '创建订单中...', mask: true })
|
2026-01-23 05:44:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 2. 调用后端创建预支付订单
|
2026-01-21 15:49:12 +08:00
|
|
|
|
let paymentData = null
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-29 11:11:58 +08:00
|
|
|
|
// 获取章节完整名称用于支付描述
|
|
|
|
|
|
const sectionTitle = this.data.section?.title || sectionId
|
|
|
|
|
|
const description = type === 'fullbook'
|
|
|
|
|
|
? '《一场Soul的创业实验》全书'
|
|
|
|
|
|
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
|
|
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
|
|
|
|
|
|
const referralCode = wx.getStorageSync('referral_code') || ''
|
2026-01-23 05:44:21 +08:00
|
|
|
|
const res = await app.request('/api/miniprogram/pay', {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
method: 'POST',
|
2026-01-23 05:44:21 +08:00
|
|
|
|
data: {
|
|
|
|
|
|
openId,
|
|
|
|
|
|
productType: type,
|
|
|
|
|
|
productId: sectionId,
|
|
|
|
|
|
amount,
|
2026-01-29 11:11:58 +08:00
|
|
|
|
description,
|
2026-02-24 14:35:58 +08:00
|
|
|
|
userId: app.globalData.userInfo?.id || '',
|
|
|
|
|
|
referralCode: referralCode || undefined
|
2026-01-23 05:44:21 +08:00
|
|
|
|
}
|
2026-01-21 15:49:12 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-23 05:44:21 +08:00
|
|
|
|
console.log('[Pay] 创建订单响应:', res)
|
|
|
|
|
|
|
|
|
|
|
|
if (res.success && res.data?.payParams) {
|
|
|
|
|
|
paymentData = res.data.payParams
|
2026-02-28 10:19:46 +08:00
|
|
|
|
paymentData._orderSn = res.data.orderSn // 保存订单号,支付成功后用于主动同步
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.log('[Pay] 获取支付参数成功:', paymentData)
|
2026-01-23 05:44:21 +08:00
|
|
|
|
} else {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
throw new Error(res.error || res.message || '创建订单失败')
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (apiError) {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.error('[Pay] API创建订单失败:', apiError)
|
|
|
|
|
|
wx.hideLoading()
|
2026-01-25 21:29:04 +08:00
|
|
|
|
// 支付接口失败时,显示客服联系方式
|
|
|
|
|
|
wx.showModal({
|
|
|
|
|
|
title: '支付通道维护中',
|
2026-03-22 08:34:28 +08:00
|
|
|
|
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
|
2026-01-25 21:29:04 +08:00
|
|
|
|
confirmText: '复制微信号',
|
2026-03-23 18:38:23 +08:00
|
|
|
|
cancelText: '关闭',
|
2026-01-25 21:29:04 +08:00
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
|
wx.setClipboardData({
|
2026-03-22 08:34:28 +08:00
|
|
|
|
data: app.globalData.serviceWechat || '28533368',
|
2026-01-25 21:29:04 +08:00
|
|
|
|
success: () => {
|
|
|
|
|
|
wx.showToast({ title: '微信号已复制', icon: 'success' })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-01-23 05:44:21 +08:00
|
|
|
|
this.setData({ isPaying: false })
|
|
|
|
|
|
return
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 05:44:21 +08:00
|
|
|
|
// 3. 调用微信支付
|
2026-01-29 09:47:04 +08:00
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
|
2026-01-23 05:44:21 +08:00
|
|
|
|
|
2026-01-29 09:47:04 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await this.callWechatPay(paymentData)
|
|
|
|
|
|
|
2026-02-28 10:19:46 +08:00
|
|
|
|
// 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. 【标准流程】刷新权限并解锁内容
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.log('[Pay] 微信支付成功!')
|
2026-02-24 14:35:58 +08:00
|
|
|
|
await this.onPaymentSuccess()
|
2026-01-29 09:47:04 +08:00
|
|
|
|
|
|
|
|
|
|
} 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: '支付失败',
|
2026-03-22 08:34:28 +08:00
|
|
|
|
content: '微信支付暂不可用,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买',
|
2026-01-29 09:47:04 +08:00
|
|
|
|
confirmText: '复制微信号',
|
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
|
wx.setClipboardData({
|
2026-03-22 08:34:28 +08:00
|
|
|
|
data: app.globalData.serviceWechat || '28533368',
|
2026-01-29 09:47:04 +08:00
|
|
|
|
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
wx.showToast({ title: payErr.errMsg || '支付失败', icon: 'none' })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-21 15:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
2026-01-29 09:47:04 +08:00
|
|
|
|
console.error('[Pay] 支付流程异常:', e)
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
wx.showToast({ title: '支付出错,请重试', icon: 'none' })
|
2026-01-21 15:49:12 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
this.setData({ isPaying: false })
|
|
|
|
|
|
}
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 【新增】支付成功后的标准处理流程
|
|
|
|
|
|
async onPaymentSuccess() {
|
|
|
|
|
|
wx.showLoading({ title: '确认购买中...', mask: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 1. 等待服务端处理支付回调(1-2秒)
|
|
|
|
|
|
await this.sleep(2000)
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 刷新用户购买状态
|
|
|
|
|
|
await accessManager.refreshUserPurchaseStatus()
|
|
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
// 3. 重新拉取章节并判断权限(应为 unlocked_purchased)
|
|
|
|
|
|
const chapterRes = await app.request({
|
2026-03-06 12:12:13 +08:00
|
|
|
|
url: this._getChapterUrl({}),
|
2026-03-04 19:06:06 +08:00
|
|
|
|
silent: true
|
|
|
|
|
|
})
|
2026-02-24 14:35:58 +08:00
|
|
|
|
let newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
2026-03-04 19:06:06 +08:00
|
|
|
|
chapterRes
|
2026-02-24 14:35:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (newAccessState !== 'unlocked_purchased') {
|
|
|
|
|
|
console.log('[Pay] 权限未生效,1秒后重试...')
|
|
|
|
|
|
await this.sleep(1000)
|
|
|
|
|
|
newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
2026-03-04 19:06:06 +08:00
|
|
|
|
chapterRes
|
2026-02-24 14:35:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState: newAccessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 重新加载全文
|
2026-03-04 19:06:06 +08:00
|
|
|
|
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 5. 初始化阅读追踪
|
|
|
|
|
|
if (canAccess) {
|
|
|
|
|
|
readingTracker.init(this.data.sectionId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
wx.showToast({ title: '购买成功', icon: 'success' })
|
2026-03-23 18:38:23 +08:00
|
|
|
|
checkAndExecute('after_pay', this)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
} 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 || []
|
2026-01-21 15:49:12 +08:00
|
|
|
|
|
2026-02-24 14:35:58 +08:00
|
|
|
|
// 更新用户信息中的购买记录
|
2026-01-21 15:49:12 +08:00
|
|
|
|
const userInfo = app.globalData.userInfo || {}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
userInfo.hasFullBook = res.data.hasFullBook
|
|
|
|
|
|
userInfo.purchasedSections = res.data.purchasedSections || []
|
2026-01-21 15:49:12 +08:00
|
|
|
|
app.globalData.userInfo = userInfo
|
|
|
|
|
|
wx.setStorageSync('userInfo', userInfo)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
console.log('[Pay] ✅ 购买状态已刷新:', {
|
|
|
|
|
|
hasFullBook: res.data.hasFullBook,
|
|
|
|
|
|
purchasedCount: res.data.purchasedSections.length
|
|
|
|
|
|
})
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
2026-02-24 14:35:58 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[Pay] 刷新购买状态失败:', e)
|
|
|
|
|
|
// 刷新失败时不影响用户体验,只是记录日志
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 调用微信支付
|
|
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-01-14 12:50:00 +08:00
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 跳转到上一篇
|
|
|
|
|
|
goToPrev() {
|
|
|
|
|
|
if (this.data.prevSection) {
|
2026-03-12 11:36:50 +08:00
|
|
|
|
const { id, mid } = this.data.prevSection
|
|
|
|
|
|
const query = mid ? `mid=${mid}` : `id=${id}`
|
|
|
|
|
|
wx.redirectTo({ url: `/pages/read/read?${query}` })
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到下一篇
|
|
|
|
|
|
goToNext() {
|
|
|
|
|
|
if (this.data.nextSection) {
|
2026-03-12 11:36:50 +08:00
|
|
|
|
const { id, mid } = this.data.nextSection
|
|
|
|
|
|
const query = mid ? `mid=${mid}` : `id=${id}`
|
|
|
|
|
|
wx.redirectTo({ url: `/pages/read/read?${query}` })
|
2026-01-21 15:49:12 +08:00
|
|
|
|
}
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 跳转到推广中心
|
2026-01-14 12:50:00 +08:00
|
|
|
|
goToReferral() {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
wx.navigateTo({ url: '/pages/referral/referral' })
|
2026-01-14 12:50:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
/** 海报 canvas 在弹层渲染后偶现取不到 node,多次重试 */
|
|
|
|
|
|
async _queryPosterCanvasNode(maxTry = 10, delayMs = 100) {
|
|
|
|
|
|
for (let i = 0; i < maxTry; i++) {
|
|
|
|
|
|
const node = await new Promise((resolve) => {
|
|
|
|
|
|
wx.createSelectorQuery()
|
|
|
|
|
|
.in(this)
|
|
|
|
|
|
.select('#posterCanvas')
|
|
|
|
|
|
.fields({ node: true, size: true })
|
|
|
|
|
|
.exec((res) => {
|
|
|
|
|
|
if (res && res[0] && res[0].node) resolve(res[0])
|
|
|
|
|
|
else resolve(null)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
if (node) return node
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, delayMs))
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 生成海报(Canvas 2D API)
|
2026-01-25 19:52:38 +08:00
|
|
|
|
async generatePoster() {
|
|
|
|
|
|
wx.showLoading({ title: '生成中...' })
|
2026-03-22 08:34:28 +08:00
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
|
|
this.setData({ showPosterModal: true, isGeneratingPoster: true }, () => resolve())
|
|
|
|
|
|
})
|
|
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
|
|
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
|
|
|
|
|
|
else setTimeout(resolve, 50)
|
|
|
|
|
|
})
|
2026-03-23 18:38:23 +08:00
|
|
|
|
await new Promise((r) => setTimeout(r, 120))
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
2026-01-25 19:52:38 +08:00
|
|
|
|
try {
|
2026-03-18 16:00:57 +08:00
|
|
|
|
const { section, contentParagraphs, sectionId, sectionMid } = this.data
|
2026-01-25 19:52:38 +08:00
|
|
|
|
const userInfo = app.globalData.userInfo
|
2026-01-25 21:04:31 +08:00
|
|
|
|
const userId = userInfo?.id || ''
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
2026-01-25 21:04:31 +08:00
|
|
|
|
let qrcodeImage = null
|
|
|
|
|
|
try {
|
2026-03-18 16:00:57 +08:00
|
|
|
|
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
|
|
|
|
|
const scene = userId ? `${q}&ref=${userId.slice(0,10)}` : q
|
2026-01-25 21:04:31 +08:00
|
|
|
|
const qrRes = await app.request('/api/miniprogram/qrcode', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
data: { scene, page: 'pages/read/read', width: 280 }
|
|
|
|
|
|
})
|
2026-03-22 08:34:28 +08:00
|
|
|
|
if (qrRes.success && qrRes.image) qrcodeImage = qrRes.image
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
const canvasNode = await this._queryPosterCanvasNode()
|
|
|
|
|
|
if (!canvasNode) {
|
|
|
|
|
|
throw new Error('canvas node not found')
|
|
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
|
|
|
|
|
const canvas = canvasNode.node
|
|
|
|
|
|
let dpr = 2
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (typeof wx.getWindowInfo === 'function') {
|
|
|
|
|
|
dpr = wx.getWindowInfo().pixelRatio || 2
|
|
|
|
|
|
} else if (wx.getSystemInfoSync) {
|
|
|
|
|
|
dpr = wx.getSystemInfoSync().pixelRatio || 2
|
2026-01-25 21:04:31 +08:00
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
} catch (_) {
|
|
|
|
|
|
dpr = 2
|
2026-01-25 21:04:31 +08:00
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
// 布局尺寸:优先用节点测量;为 0 时回退 300×450(避免真机 query 过早得到 0 导致空白)
|
|
|
|
|
|
const layoutW = (canvasNode.width && canvasNode.width > 1) ? Math.round(canvasNode.width) : 300
|
|
|
|
|
|
const layoutH = (canvasNode.height && canvasNode.height > 1) ? Math.round(canvasNode.height) : 450
|
|
|
|
|
|
canvas.width = Math.max(1, Math.floor(layoutW * dpr))
|
|
|
|
|
|
canvas.height = Math.max(1, Math.floor(layoutH * dpr))
|
|
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
|
|
if (!ctx) throw new Error('canvas 2d not supported')
|
2026-03-22 08:34:28 +08:00
|
|
|
|
ctx.scale(dpr, dpr)
|
|
|
|
|
|
|
2026-03-23 18:38:23 +08:00
|
|
|
|
const paintPoster = async () => {
|
|
|
|
|
|
const w = layoutW
|
|
|
|
|
|
const h = layoutH
|
|
|
|
|
|
const grd = ctx.createLinearGradient(0, 0, 0, h)
|
|
|
|
|
|
grd.addColorStop(0, '#1a1a2e')
|
|
|
|
|
|
grd.addColorStop(1, '#16213e')
|
|
|
|
|
|
ctx.fillStyle = grd
|
|
|
|
|
|
ctx.fillRect(0, 0, w, h)
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#00CED1'
|
|
|
|
|
|
ctx.fillRect(0, 0, w, 4)
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#ffffff'
|
|
|
|
|
|
ctx.font = '14px sans-serif'
|
|
|
|
|
|
ctx.fillText('卡若创业派对', 20, 35)
|
|
|
|
|
|
|
|
|
|
|
|
ctx.font = '18px sans-serif'
|
|
|
|
|
|
ctx.fillStyle = '#ffffff'
|
|
|
|
|
|
const title = section?.title || '精彩内容'
|
|
|
|
|
|
const titleLines = this.wrapText2d(ctx, title, w - 40)
|
|
|
|
|
|
let y = 70
|
|
|
|
|
|
titleLines.forEach((line) => { ctx.fillText(line, 20, y); y += 26 })
|
|
|
|
|
|
|
|
|
|
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
|
|
|
|
|
|
ctx.beginPath()
|
|
|
|
|
|
ctx.moveTo(20, y + 10)
|
|
|
|
|
|
ctx.lineTo(w - 20, y + 10)
|
|
|
|
|
|
ctx.stroke()
|
|
|
|
|
|
|
|
|
|
|
|
ctx.font = '12px sans-serif'
|
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.8)'
|
|
|
|
|
|
y += 30
|
|
|
|
|
|
let paras = Array.isArray(contentParagraphs) ? contentParagraphs.filter(Boolean) : []
|
|
|
|
|
|
if (!paras.length && this.data.content) {
|
|
|
|
|
|
const plain = String(this.data.content)
|
|
|
|
|
|
.replace(/<[^>]+>/g, ' ')
|
|
|
|
|
|
.replace(/ /g, ' ')
|
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
if (plain) paras = [plain.slice(0, 400)]
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
2026-03-23 18:38:23 +08:00
|
|
|
|
const rawSummary = paras.slice(0, 3).join(' ').trim() || '卡若创业派对 · 真实商业故事'
|
|
|
|
|
|
const summary = rawSummary.length > 160 ? rawSummary.slice(0, 157) + '...' : rawSummary
|
|
|
|
|
|
const summaryLines = this.wrapText2d(ctx, summary, w - 40)
|
|
|
|
|
|
summaryLines.slice(0, 6).forEach((line) => { ctx.fillText(line, 20, y); y += 20 })
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = 'rgba(0,206,209,0.1)'
|
|
|
|
|
|
ctx.fillRect(0, h - 100, w, 100)
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#ffffff'
|
|
|
|
|
|
ctx.font = '13px sans-serif'
|
|
|
|
|
|
ctx.fillText('长按识别小程序码', 20, h - 60)
|
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
|
|
|
|
|
ctx.font = '11px sans-serif'
|
|
|
|
|
|
ctx.fillText('长按小程序码阅读全文', 20, h - 38)
|
|
|
|
|
|
|
|
|
|
|
|
if (qrcodeImage) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fs = wx.getFileSystemManager()
|
|
|
|
|
|
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
|
|
|
|
|
|
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
|
|
|
|
|
|
fs.writeFileSync(filePath, base64Data, 'base64')
|
|
|
|
|
|
const img = canvas.createImage()
|
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
|
img.onload = resolve
|
|
|
|
|
|
img.onerror = reject
|
|
|
|
|
|
img.src = filePath
|
|
|
|
|
|
})
|
|
|
|
|
|
ctx.drawImage(img, w - 85, h - 85, 70, 70)
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
this.drawQRPlaceholder2d(ctx, w, h)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.drawQRPlaceholder2d(ctx, w, h)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof canvas.requestAnimationFrame === 'function') {
|
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
|
canvas.requestAnimationFrame(() => {
|
|
|
|
|
|
paintPoster().then(resolve).catch(reject)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-03-22 08:34:28 +08:00
|
|
|
|
} else {
|
2026-03-23 18:38:23 +08:00
|
|
|
|
await paintPoster()
|
2026-01-25 21:04:31 +08:00
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
this.setData({ isGeneratingPoster: false })
|
2026-01-25 19:52:38 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('生成海报失败:', e)
|
|
|
|
|
|
wx.hideLoading()
|
|
|
|
|
|
wx.showToast({ title: '生成失败', icon: 'none' })
|
|
|
|
|
|
this.setData({ showPosterModal: false, isGeneratingPoster: false })
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
|
|
|
|
|
drawQRPlaceholder2d(ctx, width, height) {
|
|
|
|
|
|
ctx.fillStyle = '#ffffff'
|
2026-01-25 21:04:31 +08:00
|
|
|
|
ctx.beginPath()
|
|
|
|
|
|
ctx.arc(width - 50, height - 50, 35, 0, Math.PI * 2)
|
|
|
|
|
|
ctx.fill()
|
2026-03-22 08:34:28 +08:00
|
|
|
|
ctx.fillStyle = '#00CED1'
|
|
|
|
|
|
ctx.font = '9px sans-serif'
|
2026-01-25 21:04:31 +08:00
|
|
|
|
ctx.fillText('扫码', width - 57, height - 52)
|
|
|
|
|
|
ctx.fillText('阅读', width - 57, height - 40)
|
|
|
|
|
|
},
|
2026-03-22 08:34:28 +08:00
|
|
|
|
|
|
|
|
|
|
wrapText2d(ctx, text, maxWidth) {
|
2026-01-25 19:52:38 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
// 保存海报到相册(Canvas 2D)
|
2026-01-25 19:52:38 +08:00
|
|
|
|
savePoster() {
|
2026-03-22 08:34:28 +08:00
|
|
|
|
wx.createSelectorQuery().in(this)
|
|
|
|
|
|
.select('#posterCanvas')
|
|
|
|
|
|
.fields({ node: true, size: true })
|
|
|
|
|
|
.exec(res => {
|
|
|
|
|
|
if (!res || !res[0] || !res[0].node) {
|
|
|
|
|
|
wx.showToast({ title: '保存失败', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const canvas = res[0].node
|
|
|
|
|
|
wx.canvasToTempFilePath({
|
|
|
|
|
|
canvas,
|
|
|
|
|
|
success: (r2) => {
|
|
|
|
|
|
wx.saveImageToPhotosAlbum({
|
|
|
|
|
|
filePath: r2.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: (m) => {
|
|
|
|
|
|
if (m.confirm) wx.openSetting()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
wx.showToast({ title: '保存失败', icon: 'none' })
|
2026-01-25 19:52:38 +08:00
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: () => {
|
|
|
|
|
|
wx.showToast({ title: '生成图片失败', icon: 'none' })
|
2026-01-25 19:52:38 +08:00
|
|
|
|
}
|
2026-03-22 08:34:28 +08:00
|
|
|
|
}, this)
|
|
|
|
|
|
})
|
2026-01-25 19:52:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-01-14 12:50:00 +08:00
|
|
|
|
// 阻止冒泡
|
2026-02-24 14:35:58 +08:00
|
|
|
|
stopPropagation() {},
|
|
|
|
|
|
|
|
|
|
|
|
// 【新增】页面隐藏时上报阅读进度
|
|
|
|
|
|
onHide() {
|
|
|
|
|
|
readingTracker.onPageHide()
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 【新增】页面卸载时清理追踪器
|
|
|
|
|
|
onUnload() {
|
|
|
|
|
|
readingTracker.cleanup()
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 【新增】重试加载(当 accessState 为 error 时)
|
|
|
|
|
|
async handleRetry() {
|
|
|
|
|
|
wx.showLoading({ title: '重试中...', mask: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const config = await accessManager.fetchLatestConfig()
|
2026-03-04 19:06:06 +08:00
|
|
|
|
this.setData({
|
|
|
|
|
|
sectionPrice: config.prices?.section ?? 1,
|
|
|
|
|
|
fullBookPrice: config.prices?.fullbook ?? 9.9
|
|
|
|
|
|
})
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
// 重新拉取章节,用 isFree/price 判断免费
|
|
|
|
|
|
const chapterRes = await app.request({
|
2026-03-06 12:12:13 +08:00
|
|
|
|
url: this._getChapterUrl({}),
|
2026-03-04 19:06:06 +08:00
|
|
|
|
silent: true
|
|
|
|
|
|
})
|
2026-02-24 14:35:58 +08:00
|
|
|
|
const newAccessState = await accessManager.determineAccessState(
|
|
|
|
|
|
this.data.sectionId,
|
2026-03-04 19:06:06 +08:00
|
|
|
|
chapterRes
|
2026-02-24 14:35:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
const canAccess = accessManager.canAccessFullContent(newAccessState)
|
|
|
|
|
|
|
|
|
|
|
|
this.setData({
|
|
|
|
|
|
accessState: newAccessState,
|
|
|
|
|
|
canAccess,
|
|
|
|
|
|
showPaywall: !canAccess
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 19:06:06 +08:00
|
|
|
|
await this.loadContent(this.data.sectionId, newAccessState, chapterRes)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果有权限,初始化阅读追踪
|
|
|
|
|
|
if (canAccess) {
|
|
|
|
|
|
readingTracker.init(this.data.sectionId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 16:00:57 +08:00
|
|
|
|
this._applyPrevNext(chapterRes)
|
2026-02-24 14:35:58 +08:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
2026-01-14 12:50:00 +08:00
|
|
|
|
})
|