Files
soul-yongping/miniprogram/pages/index/index.js
卡若 fa3da12b16 feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
2026-03-23 18:38:23 +08:00

626 lines
22 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 { cleanSingleLineField } = require('../../utils/contentParser')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
function isKaruoHostDuplicateName(displayName) {
const s = String(displayName || '').trim()
return s === '卡若' || s === '卡路'
}
/** 超级个体无头像占位:仅展示中文首字,避免头像圆里出现英文字母 */
function superAvatarLetter(displayName) {
const s = String(displayName || '').trim()
if (!s) return '会'
const ch = s[0]
return /[\u4e00-\u9fff]/.test(ch) ? ch : '会'
}
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 用户信息
isLoggedIn: false,
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: '真实的赚钱' }
],
// Banner 推荐(优先用 recommended API 第一条,回退 latest-chapters
bannerSection: 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: '未来职业与商业生态' }
],
// 超级个体VIP会员
superMembers: [],
superMembersLoading: true,
// 最新新增章节(完整列表 + 展示列表,用于展开/折叠)
latestChapters: [],
displayLatestChapters: [],
// 篇章数(从 bookData 计算)
partCount: 0,
// 加载状态
loading: true,
// 链接卡若 - 留资弹窗
showLeadModal: false,
leadPhone: '',
showPrivacyModal: false,
// 展开状态(首页精选/最新)
featuredExpanded: false,
latestExpanded: false,
featuredSectionsFull: [], // 精选排行榜全量(最多 50默认只展示前 3 条
// 功能配置(搜索开关)
searchEnabled: true,
// 审核模式:隐藏支付相关入口
auditMode: false,
// mp_config.mpUi.homePage后台系统设置 mpUi
mpUiLogoTitle: '卡若创业派对',
mpUiLogoSubtitle: '来自派对房的真实故事',
mpUiLinkKaruoText: '点击链接卡若',
mpUiSearchPlaceholder: '搜索章节标题或内容...',
mpUiBannerTag: '推荐',
mpUiBannerReadMore: '点击阅读',
mpUiSuperTitle: '超级个体',
mpUiSuperLinkText: '获客入口',
mpUiPickTitle: '精选推荐',
mpUiLatestTitle: '最新新增'
},
onLoad(options) {
console.log('[Index] ===== onLoad 触发 =====')
// 获取系统信息
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
})
// 处理分享参数(推荐码绑定)
if (options && options.ref) {
console.log('[Index] 检测到推荐码:', options.ref)
app.handleReferralCode({ query: options })
}
wx.showShareMenu({ withShareTimeline: true })
this.loadFeatureConfig()
this.initData()
},
onShow() {
console.log('[Index] onShow 触发')
this.setData({ auditMode: app.globalData.auditMode || false })
this._applyHomeMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
console.log('[Index] TabBar 组件:', tabBar ? '已找到' : '未找到')
// 主动触发配置加载
if (tabBar && tabBar.loadFeatureConfig) {
console.log('[Index] 主动调用 TabBar.loadFeatureConfig()')
tabBar.loadFeatureConfig()
}
// 更新选中状态
if (tabBar && tabBar.updateSelected) {
tabBar.updateSelected()
} else if (tabBar) {
tabBar.setData({ selected: 0 })
}
} else {
console.log('[Index] TabBar 组件未找到或 getTabBar 方法不存在')
}
// 更新用户状态
this.updateUserStatus()
},
// 初始化数据:首次进页面并行异步加载,加快首屏展示
initData() {
this.setData({ loading: false })
this.loadBookData()
this.loadFeaturedAndLatest()
this.loadSuperMembers()
},
async loadSuperMembers() {
this.setData({ superMembersLoading: true })
try {
// 仅走后端 VIP 列表排序vip_sort、vip_activated_at不在端上拼普通用户
const vipRes = await app.request({ url: '/api/miniprogram/vip/members?limit=24', silent: true }).catch(() => null)
let members = []
if (vipRes && vipRes.success && Array.isArray(vipRes.data) && vipRes.data.length > 0) {
members = vipRes.data.map(u => {
const raw = u.name || u.nickname || u.vipName || u.vip_name || '会员'
const name = cleanSingleLineField(raw) || '会员'
return {
id: u.id,
name,
avatar: u.avatar || '',
isVip: true,
avatarLetter: superAvatarLetter(name)
}
}).filter((m) => !isKaruoHostDuplicateName(m.name))
console.log('[Index] 超级个体(后端排序):', members.length, '人')
}
this.setData({ superMembers: members, superMembersLoading: false })
} catch (e) {
console.log('[Index] 加载超级个体失败:', e)
this.setData({ superMembersLoading: false })
}
},
// 精选推荐 + 最新更新 + 最新列表顺序以后端为准recommended=排行榜算法latest=updated_at
async loadFeaturedAndLatest() {
try {
const tagClassForTag = (tag) => (tag === '热门' ? 'tag-hot' : 'tag-rec')
const toSectionFromRanking = (s) => {
const tag = s.tag || '精选'
return {
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag,
tagClass: tagClassForTag(tag)
}
}
const fallbackTags = ['热门', '推荐', '精选']
const toSectionFromHot = (s, i) => {
const tag = fallbackTags[i % 3]
return {
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag,
tagClass: tagClassForTag(tag)
}
}
const [recRes, latestRes] = await Promise.all([
app.request({ url: '/api/miniprogram/book/recommended?limit=50', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true }).catch(() => null)
])
// 1. 精选推荐一次拉全量≤50默认只显示 3 条;点列表下三角展开(与「最新新增」一致)
let featuredFull = []
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
featuredFull = recRes.data.map((s) => toSectionFromRanking(s))
}
if (featuredFull.length === 0) {
try {
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
const hotList = (hotRes && hotRes.data) ? hotRes.data : []
if (hotList.length > 0) featuredFull = hotList.map((s, i) => toSectionFromHot(s, i))
} catch (e) { console.log('[Index] book/hot 兜底失败:', e) }
}
if (featuredFull.length > 0) {
this.setData({
featuredSectionsFull: featuredFull,
featuredSections: featuredFull.slice(0, 3),
featuredExpanded: false
})
} else {
this.setData({
featuredSectionsFull: [],
featuredSections: [],
featuredExpanded: false
})
}
// 2. Banner 推荐:优先取 recommended 第一条,回退 latest 第一条
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
// 按更新时间倒序,最新在前(与后台展示一致)
const latestList = [...rawList].sort((a, b) => {
const ta = new Date(a.updatedAt || a.updated_at || 0).getTime()
const tb = new Date(b.updatedAt || b.updated_at || 0).getTime()
return tb - ta
})
if (featuredFull.length > 0) {
this.setData({ bannerSection: featuredFull[0] })
} else if (latestList.length > 0) {
const l = latestList[0]
this.setData({
bannerSection: {
id: l.id,
mid: l.mid ?? l.MID ?? 0,
title: l.section_title || l.sectionTitle || l.title || l.chapterTitle || '',
part: l.part_title || l.partTitle || ''
}
})
}
const latestChapters = latestList.slice(0, 20).map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title,
desc: '',
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
const display = this.data.latestExpanded ? latestChapters : latestChapters.slice(0, 5)
this.setData({ latestChapters, displayLatestChapters: display })
} catch (e) {
console.log('[Index] 从服务端加载推荐失败:', e)
}
},
async loadBookData() {
try {
const res = await app.request({ url: '/api/miniprogram/book/parts', silent: true })
if (res?.success) {
const total = res.totalSections ?? 0
const parts = res.parts || []
app.globalData.totalSections = total || 62
this.setData({
totalSections: app.globalData.totalSections,
partCount: parts.length || 5
})
}
} catch (e) {
this.setData({ totalSections: app.globalData.totalSections || 62, partCount: 5 })
}
},
// 更新用户状态(已读数 = 用户实际打开过的章节数,仅统计有权限阅读的)
updateUserStatus() {
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
const readCount = Math.min(app.getReadCount(), this.data.totalSections || 62)
this.setData({
isLoggedIn,
hasFullBook,
readCount
})
},
// 跳转到目录
goToChapters() {
trackClick('home', 'nav_click', '阅读进度')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
_applyHomeMpUi() {
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
this.setData({
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若',
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读',
mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体',
mpUiSuperLinkText: String(h.superSectionLinkText || '获客入口').trim() || '获客入口',
mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐',
mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增'
})
},
/** 超级个体右侧文案:默认跳转找伙伴 Tab路径可由 homePage.superSectionLinkPath 配置) */
goSuperSectionLink() {
const p = String(
app.globalData.configCache?.mpConfig?.mpUi?.homePage?.superSectionLinkPath || '/pages/match/match'
).trim()
if (p) navigateMpPath(p)
},
async loadFeatureConfig() {
try {
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
if (hasCachedFeatures) {
this.setData({
searchEnabled: app.globalData.features.searchEnabled,
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
return
}
const res = await app.getConfig()
const features = (res && res.features) || {}
const mp = (res && res.mpConfig) || {}
const searchEnabled = features.searchEnabled !== false
const auditMode = !!mp.auditMode
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
app.globalData.auditMode = auditMode
this.setData({ searchEnabled, auditMode })
this._applyHomeMpUi()
} catch (e) {
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
this._applyHomeMpUi()
}
},
// 跳转到搜索页
goToSearch() {
if (!this.data.searchEnabled) return
trackClick('home', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页(传 mid与分享一致无 mid 时传 id
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
trackClick('home', 'card_click', id || '章节')
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 跳转到匹配页
goToMatch() {
wx.switchTab({ url: '/pages/match/match' })
},
goToVip() {
trackClick('home', 'btn_click', '加入创业派对')
wx.navigateTo({ url: '/pages/vip/vip' })
},
async onLinkKaruo() {
trackClick('home', 'btn_click', '链接卡若')
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 userId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
}
} catch (e) {}
}
if (phone || wechatId) {
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '提交失败', icon: 'none' })
}
return
}
this.setData({ showLeadModal: true, leadPhone: '' })
},
closeLeadModal() {
this.setData({ showLeadModal: false, leadPhone: '', showPrivacyModal: false })
},
// 阻止弹窗内部点击事件冒泡到遮罩层
stopPropagation() {},
onLeadPhoneInput(e) {
this.setData({ leadPhone: (e.detail.value || '').trim() })
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForLead() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 一键获取手机号(微信能力),成功后直接提交链接卡若
async onGetPhoneNumberForLead(e) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
wx.showToast({ title: '未获取到手机号,请手动输入', icon: 'none' })
return
}
const code = e.detail.code
if (!code) {
wx.showToast({ title: '获取失败,请手动输入', icon: 'none' })
return
}
const app = getApp()
const userId = app.globalData.userInfo?.id
wx.showLoading({ title: '获取中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/phone',
method: 'POST',
data: { code, userId }
})
wx.hideLoading()
if (res && res.success && res.phoneNumber) {
await this._submitLeadWithPhone(res.phoneNumber)
} else {
wx.showToast({ title: '获取失败,请手动输入', icon: 'none' })
}
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '获取失败,请手动输入', icon: 'none' })
}
},
// 内部:用手机号提交链接卡若(一键获取与手动输入共用)
async _submitLeadWithPhone(phone) {
const p = (phone || '').trim().replace(/\s/g, '')
if (!p || p.length < 11) {
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const app = getApp()
const userId = app.globalData.userInfo?.id
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
phone: p,
name: (app.globalData.userInfo?.nickname || '').trim() || undefined,
},
})
wx.hideLoading()
this.setData({ showLeadModal: false, leadPhone: '' })
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
// 同步手机号到用户资料
try {
if (userId) {
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId, phone: p },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.phone = p
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.setStorageSync('user_phone', p)
}
} catch (e) {
console.log('[Index] 同步手机号到用户资料失败:', e && e.message)
}
wx.showToast({ title: res.message || '提交成功,卡若会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '提交失败', icon: 'none' })
}
},
async submitLead() {
const phone = (this.data.leadPhone || '').trim().replace(/\s/g, '')
if (!phone) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
return
}
await this._submitLeadWithPhone(phone)
},
goToSuperList() {
wx.switchTab({ url: '/pages/match/match' })
},
// 精选推荐:列表下方小三角展开(数据已在 loadFeaturedAndLatest 一次拉齐)
expandFeaturedChapters() {
if (this.data.featuredExpanded) return
const full = this.data.featuredSectionsFull || []
if (full.length <= 3) return
trackClick('home', 'tab_click', '精选展开_底部三角')
this.setData({ featuredExpanded: true, featuredSections: full })
},
// 最新新增:列表下方小三角展开(无「收起」,展开后整页向下滚动查看)
expandLatestChapters() {
if (this.data.latestExpanded) return
trackClick('home', 'tab_click', '最新展开_底部三角')
const full = this.data.latestChapters || []
this.setData({
latestExpanded: true,
displayLatestChapters: full
})
},
goToMemberDetail(e) {
const id = e.currentTarget.dataset.id
trackClick('home', 'card_click', '超级个体_' + (id || ''))
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${id}` })
},
// 跳转到我的页面
goToMy() {
wx.switchTab({ url: '/pages/my/my' })
},
// 下拉刷新(等待各异步加载完成后再结束)
async onPullDownRefresh() {
await Promise.all([
this.loadBookData(),
this.loadFeaturedAndLatest(),
this.loadSuperMembers()
])
this.updateUserStatus()
wx.stopPullDownRefresh()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: '卡若创业派对 - 真实商业故事',
path: ref ? `/pages/index/index?ref=${ref}` : '/pages/index/index'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: '卡若创业派对 - 真实商业故事', query: ref ? `ref=${ref}` : '' }
}
})