/** * 卡若创业派对 - 首页 * 开发: 卡若 * 技术支持: 存客宝 */ const app = getApp() const { trackClick } = require('../../utils/trackClick') const { cleanSingleLineField } = require('../../utils/contentParser') const { navigateMpPath } = require('../../utils/mpNavigate.js') const { isSafeImageSrc } = require('../../utils/imageUrl.js') const { submitCkbLead, getReferralCodeForPay, requestWxJsapiPayment, syncOrderStatusQuery, } = require('../../utils/soulBridge') const DEFAULT_TIP_GIFTS = [ { id: 'gift_lite', name: '小心心', price: 9.9, emoji: '❤️', icon: '', micOrder: 1, iconScale: 1, micFeatured: true }, { id: 'gift_star', name: '人气票', price: 99, emoji: '🎫', icon: '', micOrder: 0, iconScale: 1, micFeatured: false }, { id: 'gift_super', name: '嘉年华', price: 1980, emoji: '🎡', icon: '', micOrder: 2, iconScale: 1, micFeatured: false }, ] /** 置顶人物无头像时的占位图 */ const DEFAULT_KARUO_LINK_AVATAR = '/assets/images/karuo-link-avatar.png' /** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */ 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 : '会' } /** 旧 OSS 存 16personalities .svg 时改为 png 目录,与后端 resolveAvatarURL 一致 */ function normalizeLegacyMbtiAvatarUrl(u) { if (!u || typeof u !== 'string') return u const lower = u.toLowerCase() if (!lower.includes('mbti-avatar')) return u let s = u.replace(/mbti-avatars-16personalities-male/g, 'mbti-avatars-png-male') s = s.replace(/mbti-avatars-16personalities-female/g, 'mbti-avatars-png-female') if (/\.svg$/i.test(s)) s = s.replace(/\.svg$/i, '.png') return s } function parseTimeToMinutes(t) { const m = String(t || '').trim().match(/^(\d{1,2}):(\d{2})$/) if (!m) return null const h = parseInt(m[1], 10) const min = parseInt(m[2], 10) if (h < 0 || h > 23 || min < 0 || min > 59) return null return h * 60 + min } /** 是否在直播上麦时段(支持跨午夜 end < start) */ function isNowInMicWindow(startStr, endStr) { const a = parseTimeToMinutes(startStr) const b = parseTimeToMinutes(endStr) if (a == null || b == null) return false const now = new Date() const cur = now.getHours() * 60 + now.getMinutes() if (a <= b) return cur >= a && cur < b return cur >= a || cur < b } function normCfgBool(v) { return v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true' } function normalizeTipGifts(raw) { const base = DEFAULT_TIP_GIFTS if (!Array.isArray(raw) || raw.length === 0) return base.map((g) => ({ ...g })) return raw.map((row, i) => { const dFallback = base[Math.min(i, base.length - 1)] const rn = String(row.name != null ? row.name : '').trim() let id = row.id != null ? String(row.id).trim() : '' let d = dFallback // 无 id / 空 id 时禁止按下标套默认 id:后台顺序多为「人气票、小心心、嘉年华」,下标1会变成 gift_star, // 导致中间显示小心心¥9.9 却带 gift_star 去支付变成¥29.8。优先按礼物名称、再按 micOrder 对齐默认档。 if (!id) { const byName = rn ? base.find((g) => g.name === rn) : undefined if (byName) { d = byName id = byName.id } else { let mo = row.micOrder != null ? parseInt(String(row.micOrder), 10) : NaN if (!Number.isNaN(mo)) { const byOrder = base.find((g) => Number(g.micOrder) === mo) if (byOrder) { d = byOrder id = byOrder.id } } if (!id) { d = dFallback id = d.id } } } const name = String(row.name != null ? row.name : d.name).trim() || d.name let price = parseFloat(row.price) if (Number.isNaN(price) || price <= 0) price = d.price const emoji = String(row.emoji != null ? row.emoji : d.emoji).trim() || d.emoji const icon = String(row.icon != null ? row.icon : '').trim() let micOrder = row.micOrder != null ? parseInt(String(row.micOrder), 10) : NaN if (Number.isNaN(micOrder)) micOrder = 999 let iconScale = parseFloat(row.iconScale) if (Number.isNaN(iconScale) || iconScale <= 0) iconScale = 1 const micFeatured = row.micFeatured === true || row.micFeatured === 1 || row.micFeatured === '1' || String(row.micFeatured).toLowerCase() === 'true' return { id, name, price, emoji, icon, micOrder, iconScale, micFeatured } }) } function sortGiftsForMic(arr) { return [...arr].sort((a, b) => { const oa = a.micOrder != null && !Number.isNaN(Number(a.micOrder)) ? Number(a.micOrder) : 999 const ob = b.micOrder != null && !Number.isNaN(Number(b.micOrder)) ? Number(b.micOrder) : 999 if (oa !== ob) return oa - ob return 0 }) } /** * 上麦横排:按礼物 micOrder 排序,最多 3 档;默认档优先 micFeatured,否则中间位 * micEmojiRpx:默认档再乘 liveMicSchedule.micCenterEmojiScale */ function layoutMicHorizontalSlots(gifts, liveMicSchedule) { const fallback = DEFAULT_TIP_GIFTS.map((g) => ({ ...g })) let arr = Array.isArray(gifts) && gifts.length ? gifts : fallback arr = sortGiftsForMic(arr) const slotsRaw = arr.slice(0, 3) let defaultIndex = slotsRaw.length >= 2 ? 1 : 0 const fi = slotsRaw.findIndex( (g) => g.micFeatured === true || g.micFeatured === 1 || g.micFeatured === '1' || String(g.micFeatured).toLowerCase() === 'true' ) if (fi >= 0) defaultIndex = fi const def = slotsRaw[defaultIndex] const defaultId = def && def.id ? def.id : '' let centerScale = 1.55 if (liveMicSchedule && liveMicSchedule.micCenterEmojiScale != null) { const v = parseFloat(String(liveMicSchedule.micCenterEmojiScale)) if (!Number.isNaN(v) && v >= 1) centerScale = Math.min(v, 2.4) } const n = slotsRaw.length /** 几何中间列:3 档为 index 1;2 档为 index 1;1 档为 0 */ const centerIdx = n <= 1 ? 0 : Math.floor(n / 2) const baseRpx = 44 const slots = slotsRaw.map((g, idx) => { const per = g.iconScale != null && Number(g.iconScale) > 0 ? Number(g.iconScale) : 1 const isDef = g.id === defaultId const isCenterSlot = idx === centerIdx let rpx = Math.round(baseRpx * per * (isDef ? centerScale : 1)) if (isCenterSlot) rpx = Math.round(rpx * 1.22) rpx = Math.max(32, Math.min(110, rpx)) const iconStr = String(g.icon || '').trim() const micShowIcon = !!(iconStr && isSafeImageSrc(iconStr)) return { ...g, micEmojiRpx: rpx, micShowIcon, micIsCenterSlot: isCenterSlot } }) return { slots, defaultId } } Page({ data: { // 系统信息 statusBarHeight: 44, navBarHeight: 88, // 用户信息 isLoggedIn: false, hasFullBook: false, readCount: 0, // 书籍数据(totalSections 来自 book/parts,初始用 app.getTotalSections() 兜底) totalSections: 0, // onLoad 后由 loadBookData 更新 bookData: [], // 推荐章节(来自 recommended/hot API,初始为空避免占位错误) featuredSections: [], // Banner 推荐(优先用 recommended API 第一条,回退 latest-chapters) bannerSection: null, /** 首屏 Banner 请求完成前为 true;完成后为 false,避免接口全失败时长期显示「加载中」 */ bannerLoading: true, 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, // 收益排行榜 earningsBoard: [], earningsBoardEnabled: false, // 最新新增章节(完整列表 + 展示列表,用于展开/折叠) latestChapters: [], displayLatestChapters: [], // 篇章数(从 bookData 计算) partCount: 0, // 加载状态 loading: true, // 展开状态(首页精选/最新) featuredExpanded: false, latestExpanded: false, featuredSectionsFull: [], // 精选排行榜全量(最多 50),默认只展示前 3 条 // 功能配置(搜索开关) searchEnabled: true, // 审核模式:隐藏支付相关入口 auditMode: false, // mp_config.mpUi.homePage(后台系统设置 mpUi) mpUiLogoTitle: '卡若创业派对', mpUiLogoSubtitle: '来自派对房的真实故事', /** 仅当有置顶 @人物时展示,文案与头像由 _applyHomeMpUi 写入 */ mpUiLinkKaruoText: '', mpUiLinkKaruoDisplay: DEFAULT_KARUO_LINK_AVATAR, mpUiSearchPlaceholder: '搜索章节标题或内容...', mpUiBannerTag: '推荐', mpUiBannerReadMore: '点击阅读', mpUiSuperTitle: '超级个体', mpUiPickTitle: '精选推荐', mpUiLatestTitle: '最新新增', /** 后台 @列表置顶人物:有则右上角展示绑定用户头像 + @名称,点击走 ckb/lead */ homePinnedPerson: null, /** 首页入口:打赏 + 直播时段上麦(mpUi.homePage.linkKaruoReward / liveMicSchedule) */ linkKaruoRewardEnabled: false, linkKaruoRewardUi: { entryButtonText: '支持一下', modalTitle: '打赏支持', modalSubtitle: '感谢支持,任选礼物与数量', payButtonText: '立即支付', leadLinkText: '留下联系方式', afterPayToast: '支付成功,感谢支持!', }, liveMicSchedule: { enabled: false, micStart: '18:00', micEnd: '21:00', micEmoji: '🎤', micButtonText: '申请上麦', micModalTitle: '申请上麦', micModalContent: '', micAfterSubmitToast: '支付成功,感谢支持!', micCenterEmojiScale: 1.55, }, homeMicEligible: false, tipGifts: [], showTipModal: false, tipHomeMicMode: false, tipModalTitle: '', tipModalSubtitle: '', selectedTipGiftId: '', tipQty: 1, tipLineTotal: '0.00', /** 上麦弹窗横排礼物(与后台 gifts 顺序一致) */ micHorizontalSlots: [], micDefaultGiftId: '', /** 右上角唯一入口:由置顶超级个体决定 */ headerCornerAction: { show: false, kind: '', label: '', emoji: '', avatar: '' }, /** 文旅/落地体验链:未登录时弹一键登录,成功后继续打赏/上麦/留资 */ showLoginModal: false, showPrivacyModal: false, loginModalDesc: '登录后可参与打赏、上麦与链接嘉宾', pendingAfterLogin: null, }, 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() }, async onShow() { console.log('[Index] onShow 触发') try { await app.getConfig(true) } catch (_) {} this.setData({ auditMode: app.globalData.auditMode || false }) this._refreshHomeEntryFlags() void this.loadHomePinnedPerson() // 首页 tab 再次展示时重拉超级个体(避免首进网络失败或仅 onLoad 拉一次后长期空白) if (!app.globalData.auditMode) { void this.loadSuperMembers() } // 设置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() this.loadEarningsBoard() }, 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 }) } }, async loadEarningsBoard() { try { const res = await app.request({ url: '/api/miniprogram/earnings/leaderboard', silent: true }).catch(() => null) if (res && res.success && res.enabled && Array.isArray(res.data) && res.data.length > 0) { const board = res.data.map(item => ({ rank: item.rank, nickname: item.nickname || '分享达人', avatar: normalizeLegacyMbtiAvatarUrl(item.avatar || ''), avatarLetter: superAvatarLetter(item.nickname || '创') })) this.setData({ earningsBoard: board, earningsBoardEnabled: true }) console.log('[Index] 收益排行榜:', board.length, '人') } else { this.setData({ earningsBoardEnabled: res && res.enabled === false ? false : true }) } } catch (e) { console.log('[Index] 加载收益排行榜失败:', e) } }, // 精选推荐 + 最新更新 + 最新列表:顺序以后端为准(recommended=排行榜算法,latest=updated_at) async loadFeaturedAndLatest() { this.setData({ bannerLoading: true }) 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) } finally { this.setData({ bannerLoading: false }) } }, 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 != null && total > 0) ? total : app.getTotalSections() this.setData({ totalSections: app.globalData.totalSections, partCount: parts.length || 5 }) } } catch (e) { this.setData({ totalSections: app.getTotalSections(), partCount: 5 }) } }, // 更新用户状态(已读数 = 用户实际打开过的章节数,仅统计有权限阅读的) updateUserStatus() { const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData const readCount = Math.min(app.getReadCount(), this.data.totalSections || app.getTotalSections()) 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 || {} const baseTitle = String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对' const prefix = String(h.pinnedTitlePrefix != null ? h.pinnedTitlePrefix : '派对会员').trim() const tpl = String(h.pinnedMainTitleTemplate || '').trim() const patch = { mpUiLogoTitle: baseTitle, mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事', mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...', mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐', mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读', mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体', mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐', mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增', } const pinned = this.data.homePinnedPerson if (pinned && pinned.token) { const displayAv = pinned.avatar && isSafeImageSrc(pinned.avatar) ? pinned.avatar : DEFAULT_KARUO_LINK_AVATAR const nm = pinned.name || '好友' patch.mpUiLinkKaruoText = `点击链接${nm}` patch.mpUiLinkKaruoDisplay = displayAv let mainTitle = baseTitle if (tpl) { mainTitle = tpl .replace(/\{\{name\}\}/g, nm) .replace(/\{\{prefix\}\}/g, prefix) .trim() || baseTitle } else if (prefix) { mainTitle = baseTitle } else { mainTitle = `@${nm}` } patch.mpUiLogoTitle = mainTitle } else { patch.mpUiLinkKaruoText = '' patch.mpUiLinkKaruoDisplay = DEFAULT_KARUO_LINK_AVATAR } this.setData(patch, () => { try { wx.setNavigationBarTitle({ title: patch.mpUiLogoTitle || '首页' }) } catch (_) {} this._syncHeaderCornerAction() }) }, /** 拉取后台置顶 @人物,合并到首页右上角「链接」区;同时拿到该人的 homeEntryConfig */ async loadHomePinnedPerson() { let pinnedPerson = null let override try { const res = await app.request({ url: '/api/miniprogram/ckb/pinned-person', silent: true }) if (res && res.success && res.data && res.data.token) { const name = cleanSingleLineField(res.data.name) || '好友' let av = String(res.data.avatar || '').trim() if (!isSafeImageSrc(av)) av = '' pinnedPerson = { token: String(res.data.token).trim(), name, avatar: av, homeEntryConfig: res.data.homeEntryConfig || null, } if (pinnedPerson.homeEntryConfig) { override = {} if (pinnedPerson.homeEntryConfig.linkKaruoReward) override.linkKaruoReward = pinnedPerson.homeEntryConfig.linkKaruoReward if (pinnedPerson.homeEntryConfig.liveMicSchedule) override.liveMicSchedule = pinnedPerson.homeEntryConfig.liveMicSchedule } } else { pinnedPerson = null } } catch (e) { console.log('[Index] pinned-person:', e) pinnedPerson = null } this.setData({ homePinnedPerson: pinnedPerson }, () => { this._applyHomeMpUi() this._refreshHomeEntryFlags(override) }) }, async loadFeatureConfig() { try { await app.getConfig(true) const res = app.globalData.configCache if (res) { const features = res.features || (res.data && res.data.features) || {} const searchEnabled = features.searchEnabled !== false if (!app.globalData.features) app.globalData.features = {} app.globalData.features.searchEnabled = searchEnabled if (typeof features.matchEnabled === 'boolean') app.globalData.features.matchEnabled = features.matchEnabled if (typeof features.referralEnabled === 'boolean') app.globalData.features.referralEnabled = features.referralEnabled const mp = res.mpConfig || {} app.globalData.auditMode = !!mp.auditMode } await app.getAuditMode() const searchEnabled = app.globalData.features?.searchEnabled !== false this.setData({ searchEnabled, auditMode: app.globalData.auditMode || false }) this._applyHomeMpUi() this._refreshHomeEntryFlags() } catch (e) { try { await app.getAuditMode() } catch (_) {} this.setData({ searchEnabled: app.globalData.features?.searchEnabled !== false, auditMode: app.globalData.auditMode || false }) this._applyHomeMpUi() this._refreshHomeEntryFlags() } await this.loadHomePinnedPerson() }, _refreshHomeEntryFlags(override) { const audit = !!app.globalData.auditMode // 有置顶人物时:入口开关严格按该人物配置,不再回退全局,避免“人字拖未开上麦却显示申请上麦”。 // 无置顶人物时:沿用全局 mpUi.homePage。 const hasPinned = !!(this.data.homePinnedPerson && this.data.homePinnedPerson.token) const personCfg = override || ((this.data.homePinnedPerson && this.data.homePinnedPerson.homeEntryConfig) || null) const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {} const rw = hasPinned ? ((personCfg && personCfg.linkKaruoReward) || {}) : (h.linkKaruoReward || {}) const schReal = hasPinned ? ((personCfg && personCfg.liveMicSchedule) || {}) : (h.liveMicSchedule || {}) const rewardEnabled = normCfgBool(rw.enabled) const micEnabled = normCfgBool(schReal.enabled) const inWindow = isNowInMicWindow(schReal.micStart, schReal.micEnd) const homeMicEligible = !audit && micEnabled && inWindow const gifts = normalizeTipGifts(Array.isArray(rw.gifts) && rw.gifts.length ? rw.gifts : null) const tipGifts = gifts.length ? gifts : DEFAULT_TIP_GIFTS.map((g) => ({ ...g })) this.setData({ linkKaruoRewardEnabled: rewardEnabled, linkKaruoRewardUi: { entryButtonText: String(rw.entryButtonText || '支持一下').trim() || '支持一下', modalTitle: String(rw.modalTitle || '打赏支持').trim() || '打赏支持', modalSubtitle: String(rw.modalSubtitle || '感谢支持,任选礼物与数量').trim() || '感谢支持,任选礼物与数量', payButtonText: String(rw.payButtonText || '立即支付').trim() || '立即支付', leadLinkText: String(rw.leadLinkText || '留下联系方式').trim() || '留下联系方式', afterPayToast: String(rw.afterPayToast || '支付成功,感谢支持!').trim() || '支付成功,感谢支持!', }, liveMicSchedule: { enabled: micEnabled, micStart: String(schReal.micStart || '18:00').trim() || '18:00', micEnd: String(schReal.micEnd || '21:00').trim() || '21:00', micEmoji: String(schReal.micEmoji || '🎤').trim() || '🎤', micButtonText: String(schReal.micButtonText || '申请上麦').trim() || '申请上麦', micModalTitle: String(schReal.micModalTitle || '申请上麦').trim() || '申请上麦', micModalContent: String( schReal.micModalContent || '当前为直播时段,提交后工作人员将按顺序处理连麦申请。', ).trim(), micAfterSubmitToast: String(schReal.micAfterSubmitToast || '支付成功,感谢支持!').trim() || '支付成功,感谢支持!', micCenterEmojiScale: (() => { const v = parseFloat(String(schReal.micCenterEmojiScale != null ? schReal.micCenterEmojiScale : '1.55')) if (Number.isNaN(v) || v < 1) return 1.55 return Math.min(v, 2.4) })(), }, homeMicEligible, tipGifts, }) this._syncHeaderCornerAction() }, /** 右上角单一入口:上麦时段 > 置顶超级个体 > 打赏 */ _syncHeaderCornerAction() { if (app.globalData.auditMode) { this.setData({ headerCornerAction: { show: false, kind: '', label: '', emoji: '', avatar: '' } }) return } const mic = !!this.data.homeMicEligible const pinned = !!(this.data.homePinnedPerson && this.data.homePinnedPerson.token) const reward = !!this.data.linkKaruoRewardEnabled const sch = this.data.liveMicSchedule || {} let show = false, kind = '', label = '', emoji = '', avatar = '' if (mic) { show = true; kind = 'mic' label = String(sch.micButtonText || '申请上麦').trim() || '申请上麦' emoji = String(sch.micEmoji || '🎤').trim() || '🎤' } else if (pinned) { show = true; kind = 'ckb' label = String(this.data.mpUiLinkKaruoText || '').trim() || '链接卡若' avatar = this.data.mpUiLinkKaruoDisplay || DEFAULT_KARUO_LINK_AVATAR } else if (reward) { show = true; kind = 'reward' const rwUi = this.data.linkKaruoRewardUi || {} label = String(rwUi.entryButtonText || '支持一下').trim() || '支持一下' emoji = '' } this.setData({ headerCornerAction: { show, kind, label, emoji, avatar } }) }, /** 右上角统一点击 */ onHeaderCornerTap() { const a = this.data.headerCornerAction if (!a || !a.show) return if (a.kind === 'ckb') { this.onLinkKaruo() return } this._openHorizontalGiftModal(a.kind === 'mic' ? 'live_mic' : 'home_reward') }, /** * 体验环节未登录:弹公用 login-modal(单页模式仍引导「前往小程序」) * @param {{ type: 'tip', tipSource: string } | { type: 'link_karuo' }} pending */ _showHomeLoginModal(pending, desc) { try { const sys = wx.getSystemInfoSync() const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode if (isSinglePage) { wx.showModal({ title: '请前往完整小程序', content: '当前为单页模式,登录与支付需点击底部「前往小程序」进入完整版。', showCancel: false, confirmText: '我知道了', }) return } } catch (e) { console.warn('[Index] 检测单页模式失败,回退为登录弹窗:', e) } this.setData({ pendingAfterLogin: pending, loginModalDesc: desc || '登录后可参与打赏、上麦与链接嘉宾', showLoginModal: true, }) }, onLoginModalClose() { this.setData({ showLoginModal: false, showPrivacyModal: false, pendingAfterLogin: null }) }, onLoginModalPrivacyAgree() { this.setData({ showPrivacyModal: false }) }, onLoginModalSuccess() { const pending = this.data.pendingAfterLogin this.setData({ showLoginModal: false, pendingAfterLogin: null }) this.updateUserStatus() wx.showToast({ title: '登录成功', icon: 'success' }) if (!pending) return const run = (fn) => { if (typeof wx.nextTick === 'function') wx.nextTick(fn) else setTimeout(fn, 0) } if (pending.type === 'tip' && pending.tipSource) { run(() => this._openHorizontalGiftModal(pending.tipSource)) } else if (pending.type === 'link_karuo') { run(() => this.onLinkKaruo()) } }, /** 打开横排礼物弹窗(上麦/打赏统一入口) */ _openHorizontalGiftModal(tipSource) { if (app.globalData.auditMode) return const label = tipSource === 'live_mic' ? '申请上麦' : '打赏支持' trackClick('home', tipSource === 'live_mic' ? 'live_mic_request' : 'btn_click', label) if (!app.globalData.isLoggedIn) { const desc = tipSource === 'live_mic' ? '登录后可申请上麦并完成礼遇支付' : '登录后可打赏支持' this._showHomeLoginModal({ type: 'tip', tipSource }, desc) return } const sch = this.data.liveMicSchedule const gifts = this.data.tipGifts || [] const { slots, defaultId } = layoutMicHorizontalSlots(gifts, sch) const title = tipSource === 'live_mic' ? String(sch.micModalTitle || '申请上麦').trim() : String(this.data.linkKaruoRewardUi.modalTitle || '打赏支持').trim() const sub = tipSource === 'live_mic' ? String(sch.micModalContent || '当前为直播时段,提交后工作人员将按顺序处理连麦申请。').trim() : String(this.data.linkKaruoRewardUi.modalSubtitle || '感谢支持,点选礼物直接支付').trim() this.setData({ showTipModal: true, tipHomeMicMode: tipSource === 'live_mic', tipModalTitle: title, tipModalSubtitle: sub, micHorizontalSlots: slots, micDefaultGiftId: defaultId, selectedTipGiftId: defaultId, tipQty: 1, }) }, closeTipModal() { this.setData({ showTipModal: false, micHorizontalSlots: [], micDefaultGiftId: '' }) }, stopTipModalBubble() {}, /** 横排礼物卡片点击 → 直接调起微信支付 */ onHorizontalTipPay(e) { const id = e.currentTarget.dataset.id if (!id) return const src = this.data.tipHomeMicMode ? 'live_mic' : 'home_reward' this._executeLinkKaruoTipPay(src, id, 1) }, async _executeLinkKaruoTipPay(tipSource, giftId, qty) { const gid = String(giftId || '').trim() const q = Math.max(1, parseInt(String(qty), 10) || 1) if (!gid) { wx.showToast({ title: '请选择礼物', icon: 'none' }) return } if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) { wx.showToast({ title: '请先登录', icon: 'none' }) return } const openId = await app.ensurePayOpenId() if (!openId) { wx.showModal({ title: '无法拉起支付', content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。', showCancel: false, }) return } const g = (this.data.tipGifts || []).find((x) => String(x.id) === gid) const amount = g && Number(g.price) > 0 ? Math.round(Number(g.price) * q * 100) / 100 : 0 if (amount <= 0) { wx.showToast({ title: '礼物价格无效', icon: 'none' }) return } wx.showLoading({ title: '下单中...', mask: true }) try { const referralCode = getReferralCodeForPay(app) || undefined const res = await app.request({ url: '/api/miniprogram/pay', method: 'POST', silent: true, data: { openId, productType: 'link_karuo_tip', productId: tipSource === 'live_mic' ? `live_mic|${gid}|${q}` : `${gid}|${q}`, amount, description: tipSource === 'live_mic' ? '上麦礼遇' : '首页打赏', userId: app.globalData.userInfo.id, referralCode, tipSource: tipSource === 'live_mic' ? 'live_mic' : 'home_reward', }, }) wx.hideLoading() if (!res || !res.success || !res.data || !res.data.payParams) { const errText = (res && (res.error || res.message)) || '下单失败' wx.showToast({ title: String(errText).slice(0, 36), icon: 'none' }) return } await requestWxJsapiPayment(res.data.payParams) const sn = res.data.orderSn if (sn) { try { await syncOrderStatusQuery(app, sn) } catch (_) {} } const toast = tipSource === 'live_mic' ? this.data.liveMicSchedule.micAfterSubmitToast || '支付成功' : this.data.linkKaruoRewardUi.afterPayToast || '支付成功' wx.showToast({ title: toast, icon: 'success' }) this.closeTipModal() } catch (e) { wx.hideLoading() const msg = (e && e.errMsg) || (e && e.message) || '支付失败' if (String(msg).indexOf('cancel') >= 0) { wx.showToast({ title: '已取消支付', icon: 'none' }) return } wx.showToast({ title: String(msg).slice(0, 28), icon: 'none' }) } }, // 跳转到搜索页 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() { if (app.globalData.auditMode) { wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' }) return } trackClick('home', 'btn_click', '加入创业派对') wx.navigateTo({ url: '/pages/vip/vip' }) }, async onLinkKaruo() { const pinned = this.data.homePinnedPerson if (!pinned || !pinned.token) return trackClick('home', 'btn_click', '置顶@人物留资') if (!app.globalData.isLoggedIn) { this._showHomeLoginModal({ type: 'link_karuo' }, '登录后可向嘉宾留下联系方式') return } await submitCkbLead(getApp(), { targetUserId: pinned.token, targetNickname: pinned.name, source: 'home_pinned_person', }) }, 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() { try { await app.getConfig(true) } catch (_) {} this.setData({ auditMode: app.globalData.auditMode || false }) this._refreshHomeEntryFlags() 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}` : '' } } })