feat: 同步本地三端改动并清理上传凭证风险

整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-06 15:59:34 +08:00
parent b1cc4be1c2
commit 6d11fb295d
126 changed files with 5789 additions and 2791 deletions

View File

@@ -10,9 +10,9 @@ const mpPagePopups = require('./utils/mpPagePopups.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
// baseUrl 手动切换(注释方式):
// const API_BASE_URL = 'http://localhost:9090'
const API_BASE_URL = 'https://soulapi.quwanzhi.com'
// baseUrl 手动切换(本地端口以 soul-api/.env 的 PORT 为准,当前仓库多为 8080发版前改回线上
const API_BASE_URL = 'http://127.0.0.1:8080'
// const API_BASE_URL = 'https://soulapi.quwanzhi.com'
// const API_BASE_URL = 'https://souldev.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
@@ -548,6 +548,8 @@ App({
const userId = this.globalData.userInfo.id
const base = (this.globalData.baseUrl || '').replace(/\/$/, '')
if (!base) return
// 本机 API 无稳定 WSS 时跳过,减少控制台报错与无效重连
if (/^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?$/i.test(base)) return
const wsUrl = base.replace(/^http/, 'ws') + '/ws/miniprogram'
if (this._wsHeartbeatTimer) {
clearInterval(this._wsHeartbeatTimer)
@@ -1164,14 +1166,54 @@ App({
}
},
/** 是否为微信小程序支付可用的 openid */
looksLikeWxOpenId(s) {
const x = String(s || '').trim()
if (x.length < 24 || x.length > 64) return false
if (x.indexOf('user_') === 0) return false
if (x.charAt(0) !== 'o') return false
return /^o[a-zA-Z0-9_-]+$/.test(x)
},
/** 从缓存 / userInfo 取合法微信 openid */
resolvePayOpenId() {
const u = this.globalData.userInfo || {}
const pick = (v) => {
const s = String(v || '').trim()
return this.looksLikeWxOpenId(s) ? s : ''
}
return pick(this.globalData.openId) || pick(wx.getStorageSync('openId')) || pick(u.openId || u.open_id)
},
/** 支付前确保拿到真实微信 openid */
async ensurePayOpenId() {
let oid = this.resolvePayOpenId()
if (oid) {
this.globalData.openId = oid
try { wx.setStorageSync('openId', oid) } catch (_) {}
return oid
}
try { await this.getOpenId() } catch (_) {}
oid = this.resolvePayOpenId()
if (oid) {
this.globalData.openId = oid
try { wx.setStorageSync('openId', oid) } catch (_) {}
return oid
}
return ''
},
// 获取openId (支付必需)
async getOpenId() {
if (!this.ensureFullAppForAuth()) {
return null
}
// 先检查缓存
// 缓存中如果存的是 userId 等非法值,先清掉
const cachedOpenId = wx.getStorageSync('openId')
if (cachedOpenId) {
if (cachedOpenId && !this.looksLikeWxOpenId(cachedOpenId)) {
try { wx.removeStorageSync('openId') } catch (_) {}
this.globalData.openId = null
} else if (cachedOpenId && this.looksLikeWxOpenId(cachedOpenId)) {
this.globalData.openId = cachedOpenId
return cachedOpenId
}

View File

@@ -180,7 +180,6 @@ Page({
})
}
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,
@@ -191,7 +190,7 @@ Page({
newBadgeText: this._normalizeBadgeText(
r.newBadgeText || r.new_badge_text || r.sectionBadgeText || r.section_badge_text || r.badgeText || r.badge_text
),
isPremium
isPremium: false
})
})
const chapters = Array.from(chMap.values())
@@ -327,10 +326,9 @@ Page({
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 检查是否已购买
hasPurchased(sectionId, isPremium) {
hasPurchased(sectionId) {
if (this.data.isVip) return true
if (!isPremium && this.data.hasFullBook) return true
if (this.data.hasFullBook) return true
return this.data.purchasedSections.includes(sectionId)
},

View File

@@ -125,14 +125,15 @@ Page({
wx.showToast({ title: '请先登录后再支付', icon: 'none' })
return
}
let openId = app.globalData.openId || wx.getStorageSync('openId')
wx.showLoading({ title: '获取支付凭证...', mask: true })
const openId = await app.ensurePayOpenId()
wx.hideLoading()
if (!openId) {
wx.showLoading({ title: '获取支付凭证...', mask: true })
openId = await app.getOpenId()
wx.hideLoading()
}
if (!openId) {
wx.showToast({ title: '请先登录', icon: 'none' })
wx.showModal({
title: '无法拉起支付',
content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。',
showCancel: false,
})
return
}
let { requestSn, sectionId, detail, giftQuantity, isCreateMode } = this.data

View File

@@ -9,7 +9,18 @@ const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { submitCkbLead } = require('../../utils/soulBridge')
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'
@@ -28,6 +39,102 @@ function superAvatarLetter(displayName) {
return /[\u4e00-\u9fff]/.test(ch) ? ch : '会'
}
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 d = base[Math.min(i, base.length - 1)]
const id = String(row.id != null ? row.id : d.id).trim() || 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 12 档为 index 11 档为 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: {
// 系统信息
@@ -99,6 +206,42 @@ Page({
/** 后台 @列表置顶人物:有则右上角展示绑定用户头像 + @名称,点击走 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: '' },
},
onLoad(options) {
@@ -121,9 +264,13 @@ Page({
this.initData()
},
onShow() {
async onShow() {
console.log('[Index] onShow 触发')
try {
await app.getConfig(true)
} catch (_) {}
this.setData({ auditMode: app.globalData.auditMode || false })
this._refreshHomeEntryFlags()
void this.loadHomePinnedPerson()
// 设置TabBar选中状态
@@ -357,13 +504,15 @@ Page({
patch.mpUiLinkKaruoText = ''
patch.mpUiLinkKaruoDisplay = DEFAULT_KARUO_LINK_AVATAR
}
this.setData(patch)
try {
wx.setNavigationBarTitle({ title: patch.mpUiLogoTitle || '首页' })
} catch (_) {}
this.setData(patch, () => {
try {
wx.setNavigationBarTitle({ title: patch.mpUiLogoTitle || '首页' })
} catch (_) {}
this._syncHeaderCornerAction()
})
},
/** 拉取后台置顶 @人物,合并到首页右上角「链接」区 */
/** 拉取后台置顶 @人物,合并到首页右上角「链接」区;同时拿到该人的 homeEntryConfig */
async loadHomePinnedPerson() {
try {
const res = await app.request({ url: '/api/miniprogram/ckb/pinned-person', silent: true })
@@ -376,6 +525,7 @@ Page({
token: String(res.data.token).trim(),
name,
avatar: av,
homeEntryConfig: res.data.homeEntryConfig || null,
},
})
} else {
@@ -385,21 +535,28 @@ Page({
console.log('[Index] pinned-person:', e)
this.setData({ homePinnedPerson: null })
}
const pin = (this.data.homePinnedPerson || {})
const override = {}
if (pin.homeEntryConfig) {
if (pin.homeEntryConfig.linkKaruoReward) override.linkKaruoReward = pin.homeEntryConfig.linkKaruoReward
if (pin.homeEntryConfig.liveMicSchedule) override.liveMicSchedule = pin.homeEntryConfig.liveMicSchedule
}
this._applyHomeMpUi()
this._refreshHomeEntryFlags(Object.keys(override).length ? override : undefined)
},
async loadFeatureConfig() {
try {
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
if (!hasCachedFeatures) {
const res = await app.getConfig()
const features = (res && res.features) || (res && res.data && res.data.features) || {}
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 && res.mpConfig) || {}
const mp = res.mpConfig || {}
app.globalData.auditMode = !!mp.auditMode
}
await app.getAuditMode()
@@ -409,6 +566,7 @@ Page({
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
this._refreshHomeEntryFlags()
} catch (e) {
try {
await app.getAuditMode()
@@ -418,10 +576,218 @@ Page({
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
this._refreshHomeEntryFlags()
}
await this.loadHomePinnedPerson()
},
_refreshHomeEntryFlags() {
const audit = !!app.globalData.auditMode
// 优先读置顶超级个体的 per-person 配置,回退到全局 mpUi.homePage
const personCfg = (this.data.homePinnedPerson && this.data.homePinnedPerson.homeEntryConfig) || null
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
const rw = (personCfg && personCfg.linkKaruoReward) || h.linkKaruoReward || {}
const schReal = (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'
label = String(sch.micButtonText || '申请上麦').trim() || '申请上麦'
emoji = String(sch.micEmoji || '🎤').trim() || '🎤'
}
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')
},
/** 打开横排礼物弹窗(上麦/打赏统一入口) */
_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) {
wx.showModal({
title: '提示',
content: `请先登录后再${label}`,
confirmText: '去登录',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) },
})
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
@@ -496,6 +862,11 @@ Page({
// 下拉刷新(等待各异步加载完成后再结束)
async onPullDownRefresh() {
try {
await app.getConfig(true)
} catch (_) {}
this.setData({ auditMode: app.globalData.auditMode || false })
this._refreshHomeEntryFlags()
await Promise.all([
this.loadBookData(),
this.loadFeaturedAndLatest(),

View File

@@ -16,10 +16,12 @@
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
</view>
</view>
<view class="header-right" wx:if="{{!auditMode && homePinnedPerson && homePinnedPerson.token}}">
<view class="contact-btn" catchtap="onLinkKaruo" hover-class="none">
<image class="contact-avatar" src="{{mpUiLinkKaruoDisplay}}" mode="aspectFill"/>
<text class="contact-name">{{mpUiLinkKaruoText}}</text>
<!-- 右上角单一入口:由置顶超级个体决定,点击弹出横排礼物,点选即付 -->
<view class="header-right" wx:if="{{!auditMode && headerCornerAction.show}}">
<view class="contact-btn header-corner-btn" catchtap="onHeaderCornerTap" hover-class="none">
<image wx:if="{{headerCornerAction.avatar}}" class="contact-avatar" src="{{headerCornerAction.avatar}}" mode="aspectFill"/>
<text wx:elif="{{headerCornerAction.emoji}}" class="mic-entry-emoji">{{headerCornerAction.emoji}}</text>
<text class="contact-name">{{headerCornerAction.label}}</text>
</view>
</view>
</view>
@@ -161,6 +163,32 @@
</view>
</view>
<!-- 横排礼物弹层(上麦 / 打赏统一,与「申请上麦」截图一致,点选即付) -->
<view class="tip-modal-mask" wx:if="{{showTipModal}}" catchtap="closeTipModal">
<view class="tip-modal-panel tip-modal-panel--mic" catchtap="stopTipModalBubble">
<view class="mic-modal-head">
<text class="mic-modal-title">{{tipModalTitle}}</text>
<view class="mic-modal-x" catchtap="closeTipModal">×</view>
</view>
<text class="mic-modal-desc" wx:if="{{tipModalSubtitle}}">{{tipModalSubtitle}}</text>
<view class="mic-gift-row-h" wx:if="{{micHorizontalSlots.length > 0}}">
<view
wx:for="{{micHorizontalSlots}}"
wx:key="id"
class="mic-gift-card {{micDefaultGiftId === item.id ? 'mic-gift-card--default' : ''}} {{item.micIsCenterSlot ? 'mic-gift-card--mid' : ''}}"
data-id="{{item.id}}"
catchtap="onHorizontalTipPay"
>
<view class="mic-gift-default-tag" wx:if="{{micDefaultGiftId === item.id}}">默认</view>
<image wx:if="{{item.micShowIcon}}" class="mic-gift-icon-img" src="{{item.icon}}" mode="aspectFit" style="width: {{item.micEmojiRpx}}rpx; height: {{item.micEmojiRpx}}rpx;"/>
<text wx:else class="mic-gift-emoji" style="font-size: {{item.micEmojiRpx}}rpx;">{{item.emoji}}</text>
<text class="mic-gift-name">{{item.name}}</text>
<text class="mic-gift-price">¥{{item.price}}</text>
</view>
</view>
</view>
</view>
<!-- 底部留白 -->
<view class="bottom-space"></view>
</view>

View File

@@ -962,6 +962,291 @@
margin-left: 16rpx;
}
/* ===== 首页打赏 / 上麦弹层 ===== */
.mic-entry-emoji {
font-size: 36rpx;
line-height: 1;
}
.tip-modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.65);
z-index: 10050;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.tip-modal-panel {
width: 100%;
max-width: 620rpx;
background: #1c1c1e;
border-radius: 24rpx;
border: 2rpx solid rgba(0, 206, 209, 0.25);
padding: 36rpx 32rpx 28rpx;
box-sizing: border-box;
}
/* 申请上麦:更宽横排三档 */
.tip-modal-panel--mic {
max-width: 710rpx;
padding: 28rpx 24rpx 32rpx;
border-color: rgba(0, 206, 209, 0.35);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.45);
}
.mic-modal-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
padding-right: 4rpx;
}
.mic-modal-title {
flex: 1;
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
text-align: left;
padding-right: 16rpx;
}
.mic-modal-x {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.75);
font-size: 40rpx;
line-height: 52rpx;
text-align: center;
font-weight: 300;
}
.mic-modal-desc {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
margin-bottom: 28rpx;
text-align: left;
}
.mic-gift-row-h {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
gap: 12rpx;
}
.mic-gift-card {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 28rpx 12rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.1);
box-sizing: border-box;
transform-origin: center bottom;
}
/* 中间一列整体更大(与左右对比) */
.mic-gift-card--mid {
flex: 1.12;
padding-top: 32rpx;
padding-bottom: 24rpx;
transform: scale(1.07);
z-index: 2;
}
.mic-gift-card--default {
border-color: rgba(0, 206, 209, 0.7);
background: rgba(0, 206, 209, 0.1);
box-shadow: 0 0 28rpx rgba(0, 206, 209, 0.22);
}
.mic-gift-default-tag {
position: absolute;
top: 10rpx;
right: 10rpx;
font-size: 18rpx;
font-weight: 700;
color: #0a1628;
padding: 4rpx 10rpx;
border-radius: 8rpx;
background: linear-gradient(135deg, #00ced1 0%, #ffd700 100%);
}
.mic-gift-emoji {
line-height: 1.15;
margin-bottom: 10rpx;
}
.mic-gift-icon-img {
display: block;
margin-bottom: 10rpx;
flex-shrink: 0;
}
.mic-gift-name {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.92);
text-align: center;
margin-bottom: 8rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mic-gift-price {
font-size: 28rpx;
font-weight: 700;
color: #38bdac;
margin-bottom: 0;
}
.mic-gift-card--mid .mic-gift-name {
font-size: 26rpx;
}
.mic-gift-card--mid .mic-gift-price {
font-size: 30rpx;
}
.tip-modal-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
text-align: center;
margin-bottom: 12rpx;
}
.tip-modal-sub {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.55);
text-align: center;
margin-bottom: 28rpx;
line-height: 1.45;
}
.tip-gift-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.tip-gift-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.08);
}
.tip-gift-row-on {
border-color: rgba(0, 206, 209, 0.55);
background: rgba(0, 206, 209, 0.12);
}
.tip-gift-left {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.92);
}
.tip-gift-price {
font-size: 28rpx;
color: #38bdac;
font-weight: 600;
}
.tip-qty-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.tip-qty-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.65);
}
.tip-qty-ctrl {
display: flex;
align-items: center;
gap: 24rpx;
}
.tip-qty-btn {
width: 56rpx;
height: 56rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.tip-qty-num {
font-size: 30rpx;
color: #fff;
min-width: 48rpx;
text-align: center;
}
.tip-total-row {
font-size: 28rpx;
color: #38bdac;
text-align: right;
margin-bottom: 20rpx;
}
.tip-pay-btn {
width: 100% !important;
background: linear-gradient(135deg, #00ced1 0%, #20b2aa 100%) !important;
color: #000 !important;
font-weight: 700;
font-size: 30rpx;
border-radius: 16rpx !important;
margin-bottom: 20rpx;
}
.tip-lead-link {
text-align: center;
font-size: 26rpx;
color: rgba(0, 206, 209, 0.9);
padding: 12rpx 0 8rpx;
text-decoration: underline;
}
.tip-modal-close {
text-align: center;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.45);
padding-top: 8rpx;
}
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -704,14 +704,13 @@ Page({
this.setData({ showUnlockModal: false })
try {
// 获取openId
let openId = app.globalData.openId || wx.getStorageSync('openId')
const openId = await app.ensurePayOpenId()
if (!openId) {
openId = await app.getOpenId()
}
if (!openId) {
wx.showToast({ title: '请先登录', icon: 'none' })
wx.showModal({
title: '无法拉起支付',
content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。',
showCancel: false,
})
return
}

View File

@@ -33,12 +33,12 @@ const READ_UI_DEFAULTS = {
payTapModalTitle: '解锁说明',
payTapModalContent:
'全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
fullUnlockTitle: '解锁完整内容',
fullUnlockTitle: '加入365读书会',
fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明',
fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读',
fullPaywallTip: '转发给需要的人,一起学习还能赚佣金',
fullPaywallTip: '分享给好友,好友购买你可获得约 90% 收益',
notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文',
notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金',
notLoginPaywallTip: '分享给好友,好友购买你可获得约 90% 收益',
shareTipLine: '好友经你分享购买,你可获得约 90% 收益',
momentsModalTitle: '分享到朋友圈',
momentsModalContent:
@@ -191,6 +191,7 @@ Page({
hasFullBook: false,
canAccess: false, // 保留兼容性,从 accessState 派生
purchasedCount: 0,
fullbookShowThreshold: 2,
// 阅读进度
readingProgress: 0,
@@ -491,9 +492,14 @@ Page({
try {
const config = await accessManager.fetchLatestConfig()
const purchasedSections = app.globalData.purchasedSections || []
const rawThreshold = config.fullbookShowThreshold ?? config.prices?.fullbookShowThreshold
const threshold = (rawThreshold != null && Number(rawThreshold) > 0) ? Number(rawThreshold) : 2
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9
fullBookPrice: config.prices?.fullbook ?? 9.9,
purchasedCount: purchasedSections.length,
fullbookShowThreshold: threshold,
})
// 统一:先拉章节数据,用 isFree/price===0 判断免费
@@ -1749,6 +1755,7 @@ Page({
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
this.setData({ purchasedCount: (res.data.purchasedSections || []).length })
console.log('[Pay] ✅ 购买状态已刷新:', {
hasFullBook: res.data.hasFullBook,
purchasedCount: res.data.purchasedSections.length
@@ -1756,7 +1763,6 @@ Page({
}
} catch (e) {
console.error('[Pay] 刷新购买状态失败:', e)
// 刷新失败时不影响用户体验,只是记录日志
}
},
@@ -2062,9 +2068,14 @@ Page({
try {
const config = await accessManager.fetchLatestConfig()
const purchasedSections2 = app.globalData.purchasedSections || []
const rawTh2 = config.fullbookShowThreshold ?? config.prices?.fullbookShowThreshold
const th2 = (rawTh2 != null && Number(rawTh2) > 0) ? Number(rawTh2) : 2
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9
fullBookPrice: config.prices?.fullbook ?? 9.9,
purchasedCount: purchasedSections2.length,
fullbookShowThreshold: th2,
})
// 重新拉取章节,用 isFree/price 判断免费

View File

@@ -158,7 +158,7 @@
</block>
<block wx:else>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '加入365读书会'}}</text>
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
<text class="paywall-desc">{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
@@ -170,7 +170,7 @@
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}}</text>
</block>
</view>
@@ -245,12 +245,12 @@
<text class="paywall-expanded-lead">{{readUi.singlePageExpandedHint || '预览页不能直接付款,务必先点底栏「前往小程序」。'}}</text>
<view class="paywall-expanded-divider"></view>
<text class="paywall-expanded-line">{{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}}</text>
<text class="paywall-expanded-line">{{readUi.fullPaywallTip || '转发给需要的人,一起学习还能赚佣金'}}</text>
<text class="paywall-expanded-line">{{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}}</text>
</view>
</block>
<block wx:else>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '加入365读书会'}}</text>
<text class="paywall-desc">{{readUi.fullUnlockDesc || '可先上滑阅读预览'}}</text>
<text class="paywall-subdesc" wx:if="{{readUi.fullLockedProgressText}}">{{readUi.fullLockedProgressText}}</text>
<text class="paywall-subdesc" wx:else>已阅读约 {{effectivePreviewPercent}}% ,购买后继续阅读</text>
@@ -259,7 +259,7 @@
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= fullbookShowThreshold}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
@@ -271,7 +271,7 @@
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友,好友购买你可获得约 90% 收益'}}</text>
</block>
</view>

View File

@@ -68,15 +68,13 @@ Page({
async handlePurchase() {
trackClick('vip', 'btn_click', '开通VIP')
let userId = app.globalData.userInfo?.id
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
if (!userId || !openId) {
if (!userId) {
wx.showLoading({ title: '登录中...', mask: true })
try {
await app.login()
userId = app.globalData.userInfo?.id
openId = app.globalData.openId || app.globalData.userInfo?.open_id
wx.hideLoading()
if (!userId || !openId) {
if (!userId) {
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
return
}
@@ -86,6 +84,15 @@ Page({
return
}
}
const openId = await app.ensurePayOpenId()
if (!openId) {
wx.showModal({
title: '无法拉起支付',
content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。',
showCancel: false,
})
return
}
// VIP 购买成功后再跳转资料页:购买前不拦截
this.setData({ purchasing: true })
const amount = this.data.price

View File

@@ -83,10 +83,7 @@ Page({
const userId = app.globalData.userInfo.id
const amount = this.data.selectedAmount
let openId = app.globalData.openId
if (!openId) {
openId = await app.getOpenId()
}
const openId = await app.ensurePayOpenId()
if (!openId) {
wx.showToast({ title: '获取支付凭证失败,请重新登录', icon: 'none', duration: 2500 })
return

View File

@@ -4,7 +4,7 @@
"description": "卡若创业派对 - 来自派对房的真实商业故事",
"appid": "wxb8bbb2b10dec74aa",
"setting": {
"urlCheck": true,
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
@@ -54,5 +54,7 @@
"packOptions": {
"ignore": [],
"include": []
}
},
"projectname": "miniprogram",
"libVersion": "3.13.2"
}