feat: 支持章节通过 mid 进行访问,优化阅读跳转逻辑。新增章节数据结构,包含章节的 mid 信息,提升用户体验。更新 API 以支持通过 mid 查询章节内容,确保兼容性与灵活性。

This commit is contained in:
乘风
2026-02-12 15:52:35 +08:00
parent 046e686cda
commit a571583be4
18 changed files with 353 additions and 391 deletions

View File

@@ -7,9 +7,9 @@ App({
globalData: {
// API基础地址 - 连接真实后端
// baseUrl: 'https://soulapi.quwanzhi.com',
baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'http://localhost:3006',
// baseUrl: 'http://localhost:8080',
baseUrl: 'http://localhost:8080',
// 小程序配置 - 真实AppID
appId: 'wxb8bbb2b10dec74aa',
@@ -31,6 +31,7 @@ App({
// 购买记录
purchasedSections: [],
sectionMidMap: {}, // id -> mid来自 purchase-status
hasFullBook: false,
matchCount: 0,
matchQuota: null,
@@ -100,6 +101,7 @@ App({
const val = part.slice(eq + 1)
if (key === 'ref') refCode = val
if (key === 'id' && val) this.globalData.initialSectionId = val
if (key === 'mid' && val) this.globalData.initialSectionMid = parseInt(val, 10) || 0
}
}
}
@@ -178,6 +180,13 @@ App({
}
},
// 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) {
const list = this.globalData.bookData || []
const ch = list.find(c => c.id === sectionId)
return ch?.mid || 0
},
// 获取当前用户的邀请码(用于分享带 ref未登录返回空字符串
getMyReferralCode() {
const user = this.globalData.userInfo

View File

@@ -6,200 +6,66 @@
*/
const app = getApp()
const PART_NUMBERS = { 'part-1': '一', 'part-2': '二', 'part-3': '三', 'part-4': '四', 'part-5': '五' }
function buildNestedBookData(list) {
const parts = {}
const appendices = []
let epilogueMid = 0
let prefaceMid = 0
list.forEach(ch => {
if (ch.id === 'preface') {
prefaceMid = ch.mid || 0
return
}
const section = {
id: ch.id,
mid: ch.mid || 0,
title: ch.sectionTitle || ch.chapterTitle || ch.id,
isFree: !!ch.isFree,
price: ch.price != null ? Number(ch.price) : 1
}
if (ch.id === 'epilogue') {
epilogueMid = ch.mid || 0
return
}
if ((ch.id || '').startsWith('appendix')) {
appendices.push({ id: ch.id, mid: ch.mid || 0, title: ch.sectionTitle || ch.chapterTitle || ch.id })
return
}
if (!ch.partId || ch.id === 'preface') return
const pid = ch.partId
const cid = ch.chapterId || 'chapter-' + (ch.id || '').split('.')[0]
if (!parts[pid]) {
parts[pid] = { id: pid, number: PART_NUMBERS[pid] || pid, title: ch.partTitle || pid, subtitle: ch.chapterTitle || '', chapters: {} }
}
if (!parts[pid].chapters[cid]) {
parts[pid].chapters[cid] = { id: cid, title: ch.chapterTitle || cid, sections: [] }
}
parts[pid].chapters[cid].sections.push(section)
})
const bookData = Object.values(parts)
.sort((a, b) => (a.id || '').localeCompare(b.id || ''))
.map(p => ({
...p,
chapters: Object.values(p.chapters).sort((a, b) => (a.id || '').localeCompare(b.id || ''))
}))
return { bookData, appendixList: appendices, epilogueMid, prefaceMid }
}
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 用户状态
isLoggedIn: false,
hasFullBook: false,
purchasedSections: [],
// 书籍数据 - 完整真实标题
totalSections: 62,
bookData: [
{
id: 'part-1',
number: '一',
title: '真实的人',
subtitle: '人与人之间的底层逻辑',
chapters: [
{
id: 'chapter-1',
title: '第1章人与人之间的底层逻辑',
sections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', isFree: true, price: 1 },
{ id: '1.2', title: '老墨:资源整合高手的社交方法', isFree: false, price: 1 },
{ id: '1.3', title: '笑声背后的MBTI为什么ENTJ适合做资源INTP适合做系统', isFree: false, price: 1 },
{ id: '1.4', title: '人性的三角结构:利益、情感、价值观', isFree: false, price: 1 },
{ id: '1.5', title: '沟通差的问题:为什么你说的别人听不懂', isFree: false, price: 1 }
]
},
{
id: 'chapter-2',
title: '第2章人性困境案例',
sections: [
{ id: '2.1', title: '相亲故事:你以为找的是人,实际是在找模式', isFree: false, price: 1 },
{ id: '2.2', title: '找工作迷茫者:为什么简历解决不了人生', isFree: false, price: 1 },
{ id: '2.3', title: '撸运费险:小钱困住大脑的真实心理', isFree: false, price: 1 },
{ id: '2.4', title: '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力', isFree: false, price: 1 },
{ id: '2.5', title: '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-2',
number: '二',
title: '真实的行业',
subtitle: '电商、内容、传统行业解析',
chapters: [
{
id: 'chapter-3',
title: '第3章电商篇',
sections: [
{ id: '3.1', title: '3000万流水如何跑出来(退税模式解析)', isFree: false, price: 1 },
{ id: '3.2', title: '供应链之王 vs 打工人:利润不在前端', isFree: false, price: 1 },
{ id: '3.3', title: '社区团购的底层逻辑', isFree: false, price: 1 },
{ id: '3.4', title: '跨境电商与退税套利', isFree: false, price: 1 }
]
},
{
id: 'chapter-4',
title: '第4章内容商业篇',
sections: [
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', isFree: false, price: 1 },
{ id: '4.2', title: '做号工厂:如何让一个号变成一个机器', isFree: false, price: 1 },
{ id: '4.3', title: '情绪内容为什么比专业内容更赚钱', isFree: false, price: 1 },
{ id: '4.4', title: '猫与宠物号:为什么宠物赛道永不过时', isFree: false, price: 1 },
{ id: '4.5', title: '直播间里的三种人:演员、技术工、系统流', isFree: false, price: 1 }
]
},
{
id: 'chapter-5',
title: '第5章传统行业篇',
sections: [
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', isFree: false, price: 1 },
{ id: '5.2', title: '土地拍卖:招拍挂背后的游戏规则', isFree: false, price: 1 },
{ id: '5.3', title: '地摊经济数字化一个月900块的餐车生意', isFree: false, price: 1 },
{ id: '5.4', title: '不良资产拍卖:我错过的一个亿佣金', isFree: false, price: 1 },
{ id: '5.5', title: '桶装水李总:跟物业合作的轻资产模式', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-3',
number: '三',
title: '真实的错误',
subtitle: '我和别人犯过的错',
chapters: [
{
id: 'chapter-6',
title: '第6章我人生错过的4件大钱',
sections: [
{ id: '6.1', title: '电商财税窗口2016年的千万级机会', isFree: false, price: 1 },
{ id: '6.2', title: '供应链金融:我不懂的杠杆游戏', isFree: false, price: 1 },
{ id: '6.3', title: '内容红利2019年我为什么没做抖音', isFree: false, price: 1 },
{ id: '6.4', title: '数据资产化:我还在观望的未来机会', isFree: false, price: 1 }
]
},
{
id: 'chapter-7',
title: '第7章别人犯的错误',
sections: [
{ id: '7.1', title: '投资房年轻人的迷茫:资金 vs 能力', isFree: false, price: 1 },
{ id: '7.2', title: '信息差骗局:永远有人靠卖学习赚钱', isFree: false, price: 1 },
{ id: '7.3', title: '在Soul找恋爱但想赚钱的人', isFree: false, price: 1 },
{ id: '7.4', title: '创业者的三种死法:冲动、轻信、没结构', isFree: false, price: 1 },
{ id: '7.5', title: '人情生意的终点:关系越多亏得越多', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-4',
number: '四',
title: '真实的赚钱',
subtitle: '底层结构与真实案例',
chapters: [
{
id: 'chapter-8',
title: '第8章底层结构',
sections: [
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', isFree: false, price: 1 },
{ id: '8.2', title: '价格杠杆:供应链与信息差', isFree: false, price: 1 },
{ id: '8.3', title: '时间杠杆:自动化 + AI', isFree: false, price: 1 },
{ id: '8.4', title: '情绪杠杆:咨询、婚恋、生意场', isFree: false, price: 1 },
{ id: '8.5', title: '社交杠杆:认识谁比你会什么更重要', isFree: false, price: 1 },
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', isFree: false, price: 1 }
]
},
{
id: 'chapter-9',
title: '第9章我在Soul上亲访的赚钱案例',
sections: [
{ id: '9.1', title: '游戏账号私域:账号即资产', isFree: false, price: 1 },
{ id: '9.2', title: '健康包模式:高复购、高毛利', isFree: false, price: 1 },
{ id: '9.3', title: '药物私域:长期关系赛道', isFree: false, price: 1 },
{ id: '9.4', title: '残疾机构合作:退税 × AI × 人力成本', isFree: false, price: 1 },
{ id: '9.5', title: '私域银行:粉丝即小股东', isFree: false, price: 1 },
{ id: '9.6', title: 'Soul派对房:陌生人成交的最快场景', isFree: false, price: 1 },
{ id: '9.7', title: '飞书中台:从聊天到成交的流程化体系', isFree: false, price: 1 },
{ id: '9.8', title: '餐饮女孩6万营收、1万利润的死撑生意', isFree: false, price: 1 },
{ id: '9.9', title: '电竞生态:从陪玩到签约到酒店的完整链条', isFree: false, price: 1 },
{ id: '9.10', title: '淘客大佬损耗30%的白色通道', isFree: false, price: 1 },
{ id: '9.11', title: '蔬菜供应链:农户才是最赚钱的人', isFree: false, price: 1 },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', isFree: false, price: 1 },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', isFree: false, price: 1 },
{ id: '9.14', title: '大健康私域一个月150万的70后', isFree: false, price: 1 }
]
}
]
},
{
id: 'part-5',
number: '五',
title: '真实的社会',
subtitle: '未来职业与商业生态',
chapters: [
{
id: 'chapter-10',
title: '第10章未来职业的变化趋势',
sections: [
{ id: '10.1', title: 'AI时代哪些工作会消失哪些会崛起', isFree: false, price: 1 },
{ id: '10.2', title: '一人公司:为什么越来越多人选择单干', isFree: false, price: 1 },
{ id: '10.3', title: '为什么链接能力会成为第一价值', isFree: false, price: 1 },
{ id: '10.4', title: '新型公司:Soul-飞书-线下的三位一体', isFree: false, price: 1 }
]
},
{
id: 'chapter-11',
title: '第11章中国社会商业生态的未来',
sections: [
{ id: '11.1', title: '私域经济:为什么流量越来越贵', isFree: false, price: 1 },
{ id: '11.2', title: '银发经济与孤独经济:两个被忽视的万亿市场', isFree: false, price: 1 },
{ id: '11.3', title: '流量红利的终局', isFree: false, price: 1 },
{ id: '11.4', title: '大模型 + 供应链的组合拳', isFree: false, price: 1 },
{ id: '11.5', title: '社会分层的最终逻辑', isFree: false, price: 1 }
]
}
]
}
],
// 展开状态
expandedPart: 'part-1',
// 附录
appendixList: [
{ id: 'appendix-1', title: '附录1Soul派对房精选对话' },
{ id: 'appendix-2', title: '附录2创业者自检清单' },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源' }
]
bookData: [],
expandedPart: null,
appendixList: [],
epilogueMid: 0,
prefaceMid: 0
},
onLoad() {
@@ -208,10 +74,44 @@ Page({
navBarHeight: app.globalData.navBarHeight
})
this.updateUserStatus()
this.loadAndEnrichBookData()
},
async loadAndEnrichBookData() {
try {
let list = app.globalData.bookData || []
if (!list.length) {
const res = await app.request('/api/miniprogram/book/all-chapters')
if (res?.data) {
list = res.data
app.globalData.bookData = list
}
}
if (!list.length) {
this.setData({ bookData: [], appendixList: [] })
return
}
const { bookData, appendixList, epilogueMid, prefaceMid } = buildNestedBookData(list)
const firstPartId = bookData[0]?.id || null
this.setData({
bookData,
appendixList,
epilogueMid,
prefaceMid,
totalSections: list.length,
expandedPart: firstPartId || this.data.expandedPart
})
} catch (e) {
console.error('[Chapters] 加载目录失败:', e)
this.setData({ bookData: [], appendixList: [] })
}
},
onShow() {
// 设置TabBar选中状态
this.updateUserStatus()
if (!app.globalData.bookData?.length) {
this.loadAndEnrichBookData()
}
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
if (tabBar.updateSelected) {
@@ -220,7 +120,6 @@ Page({
tabBar.setData({ selected: 1 })
}
}
this.updateUserStatus()
},
// 更新用户状态
@@ -237,10 +136,11 @@ Page({
})
},
// 跳转到阅读页
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 检查是否已购买

View File

@@ -35,7 +35,7 @@
<!-- 目录内容 -->
<view class="chapters-content">
<!-- 序言 -->
<view class="chapter-item" bindtap="goToRead" data-id="preface">
<view class="chapter-item" bindtap="goToRead" data-id="preface" data-mid="{{prefaceMid}}">
<view class="item-left">
<view class="item-icon icon-brand">📖</view>
<text class="item-title">序言为什么我每天早上6点在Soul开播?</text>
@@ -71,7 +71,7 @@
<view class="chapter-header">{{chapter.title}}</view>
<view class="section-list">
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section">
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}">
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
<view class="section-left">
<text class="section-lock {{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? 'lock-open' : 'lock-closed'}}">{{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? '○' : '●'}}</text>
<text class="section-title {{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
@@ -92,7 +92,7 @@
</view>
<!-- 尾声 -->
<view class="chapter-item" bindtap="goToRead" data-id="epilogue">
<view class="chapter-item" bindtap="goToRead" data-id="epilogue" data-mid="{{epilogueMid}}">
<view class="item-left">
<view class="item-icon icon-brand">📖</view>
<text class="item-title">尾声|这本书的真实目的</text>
@@ -113,6 +113,7 @@
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<text class="appendix-text">{{item.title}}</text>
<text class="appendix-arrow">→</text>

View File

@@ -7,6 +7,7 @@
console.log('[Index] ===== 首页文件开始加载 =====')
const app = getApp()
const PART_NUMBERS = { 'part-1': '一', 'part-2': '二', 'part-3': '三', 'part-4': '四', 'part-5': '五' }
Page({
data: {
@@ -19,31 +20,13 @@ Page({
hasFullBook: false,
readCount: 0,
// 书籍数据
totalSections: 62,
bookData: [],
// 推荐章节
featuredSections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 最新章节(动态计算)
featuredSections: [],
latestSection: null,
latestLabel: '最新更新',
// 内容概览
partsList: [
{ id: 'part-1', number: '一', title: '真实的人', subtitle: '人与人之间的底层逻辑' },
{ id: 'part-2', number: '二', title: '真实的行业', subtitle: '电商、内容、传统行业解析' },
{ id: 'part-3', number: '三', title: '真实的错误', subtitle: '我和别人犯过的错' },
{ id: 'part-4', number: '四', title: '真实的赚钱', subtitle: '底层结构与真实案例' },
{ id: 'part-5', number: '五', title: '真实的社会', subtitle: '未来职业与商业生态' }
],
// 加载状态
partsList: [],
prefaceMid: 0,
loading: true
},
@@ -98,10 +81,7 @@ Page({
this.setData({ loading: true })
try {
// 获取书籍数据
await this.loadBookData()
// 计算推荐章节
this.computeLatestSection()
} catch (e) {
console.error('初始化失败:', e)
} finally {
@@ -109,62 +89,82 @@ Page({
}
},
// 计算推荐章节根据用户ID随机、优先未付款
computeLatestSection() {
const { hasFullBook, purchasedSections } = app.globalData
const userId = app.globalData.userInfo?.id || wx.getStorageSync('userId') || 'guest'
// 所有章节列表
const allSections = [
{ id: '9.14', title: '大健康私域一个月150万的70后', part: '真实的赚钱' },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱' },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱' },
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', part: '真实的赚钱' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱' },
{ id: '3.1', title: '3000万流水如何跑出来', part: '真实的行业' },
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', part: '真实的行业' },
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', part: '真实的行业' }
]
// 用户ID生成的随机种子同一用户每天看到的不同
const today = new Date().toISOString().split('T')[0]
const seed = (userId + today).split('').reduce((a, b) => a + b.charCodeAt(0), 0)
// 筛选未付款章节
let candidates = allSections
if (!hasFullBook) {
const purchased = purchasedSections || []
const unpurchased = allSections.filter(s => !purchased.includes(s.id))
if (unpurchased.length > 0) {
candidates = unpurchased
}
}
// 根据种子选择章节
const index = seed % candidates.length
const selected = candidates[index]
// 设置标签(如果有新增章节显示"最新更新",否则显示"推荐阅读"
const label = candidates === allSections ? '推荐阅读' : '为你推荐'
this.setData({
latestSection: selected,
latestLabel: label
})
},
// 加载书籍数据
async loadBookData() {
try {
const res = await app.request('/api/miniprogram/book/all-chapters')
if (res && res.data) {
this.setData({
bookData: res.data,
totalSections: res.totalSections || 62
const [chaptersRes, hotRes] = await Promise.all([
app.request('/api/miniprogram/book/all-chapters'),
app.request('/api/miniprogram/book/hot')
])
const list = chaptersRes?.data || []
const hotList = hotRes?.data || []
app.globalData.bookData = list
const toSection = (ch) => ({
id: ch.id,
mid: ch.mid || 0,
title: ch.sectionTitle || ch.chapterTitle || ch.id,
part: ch.partTitle || ''
})
let featuredSections = []
if (hotList.length >= 3) {
const freeCh = list.find(c => c.isFree || c.id === '1.1' || c.id === 'preface')
const picks = []
if (freeCh) picks.push({ ...toSection(freeCh), tag: '免费', tagClass: 'tag-free' })
hotList.slice(0, 3 - picks.length).forEach((ch, i) => {
if (!picks.find(p => p.id === ch.id)) {
picks.push({ ...toSection(ch), tag: i === 0 ? '热门' : '推荐', tagClass: i === 0 ? 'tag-pink' : 'tag-purple' })
}
})
featuredSections = picks.slice(0, 3)
}
if (featuredSections.length < 3 && list.length > 0) {
const fallback = list.filter(c => c.id && !['preface', 'epilogue'].includes(c.id)).slice(0, 3)
featuredSections = fallback.map((ch, i) => ({
...toSection(ch),
tag: ch.isFree ? '免费' : (i === 0 ? '热门' : '推荐'),
tagClass: ch.isFree ? 'tag-free' : (i === 0 ? 'tag-pink' : 'tag-purple')
}))
}
const partMap = {}
list.forEach(ch => {
if (!ch.partId || ch.id === 'preface' || ch.id === 'epilogue' || (ch.id || '').startsWith('appendix')) return
if (!partMap[ch.partId]) {
partMap[ch.partId] = { id: ch.partId, number: PART_NUMBERS[ch.partId] || ch.partId, title: ch.partTitle || ch.partId, subtitle: ch.chapterTitle || '' }
}
})
const partsList = Object.values(partMap).sort((a, b) => (a.id || '').localeCompare(b.id || ''))
const paidCandidates = list.filter(c => c.id && !['preface', 'epilogue'].includes(c.id) && !(c.id || '').startsWith('appendix') && c.partId)
const { hasFullBook, purchasedSections } = app.globalData
let candidates = paidCandidates
if (!hasFullBook && purchasedSections?.length) {
const unpurchased = paidCandidates.filter(c => !purchasedSections.includes(c.id))
if (unpurchased.length > 0) candidates = unpurchased
}
const userId = app.globalData.userInfo?.id || wx.getStorageSync('userId') || 'guest'
const today = new Date().toISOString().split('T')[0]
const seed = (userId + today).split('').reduce((a, b) => a + b.charCodeAt(0), 0)
const selectedCh = candidates[seed % Math.max(candidates.length, 1)]
const latestSection = selectedCh ? { ...toSection(selectedCh), mid: selectedCh.mid || 0 } : null
const latestLabel = candidates.length === paidCandidates.length ? '推荐阅读' : '为你推荐'
const prefaceCh = list.find(c => c.id === 'preface')
const prefaceMid = prefaceCh?.mid || 0
this.setData({
bookData: list,
totalSections: list.length || 62,
featuredSections,
partsList,
latestSection,
latestLabel,
prefaceMid
})
} catch (e) {
console.error('加载书籍数据失败:', e)
this.setData({ featuredSections: [], partsList: [], latestSection: null })
}
},
@@ -189,10 +189,11 @@ Page({
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 跳转到匹配页

View File

@@ -37,7 +37,7 @@
<!-- 主内容区 -->
<view class="main-content">
<!-- Banner卡片 - 最新章节 -->
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}">
<view wx:if="{{latestSection}}" class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-title">{{latestSection.title}}</view>
@@ -95,6 +95,7 @@
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="featured-content">
<view class="featured-meta">
@@ -132,7 +133,7 @@
</view>
<!-- 序言入口 -->
<view class="preface-card" bindtap="goToRead" data-id="preface">
<view class="preface-card" bindtap="goToRead" data-id="preface" data-mid="{{prefaceMid}}">
<view class="preface-content">
<text class="preface-title">序言</text>
<text class="preface-desc">为什么我每天早上6点在Soul开播?</text>

View File

@@ -119,6 +119,7 @@ Page({
if (res.success && res.data) {
app.globalData.hasFullBook = res.data.hasFullBook || false
app.globalData.purchasedSections = res.data.purchasedSections || []
app.globalData.sectionMidMap = res.data.sectionMidMap || {}
app.globalData.matchCount = res.data.matchCount ?? 0
app.globalData.matchQuota = res.data.matchQuota || null
const userInfo = app.globalData.userInfo || {}
@@ -138,7 +139,8 @@ Page({
if (isLoggedIn && userInfo) {
const readIds = app.globalData.readSectionIds || []
const recentList = readIds.slice(-5).reverse().map(id => ({
id: id,
id,
mid: app.getSectionMid(id),
title: `章节 ${id}`
}))
@@ -625,10 +627,11 @@ Page({
}
},
// 跳转到阅读页
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
const mid = e.currentTarget.dataset.mid
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 跳转到目录

View File

@@ -193,6 +193,7 @@
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="recent-left">
<text class="recent-index">{{index + 1}}</text>

View File

@@ -22,11 +22,24 @@ Page({
async loadOrders() {
this.setData({ loading: true })
try {
// 模拟订单数据
const purchasedSections = app.globalData.purchasedSections || []
let purchasedSections = app.globalData.purchasedSections || []
let sectionMidMap = app.globalData.sectionMidMap || {}
const userId = app.globalData.userInfo?.id
if (userId) {
try {
const res = await app.request(`/api/miniprogram/user/purchase-status?userId=${encodeURIComponent(userId)}`)
if (res?.success && res.data) {
purchasedSections = res.data.purchasedSections || []
sectionMidMap = res.data.sectionMidMap || {}
app.globalData.purchasedSections = purchasedSections
app.globalData.sectionMidMap = sectionMidMap
}
} catch (_) { /* 使用缓存 */ }
}
const orders = purchasedSections.map((id, index) => ({
id: `order_${index}`,
sectionId: id,
mid: sectionMidMap[id] || 0,
title: `章节 ${id}`,
amount: 1,
status: 'completed',
@@ -42,7 +55,9 @@ Page({
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
const mid = e.currentTarget.dataset.mid
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
goBack() { wx.navigateBack() },

View File

@@ -15,7 +15,8 @@
</view>
<view class="orders-list" wx:elif="{{orders.length > 0}}">
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}">
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}"
data-mid="{{item.mid}}">
<view class="order-info">
<text class="order-title">{{item.title}}</text>
<text class="order-time">{{item.createTime}}</text>

View File

@@ -73,25 +73,29 @@ Page({
},
async onLoad(options) {
// 扫码进入时options.id 无id 可能在 app.globalData.initialSectionIdApp 解析 query.scene
// 或直接在 options.scene 中(页面 query 为 ?scene=id%3D1.1,后端把 & 转成 _
// 支持 mid优先或 idmid 用于新链接id 兼容旧链接
// 扫码进入时mid/id 可能在 options、app.globalData.initialSectionMid/initialSectionId、或 scene 中
let mid = options.mid ? parseInt(options.mid, 10) : (app.globalData.initialSectionMid || 0)
let id = options.id || app.globalData.initialSectionId
if (!id && options.scene) {
if ((!mid || !id) && options.scene) {
const scene = (typeof options.scene === 'string' ? decodeURIComponent(options.scene) : '').trim()
const parts = scene.split(/[&_]/)
for (const part of parts) {
const eq = part.indexOf('=')
if (eq > 0 && part.slice(0, eq) === 'id') {
id = part.slice(eq + 1)
break
if (eq > 0) {
const k = part.slice(0, eq)
const v = part.slice(eq + 1)
if (k === 'mid') mid = parseInt(v, 10) || 0
if (k === 'id' && v) id = v
}
}
}
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
const ref = options.ref
if (!id) {
console.warn('[Read] 未获取到章节 idoptions:', options)
if (!mid && !id) {
console.warn('[Read] 未获取到章节 mid/idoptions:', options)
wx.showToast({ title: '章节参数缺失', icon: 'none' })
this.setData({ accessState: 'error', loading: false })
return
@@ -100,12 +104,12 @@ Page({
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
sectionId: id,
sectionId: '', // 加载后填充
sectionMid: mid || null,
loading: true,
accessState: 'unknown'
})
// 处理推荐码绑定(异步不阻塞)
if (ref) {
console.log('[Read] 检测到推荐码:', ref)
wx.setStorageSync('referral_code', ref)
@@ -113,7 +117,6 @@ Page({
}
try {
// 【标准流程】1. 拉取最新配置(免费列表、价格)
const config = await accessManager.fetchLatestConfig()
this.setData({
freeIds: config.freeChapters,
@@ -121,8 +124,19 @@ Page({
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 【标准流程】2. 确定权限状态
const accessState = await accessManager.determineAccessState(id, config.freeChapters)
// 先拉取章节获取 idmid 时必需id 时可直接用)
let resolvedId = id
let prefetchedChapter = null
if (mid && !id) {
const chRes = await app.request(`/api/miniprogram/book/chapter/by-mid/${mid}`)
if (chRes && chRes.id) {
resolvedId = chRes.id
prefetchedChapter = chRes
}
}
this.setData({ sectionId: resolvedId })
const accessState = await accessManager.determineAccessState(resolvedId, config.freeChapters)
const canAccess = accessManager.canAccessFullContent(accessState)
this.setData({
@@ -132,16 +146,13 @@ Page({
showPaywall: !canAccess
})
// 【标准流程】3. 加载内容
await this.loadContent(id, accessState)
await this.loadContent(mid, resolvedId, accessState, prefetchedChapter)
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
readingTracker.init(resolvedId)
}
// 5. 加载导航
this.loadNavigation(id)
this.loadNavigation(resolvedId)
} catch (e) {
console.error('[Read] 初始化失败:', e)
@@ -184,8 +195,8 @@ Page({
})
},
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
async loadContent(id, accessState) {
// 【重构】加载章节内容。mid 优先用 by-mid 接口id 用旧接口prefetched 避免重复请求
async loadContent(mid, id, accessState, prefetched) {
try {
const section = this.getSectionInfo(id)
const sectionPrice = this.data.sectionPrice ?? 1
@@ -194,25 +205,29 @@ Page({
}
this.setData({ section })
// 从 API 获取内容
const res = await app.request(`/api/miniprogram/book/chapter/${id}`)
let res = prefetched
if (!res) {
res = mid
? await app.request(`/api/miniprogram/book/chapter/by-mid/${mid}`)
: await app.request(`/api/miniprogram/book/chapter/${id}`)
}
if (res && res.content) {
const lines = res.content.split('\n').filter(line => line.trim())
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
const updates = {
content: res.content,
contentParagraphs: lines,
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
})
// 如果有权限,标记为已读
}
if (res.mid) updates.sectionMid = res.mid
this.setData(updates)
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
setTimeout(() => this.drawShareCard(), 600)
}
} catch (e) {
console.error('[Read] 加载内容失败:', e)
@@ -284,50 +299,6 @@ Page({
}
return titles[id] || `章节 ${id}`
},
// 加载内容 - 三级降级方案API → 本地缓存 → 备用API
async loadContent(id) {
const cacheKey = `chapter_${id}`
// 1. 优先从API获取
try {
const res = await this.fetchChapterWithTimeout(id, 5000)
if (res && res.content) {
this.setData({ section: this.getSectionInfo(id) })
this.setChapterContent(res)
wx.setStorageSync(cacheKey, res)
console.log('[Read] 从API加载成功:', id)
setTimeout(() => this.drawShareCard(), 600)
return
}
} catch (e) {
console.warn('[Read] API加载失败尝试本地缓存:', e.message)
}
// 2. API失败尝试从本地缓存读取
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
this.setData({ section: this.getSectionInfo(id) })
this.setChapterContent(cached)
console.log('[Read] 从本地缓存加载成功:', id)
this.silentRefresh(id)
setTimeout(() => this.drawShareCard(), 600)
return
}
} catch (e) {
console.warn('[Read] 本地缓存读取失败')
}
// 3. 都失败,显示加载中并持续重试
this.setData({
contentParagraphs: ['章节内容加载中...', '正在尝试连接服务器,请稍候...'],
previewParagraphs: ['章节内容加载中...']
})
// 延迟重试最多3次
this.retryLoadContent(id, 3)
},
// 带超时的章节请求
fetchChapterWithTimeout(id, timeout = 5000) {
@@ -405,7 +376,7 @@ Page({
},
// 加载导航
// 加载导航prevSection/nextSection 含 mid 时用于跳转,否则用 id
loadNavigation(id) {
const sectionOrder = [
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
@@ -421,14 +392,19 @@ Page({
'11.1', '11.2', '11.3', '11.4', '11.5',
'epilogue'
]
const bookData = app.globalData.bookData || []
const idToMid = {}
bookData.forEach(ch => {
if (ch.id && ch.mid) idToMid[ch.id] = ch.mid
})
const currentIndex = sectionOrder.indexOf(id)
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
this.setData({
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
prevSection: prevId ? { id: prevId, mid: idToMid[prevId], title: this.getSectionTitle(prevId) } : null,
nextSection: nextId ? { id: nextId, mid: idToMid[nextId], title: this.getSectionTitle(nextId) } : null
})
},
@@ -543,14 +519,13 @@ Page({
// 统一分享配置(底部「推荐给好友」与右下角分享按钮均走此配置,由 onShareAppMessage 使用)
getShareConfig() {
const { section, sectionId, shareImagePath } = this.data
const { section, sectionId, sectionMid, shareImagePath } = this.data
const ref = app.getMyReferralCode()
const shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
const path = ref
? `/pages/read/read?id=${sectionId}&ref=${ref}`
: `/pages/read/read?id=${sectionId}`
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
return {
title: shareTitle,
path,
@@ -563,11 +538,12 @@ Page({
},
onShareTimeline() {
const { section, sectionId } = this.data
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
return {
title: `${section?.title || 'Soul创业派对'} - 来自派对房的真实故事`,
query: ref ? `id=${sectionId}&ref=${ref}` : `id=${sectionId}`
query: ref ? `${q}&ref=${ref}` : q
}
},
@@ -666,7 +642,7 @@ Page({
// 4. 如果已解锁,重新加载内容并初始化阅读追踪
if (canAccess) {
await this.loadContent(this.data.sectionId, newAccessState)
await this.loadContent(this.data.sectionMid, this.data.sectionId, newAccessState, null)
readingTracker.init(this.data.sectionId)
}
@@ -737,6 +713,7 @@ Page({
// 更新本地购买状态
app.globalData.hasFullBook = checkRes.data.hasFullBook
app.globalData.purchasedSections = checkRes.data.purchasedSections || []
app.globalData.sectionMidMap = checkRes.data.sectionMidMap || {}
// 检查是否已购买
if (type === 'section' && sectionId) {
@@ -953,7 +930,7 @@ Page({
})
// 4. 重新加载全文
await this.loadContent(this.data.sectionId, newAccessState)
await this.loadContent(this.data.sectionMid, this.data.sectionId, newAccessState, null)
// 5. 初始化阅读追踪
if (canAccess) {
@@ -990,6 +967,7 @@ Page({
// 更新全局购买状态
app.globalData.hasFullBook = res.data.hasFullBook
app.globalData.purchasedSections = res.data.purchasedSections || []
app.globalData.sectionMidMap = res.data.sectionMidMap || {}
app.globalData.matchCount = res.data.matchCount ?? 0
app.globalData.matchQuota = res.data.matchQuota || null
@@ -1027,17 +1005,19 @@ Page({
})
},
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
const s = this.data.prevSection
if (s) {
const q = s.mid ? `mid=${s.mid}` : `id=${s.id}`
wx.redirectTo({ url: `/pages/read/read?${q}` })
}
},
// 跳转到下一篇
goToNext() {
if (this.data.nextSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
const s = this.data.nextSection
if (s) {
const q = s.mid ? `mid=${s.mid}` : `id=${s.id}`
wx.redirectTo({ url: `/pages/read/read?${q}` })
}
},
@@ -1051,7 +1031,7 @@ Page({
wx.showLoading({ title: '生成中...' })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
const { section, contentParagraphs, sectionId } = this.data
const { section, contentParagraphs, sectionId, sectionMid } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
const safeParagraphs = contentParagraphs || []
@@ -1059,7 +1039,9 @@ Page({
// 通过 GET 接口下载二维码图片,得到 tempFilePath 便于开发工具与真机统一用 drawImage 绘制
let qrcodeTempPath = null
try {
const scene = userId ? `id=${sectionId}&ref=${userId.slice(0, 10)}` : `id=${sectionId}`
const scene = sectionMid
? (userId ? `mid=${sectionMid}&ref=${userId.slice(0, 10)}` : `mid=${sectionMid}`)
: (userId ? `id=${sectionId}&ref=${userId.slice(0, 10)}` : `id=${sectionId}`)
const baseUrl = app.globalData.baseUrl || ''
const url = `${baseUrl}/api/miniprogram/qrcode/image?scene=${encodeURIComponent(scene)}&page=${encodeURIComponent('pages/read/read')}&width=280`
qrcodeTempPath = await new Promise((resolve) => {
@@ -1272,7 +1254,7 @@ Page({
})
// 重新加载内容
await this.loadContent(this.data.sectionId, newAccessState)
await this.loadContent(this.data.sectionMid, this.data.sectionId, newAccessState, null)
// 如果有权限,初始化阅读追踪
if (canAccess) {

View File

@@ -96,10 +96,11 @@ Page({
}
},
// 跳转阅读
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
const mid = e.currentTarget.dataset.mid
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 返回上一页

View File

@@ -51,6 +51,7 @@
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="chapter-rank">{{index + 1}}</view>
<view class="chapter-info">
@@ -83,6 +84,7 @@
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="result-header">
<text class="result-chapter">{{item.chapterLabel}}</text>

View File

@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/chapters/chapters",
"pathName": "pages/chapters/chapters",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "ces",
"pathName": "pages/read/read",
"query": "scene=id%3D1.1",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/read/read",

View File

@@ -150,6 +150,7 @@ class ChapterAccessManager {
if (res.success && res.data) {
app.globalData.hasFullBook = res.data.hasFullBook || false
app.globalData.purchasedSections = res.data.purchasedSections || []
app.globalData.sectionMidMap = res.data.sectionMidMap || {}
app.globalData.matchCount = res.data.matchCount ?? 0
app.globalData.matchQuota = res.data.matchQuota || null