chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/ - 移除并忽略 .env 与小程序私有配置 - 同步小程序/管理端/API与脚本改动 Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
289
miniprogram/utils/contentParser.js
Normal file
289
miniprogram/utils/contentParser.js
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
|
||||
}
|
||||
|
||||
// 行纯文本用于 lines(previewParagraphs 降级展示)
|
||||
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" ) */
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
297
miniprogram/utils/ruleEngine.js
Normal file
297
miniprogram/utils/ruleEngine.js
Normal 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 }
|
||||
25
miniprogram/utils/trackClick.js
Normal file
25
miniprogram/utils/trackClick.js
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user