Enhance mini program environment configuration and chapter loading logic

- Introduced a debugging environment configuration in app.js, allowing for dynamic API endpoint selection based on the environment.
- Implemented lazy loading for chapter parts in chapters.js, improving performance by only fetching necessary data.
- Updated UI elements in chapters.wxml to reflect changes in chapter loading and added loading indicators.
- Enhanced error handling and data management for chapter retrieval, ensuring a smoother user experience.
- Added functionality for switching API environments in settings.js, visible only in development mode.
This commit is contained in:
Alex-larget
2026-03-14 18:04:05 +08:00
parent 1edceda4db
commit d82ef6d8e4
22 changed files with 1184 additions and 899 deletions

View File

@@ -5,12 +5,42 @@
const { parseScene } = require('./utils/scene.js')
// 调试环境配置:''=自动 | localhost | souldev | soulapi
const DEBUG_ENV_OPTIONS = [
{ key: '', label: '自动', url: null },
{ key: 'localhost', label: '本地', url: 'http://localhost:8080' },
{ key: 'souldev', label: '测试', url: 'https://souldev.quwanzhi.com' },
{ key: 'soulapi', label: '正式', url: 'https://soulapi.quwanzhi.com' }
]
const STORAGE_KEY_DEBUG_ENV = 'debug_env_override'
// 根据小程序环境版本或调试覆盖选择 API 地址
function getBaseUrlByEnv(override) {
if (override) {
const opt = DEBUG_ENV_OPTIONS.find(o => o.key === override)
if (opt && opt.url) return opt.url
}
try {
const accountInfo = wx.getAccountInfoSync()
const env = accountInfo?.miniProgram?.envVersion || 'release'
const urls = {
develop: 'http://localhost:8080', // 开发版(本地调试)
trial: 'https://souldev.quwanzhi.com', // 体验版
release: 'https://soulapi.quwanzhi.com' // 正式版
}
return urls[env] || urls.release
} catch (e) {
console.warn('[App] 获取环境失败,使用正式版:', e)
return 'https://soulapi.quwanzhi.com'
}
}
App({
globalData: {
// API基础地址 - 连接真实后端
// baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com',
baseUrl: 'http://localhost:8080',
// API基础地址 - 由 getBaseUrlByEnv() 在 onLaunch 时按环境自动设置
baseUrl: '',
// 调试环境覆盖:'' | localhost | souldev | soulapi持久化到 storage
debugEnvOverride: '',
// 小程序配置 - 真实AppID
@@ -70,6 +100,8 @@ App({
},
onLaunch(options) {
this.globalData.debugEnvOverride = wx.getStorageSync(STORAGE_KEY_DEBUG_ENV) || ''
this.globalData.baseUrl = getBaseUrlByEnv(this.globalData.debugEnvOverride)
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 获取系统信息
this.getSystemInfo()
@@ -248,6 +280,28 @@ App({
return ''
},
// 调试:获取当前 API 环境信息
getDebugEnvInfo() {
const override = this.globalData.debugEnvOverride || ''
const opt = DEBUG_ENV_OPTIONS.find(o => o.key === override)
return {
override,
label: opt ? opt.label : '自动',
baseUrl: this.globalData.baseUrl
}
},
// 调试:一键切换 API 环境(自动→本地→测试→正式→自动),持久化到 storage
switchDebugEnv() {
const idx = DEBUG_ENV_OPTIONS.findIndex(o => o.key === this.globalData.debugEnvOverride)
const nextIdx = (idx + 1) % DEBUG_ENV_OPTIONS.length
const next = DEBUG_ENV_OPTIONS[nextIdx]
this.globalData.debugEnvOverride = next.key
this.globalData.baseUrl = getBaseUrlByEnv(next.key)
wx.setStorageSync(STORAGE_KEY_DEBUG_ENV, next.key)
return next
},
/**
* 自定义导航栏「返回」:有上一页则返回,否则跳转首页(解决从分享进入时点返回无效的问题)
*/

View File

@@ -19,13 +19,19 @@ Page({
isVip: false,
purchasedSections: [],
// 书籍数据:以后台内容管理为准,仅用接口 /api/miniprogram/book/all-chapters 返回的数据
// 懒加载:篇章列表(不含章节详情),展开时再请求 chapters-by-part
totalSections: 0,
bookData: [],
// 展开状态:默认不展开任何篇章,直接显示目录
// 展开状态
expandedPart: null,
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
// 固定模块 id -> mid序言/尾声/附录,供 goToRead 传 mid
fixedSectionsMap: {},
// 附录
appendixList: [
{ id: 'appendix-1', title: '附录1Soul派对房精选对话' },
@@ -33,7 +39,7 @@ Page({
{ id: 'appendix-3', title: '附录3本书提到的工具和资源' }
],
// 每日新增章节
// 每日新增章节(懒加载后暂无,可后续用 latest-chapters 补充)
dailyChapters: []
},
@@ -45,73 +51,71 @@ Page({
})
this.updateUserStatus()
this.loadVipStatus()
this.loadChaptersOnce()
this.loadParts()
},
// 固定模块(序言、尾声、附录)不参与中间篇章
_isFixedPart(pt) {
if (!pt) return false
const p = String(pt).toLowerCase().replace(/[_\s|]/g, '')
return p.includes('序言') || p.includes('尾声') || p.includes('附录')
},
// 一次请求拉取全量目录,以后台内容管理为准;同时更新 totalSections / bookData / dailyChapters
async loadChaptersOnce() {
// 懒加载:仅拉取篇章列表 + totalSections + fixedSections
async loadParts() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const rows = (res && res.data) || (res && res.chapters) || []
// 无数据时清空目录,避免展示旧数据
if (rows.length === 0) {
app.globalData.bookData = []
wx.setStorageSync('bookData', [])
this.setData({
bookData: [],
totalSections: 0,
dailyChapters: [],
expandedPart: null
})
const res = await app.request({ url: '/api/miniprogram/book/parts', silent: true })
if (!res?.success) {
this.setData({ bookData: [], totalSections: 0 })
return
}
const totalSections = res.total ?? rows.length
app.globalData.bookData = rows
wx.setStorageSync('bookData', rows)
// bookData过滤序言/尾声/附录,按 part 聚合,篇章顺序按 sort_order 与后台一致含「2026每日派对干货」等
const filtered = rows.filter(r => !this._isFixedPart(r.partTitle || r.part_title))
const partMap = new Map()
const parts = res.parts || []
const totalSections = res.totalSections ?? 0
const fixedSections = res.fixedSections || []
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
filtered.forEach((r) => {
const pid = r.partId || r.part_id || 'part-1'
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) => ({
id: p.id,
number: numbers[idx] || String(idx + 1),
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [] // 展开时懒加载
}))
app.globalData.totalSections = totalSections
this.setData({
bookData,
totalSections,
fixedSectionsMap: fixedMap,
appendixList,
_loadedChapters: {}
})
} catch (e) {
console.log('[Chapters] 加载篇章失败:', e)
this.setData({ bookData: [], totalSections: 0 })
}
},
// 展开时懒加载该篇章的章节(含 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'
const sortOrder = r.sectionOrder ?? r.sort_order ?? 999999
if (!partMap.has(pid)) {
const partIdx = partMap.size
partMap.set(pid, {
id: pid,
number: numbers[partIdx] || String(partIdx + 1),
title: r.partTitle || r.part_title || '未分类',
subtitle: r.chapterTitle || r.chapter_title || '',
chapters: new Map(),
minSortOrder: sortOrder
})
}
const part = partMap.get(pid)
if (sortOrder < part.minSortOrder) part.minSortOrder = sortOrder
if (!part.chapters.has(cid)) {
part.chapters.set(cid, {
if (!chMap.has(cid)) {
chMap.set(cid, {
id: cid,
title: r.chapterTitle || r.chapter_title || '未分类',
sections: []
})
}
const ch = part.chapters.get(cid)
const isPremium =
r.editionPremium === true ||
r.edition_premium === true ||
r.edition_premium === 1 ||
r.edition_premium === '1'
const ch = chMap.get(cid)
const isPremium = r.editionPremium === true || r.edition_premium === true || r.edition_premium === 1 || r.edition_premium === '1'
ch.sections.push({
id: r.id,
mid: r.mid ?? r.MID ?? 0,
@@ -122,46 +126,27 @@ Page({
isPremium
})
})
const partList = Array.from(partMap.values())
partList.sort((a, b) => (a.minSortOrder ?? 999999) - (b.minSortOrder ?? 999999))
const bookData = partList.map((p, idx) => ({
id: p.id,
number: numbers[idx] || String(idx + 1),
title: p.title,
subtitle: p.subtitle,
chapters: Array.from(p.chapters.values())
}))
const baseSort = 62
const daily = rows
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
.slice(0, 20)
.map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title: c.section_title || c.title || c.sectionTitle,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
this.setData({
bookData,
totalSections,
dailyChapters: daily,
expandedPart: this.data.expandedPart
const chapters = Array.from(chMap.values())
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.setStorageSync('bookData', bookDataFlat)
this.setData({ bookData, _loadedChapters: loaded })
} catch (e) {
console.log('[Chapters] 加载目录失败:', e)
this.setData({ bookData: [], totalSections: 0 })
console.log('[Chapters] 加载章节失败:', e)
}
},
onPullDownRefresh() {
this.loadChaptersOnce().then(() => wx.stopPullDownRefresh()).catch(() => wx.stopPullDownRefresh())
this.loadParts().then(() => wx.stopPullDownRefresh()).catch(() => wx.stopPullDownRefresh())
},
onShow() {
@@ -204,12 +189,14 @@ Page({
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
},
// 切换展开状态
togglePart(e) {
// 切换展开状态,展开时懒加载该篇章章节
async togglePart(e) {
const partId = e.currentTarget.dataset.id
const isExpanding = this.data.expandedPart !== partId
this.setData({
expandedPart: this.data.expandedPart === partId ? null : partId
expandedPart: isExpanding ? partId : null
})
if (isExpanding) await this.loadChaptersByPart(partId)
},
// 跳转到阅读页(优先传 mid与分享逻辑一致

View File

@@ -34,8 +34,8 @@
<!-- 目录内容 -->
<view class="chapters-content">
<!-- 序言 -->
<view class="chapter-item" bindtap="goToRead" data-id="preface">
<!-- 序言(优先传 mid阅读页用 by-mid 请求) -->
<view class="chapter-item" bindtap="goToRead" data-id="preface" data-mid="{{fixedSectionsMap.preface}}">
<view class="item-left">
<view class="item-icon icon-brand">📖</view>
<text class="item-title">序言为什么我每天早上6点在Soul开播?</text>
@@ -59,14 +59,15 @@
</view>
</view>
<view class="part-right">
<text class="part-count">{{item.chapters.length}}章</text>
<text class="part-count">{{item.chapters.length || item.chapterCount}}章</text>
<text class="part-arrow {{expandedPart === item.id ? 'arrow-down' : ''}}">→</text>
</view>
</view>
<!-- 章节列表 - 展开时显示 -->
<!-- 章节列表 - 展开时显示,懒加载 -->
<block wx:if="{{expandedPart === item.id}}">
<view class="chapters-list">
<view wx:if="{{item.chapters.length === 0}}" class="chapters-loading">加载中...</view>
<block wx:for="{{item.chapters}}" wx:key="id" wx:for-item="chapter">
<view class="chapter-header">{{chapter.title}}</view>
<view class="section-list">
@@ -92,8 +93,8 @@
</view>
</view>
<!-- 尾声 -->
<view class="chapter-item" bindtap="goToRead" data-id="epilogue">
<!-- 尾声(优先传 mid -->
<view class="chapter-item" bindtap="goToRead" data-id="epilogue" data-mid="{{fixedSectionsMap.epilogue}}">
<view class="item-left">
<view class="item-icon icon-brand">📖</view>
<text class="item-title">尾声|这本书的真实目的</text>
@@ -114,6 +115,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

@@ -339,6 +339,12 @@
margin-left: 16rpx;
}
.chapters-loading {
padding: 24rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
}
.chapter-group {
background: rgba(28, 28, 30, 0.5);
border-radius: 16rpx;

View File

@@ -744,9 +744,9 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 打开设置
// 打开资料修改页(找伙伴右上角图标)
openSettings() {
wx.navigateTo({ url: '/pages/settings/settings' })
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 阻止事件冒泡

View File

@@ -73,13 +73,20 @@ Page({
contactWechat: '',
contactSaving: false,
pendingWithdraw: false,
// 设置入口:开发版、体验版显示
showSettingsEntry: false,
},
onLoad() {
wx.showShareMenu({ withShareTimeline: true })
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const envVersion = accountInfo?.miniProgram?.envVersion || ''
const showSettingsEntry = envVersion === 'develop' || envVersion === 'trial'
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
navBarHeight: app.globalData.navBarHeight,
showSettingsEntry
})
this.loadFeatureConfig()
this.initUserStatus()

View File

@@ -154,7 +154,7 @@
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" wx:if="{{false}}" bindtap="handleMenuTap" data-id="settings">
<view class="menu-item" wx:if="{{showSettingsEntry}}" bindtap="handleMenuTap" data-id="settings">
<view class="menu-left">
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
<text class="menu-text">设置</text>

View File

@@ -76,7 +76,7 @@ Page({
},
async onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
// 预加载 linkTags、linkedMiniprograms供 onLinkTagTap 用密钥查 appId
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms) {
@@ -697,6 +697,15 @@ Page({
}
},
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline只能通过右上角菜单分享点击时引导用户
onShareTimelineTap() {
wx.showToast({
title: '请点击右上角「...」→ 分享到朋友圈',
icon: 'none',
duration: 2500
})
},
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data

View File

@@ -85,10 +85,10 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="shareTimeline">
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
<text class="action-icon-small">📣</text>
<text class="action-text-small">分享到朋友圈</text>
</button>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>
<text class="action-text-small">生成海报</text>

View File

@@ -14,6 +14,8 @@ Page({
// 切换账号(开发)
showSwitchAccountModal: false,
// 调试环境:当前 API 环境标签
apiEnvLabel: '自动',
switchAccountUserId: '',
switchAccountLoading: false,
@@ -36,17 +38,30 @@ Page({
wx.showShareMenu({ withShareTimeline: true })
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const envVersion = accountInfo?.miniProgram?.envVersion || ''
const envInfo = app.getDebugEnvInfo()
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
isLoggedIn: app.globalData.isLoggedIn,
userInfo: app.globalData.userInfo,
isDevMode: envVersion === 'develop'
isDevMode: envVersion === 'develop',
apiEnvLabel: envInfo.label
})
this.loadBindingInfo()
},
onShow() {
this.loadBindingInfo()
if (this.data.isDevMode) {
const envInfo = getApp().getDebugEnvInfo()
this.setData({ apiEnvLabel: envInfo.label })
}
},
// 一键切换 API 环境(开发版)
switchApiEnv() {
const next = getApp().switchDebugEnv()
this.setData({ apiEnvLabel: next.label })
wx.showToast({ title: `已切到 ${next.label}`, icon: 'none' })
},
// 加载绑定信息

View File

@@ -109,6 +109,15 @@
<text class="tip-text">提示:绑定微信号才能使用提现功能</text>
</view>
<!-- 开发专用:切换 API 环境(仅开发版显示) -->
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="switchApiEnv">
<view class="dev-switch-inner">
<text class="dev-switch-icon">🌐</text>
<text class="dev-switch-text">切换环境</text>
<text class="dev-switch-desc">当前:{{apiEnvLabel}}(点击循环:自动→本地→测试→正式)</text>
</view>
</view>
<!-- 开发专用:切换账号(仅开发版显示) -->
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="openSwitchAccountModal">
<view class="dev-switch-inner">

779
soul-admin/dist/assets/index-7GwP_AfR.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-DJPaWrh0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bd1cCYoa.css">
<script type="module" crossorigin src="/assets/index-7GwP_AfR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1CMMwBM.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}

View File

@@ -14,9 +14,18 @@ DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/sou
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soulapi.quwanzhi.com
#添加卡若
#添加卡若(内部 API用于 /v1/api/scenarios
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 存客宝开放 API创建/更新/删除获客计划、拉取设备列表
# - CKB_OPEN_API_KEY开放 API Key开发文档中的 mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A
# - CKB_OPEN_ACCOUNT对应的存客宝登录账号手机号或用户名
CKB_OPEN_API_KEY=mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A
CKB_OPEN_ACCOUNT=karuo1
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c

View File

@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -19,6 +20,23 @@ import (
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
var excludeParts = []string{"序言", "尾声", "附录"}
// sortChaptersByNaturalID 同 sort_order 时按 id 自然排序9.1 < 9.2 < 9.10),调用 db_book 的 naturalLessSectionID
func sortChaptersByNaturalID(list []model.Chapter) {
sort.Slice(list, func(i, j int) bool {
soI, soJ := 999999, 999999
if list[i].SortOrder != nil {
soI = *list[i].SortOrder
}
if list[j].SortOrder != nil {
soJ = *list[j].SortOrder
}
if soI != soJ {
return soI < soJ
}
return naturalLessSectionID(list[i].ID, list[j].ID)
})
}
// allChaptersSelectCols 列表不加载 contentlongtext避免 502 超时
var allChaptersSelectCols = []string{
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
@@ -44,6 +62,7 @@ func WarmAllChaptersCache() {
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
return
}
sortChaptersByNaturalID(list)
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
@@ -91,6 +110,7 @@ func BookAllChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
sortChaptersByNaturalID(list)
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
@@ -122,6 +142,112 @@ func BookChapterByID(c *gin.Context) {
})
}
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
// 返回 parts排除序言/尾声/附录、totalSections、fixedSectionsid, mid, title 供序言/尾声/附录跳转用 mid
func BookParts(c *gin.Context) {
db := database.DB()
// 固定模块(序言、尾声、附录)的 id、mid、title供 goToRead 传 data-mid
var fixedList []struct {
ID string `json:"id"`
MID int `json:"mid"`
SectionTitle string `json:"title"`
}
for _, p := range excludeParts {
var rows []model.Chapter
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
Where("part_title LIKE ?", "%"+p+"%").
Order("COALESCE(sort_order, 999999) ASC, id ASC").
Find(&rows).Error; err != nil {
continue
}
sortChaptersByNaturalID(rows)
for _, r := range rows {
fixedList = append(fixedList, struct {
ID string `json:"id"`
MID int `json:"mid"`
SectionTitle string `json:"title"`
}{r.ID, r.MID, r.SectionTitle})
}
}
// 中间篇章:轻量聚合,不拉取 content
type partRow struct {
PartID string `json:"id"`
PartTitle string `json:"title"`
Subtitle string `json:"subtitle"`
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
}
where := "1=1"
args := []interface{}{}
for _, p := range excludeParts {
where += " AND part_title NOT LIKE ?"
args = append(args, "%"+p+"%")
}
var raw []struct {
PartID string `gorm:"column:part_id"`
PartTitle string `gorm:"column:part_title"`
Subtitle string `gorm:"column:subtitle"`
ChapterCount int `gorm:"column:chapter_count"`
MinSortOrder int `gorm:"column:min_sort"`
}
sql := `SELECT part_id, part_title, '' as subtitle,
COUNT(DISTINCT chapter_id) as chapter_count,
MIN(COALESCE(sort_order, 999999)) as min_sort
FROM chapters WHERE ` + where + `
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
if err := db.Raw(sql, args...).Scan(&raw).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "parts": []interface{}{}, "totalSections": 0, "fixedSections": fixedList})
return
}
parts := make([]partRow, len(raw))
for i, r := range raw {
parts[i] = partRow{
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
}
}
var total int64
db.Model(&model.Chapter{}).Count(&total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
"totalSections": total,
"fixedSections": fixedList,
})
}
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid供阅读页 by-mid 请求)
func BookChaptersByPart(c *gin.Context) {
partId := c.Query("partId")
if partId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
return
}
db := database.DB()
var list []model.Chapter
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
Where("part_id = ?", partId).
Order("COALESCE(sort_order, 999999) ASC, id ASC").
Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
sortChaptersByNaturalID(list)
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
t := true
z := float64(0)
list[i].IsFree = &t
list[i].Price = &z
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
func BookChapterByMID(c *gin.Context) {
midStr := c.Param("mid")
@@ -391,6 +517,7 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
return nil
}
sortChaptersByNaturalID(all)
// 从 reading_progress 统计阅读量
ids := make([]string, 0, len(all))
for _, c := range all {
@@ -440,6 +567,7 @@ func BookHot(c *gin.Context) {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
sortChaptersByNaturalID(list)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
@@ -491,6 +619,12 @@ func BookLatestChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
sort.Slice(list, func(i, j int) bool {
if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) {
return list[i].UpdatedAt.After(list[j].UpdatedAt)
}
return naturalLessSectionID(list[i].ID, list[j].ID)
})
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
@@ -528,6 +662,7 @@ func BookSearch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
return
}
sortChaptersByNaturalID(list)
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"soul-api/internal/database"
@@ -14,6 +16,26 @@ import (
"gorm.io/gorm"
)
// naturalLessSectionID 对章节 id如 9.1、9.2、9.10)做自然排序,避免 9.1 < 9.10 < 9.2 的字典序问题
func naturalLessSectionID(a, b string) bool {
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
for i := 0; i < len(partsA) && i < len(partsB); i++ {
na, errA := strconv.Atoi(partsA[i])
nb, errB := strconv.Atoi(partsB[i])
if errA != nil || errB != nil {
if partsA[i] != partsB[i] {
return partsA[i] < partsB[i]
}
continue
}
if na != nb {
return na < nb
}
}
return len(partsA) < len(partsB)
}
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "mid", "section_title", "price", "is_free", "is_new",
@@ -91,9 +113,23 @@ func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
// 阅读量前20名: 第1名=20分...第20名=1分最近更新前30篇: 第1名=30分...第30名=1分付款数前20名: 第1名=20分...第20名=1分
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
if err := db.Select(listSelectCols).Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&rows).Error; err != nil {
return nil, err
}
// 同 sort_order 时按 id 自然排序9.1 < 9.2 < 9.10),避免字典序 9.1 < 9.10 < 9.2
sort.Slice(rows, func(i, j int) bool {
soI, soJ := 999999, 999999
if rows[i].SortOrder != nil {
soI = *rows[i].SortOrder
}
if rows[j].SortOrder != nil {
soJ = *rows[j].SortOrder
}
if soI != soJ {
return soI < soJ
}
return naturalLessSectionID(rows[i].ID, rows[j].ID)
})
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
@@ -320,10 +356,23 @@ func DBBookAction(c *gin.Context) {
return
case "export":
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
if err := db.Select(listSelectCols).Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
sort.Slice(rows, func(i, j int) bool {
soI, soJ := 999999, 999999
if rows[i].SortOrder != nil {
soI = *rows[i].SortOrder
}
if rows[j].SortOrder != nil {
soJ = *rows[j].SortOrder
}
if soI != soJ {
return soI < soJ
}
return naturalLessSectionID(rows[i].ID, rows[j].ID)
})
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0

View File

@@ -37,6 +37,7 @@ func SearchGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
return
}
sortChaptersByNaturalID(list)
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {

View File

@@ -261,6 +261,8 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
miniprogram.GET("/book/parts", handler.BookParts)
miniprogram.GET("/book/chapters-by-part", handler.BookChaptersByPart)
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
miniprogram.GET("/book/hot", handler.BookHot)