同步数据

This commit is contained in:
乘风
2026-04-02 19:20:19 +08:00
parent 76965adb23
commit 9ec4e0f9bd
903 changed files with 130902 additions and 15896 deletions

View File

@@ -22,17 +22,19 @@ class ChapterAccessManager {
*/
async fetchLatestConfig() {
try {
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
if (res.success && res.prices) {
const res = await app.getConfig()
if (res && res.success && res.prices) {
return {
prices: res.prices || { section: 1, fullbook: 9.9 }
prices: res.prices || { section: 1, fullbook: 9.9 },
shareRate: res.shareRate != null ? res.shareRate : 90
}
}
} catch (e) {
console.warn('[AccessManager] 获取配置失败,使用默认配置:', e)
}
return {
prices: { section: 1, fullbook: 9.9 }
prices: { section: 1, fullbook: 9.9 },
shareRate: 90
}
}

View File

@@ -1,11 +1,11 @@
/**
* Soul创业派对 - 内容解析工具
* 卡若创业派对 - 内容解析工具
* 解析 TipTap HTML 为阅读页可展示的 segments
*
* segment 类型:
* { type: 'text', text }
* { type: 'mention', userId, nickname } — @某人,点击加好友
* { type: 'linkTag', label, url } — #链接标签,点击跳转
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap外链→link-preview、小程序→navigateToMiniProgram
* { type: 'image', src, alt } — 图片
*/
@@ -27,32 +27,99 @@ function decodeEntities(str) {
.replace(/'/g, "'")
}
/**
* 正文里 img 的 src管理端常见为 /uploads/...(相对 API 根),浏览器会自动补全域名;
* 小程序 <image> 不会拼接 baseUrl需在此用 assetBase 转成可请求的绝对地址。
* @param {string} src
* @param {string} [assetBase] - 如 https://soulapi.example.com无末尾 /
*/
function resolveArticleImageSrc(src, assetBase) {
if (!src || typeof src !== 'string') return src
const s = src.trim()
if (!s) return s
if (/^(https?:|wxfile:|data:|blob:)/i.test(s)) return s
if (s.startsWith('//')) return 'https:' + s
if (s.startsWith('/')) {
const base = String(assetBase || '').replace(/\/$/, '')
if (!base) return s
return base + s
}
return s
}
/** 从 <img ...> 标签取出 src双引号/单引号) */
function extractImgSrcFromTag(tag) {
const d = tag.match(/src\s*=\s*"([^"]*)"/i)
if (d && d[1] != null) return d[1]
const q = tag.match(/src\s*=\s*'([^']*)'/i)
if (q && q[1] != null) return q[1]
return ''
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
function cleanSingleLineField(s) {
if (!s && s !== 0) return ''
let t = decodeEntities(String(s))
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\r\n|\r|\n/g, ' ')
.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff\u3000]+/g, ' ')
.trim()
return t
}
/** @提及昵称:去首尾空白、零宽、全角空格;合并内部换行/<br> */
function cleanMentionNickname(n) {
return cleanSingleLineField(n)
}
/** 纯文本在 mention 节点前若已有「@」,去掉末尾 @,避免渲染成「找@@阿浪」 */
function stripTrailingAtForMention(before) {
return before.replace(/[@][\s\u00a0\u200b]*$/u, '')
}
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
*/
function parseBlockToSegments(block) {
function parseBlockToSegments(block, config) {
const segs = []
const normalize = s => (s || '').trim().toLowerCase()
const personTokenSet = new Set()
for (const p of ((config && config.persons) || [])) {
const token = normalize((p && p.token) || '')
if (token) personTokenSet.add(token)
}
// 合并匹配所有内联元素
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, ''))
// 前置纯文本mention 紧挨手写「找@」时去掉重复 @
let before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
before = stripTrailingAtForMention(before)
}
if (before.trim()) segs.push({ type: 'text', text: before })
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
// @mention — TipTap mention span
// @mention — TipTap mention spanspan 内常见「@ 昵称」多空格,统一紧挨显示)
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 })
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@]\s*/, '')
nickname = cleanMentionNickname((nickname || '').trim())
const userExists = !!normalize(userId) && personTokenSet.has(normalize(userId))
if (userExists && nickname) {
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
} else if (nickname) {
// 被 @ 人物不存在时降级为普通文本,保持“静态 @某人”展示
segs.push({ type: 'text', text: '@' + 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="..."
@@ -62,7 +129,7 @@ function parseBlockToSegments(block) {
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 innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, ''))
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
@@ -75,16 +142,21 @@ function parseBlockToSegments(block) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)
// m[1] = href, m[2] = innerText以 # 开头)
const url = m[1] || ''
const label = (m[2] || '').replace(/^#/, '').trim()
const label = cleanSingleLineField((m[2] || '').replace(/^#/, ''))
// 旧格式没有 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] : '' })
// 图片src 可能为相对路径,需结合 config.assetBase
const rawSrc = extractImgSrcFromTag(tag)
const altMatch = tag.match(/alt\s*=\s*"([^"]*)"/i) || tag.match(/alt\s*=\s*'([^']*)'/i)
if (rawSrc) {
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
segs.push({ type: 'image', src, alt: altMatch ? decodeEntities(altMatch[1]) : '' })
}
}
@@ -101,7 +173,7 @@ function parseBlockToSegments(block) {
/**
* 从 HTML 中解析出 lines纯文本行和 segments含富文本片段
* @param {string} html
* @param {object} [config] - { persons: [], linkTags: [] },用于对 text 段自动匹配 @人名 / #标签
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string },用于对 text 段自动匹配 @人名 / #标签assetBase 用于补全图片相对 URL
*/
function parseHtmlToSegments(html, config) {
const lines = []
@@ -127,7 +199,7 @@ function parseHtmlToSegments(html, config) {
for (const block of blocks) {
if (!block.trim()) continue
let blockSegs = parseBlockToSegments(block)
let blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
// 纯图片行独立成段
@@ -181,18 +253,31 @@ function stripMarkdownFormatting(text) {
/**
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
* config: { persons: [{personId, name, aliases}], linkTags: [{tagId, label, type, pagePath, mpKey, url, aliases}] }
* config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
* 点击加好友时须传 persons.token与 CKB lead 的 targetUserId 一致),不能用 personId。
*/
function matchLineToSegments(line, config) {
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
return [{ type: 'text', text: line }]
}
// 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
line = line.replace(/([@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
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 }
const token = (p.token || '').trim()
if (!token) continue
const display = (p.name || p.label || '').trim()
const aliasStr = p.aliases != null ? String(p.aliases) : ''
const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
.map((x) => (x != null ? String(x) : '').trim())
.filter(Boolean)
.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)
@@ -204,8 +289,8 @@ function matchLineToSegments(line, config) {
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('|') + ')')
if (personNames.length) parts.push('[@]\\s*(' + personNames.join('|') + ')')
if (tagLabels.length) parts.push('[#]\\s*(' + tagLabels.join('|') + ')')
const pattern = new RegExp(parts.join('|'), 'gi')
const segs = []
@@ -216,16 +301,22 @@ function matchLineToSegments(line, config) {
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 === '') {
if (/^[@]/u.test(full)) {
const body = full.replace(/^[@]\s*/u, '')
const person = personMap[normalize(body)]
if (person) {
segs.push({ type: 'mention', userId: person.personId || '', nickname: person.name || body })
const nick = cleanSingleLineField(person.name || person.label || body)
const uid = (person.token || '').trim()
if (uid) {
segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
} else {
segs.push({ type: 'text', text: full })
}
} else {
segs.push({ type: 'text', text: full })
}
} else {
const body = full.replace(/^[#]\s*/u, '')
const tag = tagMap[normalize(body)]
if (tag) {
segs.push({
@@ -285,5 +376,6 @@ function parseContent(rawContent, config) {
module.exports = {
parseContent,
isHtmlContent
isHtmlContent,
cleanSingleLineField,
}

View File

@@ -0,0 +1,14 @@
/**
* 小程序 <image src> 合法判断:避免 undefined 字符串、相对脏值触发「illegal src」
*/
function isSafeImageSrc(u) {
if (u == null) return false
const s = String(u).trim()
if (!s || s === 'undefined' || s === 'null') return false
if (/^https?:\/\//i.test(s)) return true
if (s.startsWith('wxfile://') || s.startsWith('cloud://')) return true
if (s.startsWith('/')) return true
return false
}
module.exports = { isSafeImageSrc }

View File

@@ -0,0 +1,39 @@
/**
* MBTI 默认头像:与后台 system_config.mbti_avatars + GET /api/miniprogram/config/mbti-avatars 一致
*/
const MBTI_RE = /^[EI][NS][FT][JP]$/
function normalizeMbti(m) {
const s = (m && String(m).trim().toUpperCase()) || ''
return MBTI_RE.test(s) ? s : ''
}
/**
* 展示用头像:优先用户已设头像(补全相对路径),否则合法 MBTI + 映射表中有 URL 则用映射
* @param {string} avatar
* @param {string} mbti
* @param {Record<string,string>} map
* @param {string} baseUrl
*/
function resolveAvatarWithMbti(avatar, mbti, map, baseUrl) {
let a = (avatar && String(avatar).trim()) || ''
if (a) {
if (!/^https?:\/\//i.test(a) && baseUrl) {
if (a.startsWith('/')) a = baseUrl + a
}
return a
}
const key = normalizeMbti(mbti)
if (!key || !map || typeof map !== 'object') return ''
let u = (map[key] && String(map[key]).trim()) || ''
if (!u) return ''
if (!/^https?:\/\//i.test(u) && baseUrl && u.startsWith('/')) u = baseUrl + u
return u
}
module.exports = {
MBTI_RE,
normalizeMbti,
resolveAvatarWithMbti,
}

View File

@@ -0,0 +1,26 @@
/**
* 按 mp_config.mpUi 配置的路径跳转Tab 页用 switchTab其余 navigateTo
*/
const TAB_PATHS = [
'/pages/index/index',
'/pages/chapters/chapters',
'/pages/match/match',
'/pages/my/my'
]
function navigateMpPath(path) {
if (!path || typeof path !== 'string') return false
const full = path.trim()
if (!full.startsWith('/')) return false
const q = full.indexOf('?')
const route = q >= 0 ? full.slice(0, q) : full
const suffix = q >= 0 ? full.slice(q) : ''
if (TAB_PATHS.includes(route)) {
wx.switchTab({ url: route })
return true
}
wx.navigateTo({ url: route + suffix })
return true
}
module.exports = { navigateMpPath, TAB_PATHS }

View File

@@ -0,0 +1,129 @@
/**
* 从 mpConfig.mpUi.pagePopupItems 按 pagePath + key 取文案(管理端 CRUD
* 兼容旧版 mpUi.memberDetailPage / readPage 字段。
* 云端未配置或 content 为空时,使用本文件内写死兜底(与种子文案一致)。
*
* 当前代码显式引用的键(与 soul-admin 默认种子 / db.defaultMpUi 一致,勿改 key 除非双端同步):
* - MEMBER_PATH unlockIntroTitle, unlockIntroBody → member-detail.js _showUnlockIntroThenLogin
* - READ_PATH beforeLoginHint, singlePageTitle, singlePagePaywallHint → read.js onLoad
* - READ_PATH global* → app.js getGlobalPopupContent与 pagePopupItems 同页路径存全局键)
*/
const MEMBER_PATH = '/pages/member-detail/member-detail'
const READ_PATH = '/pages/read/read'
/** 阅读页 mpUi 兜底pagePopupItems / readPage 均无或为空时) */
const READ_PAGE_FALLBACK = {
beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。',
singlePageTitle: '解锁完整内容',
singlePagePaywallHint:
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
}
/** 成员详情解锁说明兜底 */
const MEMBER_DETAIL_FALLBACK = {
unlockIntroTitle: '解锁与链接说明',
unlockIntroBody:
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。\n\n请确认已了解后再登录。',
}
/**
* 全局弹窗键与 app.js showModal 兜底一致pagePath 仍为 READ_PATHkey 以 global 开头)
*/
const GLOBAL_POPUP_FALLBACK = {
globalSinglePageAuthTitle: '请打开完整小程序',
globalSinglePageAuthBody:
'当前是朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。',
globalAvatarGuideTitle: '设置头像与昵称',
globalAvatarGuideBody: '头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。',
globalVipProfileHintTitle: '补全对外展示信息',
globalVipProfileHintBody: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。',
globalVipPhoneTitle: '补全手机号',
globalVipPhoneBody: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。',
globalVipWechatOptionalTitle: '补全微信号(可选)',
globalVipWechatOptionalBody: '填写微信号后,对方在允许的场景下能更快加到你;不填也可继续使用。',
globalAppUpdateTitle: '更新提示',
globalAppUpdateBody: '新版本已准备好,重启后即可使用',
}
function getList(app) {
const mpUi = app.globalData.configCache && app.globalData.configCache.mpConfig
? app.globalData.configCache.mpConfig.mpUi
: null
const list = mpUi && mpUi.pagePopupItems
return Array.isArray(list) ? list : []
}
/**
* @param {object} app getApp()
* @param {string} pagePath 如 /pages/read/read
* @param {string} key 英文键
* @returns {string} 文案,未配置时返回空串
*/
function getPagePopupContent(app, pagePath, key) {
const list = getList(app)
const it = list.find(function (p) {
return p && p.pagePath === pagePath && p.key === key
})
if (it && typeof it.content === 'string') return it.content.trim()
return ''
}
/**
* 带旧版 readPage / memberDetailPage 兜底
*/
function getReadPageContent(app, key) {
const v = getPagePopupContent(app, READ_PATH, key)
if (v) return v
const rp = (app.globalData.configCache && app.globalData.configCache.mpConfig &&
app.globalData.configCache.mpConfig.mpUi &&
app.globalData.configCache.mpConfig.mpUi.readPage) || {}
if (key === 'beforeLoginHint') {
const s = String(rp.beforeLoginHint || '').trim()
return s || READ_PAGE_FALLBACK.beforeLoginHint
}
if (key === 'singlePageTitle') {
const s = String(rp.singlePageTitle || '').trim()
return s || READ_PAGE_FALLBACK.singlePageTitle
}
if (key === 'singlePagePaywallHint') {
const s = String(rp.singlePagePaywallHint || '').trim()
return s || READ_PAGE_FALLBACK.singlePagePaywallHint
}
return ''
}
function getMemberDetailContent(app, key) {
const v = getPagePopupContent(app, MEMBER_PATH, key)
if (v) return v
const md = (app.globalData.configCache && app.globalData.configCache.mpConfig &&
app.globalData.configCache.mpConfig.mpUi &&
app.globalData.configCache.mpConfig.mpUi.memberDetailPage) || {}
if (key === 'unlockIntroTitle') {
const s = String(md.unlockIntroTitle || '').trim()
return s || MEMBER_DETAIL_FALLBACK.unlockIntroTitle
}
if (key === 'unlockIntroBody') {
const s = String(md.unlockIntroBody || '').trim()
return s || MEMBER_DETAIL_FALLBACK.unlockIntroBody
}
return ''
}
/**
* 全局弹窗文案:与阅读页共用 pagePath后台种子挂在 /pages/read/readkey 为 global* 开头
*/
function getGlobalPopupContent(app, key) {
const v = getPagePopupContent(app, READ_PATH, key)
if (v) return v
return GLOBAL_POPUP_FALLBACK[key] || ''
}
module.exports = {
getPagePopupContent,
getReadPageContent,
getMemberDetailContent,
getGlobalPopupContent,
MEMBER_PATH,
READ_PATH,
}

View File

@@ -0,0 +1,13 @@
/**
* 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致
* 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时)
*/
const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖']
/** 正文篇在列表中的从 0 开始的序号 → emoji与 ChapterTree bodyPartOrdinal 一致) */
function partEmojiForBodyIndex(bodyIndex) {
const i = Math.max(0, Number(bodyIndex) || 0)
return PART_ICONS[i % PART_ICONS.length]
}
module.exports = { PART_ICONS, partEmojiForBodyIndex }

View File

@@ -32,13 +32,13 @@ class ReadingTracker {
console.log('[ReadingTracker] 初始化追踪:', sectionId)
// 恢复上次阅读位置
this.saveProgressLocal()
app.touchRecentSection(sectionId)
this.restoreLastPosition(sectionId)
// 开始定期上报每30秒
this.startProgressReport()
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
setTimeout(() => this.reportProgressToServer(false), 0)
}
@@ -170,23 +170,27 @@ class ReadingTracker {
const userId = app.globalData.userInfo?.id
if (!userId) return
// 计算本次上报的时长(仅发送增量 delta后端会累加避免重复累加导致阅读分钟数异常
// 计算本次上报的时长
const now = Date.now()
const delta = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += delta
const duration = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += duration
this.activeTracker.lastScrollTime = now
try {
const data = {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: this.activeTracker.totalDuration,
status: this.activeTracker.isCompleted ? 'completed' : 'reading'
}
if (this.activeTracker.isCompleted && this.activeTracker.completedAt != null) {
const t = this.activeTracker.completedAt
data.completedAt = typeof t === 'number' ? new Date(t).toISOString() : String(t)
}
await app.request('/api/miniprogram/user/reading-progress', {
method: 'POST',
data: {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: Math.max(0, delta),
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}
data
})
if (isCompletion) {

View File

@@ -1,10 +1,12 @@
/**
* Soul创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
* 卡若创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式)
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
* 注册 → after_login(头像或昵称未完善)
* update_avatar / 完善头像 → 仅头像未完善
* update_nickname / 修改昵称 → 仅昵称为默认
* 点击收费章节 → before_read
* 完成匹配 → after_match
* 完成付款 → after_pay
@@ -16,19 +18,30 @@
* 浏览导师页 → browse_mentor
*/
const app = getApp()
function getAppInstance() {
try {
const a = getApp()
return a && a.globalData ? a : null
} catch (e) {
return null
}
}
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
// 0 = 关闭冷却(需求:去掉「操作频繁 / N 分钟」类体感限制)
const COOLDOWN_MS = 0
let _cachedRules = null
let _cacheTs = 0
const CACHE_TTL = 5 * 60 * 1000
const TRIGGER_SCENE_MAP = {
'注册': 'after_login',
'完善头像': 'after_login',
'修改昵称': 'after_login',
'点击收费章节': 'before_read',
'完成匹配': 'after_match',
'完成付款': 'after_pay',
'发起支付': 'before_pay',
'累计浏览5章节': 'page_show',
'加入派对房': 'before_join_party',
'绑定微信': 'after_bindwechat',
@@ -37,7 +50,12 @@ const TRIGGER_SCENE_MAP = {
'浏览导师页': 'browse_mentor',
}
// 与后台「规则类型 trigger」一致支持英文 key 与同义中文(逻辑在 isRuleEnabled 定义之后)
const TRIGGER_KEYS_AVATAR = ['update_avatar', '完善头像']
const TRIGGER_KEYS_NICKNAME = ['update_nickname', '修改昵称']
function isInCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return false
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
const ts = map[ruleId]
@@ -50,6 +68,7 @@ function isInCooldown(ruleId) {
}
function setCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
map[ruleId] = Date.now()
@@ -60,13 +79,48 @@ function setCooldown(ruleId) {
}
function getUserInfo() {
return app.globalData.userInfo || {}
const app = getAppInstance()
return app ? (app.globalData.userInfo || {}) : {}
}
function trimStr(v) {
if (v == null || v === undefined) return ''
const s = String(v).trim()
return s
}
/** 合并服务端 profile避免本地 userInfo 未同步导致「已填写仍弹窗」 */
async function fetchProfileMergeUser() {
const base = { ...getUserInfo() }
const userId = base.id
if (!userId) return base
try {
const app = getAppInstance()
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (res?.success && res.data) {
const d = res.data
return {
...base,
mbti: d.mbti != null ? d.mbti : base.mbti,
industry: d.industry != null ? d.industry : base.industry,
position: d.position != null ? d.position : base.position,
projectIntro: d.projectIntro || d.project_intro || base.projectIntro,
phone: d.phone != null ? d.phone : base.phone,
wechatId: d.wechatId || d.wechat_id || base.wechatId,
}
}
} catch (e) {}
return base
}
async function loadRules() {
if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules
const app = getAppInstance()
if (!app) return _cachedRules || []
const userId = (app.globalData.userInfo || {}).id || ''
try {
const res = await app.request({ url: '/api/miniprogram/user-rules', method: 'GET', silent: true })
const url = userId ? `/api/miniprogram/user-rules?userId=${userId}` : '/api/miniprogram/user-rules'
const res = await app.request({ url, method: 'GET', silent: true })
if (res && res.success && res.rules) {
_cachedRules = res.rules
_cacheTs = Date.now()
@@ -79,33 +133,132 @@ async function loadRules() {
}
function isRuleEnabled(rules, triggerName) {
return rules.some(r => r.trigger === triggerName)
return rules.some(r => r.trigger === triggerName && !r.completed)
}
function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
return rules.find(r => r.trigger === triggerName && !r.completed)
}
// 稳定版:跳转 avatar-nickname与 _ensureProfileCompletedAfterLogin 一致)
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) return null
function isAnyTriggerEnabled(rules, keys) {
return keys.some((k) => isRuleEnabled(rules, k))
}
function getFirstRuleInfo(rules, keys) {
for (let i = 0; i < keys.length; i++) {
const info = getRuleInfo(rules, keys[i])
if (info) return info
}
return null
}
function isAvatarMissingOrDefault(user) {
user = user || getUserInfo()
const avatar = user.avatar || user.avatarUrl || ''
return !avatar || avatar.includes('default')
}
function isNicknamePlaceholder(nickname) {
const n = trimStr(nickname)
return !n || n === '微信用户' || n.startsWith('微信用户')
}
function markRuleCompleted(ruleId) {
const userId = getUserInfo().id
if (!userId || !ruleId) return
const app = getAppInstance()
if (!app) return
const numericId = typeof ruleId === 'number' ? ruleId : null
if (!numericId) return
app.request({
url: '/api/miniprogram/user-rules/complete',
method: 'POST',
data: { userId, ruleId: numericId },
silent: true
}).catch(() => {})
}
// 仅头像trigger = update_avatar 或 完善头像
function checkRule_UpdateAvatar(rules) {
if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_AVATAR)) return null
const app = getAppInstance()
if (app && app.globalData.isVip) 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 (!isAvatarMissingOrDefault(user)) return null
if (isInCooldown('update_avatar')) return null
setCooldown('update_avatar')
const info = getFirstRuleInfo(rules, TRIGGER_KEYS_AVATAR)
return {
ruleId: 'update_avatar',
serverRuleId: info?.id,
title: info?.title || '上传头像',
message: info?.description || '换一张清晰头像,伙伴在名片和匹配里更容易认出你。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname?focus=avatar'
}
}
// 仅昵称trigger = update_nickname 或 修改昵称
function checkRule_UpdateNickname(rules) {
if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_NICKNAME)) return null
const app = getAppInstance()
if (app && app.globalData.isVip) return null
const user = getUserInfo()
if (!user.id) return null
const nickname = user.nickname || user.nickName || ''
if (!isNicknamePlaceholder(nickname)) return null
if (isInCooldown('update_nickname')) return null
setCooldown('update_nickname')
const info = getFirstRuleInfo(rules, TRIGGER_KEYS_NICKNAME)
return {
ruleId: 'update_nickname',
serverRuleId: info?.id,
title: info?.title || '修改昵称',
message: info?.description || '改一个真实好记的昵称,方便伙伴称呼你。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname?focus=nickname'
}
}
// 稳定版trigger=注册 时头像或昵称任一未完善则引导(与上面两项拆分配置并存)
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 跳转 profile-edit避免与主流程冲突
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) return null
const app = getAppInstance()
if (app && app.globalData.isVip) return null
const user = getUserInfo()
if (!user.id) return null
const nickname = user.nickname || user.nickName || ''
if (!isAvatarMissingOrDefault(user) && !isNicknamePlaceholder(nickname)) return null
if (isInCooldown('fill_avatar')) return null
setCooldown('fill_avatar')
const info = getRuleInfo(rules, '注册')
const needNick = isNicknamePlaceholder(nickname)
const needAv = isAvatarMissingOrDefault(user)
const focus = needAv && !needNick ? 'avatar' : needNick && !needAv ? 'nickname' : ''
const qs = focus ? `?focus=${focus}` : ''
return {
ruleId: 'fill_avatar',
title: info?.title || '完善个人信息',
message: info?.description || '设置头像昵称,让其他创业者更容易认识你',
serverRuleId: info?.id,
title: info?.title || '设置头像昵称',
message: info?.description || '头像与昵称会展示在名片与匹配卡片上,方便伙伴认出你。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname'
target: '/pages/avatar-nickname/avatar-nickname' + qs
}
}
/** 头像/昵称类引导:优先拆条规则(完善头像、修改昵称),其次合并规则(注册) */
function checkAvatarNicknameGuides(rules) {
return checkRule_UpdateAvatar(rules) || checkRule_UpdateNickname(rules) || checkRule_FillAvatar(rules)
}
function checkRule_BindPhone(rules) {
if (!isRuleEnabled(rules, '点击收费章节')) return null
const user = getUserInfo()
@@ -116,25 +269,34 @@ function checkRule_BindPhone(rules) {
const info = getRuleInfo(rules, '点击收费章节')
return {
ruleId: 'bind_phone',
serverRuleId: info?.id,
title: info?.title || '绑定手机号',
message: info?.description || '绑定手机号解锁更多功能,保障账户安全',
message: info?.description || '绑定后可用于登录验证、收益与重要通知,账户安全',
confirmText: '去绑定',
cancelText: '关闭',
action: 'bind_phone',
target: null
}
}
function checkRule_FillProfile(rules) {
function checkRule_FillProfile(rules, user) {
if (!isRuleEnabled(rules, '完成匹配')) return null
const user = getUserInfo()
user = user || getUserInfo()
if (!user.id) return null
if (user.mbti && user.industry) return null
const mbti = trimStr(user.mbti)
const industry = trimStr(user.industry)
const position = trimStr(user.position)
if (mbti && industry && position) 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 和行业信息,帮你精准匹配创业伙伴',
serverRuleId: info?.id,
title: info?.title || '补充档案信息',
message: info?.description || '补全 MBTI、行业和职位后匹配页能更准确地向对方展示你减少无效沟通。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
@@ -145,51 +307,64 @@ 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))
const app = getAppInstance()
const readCount = app ? (typeof app.getReadCount === 'function' ? app.getReadCount() : (app.globalData.readCount || 0)) : 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',
serverRuleId: info?.id,
title: info?.title || '邀请好友一起看',
message: info?.description || '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
message: info?.description || '你已阅读 ' + readCount + ' 个章节,好友通过你的分享购买时,你可获得对应分销收益',
confirmText: '查看分享',
cancelText: '关闭',
action: 'navigate',
target: '/pages/referral/referral'
}
}
// 稳定版兼容hasPurchasedFull 用 hasFullBook
function checkRule_FillVipInfo(rules) {
function checkRule_FillVipInfo(rules, user) {
if (!isRuleEnabled(rules, '完成付款')) return null
const user = getUserInfo()
user = user || getUserInfo()
if (!user.id) return null
if (!(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
if (user.wechatId && user.address) return null
const app = getAppInstance()
if (!app || !(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
const wxId = trimStr(user.wechatId || user.wechat_id)
const addr = trimStr(user.address)
if (wxId && addr) 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 ',
serverRuleId: info?.id,
title: info?.title || '补全 VIP 资料',
message: info?.description || '补全微信号与收货地址等信息,便于进入 VIP 群、寄送物料与售后联系。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_JoinParty(rules) {
function checkRule_JoinParty(rules, user) {
if (!isRuleEnabled(rules, '加入派对房')) return null
const user = getUserInfo()
user = user || getUserInfo()
if (!user.id) return null
if (user.projectIntro) return null
if (trimStr(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 || '进入派对房前,引导填写项目介绍和核心需求',
serverRuleId: info?.id,
title: info?.title || '补充项目介绍',
message: info?.description || '用简短文字说明项目与需求,派对房里的伙伴能更快判断是否与你有合作空间。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
@@ -199,14 +374,17 @@ function checkRule_BindWechat(rules) {
if (!isRuleEnabled(rules, '绑定微信')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.wechatId) return null
if (trimStr(user.wechatId || user.wechat_id)) return null
if (isInCooldown('bind_wechat')) return null
setCooldown('bind_wechat')
const info = getRuleInfo(rules, '绑定微信')
return {
ruleId: 'bind_wechat',
serverRuleId: info?.id,
title: info?.title || '绑定微信号',
message: info?.description || '绑定微信后,引导开启分销功能',
message: info?.description || '绑定后可用于分销结算、提现核对与重要通知。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/settings/settings'
}
@@ -216,15 +394,19 @@ function checkRule_Withdraw(rules) {
if (!isRuleEnabled(rules, '收益满50元')) return null
const user = getUserInfo()
if (!user.id) return null
const earnings = app.globalData.totalEarnings || 0
const app = getAppInstance()
const earnings = app ? (app.globalData.totalEarnings || 0) : 0
if (earnings < 50) return null
if (isInCooldown('withdraw_50')) return null
setCooldown('withdraw_50')
const info = getRuleInfo(rules, '收益满50元')
return {
ruleId: 'withdraw_50',
serverRuleId: info?.id,
title: info?.title || '可以提现了',
message: info?.description || '累计分销收益超过 50 元,快去申请提现吧',
message: info?.description || '累计分销收益已达到提现条件,可在推荐收益页发起提现到微信零钱。',
confirmText: '去查看',
cancelText: '关闭',
action: 'navigate',
target: '/pages/referral/referral'
}
@@ -236,15 +418,17 @@ function checkRulesSync(scene, rules) {
switch (scene) {
case 'after_login':
return checkRule_FillAvatar(rules)
return checkAvatarNicknameGuides(rules)
case 'before_read':
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
return checkRule_BindPhone(rules) || checkAvatarNicknameGuides(rules)
case 'before_pay':
return checkAvatarNicknameGuides(rules) || checkRule_BindPhone(rules) || checkRule_FillProfile(rules)
case 'after_match':
return checkRule_FillProfile(rules) || checkRule_JoinParty(rules)
return null
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)
return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
case 'before_join_party':
return checkRule_JoinParty(rules)
default:
@@ -258,8 +442,8 @@ function executeRule(rule, pageInstance) {
wx.showModal({
title: rule.title,
content: rule.message,
confirmText: '去完善',
cancelText: '稍后再说',
confirmText: rule.confirmText || '去填写',
cancelText: rule.cancelText !== undefined ? rule.cancelText : '关闭',
success: (res) => {
if (res.confirm) {
if (rule.action === 'navigate' && rule.target) {
@@ -269,6 +453,9 @@ function executeRule(rule, pageInstance) {
pageInstance.showPhoneBinding()
}
}
if (rule.serverRuleId) {
markRuleCompleted(rule.serverRuleId)
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
@@ -278,6 +465,8 @@ function executeRule(rule, pageInstance) {
function _trackRuleAction(ruleId, action) {
const userId = getUserInfo().id
if (!userId) return
const app = getAppInstance()
if (!app) return
app.request({
url: '/api/miniprogram/track',
method: 'POST',
@@ -288,10 +477,16 @@ function _trackRuleAction(ruleId, action) {
async function checkAndExecute(scene, pageInstance) {
const rules = await loadRules()
const rule = checkRulesSync(scene, rules)
let rule = null
if (scene === 'after_match') {
const u = await fetchProfileMergeUser()
rule = checkRule_FillProfile(rules, u) || checkRule_JoinParty(rules, u)
} else {
rule = checkRulesSync(scene, rules)
}
if (rule) {
setTimeout(() => executeRule(rule, pageInstance), 800)
}
}
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules }
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted }

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 小程序码 scene 参数统一编解码(海报生成 ↔ 扫码解析闭环)
* 卡若创业派对 - 小程序码 scene 参数统一编解码(海报生成 ↔ 扫码解析闭环)
* 官方以 options.scene 接收扫码参数;后端生成码时会把 & 转为 _故解析时同时支持 & 和 _
* scene 同时可带两个参数:章节标识(mid/id) + 推荐人(ref)
*/

View File

@@ -0,0 +1,170 @@
/**
* 分销 / 微信支付 / 代付链路 / 存客宝留资 — 小程序侧统一桥接
* 阅读页 @mention、会员详情点头像、章节与代付支付等共用。
*/
/**
* 支付订单携带的推荐码:优先落地页写入的 storage否则当前用户自己的码便于自购归因一致
*/
function getReferralCodeForPay(app) {
try {
const s = wx.getStorageSync('referral_code')
if (s != null && String(s).trim() !== '') return String(s).trim()
} catch (e) {}
if (app && typeof app.getMyReferralCode === 'function') {
const c = app.getMyReferralCode()
if (c) return String(c).trim()
}
return ''
}
/** 章节 / 全书支付描述(与 read 页原逻辑一致) */
function buildSectionPayDescription(productType, sectionId, sectionTitle) {
if (productType === 'fullbook') return '《一场Soul的创业实验》全书'
if (productType === 'section') {
const t = sectionTitle || sectionId || ''
const short = t.length > 20 ? t.slice(0, 20) + '...' : t
return `章节${sectionId}-${short}`
}
return ''
}
/**
* 调起微信 JSAPI 支付(字段与 soul-api GetJSAPIPayParams 一致,勿 spread 全对象以免带入多余字段)
*/
function requestWxJsapiPayment(payParams) {
return new Promise((resolve, reject) => {
if (!payParams || payParams.timeStamp == null) {
reject(new Error('支付参数异常'))
return
}
wx.requestPayment({
timeStamp: String(payParams.timeStamp),
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType || 'RSA',
paySign: payParams.paySign,
success: resolve,
fail: reject
})
})
}
/** 支付成功后主动查单,缓解回调延迟导致订单长期 created */
function syncOrderStatusQuery(app, orderSn) {
if (!app || !orderSn) return Promise.resolve()
return app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true }).catch(() => null)
}
/**
* 提交存客宝 lead与阅读页 @、会员详情点头像同接口)
* @param {object} app getApp()
* @param {{ targetUserId?: string, targetNickname?: string, targetMemberId?: string, targetMemberName?: string, source: string, phoneModalContent?: string }} opts
* @returns {Promise<boolean>} 是否提交成功
*/
async function submitCkbLead(app, opts) {
const targetUserId = ((opts && opts.targetUserId) || '').trim()
const targetMemberId = ((opts && opts.targetMemberId) || '').trim()
let targetNickname = (opts && opts.targetNickname != null) ? String(opts.targetNickname).trim() : ''
if (targetUserId && !targetNickname) targetNickname = 'TA'
const targetMemberName = ((opts && opts.targetMemberName) || '').trim()
const source = (opts && opts.source) || 'article_mention'
const phoneModalContent = (opts && opts.phoneModalContent) || '请先填写手机号(必填),以便对方联系您'
// 文章 @ 为 token会员详情无 token 时用 targetMemberId 走全局获客计划(与后端 CKBLead 一致)
if (!targetUserId && !targetMemberId) return false
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
return await new Promise((resolve) => {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
resolve(false)
}
})
})
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes && profileRes.success && profileRes.data) {
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
return await new Promise((resolve) => {
wx.showModal({
title: '完善资料',
content: phoneModalContent,
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
resolve(false)
}
})
})
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId: targetUserId || undefined,
targetNickname: targetNickname !== '' ? targetNickname : undefined,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source
}
})
wx.hideLoading()
if (res && res.success) {
const data = res.data && typeof res.data === 'object' ? res.data : {}
const skipped = !!(data.skipped || data.alreadySubmitted)
if (!skipped) {
try {
wx.setStorageSync('lead_last_submit_ts', Date.now())
} catch (e) {}
}
wx.showToast({
title: res.message || (skipped ? '无需重复提交' : '提交成功,对方会尽快联系您'),
icon: skipped ? 'none' : 'success',
})
return true
}
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
return false
} catch (e) {
wx.hideLoading()
const resp = e && e.response
const hint = (resp && (resp.message || resp.error)) || (e && e.message) || '提交失败'
wx.showToast({ title: String(hint), icon: 'none' })
return false
}
}
module.exports = {
getReferralCodeForPay,
buildSectionPayDescription,
requestWxJsapiPayment,
syncOrderStatusQuery,
submitCkbLead
}

View File

@@ -9,11 +9,11 @@ const app = getApp()
*/
function trackClick(module, action, target, extra) {
const userId = app.globalData.userInfo?.id || ''
app.request({
url: '/api/miniprogram/track',
if (!userId) return
app.request('/api/miniprogram/track', {
method: 'POST',
data: {
userId: userId || undefined,
userId,
action,
target,
extraData: Object.assign({ module, page: module }, extra || {})

View File

@@ -32,16 +32,17 @@ 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)
/** 「我的」等页统计数字展示非法值→0≥1 万可缩写为「x万」 */
const formatStatNum = (n) => {
const x = Number(n)
if (Number.isNaN(x) || !Number.isFinite(x)) return '0'
const v = Math.floor(x)
if (v >= 10000) {
const w = v / 10000
const s = w >= 10 ? String(Math.floor(w)) : String(Math.round(w * 10) / 10).replace(/\.0$/, '')
return s + ''
}
return String(v)
}
// 防抖函数
@@ -183,6 +184,20 @@ const showConfirm = (title, content) => {
})
}
/**
* 从头像 URL 提取路径部分(不含域名),用于保存到后端
* 例如https://xxx.com/uploads/avatars/1.jpg → /uploads/avatars/1.jpg
* @param {string} url - 完整 URL 或路径
* @returns {string}
*/
const toAvatarPath = url => {
if (!url || typeof url !== 'string') return url || ''
const idx = url.indexOf('/uploads/')
if (idx >= 0) return url.substring(idx)
if (url.startsWith('/')) return url
return url
}
module.exports = {
formatTime,
formatDate,
@@ -201,5 +216,6 @@ module.exports = {
showToast,
showLoading,
hideLoading,
showConfirm
showConfirm,
toAvatarPath
}