chore: 清理敏感与开发文档,仅同步代码

- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
This commit is contained in:
卡若
2026-03-17 17:50:12 +08:00
parent 868b0a10d9
commit 76965adb23
443 changed files with 24175 additions and 64154 deletions

View File

@@ -125,6 +125,15 @@ class ChapterAccessManager {
app.globalData.hasFullBook = true
}
// VIP 会员:标记全局 isVip供超级个体解锁、前端展示等使用
if (purchaseData.reason === 'has_vip') {
app.globalData.isVip = true
const userInfo = app.globalData.userInfo || {}
userInfo.isVip = true
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
}
if (!app.globalData.purchasedSections.includes(sectionId)) {
app.globalData.purchasedSections = [...app.globalData.purchasedSections, sectionId]
}
@@ -197,4 +206,4 @@ class ChapterAccessManager {
// 导出单例
const accessManager = new ChapterAccessManager()
export default accessManager
module.exports = accessManager

View File

@@ -0,0 +1,289 @@
/**
* Soul创业派对 - 内容解析工具
* 解析 TipTap HTML 为阅读页可展示的 segments
*
* segment 类型:
* { type: 'text', text }
* { type: 'mention', userId, nickname } — @某人,点击加好友
* { type: 'linkTag', label, url } — #链接标签,点击跳转
* { type: 'image', src, alt } — 图片
*/
/** 判断内容是否为 HTML */
function isHtmlContent(content) {
if (!content || typeof content !== 'string') return false
const trimmed = content.trim()
return trimmed.includes('<') && trimmed.includes('>') && /<[a-z][^>]*>/i.test(trimmed)
}
/** 解码常见 HTML 实体 */
function decodeEntities(str) {
return str
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
*/
function parseBlockToSegments(block) {
const segs = []
// 合并匹配所有内联元素
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<img[^>]*\/?>/gi
let lastEnd = 0
let m
while ((m = tokenRe.exec(block)) !== null) {
// 前置纯文本
const before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
if (before.trim()) segs.push({ type: 'text', text: before })
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
// @mention — TipTap mention span
const idMatch = tag.match(/data-id="([^"]*)"/)
const labelMatch = tag.match(/data-label="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '')
const userId = idMatch ? idMatch[1].trim() : ''
const nickname = labelMatch ? labelMatch[1].trim() : innerText.replace(/^@/, '').trim()
if (userId || nickname) segs.push({ type: 'mention', userId, nickname })
} else if (/data-type="linkTag"/i.test(tag)) {
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="..."
const urlMatch = tag.match(/data-url="([^"]*)"/)
const tagTypeMatch = tag.match(/data-tag-type="([^"]*)"/)
const pagePathMatch = tag.match(/data-page-path="([^"]*)"/)
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '').replace(/^#/, '').trim()
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
const tagId = tagIdMatch ? tagIdMatch[1] : ''
const appId = appIdMatch ? appIdMatch[1] : ''
const mpKey = mpKeyMatch ? mpKeyMatch[1] : ''
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey })
} else if (/^<a /i.test(tag)) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)
// m[1] = href, m[2] = innerText以 # 开头)
const url = m[1] || ''
const label = (m[2] || '').replace(/^#/, '').trim()
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
} else if (/^<img /i.test(tag)) {
// 图片
const srcMatch = tag.match(/src="([^"]*)"/)
const altMatch = tag.match(/alt="([^"]*)"/)
if (srcMatch) {
segs.push({ type: 'image', src: srcMatch[1], alt: altMatch ? altMatch[1] : '' })
}
}
lastEnd = m.index + tag.length
}
// 尾部纯文本
const after = decodeEntities(block.slice(lastEnd).replace(/<[^>]+>/g, ''))
if (after.trim()) segs.push({ type: 'text', text: after })
return segs
}
/**
* 从 HTML 中解析出 lines纯文本行和 segments含富文本片段
* @param {string} html
* @param {object} [config] - { persons: [], linkTags: [] },用于对 text 段自动匹配 @人名 / #标签
*/
function parseHtmlToSegments(html, config) {
const lines = []
const segments = []
// 1. 块级标签换行,保留内联标签供后续解析
let text = html
text = text.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
text = text.replace(/<p[^>]*>/gi, '')
text = text.replace(/<\/p>/gi, '\n')
text = text.replace(/<div[^>]*>/gi, '')
text = text.replace(/<\/div>/gi, '\n')
text = text.replace(/<br\s*\/?>/gi, '\n')
text = text.replace(/<\/?h[1-6][^>]*>/gi, '\n')
text = text.replace(/<\/?blockquote[^>]*>/gi, '\n')
text = text.replace(/<\/?ul[^>]*>/gi, '\n')
text = text.replace(/<\/?ol[^>]*>/gi, '\n')
text = text.replace(/<li[^>]*>/gi, '• ')
text = text.replace(/<\/li>/gi, '\n')
// 2. 逐段解析
const blocks = text.split(/\n+/)
for (const block of blocks) {
if (!block.trim()) continue
let blockSegs = parseBlockToSegments(block)
if (!blockSegs.length) continue
// 纯图片行独立成段
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
lines.push('')
segments.push(blockSegs)
continue
}
// 对 text 段再跑一遍 @人名 / #标签 自动匹配(处理未用 TipTap 插入而是手打的 @xxx
if (config && (config.persons?.length || config.linkTags?.length)) {
const expanded = []
for (const seg of blockSegs) {
if (seg.type === 'text' && seg.text) {
const sub = matchLineToSegments(seg.text, config)
expanded.push(...sub)
} else {
expanded.push(seg)
}
}
blockSegs = expanded
}
// 行纯文本用于 linespreviewParagraphs 降级展示)
const lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
lines.push(lineText)
segments.push(blockSegs)
}
return { lines, segments }
}
/** 清理 Markdown 格式标记(**加粗** *斜体* __加粗__ _斜体_ ~~删除线~~ `代码` 等)*/
function stripMarkdownFormatting(text) {
if (!text) return text
let s = text
s = s.replace(/^#{1,6}\s+/gm, '')
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
s = s.replace(/__(.+?)__/g, '$1')
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1')
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '$1')
s = s.replace(/~~(.+?)~~/g, '$1')
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/^>\s+/gm, '')
s = s.replace(/^---$/gm, '')
s = s.replace(/^\* /gm, '• ')
s = s.replace(/^- /gm, '• ')
s = s.replace(/^\d+\.\s/gm, '')
return s
}
/**
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
* config: { persons: [{personId, name, aliases}], linkTags: [{tagId, label, type, pagePath, mpKey, url, aliases}] }
*/
function matchLineToSegments(line, config) {
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
return [{ type: 'text', text: line }]
}
const normalize = s => (s || '').trim().toLowerCase()
const personMap = {}
const tagMap = {}
for (const p of (config.persons || [])) {
const keys = [p.name, ...(p.aliases ? p.aliases.split(',') : [])].map(normalize).filter(Boolean)
for (const k of keys) { if (!personMap[k]) personMap[k] = p }
}
for (const t of (config.linkTags || [])) {
const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
for (const k of keys) { if (!tagMap[k]) tagMap[k] = t }
}
const esc = n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const personNames = Object.keys(personMap).sort((a, b) => b.length - a.length).map(esc)
const tagLabels = Object.keys(tagMap).sort((a, b) => b.length - a.length).map(esc)
if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
const parts = []
if (personNames.length) parts.push('[@](' + personNames.join('|') + ')')
if (tagLabels.length) parts.push('[#](' + tagLabels.join('|') + ')')
const pattern = new RegExp(parts.join('|'), 'gi')
const segs = []
let lastEnd = 0
let m
while ((m = pattern.exec(line)) !== null) {
if (m.index > lastEnd) {
segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
}
const full = m[0]
const prefix = full[0]
const body = full.slice(1)
if (prefix === '@' || prefix === '') {
const person = personMap[normalize(body)]
if (person) {
segs.push({ type: 'mention', userId: person.personId || '', nickname: person.name || body })
} else {
segs.push({ type: 'text', text: full })
}
} else {
const tag = tagMap[normalize(body)]
if (tag) {
segs.push({
type: 'linkTag',
label: tag.label || body,
url: tag.url || '',
tagType: tag.type || 'url',
pagePath: tag.pagePath || '',
tagId: tag.tagId || '',
appId: tag.appId || '',
mpKey: tag.mpKey || ''
})
} else {
segs.push({ type: 'text', text: full })
}
}
lastEnd = m.index + full.length
}
if (lastEnd < line.length) {
segs.push({ type: 'text', text: line.slice(lastEnd) })
}
return segs.length ? segs : [{ type: 'text', text: line }]
}
/** 纯文本/Markdown 按行解析 */
function parsePlainTextToSegments(text, config) {
const cleaned = stripMarkdownFormatting(text)
const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => matchLineToSegments(line, config))
return { lines, segments }
}
/** 清理残留的 Markdown 图片引用文本(如 "image.png![](xxx)" */
function stripOrphanImageRefs(text) {
if (!text) return text
text = text.replace(/[^\s]*\.(?:png|jpg|jpeg|gif|webp|svg|bmp)!\[[^\]]*\]\([^)]*\)/gi, '')
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
return text
}
/**
* 将原始内容解析为 contentSegments用于阅读页展示
* @param {string} rawContent
* @param {object} [config] - { persons: [], linkTags: [] }
* @returns {{ lines: string[], segments: Array<Array<segment>> }}
*/
function parseContent(rawContent, config) {
if (!rawContent || typeof rawContent !== 'string') {
return { lines: [], segments: [] }
}
let content = stripOrphanImageRefs(rawContent)
if (isHtmlContent(content)) {
return parseHtmlToSegments(content, config)
}
return parsePlainTextToSegments(content, config)
}
module.exports = {
parseContent,
isHtmlContent
}

View File

@@ -1,211 +0,0 @@
// miniprogram/utils/payment.js
// 微信支付工具类
const app = getApp()
/**
* 发起微信支付
* @param {Object} options - 支付选项
* @param {String} options.orderId - 订单ID
* @param {Number} options.amount - 支付金额(元)
* @param {String} options.description - 商品描述
* @param {Function} options.success - 成功回调
* @param {Function} options.fail - 失败回调
*/
function wxPay(options) {
const { orderId, amount, description, success, fail } = options
wx.showLoading({
title: '正在支付...',
mask: true
})
// 1. 调用后端创建支付订单
wx.request({
url: `${app.globalData.apiBase}/payment/create`,
method: 'POST',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: {
orderId,
amount,
description,
paymentMethod: 'wechat'
},
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200) {
const paymentData = res.data
// 2. 调起微信支付
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'RSA',
paySign: paymentData.paySign,
success: (payRes) => {
console.log('支付成功', payRes)
// 3. 通知后端支付成功
notifyPaymentSuccess(orderId, paymentData.prepayId)
wx.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
success && success(payRes)
},
fail: (payErr) => {
console.error('支付失败', payErr)
if (payErr.errMsg.indexOf('cancel') !== -1) {
wx.showToast({
title: '支付已取消',
icon: 'none'
})
} else {
wx.showToast({
title: '支付失败',
icon: 'none'
})
}
fail && fail(payErr)
}
})
} else {
wx.showToast({
title: res.data.message || '创建订单失败',
icon: 'none'
})
fail && fail(res)
}
},
fail: (err) => {
wx.hideLoading()
console.error('请求失败', err)
wx.showToast({
title: '网络请求失败',
icon: 'none'
})
fail && fail(err)
}
})
}
/**
* 通知后端支付成功
* @param {String} orderId
* @param {String} prepayId
*/
function notifyPaymentSuccess(orderId, prepayId) {
wx.request({
url: `${app.globalData.apiBase}/payment/notify`,
method: 'POST',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: {
orderId,
prepayId,
status: 'success'
},
success: (res) => {
console.log('支付通知成功', res)
},
fail: (err) => {
console.error('支付通知失败', err)
}
})
}
/**
* 查询订单状态
* @param {String} orderId
* @param {Function} callback
*/
function queryOrderStatus(orderId, callback) {
wx.request({
url: `${app.globalData.apiBase}/payment/query`,
method: 'GET',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: { orderId },
success: (res) => {
if (res.statusCode === 200) {
callback && callback(true, res.data)
} else {
callback && callback(false, null)
}
},
fail: () => {
callback && callback(false, null)
}
})
}
/**
* 购买完整电子书
* @param {Function} success
* @param {Function} fail
*/
function purchaseFullBook(success, fail) {
// 计算动态价格9.9 + (天数 * 1元)
const basePrice = 9.9
const startDate = new Date('2025-01-01') // 书籍上架日期
const today = new Date()
const daysPassed = Math.floor((today - startDate) / (1000 * 60 * 60 * 24))
const currentPrice = basePrice + daysPassed
const orderId = `ORDER_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
wxPay({
orderId,
amount: currentPrice,
description: 'Soul派对·创业实验 完整版',
success: (res) => {
// 更新本地购买状态
updatePurchaseStatus(true)
success && success(res)
},
fail
})
}
/**
* 更新购买状态
* @param {Boolean} isPurchased
*/
function updatePurchaseStatus(isPurchased) {
const userInfo = app.getUserInfo()
if (userInfo) {
userInfo.isPurchased = isPurchased
wx.setStorageSync('userInfo', userInfo)
app.globalData.userInfo = userInfo
}
}
/**
* 检查是否已购买
* @returns {Boolean}
*/
function checkPurchaseStatus() {
const userInfo = app.getUserInfo()
return userInfo ? userInfo.isPurchased : false
}
module.exports = {
wxPay,
queryOrderStatus,
purchaseFullBook,
checkPurchaseStatus,
updatePurchaseStatus
}

View File

@@ -170,10 +170,10 @@ class ReadingTracker {
const userId = app.globalData.userInfo?.id
if (!userId) return
// 计算本次上报的时长
// 计算本次上报的时长(仅发送增量 delta后端会累加避免重复累加导致阅读分钟数异常
const now = Date.now()
const duration = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += duration
const delta = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += delta
this.activeTracker.lastScrollTime = now
try {
@@ -183,7 +183,7 @@ class ReadingTracker {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: this.activeTracker.totalDuration,
duration: Math.max(0, delta),
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}
@@ -246,4 +246,4 @@ class ReadingTracker {
// 导出单例
const readingTracker = new ReadingTracker()
export default readingTracker
module.exports = readingTracker

View File

@@ -0,0 +1,297 @@
/**
* Soul创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
* 点击收费章节 → before_read
* 完成匹配 → after_match
* 完成付款 → after_pay
* 累计浏览5章节 → page_show
* 加入派对房 → before_join_party
* 绑定微信 → after_bindwechat
* 收益满50元 → earnings_check
* 手动触发 → manual
* 浏览导师页 → browse_mentor
*/
const app = getApp()
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
let _cachedRules = null
let _cacheTs = 0
const CACHE_TTL = 5 * 60 * 1000
const TRIGGER_SCENE_MAP = {
'注册': 'after_login',
'点击收费章节': 'before_read',
'完成匹配': 'after_match',
'完成付款': 'after_pay',
'累计浏览5章节': 'page_show',
'加入派对房': 'before_join_party',
'绑定微信': 'after_bindwechat',
'收益满50元': 'earnings_check',
'手动触发': 'manual',
'浏览导师页': 'browse_mentor',
}
function isInCooldown(ruleId) {
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
const ts = map[ruleId]
if (!ts) return false
return Date.now() - ts < COOLDOWN_MS
} catch (e) {
console.warn('[RuleEngine] 读取冷却状态失败:', e)
return false
}
}
function setCooldown(ruleId) {
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
map[ruleId] = Date.now()
wx.setStorageSync(RULE_COOLDOWN_KEY, map)
} catch (e) {
console.warn('[RuleEngine] 写入冷却状态失败:', e)
}
}
function getUserInfo() {
return app.globalData.userInfo || {}
}
async function loadRules() {
if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules
try {
const res = await app.request({ url: '/api/miniprogram/user-rules', method: 'GET', silent: true })
if (res && res.success && res.rules) {
_cachedRules = res.rules
_cacheTs = Date.now()
return _cachedRules
}
} catch (e) {
console.warn('[RuleEngine] 加载规则失败,继续使用缓存:', e)
}
return _cachedRules || []
}
function isRuleEnabled(rules, triggerName) {
return rules.some(r => r.trigger === triggerName)
}
function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
}
// 稳定版:跳转 avatar-nickname与 _ensureProfileCompletedAfterLogin 一致)
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) return null
const user = getUserInfo()
if (!user.id) return null
const avatar = user.avatar || user.avatarUrl || ''
const nickname = user.nickname || ''
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return null
if (isInCooldown('fill_avatar')) return null
setCooldown('fill_avatar')
const info = getRuleInfo(rules, '注册')
return {
ruleId: 'fill_avatar',
title: info?.title || '完善个人信息',
message: info?.description || '设置头像和昵称,让其他创业者更容易认识你',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname'
}
}
function checkRule_BindPhone(rules) {
if (!isRuleEnabled(rules, '点击收费章节')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.phone) return null
if (isInCooldown('bind_phone')) return null
setCooldown('bind_phone')
const info = getRuleInfo(rules, '点击收费章节')
return {
ruleId: 'bind_phone',
title: info?.title || '绑定手机号',
message: info?.description || '绑定手机号解锁更多功能,保障账户安全',
action: 'bind_phone',
target: null
}
}
function checkRule_FillProfile(rules) {
if (!isRuleEnabled(rules, '完成匹配')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.mbti && user.industry) return null
if (isInCooldown('fill_profile')) return null
setCooldown('fill_profile')
const info = getRuleInfo(rules, '完成匹配')
return {
ruleId: 'fill_profile',
title: info?.title || '完善创业档案',
message: info?.description || '填写 MBTI 和行业信息,帮你精准匹配创业伙伴',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
// 稳定版兼容readCount 用 getReadCount()
function checkRule_ShareAfter5Chapters(rules) {
if (!isRuleEnabled(rules, '累计浏览5章节')) return null
const user = getUserInfo()
if (!user.id) return null
const readCount = (typeof app.getReadCount === 'function' ? app.getReadCount() : (app.globalData.readCount || 0))
if (readCount < 5) return null
if (isInCooldown('share_after_5')) return null
setCooldown('share_after_5')
const info = getRuleInfo(rules, '累计浏览5章节')
return {
ruleId: 'share_after_5',
title: info?.title || '邀请好友一起看',
message: info?.description || '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
action: 'navigate',
target: '/pages/referral/referral'
}
}
// 稳定版兼容hasPurchasedFull 用 hasFullBook
function checkRule_FillVipInfo(rules) {
if (!isRuleEnabled(rules, '完成付款')) return null
const user = getUserInfo()
if (!user.id) return null
if (!(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
if (user.wechatId && user.address) return null
if (isInCooldown('fill_vip_info')) return null
setCooldown('fill_vip_info')
const info = getRuleInfo(rules, '完成付款')
return {
ruleId: 'fill_vip_info',
title: info?.title || '填写完整信息',
message: info?.description || '购买全书后,需填写完整信息以进入 VIP 群',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_JoinParty(rules) {
if (!isRuleEnabled(rules, '加入派对房')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.projectIntro) return null
if (isInCooldown('join_party')) return null
setCooldown('join_party')
const info = getRuleInfo(rules, '加入派对房')
return {
ruleId: 'join_party',
title: info?.title || '填写项目介绍',
message: info?.description || '进入派对房前,引导填写项目介绍和核心需求',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_BindWechat(rules) {
if (!isRuleEnabled(rules, '绑定微信')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.wechatId) return null
if (isInCooldown('bind_wechat')) return null
setCooldown('bind_wechat')
const info = getRuleInfo(rules, '绑定微信')
return {
ruleId: 'bind_wechat',
title: info?.title || '绑定微信号',
message: info?.description || '绑定微信后,引导开启分销功能',
action: 'navigate',
target: '/pages/settings/settings'
}
}
function checkRule_Withdraw(rules) {
if (!isRuleEnabled(rules, '收益满50元')) return null
const user = getUserInfo()
if (!user.id) return null
const earnings = app.globalData.totalEarnings || 0
if (earnings < 50) return null
if (isInCooldown('withdraw_50')) return null
setCooldown('withdraw_50')
const info = getRuleInfo(rules, '收益满50元')
return {
ruleId: 'withdraw_50',
title: info?.title || '可以提现了',
message: info?.description || '累计分销收益超过 50 元,快去申请提现吧',
action: 'navigate',
target: '/pages/referral/referral'
}
}
function checkRulesSync(scene, rules) {
const user = getUserInfo()
if (!user.id) return null
switch (scene) {
case 'after_login':
return checkRule_FillAvatar(rules)
case 'before_read':
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
case 'after_match':
return checkRule_FillProfile(rules) || checkRule_JoinParty(rules)
case 'after_pay':
return checkRule_FillVipInfo(rules) || checkRule_FillProfile(rules)
case 'page_show':
return checkRule_FillAvatar(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
case 'before_join_party':
return checkRule_JoinParty(rules)
default:
return null
}
}
function executeRule(rule, pageInstance) {
if (!rule) return
wx.showModal({
title: rule.title,
content: rule.message,
confirmText: '去完善',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
if (rule.action === 'navigate' && rule.target) {
wx.navigateTo({ url: rule.target })
} else if (rule.action === 'bind_phone' && pageInstance) {
if (typeof pageInstance.showPhoneBinding === 'function') {
pageInstance.showPhoneBinding()
}
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
})
}
function _trackRuleAction(ruleId, action) {
const userId = getUserInfo().id
if (!userId) return
app.request({
url: '/api/miniprogram/track',
method: 'POST',
data: { userId, action: 'rule_trigger', target: ruleId, extraData: { result: action } },
silent: true
}).catch(() => {})
}
async function checkAndExecute(scene, pageInstance) {
const rules = await loadRules()
const rule = checkRulesSync(scene, rules)
if (rule) {
setTimeout(() => executeRule(rule, pageInstance), 800)
}
}
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules }

View File

@@ -0,0 +1,25 @@
const app = getApp()
/**
* 全局按钮/标签点击埋点
* @param {string} module 模块home|chapters|read|my|vip|wallet|match|referral|search|settings|about
* @param {string} action 行为tab_click|btn_click|nav_click|card_click|link_click 等
* @param {string} target 目标标识按钮文案、章节ID、标签名等
* @param {object} [extra] 附加数据
*/
function trackClick(module, action, target, extra) {
const userId = app.globalData.userInfo?.id || ''
app.request({
url: '/api/miniprogram/track',
method: 'POST',
data: {
userId: userId || undefined,
action,
target,
extraData: Object.assign({ module, page: module }, extra || {})
},
silent: true
}).catch(() => {})
}
module.exports = { trackClick }

View File

@@ -32,6 +32,18 @@ const formatMoney = (amount, decimals = 2) => {
return Number(amount).toFixed(decimals)
}
/**
* 格式化统计数字≥1万显示 x.xw≥1千显示 x.xk否则原样
* @param {number} n - 原始数字
* @returns {string}
*/
const formatStatNum = n => {
const num = Number(n) || 0
if (num >= 10000) return (num / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(num)
}
// 防抖函数
const debounce = (fn, delay = 300) => {
let timer = null
@@ -151,6 +163,15 @@ const hideLoading = () => {
wx.hideLoading()
}
// 修复图片 URL 中 protocol 缺少冒号的问题(如 "https//..." → "https://..."
const normalizeImageUrl = (url) => {
if (!url || typeof url !== 'string') return ''
let s = url.trim()
if (!s) return ''
s = s.replace(/^(https?)\/\//, '$1://')
return s
}
// 显示确认框
const showConfirm = (title, content) => {
return new Promise((resolve) => {
@@ -166,6 +187,7 @@ module.exports = {
formatTime,
formatDate,
formatMoney,
formatStatNum,
formatNumber,
debounce,
throttle,
@@ -174,6 +196,7 @@ module.exports = {
isValidWechat,
deepClone,
getQueryParams,
normalizeImageUrl,
storage,
showToast,
showLoading,