Files
Mycontent/miniprogram/pages/chapters/chapters.js
2026-04-13 14:32:32 +08:00

442 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 卡若创业派对 - 目录页
* 开发: 卡若
* 技术支持: 存客宝
* 数据: 完整真实文章标题
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { partEmojiForBodyIndex } = require('../../utils/partIcons.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
Page({
data: {
statusBarHeight: 44,
navBarHeight: 88,
isLoggedIn: false,
hasFullBook: false,
isVip: false,
purchasedSections: [],
// 多书架构
bookList: [],
expandedBookId: null,
// 当前展开书的篇章数据(按 bookId 索引)
_bookPartsData: {},
totalSections: 0,
bookData: [],
expandedPart: null,
bookCollapsed: false,
_loadedChapters: {},
_triangleAnimating: '',
fixedSectionsMap: {},
appendixList: [
{ id: 'appendix-1', title: '附录1Soul派对房精选对话' },
{ id: 'appendix-2', title: '附录2创业者自检清单' },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源' }
],
partsLoading: true,
searchEnabled: true,
bookFreePreviewPercent: 100,
chaptersBookTitle: '一场Soul的创业实验',
chaptersBookSubtitle: '来自派对房的真实故事',
chaptersNewBadgeText: 'NEW'
},
onLoad() {
wx.showShareMenu({ withShareTimeline: true })
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
})
this.updateUserStatus()
this.loadVipStatus()
this.loadBooks()
this.loadFeatureConfig()
},
_applyChaptersMpUi() {
const c = app.globalData.configCache?.mpConfig?.mpUi?.chaptersPage || {}
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
const newBadgeText = String(c.newBadgeText || c.sectionNewBadgeText || h.latestSectionTitle || 'NEW').trim() || 'NEW'
this.setData({
chaptersBookTitle: String(c.bookTitle || '一场Soul的创业实验').trim() || '一场Soul的创业实验',
chaptersBookSubtitle: String(c.bookSubtitle || '来自派对房的真实故事').trim() ||
'来自派对房的真实故事',
chaptersNewBadgeText: newBadgeText
})
},
_normalizeBadgeText(v) {
return String(v || '').trim().slice(0, 8)
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
const pct = app.globalData.bookFreePreviewPercent
if (typeof pct === 'number') this.setData({ bookFreePreviewPercent: pct })
this._applyChaptersMpUi()
return
}
const res = await app.getConfig()
const features = (res && res.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
const previewPct = (typeof res?.bookFreePreviewPercent === 'number') ? res.bookFreePreviewPercent : 100
app.globalData.bookFreePreviewPercent = previewPct
this.setData({ searchEnabled, bookFreePreviewPercent: previewPct })
this._applyChaptersMpUi()
} catch (e) {
this.setData({ searchEnabled: true })
this._applyChaptersMpUi()
}
},
async loadBooks() {
this.setData({ partsLoading: true })
let books = []
try {
const res = await app.request({ url: '/api/miniprogram/book/books', silent: true })
if (res?.success && Array.isArray(res.books)) {
books = res.books
}
} catch (e) {
console.log('[Chapters] 加载书列表失败,使用默认书:', e?.message || e)
}
if (books.length === 0) {
books = [{
id: 1, bookKey: 'main',
title: this.data.chaptersBookTitle || '一场Soul的创业实验',
subtitle: this.data.chaptersBookSubtitle || '来自派对房的真实故事',
icon: '', iconEmoji: '📖', sortOrder: 0, sectionCount: 0,
}]
}
this.setData({
bookList: books.map(b => ({
id: b.id,
bookKey: b.bookKey || 'main',
title: b.title,
subtitle: b.subtitle || '',
icon: b.icon || '',
iconEmoji: b.iconEmoji || '',
sortOrder: b.sortOrder || 0,
sectionCount: b.sectionCount || 0,
})),
expandedBookId: null,
partsLoading: false,
})
},
async loadPartsForBook(bookId) {
const effectiveBookId = bookId > 0 ? bookId : 1
if (effectiveBookId > 0 && this.data._bookPartsData[effectiveBookId]) {
this.setData({
bookData: this.data._bookPartsData[effectiveBookId].parts,
totalSections: this.data._bookPartsData[effectiveBookId].total,
fixedSectionsMap: this.data._bookPartsData[effectiveBookId].fixedMap,
appendixList: this.data._bookPartsData[effectiveBookId].appendixList,
})
return
}
this.setData({ partsLoading: true })
try {
const url = `/api/miniprogram/book/parts${effectiveBookId > 0 ? '?bookId=' + effectiveBookId : ''}`
const res = await app.request({ url, silent: true })
let parts = []
let totalSections = 0
let fixedSections = []
if (res?.success && Array.isArray(res.parts) && res.parts.length > 0) {
parts = res.parts
totalSections = res.totalSections ?? 0
fixedSections = res.fixedSections || []
}
const fixedMap = {}
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
const appendixList = [
{ id: 'appendix-1', title: '附录1Soul派对房精选对话', mid: fixedMap['appendix-1'] },
{ id: 'appendix-2', title: '附录2创业者自检清单', mid: fixedMap['appendix-2'] },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源', mid: fixedMap['appendix-3'] }
]
const bookData = parts.map((p, idx) => {
let icon = String(p.icon || '').trim()
if (icon && !isSafeImageSrc(icon)) icon = ''
const iconEmoji = icon ? '' : partEmojiForBodyIndex(idx)
const partBadgeText = this._normalizeBadgeText(
p.badgeText || p.badge_text || p.partBadgeText || p.part_badge_text
)
return {
id: p.id,
icon,
iconEmoji,
iconText: partBadgeText,
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [],
alwaysShow: false
}
})
app.globalData.totalSections = totalSections
if (effectiveBookId > 0) {
const cached = { ...this.data._bookPartsData }
cached[effectiveBookId] = { parts: bookData, total: totalSections, fixedMap, appendixList }
this.setData({ _bookPartsData: cached })
}
this.setData({
bookData,
totalSections,
fixedSectionsMap: fixedMap,
appendixList,
_loadedChapters: {},
partsLoading: false,
expandedPart: null
})
} catch (e) {
console.log('[Chapters] 加载篇章失败:', e)
this.setData({ bookData: [], totalSections: 0, partsLoading: false })
}
},
// 展开时懒加载该篇章的章节(含 mid供阅读页 by-mid 请求)
async loadChaptersByPart(partId) {
if (this.data._loadedChapters[partId]) return
try {
const res = await app.request({
url: `/api/miniprogram/book/chapters-by-part?partId=${encodeURIComponent(partId)}`,
silent: true
})
const rows = (res && res.data) || []
const chMap = new Map()
rows.forEach(r => {
const cid = r.chapterId || r.chapter_id || 'chapter-1'
if (!chMap.has(cid)) {
chMap.set(cid, {
id: cid,
title: r.chapterTitle || r.chapter_title || '未分类',
sections: []
})
}
const ch = chMap.get(cid)
ch.sections.push({
id: r.id,
mid: r.mid ?? r.MID ?? 0,
title: r.sectionTitle || r.section_title || r.title || '',
isFree: r.isFree === true || (r.price !== undefined && r.price === 0),
price: r.price ?? 1,
isNew: r.isNew === true || r.is_new === true,
newBadgeText: this._normalizeBadgeText(
r.newBadgeText || r.new_badge_text || r.sectionBadgeText || r.section_badge_text || r.badgeText || r.badge_text
),
isPremium: false
})
})
const chapters = Array.from(chMap.values())
// 按免费展示比例标记:超出比例的章节标记为需付费
const freePercent = this.data.bookFreePreviewPercent ?? 100
if (freePercent < 100) {
let allSections = []
chapters.forEach(ch => { allSections = allSections.concat(ch.sections) })
const freeCount = Math.floor(allSections.length * freePercent / 100)
allSections.forEach((s, idx) => {
if (idx >= freeCount && !s.isFree) {
s.needPay = true
}
})
}
chapters.forEach((ch) => {
const n = ch.sections.length
ch.sectionVisibleLimit = n === 0 ? 0 : Math.min(5, n)
})
const loaded = { ...this.data._loadedChapters, [partId]: chapters }
const bookData = this.data.bookData.map(p =>
p.id === partId ? { ...p, chapters } : p
)
const bookDataFlat = app.globalData.bookData || []
rows.forEach(r => {
const idx = bookDataFlat.findIndex(c => c.id === r.id)
if (idx >= 0) bookDataFlat[idx] = { ...bookDataFlat[idx], ...r }
else bookDataFlat.push(r)
})
app.globalData.bookData = bookDataFlat
wx.setStorage({ key: 'bookData', data: bookDataFlat }) // 异步写入,避免阻塞主线程
this.setData({ bookData, _loadedChapters: loaded })
} catch (e) {
console.log('[Chapters] 加载章节失败:', e)
}
},
async toggleBook(e) {
const bookId = Number(e.currentTarget.dataset.bookId)
trackClick('chapters', 'tab_click', `book_${bookId}`)
const isExpanding = this.data.expandedBookId !== bookId
this.setData({
expandedBookId: isExpanding ? bookId : null,
expandedPart: null
})
if (isExpanding) {
await this.loadPartsForBook(bookId)
// 自动展开第一个篇章(二级默认展开)
if (this.data.bookData.length > 0) {
const firstPartId = this.data.bookData[0].id
this.setData({ expandedPart: firstPartId })
await this.loadChaptersByPart(firstPartId)
}
}
},
onPullDownRefresh() {
this.setData({ _bookPartsData: {} })
this.loadBooks()
.then(() => wx.stopPullDownRefresh())
.catch(() => wx.stopPullDownRefresh())
},
onShow() {
this._applyChaptersMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
if (tabBar.updateSelected) {
tabBar.updateSelected()
} else {
tabBar.setData({ selected: 1 })
}
}
this.updateUserStatus()
this.loadVipStatus()
},
// 拉取 VIP 状态isVip=会员hasFullBook=365读书会权益
async loadVipStatus() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true, timeout: 3000 })
if (res?.success) {
app.globalData.isVip = !!res.data?.isVip
app.globalData.vipExpireDate = res.data?.expireDate || ''
this.setData({ isVip: app.globalData.isVip })
const userInfo = app.globalData.userInfo || {}
userInfo.isVip = app.globalData.isVip
userInfo.vipExpireDate = app.globalData.vipExpireDate
wx.setStorageSync('userInfo', userInfo)
}
} catch (e) {
// 静默失败不影响目录展示
}
},
// 更新用户状态
updateUserStatus() {
const { isLoggedIn, hasFullBook, purchasedSections, isVip } = app.globalData
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
},
toggleBookCollapse() {
trackClick('chapters', 'btn_click', '折叠书名')
this.setData({ bookCollapsed: !this.data.bookCollapsed })
},
async togglePart(e) {
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
const partId = e.currentTarget.dataset.id
const isExpanding = this.data.expandedPart !== partId
this.setData({
expandedPart: isExpanding ? partId : null
})
if (isExpanding) await this.loadChaptersByPart(partId)
},
expandSectionChapter(e) {
const partId = e.currentTarget.dataset.partId
const chapterId = e.currentTarget.dataset.chapterId
if (!partId || !chapterId) return
trackClick('chapters', 'tab_click', '目录_子章展开5条')
const part = this.data.bookData.find((p) => p.id === partId)
const chapter = part && (part.chapters || []).find((c) => c.id === chapterId)
if (!chapter || !chapter.sections || chapter.sections.length === 0) return
const total = chapter.sections.length
const cur = typeof chapter.sectionVisibleLimit === 'number' ? chapter.sectionVisibleLimit : Math.min(5, total)
const next = Math.min(cur + 5, total)
if (next === cur) return
const bookData = this.data.bookData.map((p) => {
if (p.id !== partId) return p
return {
...p,
chapters: (p.chapters || []).map((ch) =>
ch.id === chapterId ? { ...ch, sectionVisibleLimit: next } : ch
),
}
})
// 先去掉动画 class 再打上,便于连续点击重复触发动画
this.setData({ _triangleAnimating: '', bookData })
setTimeout(() => {
this.setData({ _triangleAnimating: chapterId })
setTimeout(() => {
if (this.data._triangleAnimating === chapterId) {
this.setData({ _triangleAnimating: '' })
}
}, 480)
}, 30)
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
trackClick('chapters', 'card_click', id || '章节')
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
hasPurchased(sectionId) {
if (this.data.isVip) return true
if (this.data.hasFullBook) return true
return this.data.purchasedSections.includes(sectionId)
},
// 返回首页
goBack() {
wx.switchTab({ url: '/pages/index/index' })
},
// 跳转到搜索页
goToSearch() {
if (!this.data.searchEnabled) return
trackClick('chapters', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: '卡若创业派对 - 目录',
path: ref ? `/pages/chapters/chapters?ref=${ref}` : '/pages/chapters/chapters'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: '卡若创业派对 - 真实商业故事', query: ref ? `ref=${ref}` : '' }
}
})