Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	miniprogram/app.js   resolved by devlop version
#	miniprogram/pages/chapters/chapters.js   resolved by devlop version
#	miniprogram/pages/match/match.js   resolved by devlop version
#	miniprogram/pages/member-detail/member-detail.js   resolved by devlop version
#	miniprogram/pages/my/my.js   resolved by devlop version
#	miniprogram/pages/read/read.js   resolved by devlop version
#	miniprogram/pages/referral/referral.js   resolved by devlop version
#	soul-api/internal/model/person.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-24 15:44:56 +08:00
127 changed files with 9196 additions and 3504 deletions

View File

@@ -9,10 +9,10 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.1'
// 章节总数API 获取失败时的统一兜底,避免 90/62 混用
const FALLBACK_TOTAL_SECTIONS = 62
const APP_DISPLAY_VERSION = '1.7.2'
App({
globalData: {
@@ -42,7 +42,7 @@ App({
// 书籍数据bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters
bookData: null,
totalSections: FALLBACK_TOTAL_SECTIONS, // 来自 book/parts 或 book/stats失败时用常量
totalSections: 90,
// 购买记录
purchasedSections: [],
@@ -98,32 +98,34 @@ App({
lastVipContactCheck: 0,
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
// 登录过期401 后用户点「去登录」时设为 true我的页 onShow 会检测并自动弹登录
pendingLoginAfterExpire: false,
/** MBTI → 默认头像 URL/api/miniprogram/config/mbti-avatars供推广海报等 */
mbtiAvatarsMap: {},
mbtiAvatarsExpires: 0,
},
/** 正式版强制生产 API避免误传 localhost 导致审核/线上全挂 */
initApiBaseUrl() {
const PRODUCTION = 'https://soulapi.quwanzhi.com'
const KEY = 'apiBaseUrl'
try {
const info = wx.getAccountInfoSync?.()
const env = info?.miniProgram?.envVersion || 'release'
if (env === 'release') {
this.globalData.baseUrl = PRODUCTION
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY)
if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
} else {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
},
@@ -397,8 +399,8 @@ App({
if (!this.globalData.isSinglePageMode) return true
wx.showModal({
title: '请前往完整小程序',
content: '当前朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。',
title: '请打开完整小程序',
content: '当前朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。',
showCancel: false,
confirmText: '我知道了',
})
@@ -414,7 +416,7 @@ App({
},
/**
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* 头像/昵称未改:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit避免乱跳
*/
checkAvatarNicknameAndGuide() {
@@ -433,10 +435,10 @@ App({
if (lastDate === today) return
wx.setStorageSync('lastAvatarGuideDate', today)
wx.showModal({
title: '完善个人资料',
content: '请设置头像昵称,让其他创业者更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '头像昵称会出现在名片与匹配卡片上,方便伙伴认出你。',
confirmText: '去设置',
cancelText: '关闭',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
@@ -594,7 +596,7 @@ App({
/**
* VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit避免与 avatar-nickname 乱跳。
* 旧数据VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit
* 旧数据VIP 但头像昵称未改):说明原因后 redirectTo profile-edit
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
@@ -623,12 +625,12 @@ App({
const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
const needsAvatarNickname = this._needsAvatarNickname(profileData)
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit,弹窗「为了更好服务,请完善资料」
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit
if (needsAvatarNickname) {
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请完善资料',
confirmText: '去完善',
title: '补全对外展示信息',
content: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
@@ -641,9 +643,9 @@ App({
// VIP 无手机号:弹窗说明后跳转
if (!phone) {
wx.showModal({
title: '完善资料',
content: 'VIP会员需完善手机号以便使用找伙伴、提现等功能',
confirmText: '去完善',
title: '补全手机号',
content: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
@@ -651,12 +653,12 @@ App({
})
return
}
// 有手机号但缺微信号:弹窗引导(非强制)
// 有手机号但缺微信号:可选补全(非强制)
wx.showModal({
title: '完善联系方式',
content: '请到资料页完善微信号,便于他人联系您',
confirmText: '去完善',
cancelText: '稍后',
title: '补全微信号(可选)',
content: '填写微信号后,对方在允许的场景下能更快加到你;不填也可继续使用。',
confirmText: '去填写',
cancelText: '关闭',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
@@ -695,6 +697,17 @@ App({
if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) {
return this.globalData.configCache
}
if (!forceRefresh && !this.globalData.configCache) {
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
const exp = Number(local && local.expiresAt)
if (local && local.data && exp > now) {
this.globalData.configCache = local.data
this.globalData.configCacheExpires = exp
return local.data
}
} catch (_) {}
}
try {
const [coreRes, auditRes] = await Promise.all([
this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }),
@@ -713,11 +726,45 @@ App({
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (e) {
if (this.globalData.configCache) return this.globalData.configCache
}
} catch (_) {}
try {
const legacy = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
if (legacy) {
const cfg = (legacy.configs && legacy.configs.mp_config) || legacy.mpConfig || {}
const res = {
success: legacy.success !== false,
prices: legacy.prices || (legacy.configs && legacy.configs.chapter_config && legacy.configs.chapter_config.prices) || {},
features: legacy.features || (legacy.configs && legacy.configs.feature_config) || {},
userDiscount: legacy.userDiscount,
mpConfig: cfg,
configs: legacy.configs || {}
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (_) {}
if (this.globalData.configCache) return this.globalData.configCache
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
if (local && local.data) return local.data
} catch (_) {}
return null
},
@@ -780,27 +827,58 @@ App({
async loadMpConfig() {
try {
const res = await this.getConfig()
if (!res) return
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
this.globalData.auditMode = !!mp.auditMode
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
// 通知当前已加载的页面刷新 auditMode从后台切回时配置更新后立即生效
try {
const pages = getCurrentPages()
pages.forEach(p => {
if (p && p.data && 'auditMode' in p.data) {
p.setData({ auditMode: this.globalData.auditMode || false })
}
})
} catch (_) {}
if (res) {
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
this.globalData.auditMode = !!mp.auditMode
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
}
}
} catch (e) {
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
}
// 审核模式不走 5min 本地 config 缓存:始终以独立接口为准,避免后台已开审核端仍显示支付入口
try {
await this.getAuditMode()
} catch (_) {}
this.loadMbtiAvatarsMap()
},
/** 拉取后台配置的 16 型 MBTI 默认头像(公开接口,约 5 分钟本地缓存) */
async loadMbtiAvatarsMap() {
try {
const now = Date.now()
if (this.globalData.mbtiAvatarsExpires && this.globalData.mbtiAvatarsExpires > now) return
const res = await this.request({
url: '/api/miniprogram/config/mbti-avatars',
silent: true,
timeout: 8000,
})
if (res && res.success && res.avatars && typeof res.avatars === 'object') {
this.globalData.mbtiAvatarsMap = res.avatars
this.globalData.mbtiAvatarsExpires = now + 5 * 60 * 1000
}
} catch (e) {
console.warn('[App] loadMbtiAvatarsMap:', e?.message || e)
}
},
/** 展示用头像:优先用户头像,否则 MBTI 映射(需已 loadMbtiAvatarsMap */
resolveAvatarWithMbti(avatar, mbti) {
try {
const { resolveAvatarWithMbti } = require('./utils/mbtiAvatar.js')
return resolveAvatarWithMbti(
avatar,
mbti,
this.globalData.mbtiAvatarsMap || {},
this.globalData.baseUrl || ''
)
} catch (_) {
return (avatar && String(avatar).trim()) || ''
}
},
/**
@@ -855,6 +933,79 @@ App({
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
},
_shouldFallbackToProduction(err) {
const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase()
return (
msg.includes('connection_failed') ||
msg.includes('err_proxy_connection_failed') ||
msg.includes('dns') ||
msg.includes('name not resolved') ||
msg.includes('failed to fetch') ||
msg.includes('econnrefused') ||
msg.includes('network') ||
msg.includes('timeout')
)
},
_switchBaseUrlToProduction() {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL)
} catch (_) {}
},
_requestOnce(url, options = {}, silent = false) {
const showError = (msg) => {
if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
this.logout()
}
showError(msg)
reject(new Error(msg))
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
showError('未授权,请重新登录')
reject(new Error('未授权'))
return
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
},
fail: (err) => {
const msg = (err && err.errMsg)
? (String(err.errMsg).indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试')
: '网络异常,请重试'
showError(msg)
reject(err || new Error(msg))
}
})
})
},
/**
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
* GET 请求 200ms 内相同 url 去重,避免并发重复请求。
@@ -874,98 +1025,21 @@ App({
}
const method = (options.method || 'GET').toUpperCase()
const silent = !!options.silent
const showError = (msg) => {
if (!silent && msg) {
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
}
// GET 短时去重:相同 url 的并发请求共享同一 promise
if (method === 'GET') {
const dedupKey = url + (options.data ? JSON.stringify(options.data) : '')
const pending = this._requestPending || (this._requestPending = {})
if (pending[dedupKey]) {
return pending[dedupKey].promise
}
if (pending[dedupKey]) return pending[dedupKey].promise
}
const promise = new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
// 业务失败success === falsesoul-api 用 message 或 error 返回原因
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
// 登录态不一致:本地有 token/userInfo但后端查不到该用户
// 典型原因:切换环境(baseUrl)、换库/清库、用户被删除、token 与用户不匹配
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
this.logout()
}
const err = new Error(msg)
err.response = data
const skipToast = data.needBindWechat === true || data.needBind === true ||
(data.errorCode && String(data.errorCode).indexOf('ERR_') === 0)
if (!silent && !skipToast) showError(msg)
reject(err)
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
if (!silent) {
wx.showModal({
title: '登录已过期',
content: '请重新登录后继续使用',
confirmText: '去登录',
cancelText: '稍后',
success: (modalRes) => {
if (modalRes.confirm) {
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
const route = (cur && cur.route) || ''
if (route === 'pages/my/my' && typeof cur.showLogin === 'function') {
cur.showLogin()
} else {
this.globalData.pendingLoginAfterExpire = true
wx.switchTab({ url: '/pages/my/my' })
}
}
}
})
} else {
showError('未授权,请重新登录')
}
reject(new Error('未授权'))
return
}
// 4xx/5xx优先用返回体的 message/error
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
const err = new Error(msg)
if (data && typeof data === 'object') err.response = data
const skipToast = data && (data.needBindWechat === true || data.needBind === true ||
(data.errorCode && String(data.errorCode).indexOf('ERR_') === 0))
if (!silent && !skipToast) showError(msg)
reject(err)
},
fail: (err) => {
const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试'
showError(msg)
reject(new Error(msg))
}
})
const promise = this._requestOnce(url, options, silent).catch(async (err) => {
const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '')
if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) {
this._switchBaseUrlToProduction()
return this._requestOnce(url, options, silent)
}
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
})
if (method === 'GET') {
@@ -1134,11 +1208,6 @@ App({
if (res.success && res.data) {
const user = res.data.user
const oid = res.data.openId || user.openId
if (oid) {
this.globalData.openId = oid
wx.setStorageSync('openId', oid)
}
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
@@ -1169,7 +1238,6 @@ App({
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
setTimeout(() => this.connectWsHeartbeat(), 2000)
}
return res.data
@@ -1209,15 +1277,30 @@ App({
wx.setStorageSync('readSectionIds', list)
},
/**
* 记录「最近打开过」某章节(含仅预览/未登录),供「我的」最近阅读展示;
* 不写入 readSectionIds避免把「点开预览」算进已读章节数。
*/
touchRecentSection(sectionId) {
if (!sectionId) return
try {
let list = wx.getStorageSync('recent_section_opens')
if (!Array.isArray(list)) list = []
const now = Date.now()
list = list.filter((x) => x && x.id !== sectionId)
list.unshift({ id: String(sectionId), t: now })
wx.setStorageSync('recent_section_opens', list.slice(0, 40))
} catch (_) {}
},
// 已读章节数(用于首页展示)
getReadCount() {
return (this.globalData.readSectionIds || []).length
},
// 获取章节总数(优先 API 已加载值,失败时返回统一兜底常量)
// 获取章节总数
getTotalSections() {
const v = this.globalData.totalSections
return (v != null && v > 0) ? v : FALLBACK_TOTAL_SECTIONS
return this.globalData.totalSections
},
// 切换TabBar

View File

@@ -4,8 +4,8 @@
"login-modal": "/components/login-modal/login-modal"
},
"pages": [
"pages/chapters/chapters",
"pages/index/index",
"pages/chapters/chapters",
"pages/match/match",
"pages/my/my",
"pages/read/read",
@@ -14,6 +14,7 @@
"pages/privacy/privacy",
"pages/referral/referral",
"pages/purchases/purchases",
"pages/reading-records/reading-records",
"pages/settings/settings",
"pages/search/search",
"pages/addresses/addresses",
@@ -29,7 +30,8 @@
"pages/avatar-nickname/avatar-nickname",
"pages/gift-pay/detail",
"pages/gift-pay/list",
"pages/gift-pay/redemption-detail"
"pages/gift-pay/redemption-detail",
"pages/dev-login/dev-login"
],
"window": {
"backgroundTextStyle": "light",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -6,14 +6,14 @@
<text class="login-title">登录 卡若创业派对</text>
<text class="login-desc">{{desc}}</text>
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}">
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-wechat-hover">
<text class="btn-wechat-icon">微</text>
<text>{{isLoggingIn ? '登录中...' : '手机号一键登录'}}</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy">同意</button>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
</view>
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
<view class="login-agree-row" catchtap="onToggleAgree">

View File

@@ -134,3 +134,7 @@
text-decoration: underline;
padding: 0 4rpx;
}
/* 显式 hover 类名,避免基础库 3.x 报 hoverClass / hoverClassDisable 类型非法 */
.btn-wechat-hover { opacity: 0.92; }
.privacy-agree-btn-hover { opacity: 0.88; }

View File

@@ -87,7 +87,14 @@ Component({
console.log('[TabBar] res对象keys:', Object.keys(res || {}))
}
this.setData({ matchEnabled }, () => {
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
const list = [...this.data.list]
if (tabUi.home) list[0] = { ...list[0], text: String(tabUi.home) }
if (tabUi.chapters) list[1] = { ...list[1], text: String(tabUi.chapters) }
if (tabUi.match) list[2] = { ...list[2], text: String(tabUi.match) }
if (tabUi.my) list[3] = { ...list[3], text: String(tabUi.my) }
this.setData({ matchEnabled, list }, () => {
console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled)
// 配置加载完成后,根据当前路由设置选中状态
this.updateSelected()

View File

@@ -61,7 +61,7 @@
</view>
</view>
<!-- 联系方式 - 引导到Soul派对房 -->
<!-- 联系方式 - Soul 派对房 -->
<view class="contact-card" wx:if="{{!authorLoading}}">
<text class="card-title">联系作者</text>
<view class="contact-item">

View File

@@ -1,6 +1,6 @@
/**
* 卡若创业派对 - 头像昵称引导
* 登录后资料未完善时引导用户修改默认头像昵称,仅包含头像+昵称两项
* 卡若创业派对 - 头像昵称设置
* 登录后若仍为默认头像/昵称,在此修改;仅头像昵称两项
*/
const app = getApp()
@@ -12,11 +12,29 @@ Page({
saving: false,
showPrivacyModal: false,
nicknameInputFocus: false,
/** 规则引擎传入avatar | nickname用于高亮对应区块 */
uiFocus: '',
},
onLoad() {
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
const focus = String(options.focus || '').toLowerCase()
if (focus === 'avatar' || focus === 'nickname') {
this.setData({ uiFocus: focus })
}
this.loadFromUser()
if (focus === 'nickname') {
setTimeout(() => {
if (typeof wx.requirePrivacyAuthorize === 'function') {
wx.requirePrivacyAuthorize({
success: () => this.setData({ nicknameInputFocus: true }),
fail: () => {},
})
} else {
this.setData({ nicknameInputFocus: true })
}
}, 400)
}
},
loadFromUser() {

View File

@@ -1,4 +1,4 @@
{
"navigationBarTitleText": "完善资料",
"navigationBarTitleText": "头像与昵称",
"usingComponents": {}
}

View File

@@ -1,22 +1,23 @@
<!--卡若创业派对 - 头像昵称引导页,仅头像+昵称-->
<!--卡若创业派对 - 头像昵称设置页-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
<text class="nav-title">完善资料</text>
<text class="nav-title">头像与昵称</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content">
<!-- 引导文案 -->
<view class="guide-card">
<icon name="handshake" size="64" color="#00CED1" customClass="guide-icon"></icon>
<text class="guide-title">完善头像和昵称</text>
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
<text class="guide-title">设置对外展示信息</text>
<text class="guide-desc" wx:if="{{uiFocus === 'avatar'}}">请先换一张清晰头像,伙伴更容易认出你。</text>
<text class="guide-desc" wx:elif="{{uiFocus === 'nickname'}}">请改一个真实好记的昵称,方便伙伴称呼你。</text>
<text class="guide-desc" wx:else>头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
<view class="avatar-section">
<view class="avatar-section {{uiFocus === 'avatar' ? 'section-focus' : ''}}">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
@@ -29,7 +30,7 @@
</view>
<!-- 昵称:点击前先请求隐私授权,解决 errno:104 昵称选择器无法弹出 -->
<view class="form-section">
<view class="form-section {{uiFocus === 'nickname' ? 'section-focus' : ''}}">
<text class="form-label">昵称</text>
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
@@ -62,7 +63,7 @@
</view>
<view class="link-row" bindtap="goToFullProfile">
<text class="link-text">完善更多资料</text>
<text class="link-text">编辑完整档案</text>
<icon name="chevron-right" size="28" color="#00CED1" customClass="link-arrow"></icon>
</view>
</view>

View File

@@ -1,4 +1,4 @@
/* 卡若创业派对 - 头像昵称引导页 */
/* 卡若创业派对 - 头像昵称设置页 */
.page {
background: #050B14;
min-height: 100vh;
@@ -78,6 +78,13 @@
gap: 32rpx;
margin-bottom: 48rpx;
}
.avatar-section.section-focus .avatar-wrap {
box-shadow: 0 0 0 6rpx rgba(94, 234, 212, 0.45), 0 0 36rpx rgba(94, 234, 212, 0.45);
}
.form-section.section-focus .form-input-wrap {
border-color: rgba(94, 234, 212, 0.55);
box-shadow: 0 0 0 2rpx rgba(94, 234, 212, 0.25);
}
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;

View File

@@ -7,6 +7,8 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { partEmojiForBodyIndex } = require('../../utils/partIcons.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
Page({
data: {
@@ -26,6 +28,7 @@ Page({
// 展开状态
expandedPart: null,
bookCollapsed: false,
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
@@ -47,7 +50,11 @@ Page({
partsLoading: true,
// 功能配置(搜索开关)
searchEnabled: true
searchEnabled: true,
// mp_config.mpUi.chaptersPage
chaptersBookTitle: '一场SOUL的创业实验场',
chaptersBookSubtitle: '来自Soul派对房的真实商业故事'
},
onLoad() {
@@ -62,10 +69,20 @@ Page({
this.loadFeatureConfig()
},
_applyChaptersMpUi() {
const c = app.globalData.configCache?.mpConfig?.mpUi?.chaptersPage || {}
this.setData({
chaptersBookTitle: String(c.bookTitle || '一场SOUL的创业实验场').trim() || '一场SOUL的创业实验场',
chaptersBookSubtitle: String(c.bookSubtitle || '来自Soul派对房的真实商业故事').trim() ||
'来自Soul派对房的真实商业故事'
})
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
this._applyChaptersMpUi()
return
}
const res = await app.getConfig()
@@ -74,8 +91,10 @@ Page({
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
this._applyChaptersMpUi()
} catch (e) {
this.setData({ searchEnabled: true })
this._applyChaptersMpUi()
}
},
@@ -92,7 +111,6 @@ Page({
totalSections = res.totalSections ?? 0
fixedSections = res.fixedSections || []
}
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
const fixedMap = {}
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
const appendixList = [
@@ -100,22 +118,21 @@ Page({
{ id: 'appendix-2', title: '附录2创业者自检清单', mid: fixedMap['appendix-2'] },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源', mid: fixedMap['appendix-3'] }
]
const getPartBadge = (p, idx) => {
if (p.partLabel) return p.partLabel
const title = p.title || ''
const m = title.match(/^第(.+?)篇/)
if (m) return m[1].trim()
if (p.id === 'part-2026-daily') return '派'
return numbers[idx] || String(idx + 1)
}
const bookData = parts.map((p, idx) => ({
id: p.id,
number: getPartBadge(p, idx),
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [] // 展开时懒加载
}))
const bookData = parts.map((p, idx) => {
let icon = String(p.icon || '').trim()
if (icon && !isSafeImageSrc(icon)) icon = ''
const iconEmoji = icon ? '' : partEmojiForBodyIndex(idx)
return {
id: p.id,
icon,
iconEmoji,
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [],
alwaysShow: (p.title || '').indexOf('每日派对干货') > -1
}
})
app.globalData.totalSections = totalSections
this.setData({
bookData,
@@ -194,6 +211,7 @@ Page({
},
onShow() {
this._applyChaptersMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
@@ -233,7 +251,11 @@ Page({
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
},
// 切换展开状态,展开时懒加载该篇章章节
toggleBookCollapse() {
trackClick('chapters', 'btn_click', '折叠书名')
this.setData({ bookCollapsed: !this.data.bookCollapsed })
},
async togglePart(e) {
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
const partId = e.currentTarget.dataset.id

View File

@@ -34,18 +34,21 @@
</view>
</view>
<!-- 书籍信息卡 -->
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}">
<!-- 书籍信息卡(点击折叠/展开除"每日派对干货"外的篇章) -->
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}" bindtap="toggleBookCollapse">
<view class="book-icon">
<view class="book-icon-inner"><icon name="book-open" size="56" color="#ffffff"></icon></view>
</view>
<view class="book-info">
<text class="book-title">一场SOUL的创业实验场</text>
<text class="book-subtitle">来自Soul派对房的真实商业故事</text>
<text class="book-title">{{chaptersBookTitle}}</text>
<text class="book-subtitle">{{chaptersBookSubtitle}}</text>
</view>
<view class="book-count">
<text class="count-value brand-color">{{totalSections}}</text>
<text class="count-label">章节</text>
<view class="book-right-area">
<view class="book-count">
<text class="count-value brand-color">{{totalSections}}</text>
<text class="count-label">章节</text>
</view>
<text class="book-collapse-hint">{{bookCollapsed ? '展开 ▸' : '折叠 ▾'}}</text>
</view>
</view>
@@ -65,11 +68,13 @@
<!-- 篇章列表 -->
<view class="part-list">
<view class="part-item" wx:for="{{bookData}}" wx:key="id">
<view class="part-item" wx:for="{{bookData}}" wx:key="id" wx:if="{{!bookCollapsed || item.alwaysShow}}">
<!-- 篇章标题 -->
<view class="part-header" bindtap="togglePart" data-id="{{item.id}}">
<view class="part-left">
<view class="part-icon">{{item.number}}</view>
<image wx:if="{{item.icon}}" class="part-icon-img" src="{{item.icon}}" mode="aspectFill"/>
<view wx:elif="{{item.iconEmoji}}" class="part-icon part-icon-emoji">{{item.iconEmoji}}</view>
<view wx:else class="part-icon">{{item.title[0] || '篇'}}</view>
<view class="part-info">
<text class="part-title">{{item.title}}</text>
<text class="part-subtitle">{{item.subtitle}}</text>

View File

@@ -195,20 +195,11 @@
color: rgba(255, 255, 255, 0.4);
}
.book-count {
text-align: right;
}
.count-value {
font-size: 40rpx;
font-weight: 700;
display: block;
}
.count-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.4);
}
.book-right-area { display: flex; flex-direction: column; align-items: flex-end; gap: 8rpx; }
.book-count { text-align: right; }
.count-value { font-size: 40rpx; font-weight: 700; display: block; }
.count-label { font-size: 20rpx; color: rgba(255, 255, 255, 0.4); }
.book-collapse-hint { font-size: 20rpx; color: #00CED1; opacity: 0.7; }
/* ===== 目录内容 ===== */
.chapters-content {
@@ -365,6 +356,18 @@
color: #ffffff;
flex-shrink: 0;
}
/* 与管理端 ChapterTree 篇头 emoji 一致 */
.part-icon-emoji {
font-size: 34rpx;
font-weight: 400;
line-height: 1;
color: #ffffff;
background: linear-gradient(135deg, #1e3a4a 0%, #0f172a 100%);
border: 2rpx solid rgba(0, 206, 209, 0.35);
}
.part-icon-img {
width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0;
}
.part-info {
display: flex;

View File

@@ -7,6 +7,11 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const DEFAULT_KARUO_LINK_AVATAR = '/assets/images/karuo-link-avatar.png'
const KARUO_USER_ID = 'ogpTW5Wbbo9DfSyB3-xCWN6EGc-g'
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
function isKaruoHostDuplicateName(displayName) {
@@ -81,7 +86,20 @@ Page({
searchEnabled: true,
// 审核模式:隐藏支付相关入口
auditMode: false
auditMode: false,
// mp_config.mpUi.homePage后台系统设置 mpUi
mpUiLogoTitle: '卡若创业派对',
mpUiLogoSubtitle: '来自派对房的真实故事',
mpUiLinkKaruoText: '点击链接卡若',
/** 最终展示:后台 linkKaruoAvatar 或本包默认卡若照片 */
mpUiLinkKaruoDisplay: DEFAULT_KARUO_LINK_AVATAR,
mpUiSearchPlaceholder: '搜索章节标题或内容...',
mpUiBannerTag: '推荐',
mpUiBannerReadMore: '点击阅读',
mpUiSuperTitle: '超级个体',
mpUiPickTitle: '精选推荐',
mpUiLatestTitle: '最新新增'
},
onLoad(options) {
@@ -107,6 +125,7 @@ Page({
onShow() {
console.log('[Index] onShow 触发')
this.setData({ auditMode: app.globalData.auditMode || false })
this._applyHomeMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
@@ -301,27 +320,65 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
_applyHomeMpUi() {
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
let linkKaruoAvatar = String(h.linkKaruoAvatar || h.linkKaruoImage || '').trim()
if (linkKaruoAvatar && !isSafeImageSrc(linkKaruoAvatar)) linkKaruoAvatar = ''
this.setData({
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若',
mpUiLinkKaruoDisplay: linkKaruoAvatar || DEFAULT_KARUO_LINK_AVATAR,
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读',
mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体',
mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐',
mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增'
})
if (!linkKaruoAvatar) this._loadKaruoAvatarLazy()
},
_loadKaruoAvatarLazy() {
app.request({ url: `/api/miniprogram/user/profile?userId=${KARUO_USER_ID}`, silent: true, timeout: 3000 })
.then(res => {
if (res?.success && res.data?.avatar && isSafeImageSrc(res.data.avatar)) {
this.setData({ mpUiLinkKaruoDisplay: res.data.avatar })
}
})
.catch(() => {})
},
async loadFeatureConfig() {
try {
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
if (hasCachedFeatures) {
this.setData({
searchEnabled: app.globalData.features.searchEnabled,
auditMode: app.globalData.auditMode || false
})
return
if (!hasCachedFeatures) {
const res = await app.getConfig()
const features = (res && res.features) || (res && res.data && res.data.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
if (typeof features.matchEnabled === 'boolean') app.globalData.features.matchEnabled = features.matchEnabled
if (typeof features.referralEnabled === 'boolean') app.globalData.features.referralEnabled = features.referralEnabled
const mp = (res && res.mpConfig) || {}
app.globalData.auditMode = !!mp.auditMode
}
const res = await app.getConfig()
const features = (res && res.features) || {}
const mp = (res && res.mpConfig) || {}
const searchEnabled = features.searchEnabled !== false
const auditMode = !!mp.auditMode
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
app.globalData.auditMode = auditMode
this.setData({ searchEnabled, auditMode })
await app.getAuditMode()
const searchEnabled = app.globalData.features?.searchEnabled !== false
this.setData({
searchEnabled,
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
} catch (e) {
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
try {
await app.getAuditMode()
} catch (_) {}
this.setData({
searchEnabled: app.globalData.features?.searchEnabled !== false,
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
}
},
@@ -414,6 +471,22 @@ Page({
// 阻止弹窗内部点击事件冒泡到遮罩层
stopPropagation() {},
preventMove() {},
onLeadPrivacyAuthorize() {
this.onAgreePrivacyForLead()
},
onDisagreePrivacyForLead() {
if (app._privacyResolve) {
try {
app._privacyResolve({ event: 'disagree' })
} catch (_) {}
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
onLeadPhoneInput(e) {
this.setData({ leadPhone: (e.detail.value || '').trim() })
},

View File

@@ -12,14 +12,14 @@
<text class="logo-text">派</text>
</view>
<view class="logo-info">
<text class="logo-title-text">卡若创业派对</text>
<text class="logo-subtitle">来自派对房的真实故事</text>
<text class="logo-title-text">{{mpUiLogoTitle}}</text>
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
</view>
</view>
<view class="header-right">
<view class="contact-btn" bindtap="onLinkKaruo">
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
<text class="contact-name">点击链接卡若</text>
<view class="header-right" wx:if="{{!auditMode}}">
<view class="contact-btn" catchtap="onLinkKaruo" hover-class="none">
<image class="contact-avatar" src="{{mpUiLinkKaruoDisplay}}" mode="aspectFill"/>
<text class="contact-name">{{mpUiLinkKaruoText}}</text>
</view>
</view>
</view>
@@ -29,7 +29,7 @@
<view class="search-icon-wrap">
<icon name="search" size="40" color="#8e8e93" customClass="search-icon-text"></icon>
</view>
<text class="search-placeholder">搜索章节标题或内容...</text>
<text class="search-placeholder">{{mpUiSearchPlaceholder}}</text>
</view>
</view>
@@ -38,26 +38,25 @@
<!-- Banner 推荐卡片(优先 recommended API 第一条) -->
<view class="banner-card" wx:if="{{bannerSection}}" bindtap="goToRead" data-id="{{bannerSection.id}}" data-mid="{{bannerSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">推荐</view>
<view class="banner-tag">{{mpUiBannerTag}}</view>
<view class="banner-title">{{bannerSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">点击阅读</text>
<text class="banner-action-text">{{mpUiBannerReadMore}}</text>
<icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon>
</view>
</view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">推荐</view>
<view class="banner-tag">{{mpUiBannerTag}}</view>
<view class="banner-title">加载中...</view>
<view class="banner-action"><text class="banner-action-text">点击阅读</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
<view class="banner-action"><text class="banner-action-text">{{mpUiBannerReadMore}}</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
</view>
<!-- 超级个体:与匹配页一致,仅 VIP 横向列表(无首位特例) -->
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">
<text class="section-title">超级个体</text>
<text class="section-subtitle">获客入口</text>
<text class="section-title">{{mpUiSuperTitle}}</text>
</view>
<!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -100,7 +99,7 @@
<!-- 精选推荐:默认 3 条,列表下三角展开更多(与最新新增一致) -->
<view class="section">
<view class="section-header">
<text class="section-title">精选推荐</text>
<text class="section-title">{{mpUiPickTitle}}</text>
</view>
<view class="featured-list">
<view
@@ -134,7 +133,7 @@
<!-- 最新新增(时间线样式;超过 5 条时在列表下方点小三角展开,展开后随页面滚动看全部) -->
<view class="section" wx:if="{{latestChapters.length > 0}}">
<view class="section-header latest-header">
<text class="section-title">最新新增</text>
<text class="section-title">{{mpUiLatestTitle}}</text>
</view>
<view class="timeline-wrap">
<view class="timeline-line"></view>
@@ -165,17 +164,22 @@
<!-- 底部留白 -->
<view class="bottom-space"></view>
<!-- 链接卡若 - 留资弹窗(未填手机/微信号时):一键获取 + 手动输入 -->
<!-- 隐私授权(首页在 needPrivacy 列表内,需有遮罩否则无法完成 agree -->
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>
<text class="privacy-desc">使用手机号能力前,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onLeadPrivacyAuthorize">同意</button>
<view class="privacy-cancel" bindtap="onDisagreePrivacyForLead">拒绝</view>
</view>
</view>
<!-- 链接卡若 - 留资弹窗 -->
<view class="lead-mask" wx:if="{{showLeadModal}}" catchtap="closeLeadModal">
<!-- 使用 catchtap="stopPropagation" 阻止内部点击冒泡到遮罩层,避免点击输入框时弹窗被关闭 -->
<view class="lead-box" catchtap="stopPropagation">
<text class="lead-title">留下联系方式</text>
<text class="lead-desc">方便卡若与您联系</text>
<button id="agree-lead-phone-btn" class="lead-get-phone-btn" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onGetPhoneNumberForLead" bindagreeprivacyauthorization="onAgreePrivacyForLead">一键获取手机号</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacyForLead">同意</button>
</view>
<button class="lead-get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumberForLead">一键获取手机号</button>
<text class="lead-divider">或手动输入</text>
<view class="lead-input-wrap">
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>

View File

@@ -85,6 +85,10 @@
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.logo-title {
@@ -963,6 +967,61 @@
height: 40rpx;
}
/* ===== 隐私授权(与 avatar-nickname 对齐) ===== */
.privacy-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.privacy-modal {
width: 100%;
max-width: 560rpx;
background: #17212F;
border-radius: 24rpx;
padding: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.privacy-title {
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 24rpx;
}
.privacy-desc {
font-size: 28rpx;
color: #94A3B8;
text-align: center;
line-height: 1.5;
margin-bottom: 40rpx;
}
.privacy-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: #5EEAD4;
color: #000;
font-size: 30rpx;
font-weight: 600;
border-radius: 16rpx;
border: none;
}
.privacy-btn::after { border: none; }
.privacy-cancel {
margin-top: 24rpx;
font-size: 28rpx;
color: #64748B;
}
/* ===== 链接卡若 - 留资弹窗 ===== */
.lead-mask {
position: fixed;
@@ -971,7 +1030,7 @@
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
z-index: 2100;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -5,17 +5,28 @@
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge.js')
const { checkAndExecute } = require('../../utils/ruleEngine.js')
const { trackClick } = require('../../utils/trackClick')
/** 是否视为未设置头像(勿用 includes('132'):微信 CDN 合法头像 URL 普遍含 /132/ 尺寸段) */
function isMissingOrPlaceholderAvatar(avatarUrl, hasAvatarFromServer) {
if (hasAvatarFromServer === true || hasAvatarFromServer === 1) return false
const a = (avatarUrl || '').trim()
if (!a) return true
const u = a.toLowerCase()
if (u.includes('default')) return true
// 微信默认占位常见以 /0 结尾;/132/ 为正常尺寸路径,不能当作占位
if (/\/0($|[?#])/.test(u)) return true
return false
}
// 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
// 资源对接:需要登录+购买章节才能使用填写2项信息我能帮到你什么、我需要什么帮助
// 导师顾问:跳转到存客宝添加微信
// 团队招募:跳转到存客宝添加微信
let MATCH_TYPES = [
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'handshake', matchFromDB: true, showJoinAfterMatch: false },
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'star', matchFromDB: true, showJoinAfterMatch: false },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: 'users', matchFromDB: true, showJoinAfterMatch: true, requirePurchase: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: 'heart', matchFromDB: true, showJoinAfterMatch: true },
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: 'gamepad', matchFromDB: true, showJoinAfterMatch: true }
@@ -227,19 +238,21 @@ Page({
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const avatar = res?.data?.avatarUrl || app.globalData.userInfo?.avatarUrl || ''
const isDefaultAvatar = !avatar || avatar.includes('default') || avatar.includes('132')
if (isDefaultAvatar) {
const d = res?.data || {}
const avatar = (d.avatar || d.avatarUrl || app.globalData.userInfo?.avatar || app.globalData.userInfo?.avatarUrl || '').trim()
const hasAvatarFlag = d.hasAvatar === true || d.hasAvatar === 1
if (isMissingOrPlaceholderAvatar(avatar, hasAvatarFlag)) {
wx.showModal({
title: '完善头像',
content: '请先设置头像后再使用匹配功能',
confirmText: '去设置',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
const phone = (res?.data?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechat = (res?.data?.wechatId || res?.data?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
const phone = (d.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechat = (d.wechatId || d.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
callback()
return
@@ -414,6 +427,16 @@ Page({
// 开始匹配 - 只匹配数据库中的真实用户
async startMatch() {
const uidEarly = app.globalData.userInfo?.id
if (!uidEarly) {
wx.showModal({
title: '需要登录',
content: '找伙伴匹配需登录账号,请先登录',
confirmText: '去登录',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) },
})
return
}
this.setData({
isMatching: true,
matchAttempts: 0,
@@ -425,55 +448,52 @@ Page({
this.setData({ matchAttempts: this.data.matchAttempts + 1 })
}, 1000)
// 从数据库获取真实用户匹配
// 从数据库获取真实用户匹配(带上手机/微信写入 match_records与流量池运营对齐
let matchedUser = null
let matchProfileError = ''
let matchFailHint = ''
const uid = app.globalData.userInfo?.id || ''
const phoneForMatch = (this.data.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechatForMatch = (this.data.wechatId || wx.getStorageSync('user_wechat') || '').trim()
try {
const res = await app.request({ url: '/api/miniprogram/match/users', silent: true,
const res = await app.request({
url: '/api/miniprogram/match/users',
silent: true,
method: 'POST',
data: {
matchType: this.data.selectedType,
userId: app.globalData.userInfo?.id || ''
}
userId: uid,
phone: phoneForMatch || undefined,
wechatId: wechatForMatch || undefined,
},
})
if (res.success && res.data) {
matchedUser = res.data
console.log('[Match] 从数据库匹配到用户:', matchedUser.nickname)
} else if (res && !res.success) {
matchFailHint = res.message || res.error || ''
if (res.code === 'QUOTA_EXCEEDED') {
matchFailHint = matchFailHint || '今日免费次数已用完,可购买额外匹配次数后再试'
} else if (res.code === 'NO_USERS') {
matchFailHint = matchFailHint || '当前流量池暂无可匹配用户,可稍后再试;补全档案后匹配范围通常更大。'
}
}
} catch (e) {
console.log('[Match] 数据库匹配失败:', e)
const r = e.response || {}
if (r.errorCode === 'ERR_PROFILE_INCOMPLETE') {
matchProfileError = r.message || '请先完善手机号或微信号后再发起匹配'
}
matchFailHint = (e && e.message) ? String(e.message) : '网络异常,请稍后重试'
}
// 延迟显示结果(模拟匹配过程)
const delay = Math.random() * 2000 + 2000
setTimeout(() => {
clearInterval(timer)
if (matchProfileError) {
this.setData({ isMatching: false })
wx.showModal({
title: '完善资料',
content: matchProfileError,
confirmText: '去完善',
showCancel: false,
success: (mr) => {
if (mr.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
// 如果没有匹配到用户,提示用户
if (!matchedUser) {
this.setData({ isMatching: false })
wx.showModal({
title: '暂无匹配',
content: '当前暂无合适的匹配用户,请稍后再试',
content: matchFailHint || '当前暂无合适的匹配用户,请稍后再试',
showCancel: false,
confirmText: '知道了'
})
@@ -484,13 +504,6 @@ Page({
const newCount = this.data.todayMatchCount + 1
const matchesRemaining = this.data.hasFullBook ? 999999 : Math.max(0, this.data.totalMatchesAllowed - newCount)
// 规范化 commonInterestsemoji 或无效 icon 转为 SVG 图标名
const normalized = matchedUser.commonInterests?.map(item => ({
...item,
icon: /^[a-z0-9-]+$/i.test(item.icon) ? item.icon : 'target'
})) || []
if (normalized.length) matchedUser = { ...matchedUser, commonInterests: normalized }
this.setData({
isMatching: false,
currentMatch: matchedUser,
@@ -525,7 +538,7 @@ Page({
}
}
})
// 匹配后规则:引导填写 MBTI/行业信息
// 匹配后规则:资料未齐时提示补全(服务端 profile 合并,见 ruleEngine
checkAndExecute('after_match', this)
} catch (e) {
console.log('上报匹配失败:', e)
@@ -652,27 +665,7 @@ Page({
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
const r = e.response || {}
if (r.errorCode === 'ERR_REQUIRE_PURCHASE') {
wx.showModal({
title: '需要先购买',
content: r.message || '请先购买章节或解锁全书后再使用资源对接',
confirmText: '去购买',
cancelText: '取消',
success: (mr) => { if (mr.confirm) this.goToChapters() }
})
} else if (r.errorCode === 'ERR_PROFILE_INCOMPLETE') {
wx.showModal({
title: '完善资料',
content: r.message || '请先完善资料',
confirmText: '去完善',
success: (mr) => {
if (mr.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
} else {
wx.showToast({ title: e.message || '网络异常,请重试', icon: 'none' })
}
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
} finally {
this.setData({ isJoining: false })
}
@@ -710,7 +703,9 @@ Page({
return
}
const referralCode = soulBridge.getReferralCodeForPay(app)
// 邀请码:与章节支付一致,写入订单便于分销归属与对账
const referralCode = wx.getStorageSync('referral_code') || ''
// 调用支付接口购买匹配次数
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -723,11 +718,17 @@ Page({
referralCode: referralCode || undefined
}
})
if (res.success && res.data?.payParams) {
await soulBridge.requestWxJsapiPayment(res.data.payParams)
await soulBridge.syncOrderStatusQuery(app, res.data.orderSn)
// 调用微信支付
await new Promise((resolve, reject) => {
wx.requestPayment({
...res.data.payParams,
success: resolve,
fail: reject
})
})
// 支付成功,增加匹配次数
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
wx.setStorageSync('extra_match_count', extraMatches)

View File

@@ -121,7 +121,8 @@
<!-- 用户卡片 -->
<view class="match-card">
<view class="card-header">
<image class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
<image wx:if="{{currentMatch.avatar}}" class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
<view wx:else class="match-avatar match-avatar-fallback"><text>{{currentMatch.nickname ? currentMatch.nickname[0] : '?'}}</text></view>
<view class="match-info">
<text class="match-name">{{currentMatch.nickname}}</text>
<view class="match-tags">

View File

@@ -445,6 +445,20 @@
flex-shrink: 0;
}
.match-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 206, 209, 0.15);
box-sizing: border-box;
}
.match-avatar-fallback text {
font-size: 48rpx;
font-weight: 600;
color: #00CED1;
}
.match-info {
flex: 1;
min-width: 0;

View File

@@ -5,22 +5,74 @@
* mbti, region, industry, position, businessScale, skills,
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
* helpOffer→canHelp, helpNeed→needHelp
* 点头像:若后台 persons.user_id 已绑定则带 ckbLeadToken走存客宝 CKBLead与阅读页 @ 一致)
* 点头像:登录后依次校验本人头像(非默认)、微信号、绑定手机号,再弹「链接「昵称」」;有 ckbLeadToken 走人物获客计划,否则走全局留资
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true },
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false },
onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
const sb = app.globalData.statusBarHeight || 44
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44 })
const myId = app.globalData.userInfo?.id
const isOwnProfile = !!(options.id && myId && String(options.id) === String(myId))
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44, isOwnProfile })
if (options.id) this.loadMember(options.id)
},
/** 本人名片:去完整编辑资料(单页) */
goMyProfileEdit() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
},
async loadMember(id) {
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const myId = app.globalData.userInfo?.id
const isOwn = !!(myId && id != null && String(id) === String(myId))
if (isOwn && app.globalData.isLoggedIn && myId) {
try {
const res = await app.request({
url: `/api/miniprogram/user/profile?userId=${encodeURIComponent(String(id))}`,
silent: true
})
if (res?.success && res.data) {
const d = res.data
this.setData({
member: this.enrichAndFormat({
id: d.id,
nickname: d.nickname,
name: d.nickname,
avatar: d.avatar,
phone: d.phone,
wechatId: d.wechatId || d.wechat_id,
isVip: !!app.globalData.isVip,
mbti: d.mbti,
region: d.region,
industry: d.industry,
position: d.position,
businessScale: d.businessScale || d.business_scale,
skills: d.skills,
storyBestMonth: d.storyBestMonth || d.story_best_month,
storyAchievement: d.storyAchievement || d.story_achievement,
storyTurning: d.storyTurning || d.story_turning,
helpOffer: d.helpOffer || d.help_offer,
helpNeed: d.helpNeed || d.help_need,
projectIntro: d.projectIntro || d.project_intro,
ckbLeadToken: d.ckbLeadToken || d.ckb_lead_token
}),
loading: false
})
return
}
} catch (e) {}
}
try {
const res = await app.request({ url: `/api/miniprogram/vip/members?id=${id}`, silent: true })
if (res?.success && res.data) {
@@ -65,10 +117,20 @@ Page({
enrichAndFormat(raw) {
const e = (v) => this._emptyIfPlaceholder(v)
const rawAv = raw.avatar || raw.vipAvatar || raw.vip_avatar || ''
let dispAv = isSafeImageSrc(rawAv) ? String(rawAv).trim() : ''
if (!dispAv) {
dispAv = resolveAvatarWithMbti(
'',
raw.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
}
const merged = {
id: raw.id,
name: raw.nickname || raw.name || raw.vipName || raw.vip_name || '创业者',
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
avatar: dispAv,
isVip: !!(raw.isVip || raw.is_vip),
mbti: e(raw.mbti),
region: e(raw.region),
@@ -153,50 +215,233 @@ Page({
return false
},
/** 链接前:头像需非空且非默认图;微信号需已填写(与 app._needsAvatarNickname 中头像规则一致) */
_hasCustomAvatarForLink(u) {
const avatar = (u && (u.avatar || u.avatarUrl) || '').trim()
return !!avatar && !avatar.includes('default')
},
_hasWechatFilledForLink(u) {
const w = (u && (u.wechatId || u.wechat_id) || wx.getStorageSync('user_wechat') || '').trim()
return w.length > 0
},
/** 拉取最新 profile 写回 globalData返回合并后的访客资料片段 */
async _refreshVisitorProfileForLink() {
const base = app.globalData.userInfo
if (!base?.id) return base || {}
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${base.id}`, silent: true })
if (profileRes?.success && profileRes.data) {
const d = profileRes.data
const updated = { ...app.globalData.userInfo }
if (d.avatar != null && String(d.avatar).trim()) updated.avatar = String(d.avatar).trim()
if (d.wechatId != null) updated.wechatId = d.wechatId
if (d.wechat_id != null) updated.wechat_id = d.wechat_id
app.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
if (d.wechatId) wx.setStorageSync('user_wechat', String(d.wechatId).trim())
return updated
}
} catch (e) {}
return base
},
async _ensureVisitorReadyForMemberLink() {
const u = await this._refreshVisitorProfileForLink()
const avatarOk = this._hasCustomAvatarForLink(u)
const wechatOk = this._hasWechatFilledForLink(u)
if (avatarOk && wechatOk) return true
const miss = []
if (!avatarOk) miss.push('头像')
if (!wechatOk) miss.push('微信号')
wx.showModal({
title: '补全本人档案',
content: `链接前请补全本人${miss.join('与')},便于对方识别与安全对接。`,
confirmText: '去填写',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return false
},
/** 链接前:必须已绑定大陆手机号(与留资接口校验一致) */
async _ensurePhoneBoundForLink(myUserId) {
const { phone } = await this._resolveLeadPhoneWechat(myUserId)
if (phone && /^1[3-9]\d{9}$/.test(phone)) return true
wx.showModal({
title: '请先绑定手机号',
content: '链接对方前需绑定本人手机号,便于跟进与对接。',
confirmText: '去绑定',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return false
},
/**
* 点头像:有存客宝人物 token 时优先 POST /api/miniprogram/ckb/lead与阅读页 @ 同链路,匹配 persons.ckb_api_key 计划)
* 否则:解锁后复制微信/手机号并引导
* 点头像:登录 → 头像+微信号 → 手机号 均校验通过后弹窗说明;确认后 POST ckb/lead。
* 有 ckbLeadToken 走人物计划;无 token 走全局留资。对方已公开联系方式时可取消后在下方自行添加。
*/
startLinkFlow() {
async startLinkFlow() {
if (this.data.isOwnProfile) return
const member = this.data.member
if (!member) return
const leadTok = (member.ckbLeadToken || '').trim()
if (leadTok) {
const nickname = ((member.name || 'TA').trim() || 'TA')
const nickname = (member.name || 'TA').trim() || 'TA'
trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || ''))
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '添加好友',
content: `是否通过获客计划联系 ${nickname}?提交后将按对方在存客宝后台配置的计划执行。`,
confirmText: '确定',
title: `链接「${nickname}`,
content: '请先登录后再发起链接。',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) this._doCkbLeadSubmit(leadTok, nickname)
}
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
if (member.wechatRaw || member.wechatDisplay) {
if (!this._ensureUnlockedForLink('wechat')) return
const m = this.data.member
if (m.wechatFull) this._copyAndGuideWechat(m.wechatFull)
return
const myUserId = app.globalData.userInfo.id
wx.showLoading({ title: '请稍候', mask: true })
let profileOk = false
let phoneOk = false
try {
profileOk = await this._ensureVisitorReadyForMemberLink()
if (profileOk) phoneOk = await this._ensurePhoneBoundForLink(myUserId)
} finally {
wx.hideLoading()
}
if (member.contactRaw || member.contactDisplay) {
if (!this._ensureUnlockedForLink('contact')) return
const m = this.data.member
if (m.contactFull) this._copyAndGuidePhone(m.contactFull)
return
}
wx.showToast({ title: '暂未公开联系方式', icon: 'none' })
if (!profileOk || !phoneOk) return
const leadTok = (member.ckbLeadToken || '').trim()
const content = leadTok
? `确定后提交联系方式,平台将按对方配置跟进;智能助手与人工协同协助对接。`
: `智能助手与人工会协同跟进,协助您对接「${nickname}」。\n\n若对方已公开手机或微信,可先点「取消」,在页面下方自行添加。`
wx.showModal({
title: `链接「${nickname}`,
content,
confirmText: '确定链接',
cancelText: '取消',
success: (r) => {
if (!r.confirm) return
if (leadTok) this._doCkbLeadSubmit(leadTok, nickname, member.id, nickname)
else this._doGlobalMemberLeadSubmit(member)
}
})
},
/** 与阅读页 @mention 同链路soulBridge.submitCkbLead */
async _doCkbLeadSubmit(targetUserId, targetNickname) {
await soulBridge.submitCkbLead(app, {
targetUserId,
targetNickname,
source: 'member_detail_avatar',
phoneModalContent: '请先填写手机号(必填),以便对方通过获客计划联系您'
})
/** 无人物 token 时:全局留资,便于运营侧主动加好友并协助链接该会员 */
async _doGlobalMemberLeadSubmit(member) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const myUserId = app.globalData.userInfo.id
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于工作人员联系您、协助链接该超级个体。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
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,
targetNickname: '',
targetMemberId: member.id || undefined,
targetMemberName: (member.name || '').trim() || undefined,
source: 'member_detail_global',
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,请留意工作人员联系', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
async _resolveLeadPhoneWechat(myUserId) {
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?.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) {}
}
return { phone, wechatId }
},
/** 与 read 页 _doMentionAddFriend 一致targetUserId = Person.token可选带超级个体 userId 写入留资 params */
async _doCkbLeadSubmit(targetUserId, targetNickname, targetMemberId, targetMemberName) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
const myUserId = app.globalData.userInfo.id
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方通过获客计划联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
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,
targetNickname: targetNickname || undefined,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source: 'member_detail_avatar'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
_ensureUnlockedForLink(field) {
@@ -321,11 +566,56 @@ Page({
goToVip() { wx.navigateTo({ url: '/pages/vip/vip' }) },
goBack() { getApp().goBackOrToHome() },
/**
* 分享标题姓名MBTI有则加擅长无擅长时用个人故事或职业一行。总长控制避免微信卡片截断难看。
*/
_buildMemberShareTitle(maxLen = 56) {
const m = this.data.member
if (!m) return '卡若创业派对 · 超级个体'
const clip = (s, n) => {
if (!s) return ''
const t = String(s).replace(/\s+/g, ' ').trim()
return t.length <= n ? t : t.slice(0, n - 1) + '…'
}
const name = clip((m.name || '创业者').trim() || '创业者', 14)
const mbti = (m.mbti || '').trim()
const skills = (m.skills || '').trim()
const achievement = (m.achievement || '').trim()
const bestMonth = (m.bestMonth || '').trim()
const turning = (m.turningPoint || '').trim()
const story = achievement || bestMonth || turning || ''
const jobLine = [m.industry, m.position].filter(Boolean).join('·')
const sep = ''
const nameSeg = name
const mbtiSeg = mbti ? clip(mbti, 8) : ''
const usedBase = nameSeg.length + (mbtiSeg ? sep.length + mbtiSeg.length : 0)
const willTail = !!(skills || story || jobLine || m.region)
const restBudget = Math.max(6, maxLen - usedBase - (willTail ? sep.length : 0))
let tail = ''
if (skills) tail = clip(skills, restBudget)
else if (story) tail = clip(story, restBudget)
else if (jobLine) tail = clip(jobLine, restBudget)
else if (m.region) tail = clip(m.region, restBudget)
let title = nameSeg
if (mbtiSeg) title += sep + mbtiSeg
if (tail) title += sep + tail
title = clip(title.replace(/\s+/g, ' '), maxLen)
if (!title || title.length < 3) title = clip(`${nameSeg}${sep}超级个体`, maxLen)
return title
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
const id = this.data.member?.id
const title = this._buildMemberShareTitle(64)
return {
title: '卡若创业派对 - 创业者详情',
title,
path: id && ref ? `/pages/member-detail/member-detail?id=${id}&ref=${ref}` : id ? `/pages/member-detail/member-detail?id=${id}` : ref ? `/pages/member-detail/member-detail?ref=${ref}` : '/pages/member-detail/member-detail'
}
},
@@ -333,7 +623,13 @@ Page({
onShareTimeline() {
const ref = app.getMyReferralCode()
const id = this.data.member?.id
const m = this.data.member
const q = id ? (ref ? `id=${id}&ref=${ref}` : `id=${id}`) : (ref ? `ref=${ref}` : '')
return { title: '卡若创业派对 - 创业者详情', query: q }
const title = this._buildMemberShareTitle(56)
const res = { title, query: q }
if (m && m.avatar && /^https?:\/\//.test(String(m.avatar))) {
res.imageUrl = m.avatar
}
return res
}
})

View File

@@ -1,20 +1,29 @@
<!-- 卡若创业派对 - 超级个体详情(居中头像区 + 低调联系方式 + 信息卡) -->
<!-- 卡若创业派对 - 超级个体详情(点头像申请对接 + 有则展示联系方式 + 信息卡) -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="#5EEAD4" customClass="nav-icon"></icon>
</view>
<text class="nav-title">个人资料</text>
<view class="nav-placeholder"></view>
<text class="nav-title">{{isOwnProfile ? '我的名片' : '个人资料'}}</text>
<view class="nav-edit-wrap" wx:if="{{isOwnProfile}}" bindtap="goMyProfileEdit">
<text class="nav-edit-text">编辑</text>
</view>
<view class="nav-placeholder" wx:else></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<scroll-view scroll-y class="scroll-wrap" style="height: calc(100vh - {{navBarTotalPx}}px);" wx:if="{{member}}">
<!-- 首屏:居中头像 + 昵称 + 标签;点头像走添加微信引导(无独立「链接 TA」大按钮 -->
<!-- 首屏:点头像申请对接;超级个体未填手机/微信则整块不展示联系方式 -->
<view class="shell">
<view class="shell-glow"></view>
<view class="hero-profile">
<view class="hero-avatar-block" bindtap="startLinkFlow" hover-class="hero-avatar-block-hover" hover-stay-time="80">
<view
class="hero-avatar-block"
wx:if="{{!isOwnProfile}}"
bindtap="startLinkFlow"
hover-class="hero-avatar-block-hover"
hover-stay-time="80"
>
<view class="avatar-outer">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
@@ -30,10 +39,39 @@
<text>{{member.region}}</text>
</view>
</view>
<text class="avatar-link-hint-one">点头像 · 申请对接</text>
</view>
<view
class="hero-avatar-block hero-avatar-block-self"
wx:else
bindtap="goMyProfileEdit"
hover-class="hero-avatar-block-hover"
hover-stay-time="80"
>
<view class="avatar-outer">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{(member.name && member.name[0]) || '创'}}</text></view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
<text class="profile-name">{{member.name}}</text>
<view class="profile-tags profile-tags-modern" wx:if="{{member.mbti || member.region}}">
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
<view class="tag tag-region" wx:if="{{member.region}}">
<icon name="map-pin" size="22" color="currentColor" customClass="pin-icon"></icon>
<text>{{member.region}}</text>
</view>
</view>
<text class="avatar-link-hint-one self-hint">这是我的超级个体名片 · 可转发分享 · 点头像去编辑</text>
</view>
</view>
<view class="contact-rows contact-rows-subtle">
<view
class="contact-rows contact-rows-subtle"
wx:if="{{member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}"
>
<view class="contact-sec-label">联系方式</view>
<view
class="link-chip link-chip-subtle {{member.contactUnlocked ? 'link-chip-open' : ''}}"
wx:if="{{member.contactRaw || member.contactDisplay}}"
@@ -69,10 +107,6 @@
<icon wx:if="{{!member.wechatUnlocked}}" name="chevron-right" size="22" color="rgba(100,116,139,0.8)" customClass="lc-arr"></icon>
</view>
</view>
<view class="link-empty link-empty-subtle" wx:if="{{!(member.contactRaw || member.contactDisplay) && !(member.wechatRaw || member.wechatDisplay)}}">
<text class="link-empty-txt">暂未公开联系方式</text>
</view>
</view>
</view>

View File

@@ -43,6 +43,23 @@
.nav-placeholder {
width: 72rpx;
}
.nav-edit-wrap {
min-width: 72rpx;
padding: 12rpx 8rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
.nav-edit-text {
font-size: 28rpx;
font-weight: 600;
color: #5eead4;
}
.self-hint {
font-size: 22rpx !important;
line-height: 1.45;
padding: 0 20rpx;
}
.scroll-wrap {
box-sizing: border-box;
@@ -75,7 +92,7 @@
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 8rpx;
padding-bottom: 4rpx;
}
.hero-avatar-block {
@@ -88,6 +105,27 @@
opacity: 0.94;
}
.avatar-link-hint-one {
margin-top: 16rpx;
padding: 0 24rpx;
font-size: 24rpx;
font-weight: 400;
color: rgba(148, 163, 184, 0.88);
text-align: center;
line-height: 1.4;
max-width: 100%;
box-sizing: border-box;
}
.contact-sec-label {
font-size: 22rpx;
font-weight: 600;
color: rgba(148, 163, 184, 0.7);
letter-spacing: 4rpx;
margin-bottom: 8rpx;
padding-left: 6rpx;
}
.contact-rows {
position: relative;
z-index: 1;
@@ -98,8 +136,8 @@
}
.contact-rows-subtle {
margin-top: 24rpx;
padding-top: 24rpx;
margin-top: 28rpx;
padding-top: 28rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
}
@@ -153,7 +191,7 @@
font-weight: 800;
padding: 6rpx 12rpx;
border-radius: 12rpx;
z-index: 2;
z-index: 5;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.35);
}
@@ -273,26 +311,6 @@
display: block;
}
.link-empty {
padding: 24rpx;
border-radius: 20rpx;
background: rgba(15, 23, 42, 0.4);
border: 1rpx dashed rgba(148, 163, 184, 0.2);
}
.link-empty-subtle {
padding: 16rpx 8rpx;
background: transparent;
border: none;
}
.link-empty-txt {
font-size: 24rpx;
color: #64748b;
}
.link-empty-subtle .link-empty-txt {
font-size: 22rpx;
color: rgba(100, 116, 139, 0.75);
}
.profile-name {
position: relative;
z-index: 1;

View File

@@ -8,27 +8,8 @@ const app = getApp()
const { formatStatNum } = require('../../utils/util.js')
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
/** 与 referral 一致:提现需已绑定微信号(便于到账核对) */
function hasWechatIdBound() {
const ui = app.globalData.userInfo
return !!(ui && (ui.wechat || ui.wechatId || wx.getStorageSync('user_wechat')))
}
/** 是否视为「单章解锁」类订单(排除全书/VIP 等聚合商品名) */
function isSectionUnlockOrder(o) {
const name = String(o.product_name || o.title || '').trim()
if (/全书|全書|VIP|会员|年费|买断/.test(name)) return false
const pid = String(o.product_id || o.section_id || o.sectionId || '')
if (/^\d+\.\d+/.test(pid)) return true
return !!pid && pid.length > 0
}
function parseOrderTimeMs(o) {
const raw = o.created_at || o.createdAt || o.pay_time || 0
const t = new Date(raw).getTime()
return Number.isFinite(t) ? t : 0
}
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
Page({
data: {
@@ -39,9 +20,11 @@ Page({
// 用户状态
isLoggedIn: false,
userInfo: null,
/** 我的页头像展示:微信头像或 MBTI 映射图 */
profileAvatarDisplay: '',
// 统计数据
totalSections: 0, // 来自 app.getTotalSections() 或 dashboard-stats
totalSections: 62,
readCount: 0,
referralCount: 0,
earnings: '-',
@@ -103,10 +86,12 @@ Page({
// 我的余额
walletBalanceText: '--',
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
// mp_config.mpUi.myPage后台可改文案/跳转
mpUiCardLabel: '名片',
mpUiVipLabelVip: '会员中心',
mpUiVipLabelGuest: '成为会员',
mpUiReadStatLabel: '已读章节',
mpUiRecentTitle: '最近阅读',
},
onLoad() {
@@ -136,11 +121,37 @@ Page({
}
}
this.initUserStatus()
// 登录过期后用户点「去登录」跳转过来,自动弹出登录弹窗
if (app.globalData.pendingLoginAfterExpire) {
app.globalData.pendingLoginAfterExpire = false
setTimeout(() => this.showLogin(), 100)
}
this._applyMyMpUiLabels()
},
_getMyPageUi() {
const cache = app.globalData.configCache || {}
const fromNew = cache?.mpConfig?.mpUi?.myPage
if (fromNew && typeof fromNew === 'object') return fromNew
const fromLegacy = cache?.configs?.mp_config?.mpUi?.myPage
if (fromLegacy && typeof fromLegacy === 'object') return fromLegacy
return {}
},
_applyMyMpUiLabels() {
const my = this._getMyPageUi()
this.setData({
mpUiCardLabel: String(my.cardLabel || '名片').trim() || '名片',
mpUiVipLabelVip: String(my.vipLabelVip || '会员中心').trim() || '会员中心',
mpUiVipLabelGuest: String(my.vipLabelGuest || '成为会员').trim() || '成为会员',
mpUiReadStatLabel: String(my.readStatLabel || '已读章节').trim() || '已读章节',
mpUiRecentTitle: String(my.recentReadTitle || '最近阅读').trim() || '最近阅读'
})
},
async _refreshMyAvatarDisplay(safeUser) {
if (!safeUser || !app.globalData.isLoggedIn) return
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const url = app.resolveAvatarWithMbti ? app.resolveAvatarWithMbti(safeUser.avatar, safeUser.mbti) : ''
if (!this.data.isLoggedIn) return
this.setData({ profileAvatarDisplay: url || '' })
},
async loadFeatureConfig() {
@@ -151,13 +162,16 @@ Page({
const referralEnabled = features.referralEnabled !== false
const searchEnabled = features.searchEnabled !== false
const mp = (res && res.mpConfig) || {}
const auditMode = !!mp.auditMode
app.globalData.auditMode = auditMode
app.globalData.auditMode = !!mp.auditMode
await app.getAuditMode()
const auditMode = app.globalData.auditMode || false
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode })
this._applyMyMpUiLabels()
} catch (error) {
console.log('加载功能配置失败:', error)
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
this._applyMyMpUiLabels()
}
},
@@ -169,15 +183,22 @@ Page({
const userId = userInfo.id || ''
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
const safeUser = { ...userInfo }
if (!isSafeImageSrc(safeUser.avatar)) safeUser.avatar = ''
app.globalData.userInfo = safeUser
try {
wx.setStorageSync('userInfo', safeUser)
} catch (_) {}
// 先设基础信息;阅读统计与收益再分别从后端刷新
this.setData({
isLoggedIn: true,
userInfo,
userInfo: safeUser,
profileAvatarDisplay: '',
userIdShort,
userWechat,
readCount: 0,
referralCount: userInfo.referralCount || 0,
referralCount: 0,
earnings: '-',
pendingEarnings: '-',
earningsLoading: true,
@@ -193,12 +214,14 @@ Page({
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
this.loadUnlockedChapters()
this._refreshMyAvatarDisplay(safeUser)
} else {
const guestReadCount = app.getReadCount()
const guestRecent = this._mergeRecentChaptersFromLocal([])
this.setData({
isLoggedIn: false,
userInfo: null,
profileAvatarDisplay: '',
userIdShort: '',
readCount: guestReadCount,
readCountText: formatStatNum(guestReadCount),
@@ -206,10 +229,7 @@ Page({
earnings: '-',
pendingEarnings: '-',
earningsLoading: false,
recentChapters: [],
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
recentChapters: guestRecent,
totalReadTime: 0,
matchHistory: 0,
totalReadTimeText: '0',
@@ -218,88 +238,83 @@ Page({
}
},
/**
* 已解锁章节:优先订单接口(按支付时间倒序);失败时用 purchasedSections + bookData 兜底
*/
async loadUnlockedChapters() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) {
this.setData({
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false
})
return
}
const userId = app.globalData.userInfo.id
const expanded = this.data.unlockedExpanded
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const metaById = (id) => {
const row = bookFlat.find((s) => s.id === id)
return {
mid: row?.mid ?? row?.MID ?? 0,
title: cleanSingleLineField(row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || '')
}
}
/** 本地已打开的章节 idreading_progress 键 + 历史 readSectionIds用于与服务端合并展示 */
_localSectionIdsFromStorage() {
try {
const res = await app.request({ url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`, silent: true })
let rows = []
if (res && res.success && Array.isArray(res.data)) {
rows = res.data
.map((item) => ({
id: item.product_id || item.section_id,
mid: item.section_mid ?? item.mid ?? item.MID ?? 0,
title: cleanSingleLineField(item.product_name || ''),
_ts: parseOrderTimeMs(item)
}))
.filter((r) => r.id && isSectionUnlockOrder({ product_id: r.id, product_name: r.title }))
}
rows.sort((a, b) => b._ts - a._ts)
const seen = new Set()
const deduped = []
for (const r of rows) {
if (seen.has(r.id)) continue
seen.add(r.id)
const meta = metaById(r.id)
deduped.push({
id: r.id,
mid: r.mid || meta.mid,
title: cleanSingleLineField(r.title || meta.title || `章节 ${r.id}`)
})
}
if (deduped.length === 0) {
const ids = [...(app.globalData.purchasedSections || [])]
ids.reverse()
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
const meta = metaById(id)
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
}
}
const display = expanded ? deduped : deduped.slice(0, 5)
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
} catch (e) {
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const seen = new Set()
const deduped = []
for (const id of ids) {
if (!id || seen.has(id)) continue
seen.add(id)
const meta = metaById(id)
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
}
const display = expanded ? deduped : deduped.slice(0, 5)
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
const progressData = wx.getStorageSync('reading_progress') || {}
const fromProgress = Object.keys(progressData).filter(Boolean)
let fromReadList = []
try {
const rs = wx.getStorageSync('readSectionIds')
if (Array.isArray(rs)) fromReadList = rs.filter(Boolean)
} catch (_) {}
return [...new Set([...fromProgress, ...fromReadList])]
} catch (_) {
return []
}
},
expandUnlockedChapters() {
if (this.data.unlockedExpanded) return
trackClick('my', 'tab_click', '已解锁章节_展开')
const full = this.data.unlockedChaptersFull || []
/** 接口无最近阅读时,用本地 reading_progress + recent_section_opens + bookData 补全 */
_mergeRecentChaptersFromLocal(apiList) {
const normalized = Array.isArray(apiList)
? apiList.map((item) => ({
id: item.id,
mid: item.mid,
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
}))
: []
if (normalized.length > 0) return normalized
try {
const progressData = wx.getStorageSync('reading_progress') || {}
let opens = wx.getStorageSync('recent_section_opens')
if (!Array.isArray(opens)) opens = []
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const titleOf = (id) => {
const row = bookFlat.find((s) => s.id === id)
return cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
}
const midOf = (id) => {
const row = bookFlat.find((s) => s.id === id)
return row?.mid ?? row?.MID ?? 0
}
const latest = new Map()
const bump = (sid, ts) => {
if (!sid) return
const id = String(sid)
const t = typeof ts === 'number' && !isNaN(ts) ? ts : 0
const prev = latest.get(id) || 0
if (t >= prev) latest.set(id, t)
}
Object.keys(progressData).forEach((id) => {
const row = progressData[id]
bump(id, row?.lastOpenAt || row?.last_open_at || 0)
})
opens.forEach((o) => bump(o && o.id, o && o.t))
return [...latest.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([id]) => ({ id, mid: midOf(id), title: titleOf(id) }))
} catch (e) {
return []
}
},
/** 接口失败或无 data 时,仅用本地 reading_progress / readSectionIds 刷新已读与最近 */
_hydrateReadStatsFromLocal() {
const localExtra = this._localSectionIdsFromStorage()
const readSectionIds = [...new Set(localExtra.filter(Boolean))]
app.globalData.readSectionIds = readSectionIds
try {
wx.setStorageSync('readSectionIds', readSectionIds)
} catch (_) {}
const recentChapters = this._mergeRecentChaptersFromLocal([])
const readCount = readSectionIds.length
this.setData({
unlockedExpanded: true,
displayUnlockedChapters: full
readCount,
readCountText: formatStatNum(readCount),
recentChapters
})
},
@@ -313,21 +328,29 @@ Page({
silent: true
})
if (!res?.success || !res.data) return
if (!res?.success || !res.data) {
this._hydrateReadStatsFromLocal()
return
}
const apiIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
const localExtra = this._localSectionIdsFromStorage()
const prevGlobal = Array.isArray(app.globalData.readSectionIds) ? app.globalData.readSectionIds.filter(Boolean) : []
const readSectionIds = [...new Set([...apiIds, ...prevGlobal, ...localExtra])]
const readSectionIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds : []
app.globalData.readSectionIds = readSectionIds
wx.setStorageSync('readSectionIds', readSectionIds)
const recentChapters = Array.isArray(res.data.recentChapters)
const apiRecent = Array.isArray(res.data.recentChapters)
? res.data.recentChapters.map((item) => ({
id: item.id,
mid: item.mid,
title: item.title || `章节 ${item.id}`
}))
: []
const recentChapters = this._mergeRecentChaptersFromLocal(apiRecent)
const readCount = Number(res.data.readCount || 0)
const readCount = readSectionIds.length
const totalReadTime = Number(res.data.totalReadMinutes || 0)
const matchHistory = Number(res.data.matchHistory || 0)
const orderCount = Number(res.data.orderCount || 0)
@@ -345,6 +368,7 @@ Page({
})
} catch (e) {
console.log('[My] 拉取阅读统计失败:', e && e.message)
this._hydrateReadStatsFromLocal()
}
},
@@ -585,7 +609,11 @@ Page({
wx.showToast({ title: '已刷新', icon: 'success' })
},
// 微信原生获取头像button open-type="chooseAvatar" 回调,点击头像直接唤起选择器)
tapAvatar() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
@@ -632,6 +660,7 @@ Page({
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
this._refreshMyAvatarDisplay(userInfo)
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
@@ -677,9 +706,9 @@ Page({
}
},
// 点击昵称:跳转资料编辑页type="nickname" 在弹窗内无法触发微信昵称选择器,需在主页面
// 点击昵称:先进个人资料名片页,再在右上角进入编辑(与需求「编辑收进名片流」一致
editNickname() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
wx.navigateTo({ url: '/pages/profile-show/profile-show' })
},
// 关闭昵称弹窗
@@ -867,9 +896,33 @@ Page({
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 跳转到目录
goToChapters() {
// 已读章节:进入阅读记录页(有列表);路径可由 mpUi.myPage.readStatPath 配置
goToReadStat() {
trackClick('my', 'nav_click', '已读章节')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const p = String(this._getMyPageUi().readStatPath || '').trim()
if (p && navigateMpPath(p)) return
navigateMpPath('/pages/reading-records/reading-records?focus=all')
},
/** 最近阅读区块标题点击:进入阅读记录(最近维度) */
goToRecentReadHub() {
trackClick('my', 'nav_click', '最近阅读区块')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const p = String(this._getMyPageUi().recentReadPath || '').trim()
if (p && navigateMpPath(p)) return
navigateMpPath('/pages/reading-records/reading-records?focus=recent')
},
// 去目录(空状态等)
goToChapters() {
trackClick('my', 'nav_click', '去目录')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
@@ -880,14 +933,17 @@ Page({
},
// 跳转到推广中心(需登录)
goToReferral() {
trackClick('my', 'nav_click', '推广中心')
goToReferral(e) {
const focus = e && e.currentTarget && e.currentTarget.dataset ? (e.currentTarget.dataset.focus || '') : ''
const action = focus === 'bindings' ? '推荐好友' : focus === 'earnings' ? '我的收益' : '推广中心'
trackClick('my', 'nav_click', action)
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
if (!this.data.referralEnabled) return
wx.navigateTo({ url: '/pages/referral/referral' })
const url = focus ? `/pages/referral/referral?focus=${focus}` : '/pages/referral/referral'
wx.navigateTo({ url })
},
// 退出登录
@@ -943,10 +999,22 @@ Page({
goToVip() {
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
const p = String(this._getMyPageUi().vipPath || '').trim()
if (p && navigateMpPath(p)) return
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 进入个人资料编辑页stitch_soul
// 本人对外名片:默认与「超级个体」同款 member-detailmpUi.myPage.cardPath 可覆盖(需含完整 query
goToMySuperCard() {
trackClick('my', 'btn_click', '名片')
if (!this.data.isLoggedIn) { this.showLogin(); return }
const uid = this.data.userInfo?.id
if (!uid) return
const p = String(this._getMyPageUi().cardPath || '').trim()
if (p && navigateMpPath(p)) return
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` })
},
goToProfileEdit() {
trackClick('my', 'nav_click', '资料编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }
@@ -967,19 +1035,7 @@ Page({
wx.showToast({ title: '暂无可提现金额', icon: 'none' })
return
}
if (!hasWechatIdBound()) {
wx.showModal({
title: '请先绑定微信号',
content: '提现需先绑定微信号,便于到账核对。请到「设置」中绑定后再提现。',
confirmText: '去绑定',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
return
}
this.doWithdraw(amount)
await this.ensureContactInfo(() => this.doWithdraw(amount))
},
async doWithdraw(amount) {
@@ -998,16 +1054,6 @@ Page({
this.loadWalletBalance()
} catch (e) {
wx.hideLoading()
const r = e.response || {}
if (r.needBind || r.needBindWechat) {
wx.showModal({
title: r.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: r.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (mr) => { if (mr.confirm) wx.navigateTo({ url: '/pages/settings/settings' }) }
})
return
}
wx.showToast({ title: e.message || '提现失败', icon: 'none' })
}
}

View File

@@ -1,10 +1,7 @@
<!-- 我的页 - 设计稿 1:1 还原 -->
<view class="page">
<!-- 顶部导航:左侧资料编辑 + 标题 -->
<!-- 顶部导航 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-settings" bindtap="goToProfileEdit">
<image class="nav-settings-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
</view>
<text class="nav-title">我的</text>
</view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
@@ -23,36 +20,30 @@
<view class="profile-card" wx:else>
<view class="profile-card-inner">
<view class="profile-top-row">
<view class="avatar-wrap">
<view class="avatar-wrap" bindtap="tapAvatar">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<image wx:if="{{profileAvatarDisplay}}" class="avatar-img" src="{{profileAvatarDisplay}}" mode="aspectFill"/>
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
</view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
<view class="vip-badge vip-badge-gray" wx:else>VIP</view>
<button class="avatar-overlay-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
<view class="profile-meta">
<view class="profile-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<view class="profile-name-actions">
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" wx:if="{{!auditMode}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
</view>
</view>
<view class="vip-tags" wx:if="{{!auditMode}}">
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
</view>
<text class="user-wechat" wx:if="{{userWechat}}" bindtap="copyUserId">微信号: {{userWechat}}</text>
</view>
</view>
<view class="profile-stats-row">
<view class="profile-stat" bindtap="goToChapters">
<view class="profile-stat" bindtap="goToReadStat">
<text class="profile-stat-val">{{readCountText || '0'}}</text>
<text class="profile-stat-label">已读章节</text>
<text class="profile-stat-label">{{mpUiReadStatLabel}}</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral" data-focus="bindings">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
@@ -60,7 +51,7 @@
<text class="profile-stat-val">{{matchHistoryText}}</text>
<text class="profile-stat-label">匹配伙伴</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral" data-focus="earnings">
<text class="profile-stat-val">{{pendingEarnings || '0.00'}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
@@ -117,43 +108,13 @@
</view>
</view>
<!-- 已解锁:仅低调图标区(无标题文案);默认 5 条 + 倒三角展开;倒序由接口/JS 保证 -->
<view class="card recent-card unlocked-card" wx:if="{{unlockedChaptersFull.length > 0}}">
<view class="unlocked-section-head">
<image class="unlocked-section-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="recent-list">
<view
class="recent-item"
wx:for="{{displayUnlockedChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="recent-left">
<text class="recent-index">{{index + 1}}</text>
<text class="recent-title">{{item.title}}</text>
</view>
<text class="recent-link">阅读</text>
</view>
</view>
<view
class="unlocked-expand-hint"
wx:if="{{unlockedChaptersFull.length > 5 && !unlockedExpanded}}"
bindtap="expandUnlockedChapters"
hover-class="unlocked-expand-hint-hover"
hover-stay-time="80"
>
<view class="unlocked-expand-triangle"></view>
</view>
</view>
<!-- 已解锁/充值/代付等流水已迁至「我的订单」页 -->
<!-- 最近阅读 -->
<view class="card recent-card">
<view class="card-header">
<view class="card-header" bindtap="goToRecentReadHub">
<image class="card-icon-img" src="/assets/icons/book-arrow-teal.svg" mode="aspectFit"/>
<text class="card-title">最近阅读</text>
<text class="card-title">{{mpUiRecentTitle}}</text>
</view>
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
<view

View File

@@ -73,23 +73,51 @@
}
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.profile-name-row { display: flex; align-items: center; justify-content: flex-start; gap: 16rpx; flex-wrap: wrap; }
.user-name {
font-size: 44rpx; font-weight: bold; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
}
.become-member-btn {
padding: 12rpx 28rpx; border: 2rpx solid #C8A146; color: #C8A146;
.profile-actions-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
/* 名片/会员中心:紧挨昵称下方(在 profile-meta 内) */
.profile-actions-under-name { margin-top: 4rpx; }
/* 名片 / 会员中心:统一品牌青,与 tabBar 选中色一致 */
.profile-action-btn {
padding: 12rpx 28rpx; border: 2rpx solid #4FD1C5; color: #4FD1C5;
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
}
.become-member-vip { border-color: rgba(200,161,70,0.5); color: rgba(200,161,70,0.8); }
.vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; }
.vip-tag {
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF;
}
.vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; }
.profile-action-btn:active { opacity: 0.75; }
.user-wechat { font-size: 26rpx; color: #6B7280; }
.super-card-entry {
position: relative;
margin-top: 24rpx;
padding: 24rpx 56rpx 24rpx 28rpx;
border-radius: 16rpx;
background: rgba(79, 209, 197, 0.08);
border: 1rpx solid rgba(79, 209, 197, 0.28);
}
.super-card-entry-txt {
font-size: 28rpx;
font-weight: 600;
color: #4fd1c5;
display: block;
}
.super-card-entry-sub {
font-size: 22rpx;
color: #9ca3af;
margin-top: 8rpx;
display: block;
}
.super-card-entry-arrow {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 40rpx;
color: rgba(255, 255, 255, 0.35);
font-weight: 300;
}
.profile-stats-row {
display: flex; justify-content: space-around; margin-top: 32rpx;
padding-top: 24rpx; border-top: 1rpx solid #374151;
@@ -98,6 +126,15 @@
.profile-stat-val { display: block; font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
.profile-stat-label { display: block; font-size: 22rpx; color: #6B7280; margin-top: 8rpx; }
.profile-edit-bar {
display: flex; align-items: center; gap: 16rpx;
margin-top: 24rpx; padding: 20rpx 24rpx;
background: rgba(79,209,197,0.06); border-radius: 12rpx;
}
.profile-edit-icon { width: 32rpx; height: 32rpx; opacity: 0.6; flex-shrink: 0; }
.profile-edit-text { flex: 1; font-size: 26rpx; color: #9ca3af; }
.profile-edit-arrow { font-size: 36rpx; color: rgba(255,255,255,0.3); font-weight: 300; }
/* ===== 主内容区 ===== */
.main-content { padding: 0 0 0 0; }
@@ -163,19 +200,6 @@
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.recent-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; }
/* 已解锁区块:仅顶部弱对比图标,无标题字 */
.unlocked-card { padding-top: 28rpx; }
.unlocked-section-head {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 8rpx 16rpx 8rpx;
}
.unlocked-section-icon {
width: 40rpx;
height: 40rpx;
opacity: 0.92;
}
.recent-index { font-size: 28rpx; color: #6B7280; font-family: monospace; }
.recent-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-link { font-size: 24rpx; color: #4FD1C5; font-weight: 500; flex-shrink: 0; }
@@ -183,25 +207,6 @@
.recent-empty-text { font-size: 28rpx; color: #6B7280; display: block; margin-bottom: 24rpx; }
.recent-empty-btn { font-size: 28rpx; color: #4FD1C5; }
/* 已解锁章节列表底部倒三角展开(与首页「最新新增」一致) */
.unlocked-expand-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 8rpx 0 8rpx;
margin-top: 8rpx;
}
.unlocked-expand-hint-hover {
opacity: 0.65;
}
.unlocked-expand-triangle {
width: 0;
height: 0;
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-top: 20rpx solid rgba(79, 209, 197, 0.85);
}
/* 菜单 */
.menu-card { padding: 0; margin-bottom: 48rpx; overflow: hidden; }
.menu-item {

View File

@@ -13,6 +13,9 @@ const { toAvatarPath } = require('../../utils/util.js')
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
/** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */
const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done'
Page({
data: {
statusBarHeight: 44,
@@ -41,9 +44,19 @@ Page({
loading: true,
showPrivacyModal: false,
nicknameInputFocus: false,
/** 首次完善:分 3 步full=1 或未达标资料时为单页 */
wizardMode: false,
wizardStep: 1,
totalWizardSteps: 3,
/** 头像区展示:含 MBTI 默认图 */
avatarPreviewUrl: '',
},
onLoad(options) {
this._wizardRouteOpts = {
full: options?.full === '1',
wizardOff: options?.wizard === '0',
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
fromVip: options?.from === 'vip',
@@ -72,6 +85,9 @@ Page({
app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }),
app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }),
])
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
this.setData({ isVip: vipRes?.data?.isVip || false })
const res = profileRes
if (res?.success && res.data) {
@@ -98,6 +114,8 @@ Page({
projectIntro: v('projectIntro'),
loading: false,
})
this._applyWizardModeFromProfile(d)
this._syncAvatarPreview()
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
@@ -109,6 +127,49 @@ Page({
goBack() { getApp().goBackOrToHome() },
/**
* 是否走三步向导:资料未「手机号+昵称」齐全且未标记完成,且非 full=1、非 VIP 开通页强制单页。
* 老用户已齐全则自动写 DONE避免重复向导。
*/
_applyWizardModeFromProfile(d) {
const ro = this._wizardRouteOpts || {}
const forceFull = ro.full === true
const forceNoWizard = ro.wizardOff === true
const phoneNum = String(d.phone || '').replace(/\D/g, '')
const phoneOk = /^1[3-9]\d{9}$/.test(phoneNum)
const nickOk = !!(d.nickname && String(d.nickname).trim())
if (phoneOk && nickOk && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
}
const wizardMode = !forceFull && !forceNoWizard && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)
this.setData({ wizardMode, wizardStep: 1 })
},
onWizardPrev() {
if (this.data.wizardStep > 1) {
this.setData({ wizardStep: this.data.wizardStep - 1 })
}
},
onWizardNext() {
if (this.data.saving) return
const { wizardMode, wizardStep } = this.data
if (!wizardMode) return
if (wizardStep === 1) {
if (!(this.data.nickname || '').trim()) {
wx.showToast({ title: '请填写昵称', icon: 'none' })
return
}
this.setData({ wizardStep: 2 })
return
}
if (wizardStep === 2) {
this.setData({ wizardStep: 3 })
return
}
this._doSaveProfile({ wizardComplete: true })
},
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
@@ -312,7 +373,22 @@ Page({
onMbtiPickerChange(e) {
const i = parseInt(e.detail.value, 10)
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] }, () => this._syncAvatarPreview())
},
_syncAvatarPreview() {
try {
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
const url = resolveAvatarWithMbti(
this.data.avatar,
this.data.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
this.setData({ avatarPreviewUrl: url || '' })
} catch (_) {
this.setData({ avatarPreviewUrl: (this.data.avatar || '').trim() })
}
},
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
@@ -345,7 +421,7 @@ Page({
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl })
this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview())
const avatarToSave = toAvatarPath(avatarUrl)
await app.request({
url: '/api/miniprogram/user/profile',
@@ -365,7 +441,11 @@ Page({
}
},
async saveProfile() {
saveProfile() {
this._doSaveProfile({ wizardComplete: false })
},
async _doSaveProfile({ wizardComplete }) {
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
@@ -373,7 +453,6 @@ Page({
}
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
// 手机号必填,格式校验(支持带空格/连字符输入)
const phoneRaw = s(this.data.phone)
if (!phoneRaw) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
@@ -428,8 +507,17 @@ Page({
if (app.globalData.userInfo) {
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
if (payload.phone) app.globalData.userInfo.phone = payload.phone
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
if (wizardComplete) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
this.setData({ wizardMode: false, saving: false })
setTimeout(() => {
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${userId}` })
}, 400)
return
}
setTimeout(() => getApp().goBackOrToHome(), 800)
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })

View File

@@ -1,158 +1,311 @@
<!-- 资料编辑 - comprehensive_profile_editor_v1_1 | input/textarea 用 view 包裹,配色 enhanced -->
<!-- 资料编辑:单页 full=1首次未完善走三步向导保存后跳转超级个体名片 -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="#5EEAD4" customClass="back-icon"></icon></view>
<text class="nav-title">编辑资料</text>
<text class="nav-title">{{wizardMode ? '分步档案(' + wizardStep + '/3' : '编辑资料'}}</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 温馨提示from=vip 时强化权益说明 -->
<view class="tip-card {{fromVip ? 'tip-card-highlight' : ''}}">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">{{fromVip ? '恭喜成为VIP完善资料后即可使用找伙伴、提现等功能手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}}</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器(用微信头像/从相册选择/拍照) -->
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
</button>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
focus="{{nicknameInputFocus}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
bindblur="onNicknameBlur"
maxlength="20"
/>
</view>
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
<!-- —— 三步向导(首次) —— -->
<block wx:if="{{wizardMode}}">
<view class="wizard-bar">
<view class="wizard-step {{wizardStep >= 1 ? 'wizard-step-on' : ''}}">1</view>
<view class="wizard-line {{wizardStep >= 2 ? 'wizard-line-on' : ''}}"></view>
<view class="wizard-step {{wizardStep >= 2 ? 'wizard-step-on' : ''}}">2</view>
<view class="wizard-line {{wizardStep >= 3 ? 'wizard-line-on' : ''}}"></view>
<view class="wizard-step {{wizardStep >= 3 ? 'wizard-step-on' : ''}}">3</view>
</view>
<view class="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
<text class="wizard-sub">分步完善,保存后将进入「我的超级个体名片」,可转发分享</text>
<block wx:if="{{wizardStep === 1}}">
<view class="tip-card tip-card-wizard">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">第 1 步:先设置对外展示的头像与昵称</text>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:杭州·余杭区" value="{{region}}" bindinput="onRegionInput"/>
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
</button>
</view>
<view class="section section-wizard">
<view class="form-row">
<text class="form-label">昵称<text class="required-mark">*</text></text>
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
focus="{{nicknameInputFocus}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
bindblur="onNicknameBlur"
maxlength="20"
/>
</view>
<text class="input-tip">可用微信昵称或手动输入</text>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/电商" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如年GMV 5000万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人/联合创始人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row" wx:if="{{isVip}}">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如短视频制作、IP打造、私域运营" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
</block>
<!-- 核心联系方式 -->
<view class="section">
<view class="section-title">
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号<text class="required-mark">*</text></text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="请输入手机号(必填)" value="{{phone}}" bindinput="onPhoneInput"/></view>
</view>
<view class="form-row">
<text class="form-label">微信号</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入微信号" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<block wx:if="{{wizardStep === 2}}">
<view class="tip-card tip-card-wizard">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">第 2 步:补充职业画像(可后补,尽量填写便于匹配)</text>
</view>
<view class="section section-wizard">
<view class="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:福建厦门" value="{{region}}" bindinput="onRegionInput"/>
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/企业服务" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:年 GMV 5000 万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人 / 业务负责人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row" wx:if="{{isVip}}">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:私域运营、投融资对接" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
</block>
<!-- 个人故事(仅 VIP 展示) -->
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月做的是什么</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如2021年主导电商大促单月GMV突破500W..." value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如帮助3个素人打造个人IP每月稳定变现5万+" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如:辞去大厂工作开始做自媒体..." value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<block wx:if="{{wizardStep === 3}}">
<view class="tip-card tip-card-wizard">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">第 3 步:手机号必填;微信号建议填写,便于链接与提现</text>
</view>
<view class="section section-wizard">
<view class="section-title">
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号<text class="required-mark">*</text></text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="11 位手机号" value="{{phone}}" bindinput="onPhoneInput"/></view>
</view>
<view class="form-row">
<text class="form-label">微信号</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="选填" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
<view class="section-title">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>互助需求</text>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="选填" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="选填" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>项目介绍</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="选填" value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
</block>
<!-- 互助需求VIP 或 资源对接已填写时展示) -->
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
<view class="section-title">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>互助需求</text>
<view class="wizard-actions">
<view class="wizard-btn-secondary" wx:if="{{wizardStep > 1}}" bindtap="onWizardPrev">上一步</view>
<view class="wizard-btn-primary {{wizardStep === 1 ? 'wizard-btn-full' : ''}}" bindtap="onWizardNext">
{{wizardStep === 3 ? (saving ? '保存中...' : '保存并查看名片') : '下一步'}}
</view>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:短视频脚本、账号冷启动、私域转化" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:寻找供应链资源、线下活动合作" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<view class="bottom-space"></view>
</block>
<!-- 项目介绍(仅 VIP 展示) -->
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>项目介绍</text>
<!-- —— 单页完整编辑(已完善过或 full=1 —— -->
<block wx:else>
<view class="tip-card {{fromVip ? 'tip-card-highlight' : ''}}">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">{{fromVip ? '恭喜成为 VIP。补全资料后找伙伴、提现与群对接更顺畅手机号为必填' : '手机号为必填;建议填写微信号,便于提现核对与对方联系。'}}</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="详细介绍您的项目,让潜在伙伴更好地了解您..." value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '保存'}}
</view>
<view class="bottom-space"></view>
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
</button>
</view>
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
focus="{{nicknameInputFocus}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
bindblur="onNicknameBlur"
maxlength="20"
/>
</view>
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
</view>
<view class="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:杭州·余杭区" value="{{region}}" bindinput="onRegionInput"/>
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/电商" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如年GMV 5000万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人/联合创始人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row" wx:if="{{isVip}}">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如短视频制作、IP打造、私域运营" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
<view class="section">
<view class="section-title">
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号<text class="required-mark">*</text></text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="请输入手机号(必填)" value="{{phone}}" bindinput="onPhoneInput"/></view>
</view>
<view class="form-row">
<text class="form-label">微信号</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入微信号" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月做的是什么</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如2021年主导电商大促单月GMV突破500W..." value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如帮助3个素人打造个人IP每月稳定变现5万+" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如:辞去大厂工作开始做自媒体..." value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
<view class="section-title">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>互助需求</text>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:短视频脚本、账号冷启动、私域转化" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:寻找供应链资源、线下活动合作" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>项目介绍</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="详细介绍您的项目,让潜在伙伴更好地了解您..." value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '保存'}}
</view>
<view class="bottom-space"></view>
</block>
</scroll-view>
<!-- 分享名片 canvas隐藏用于生成分享图 5:4 -->
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
<!-- 隐私授权弹窗(昵称需授权后方可唤起微信昵称选择器) -->
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>

View File

@@ -333,3 +333,90 @@
.privacy-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #5EEAD4; color: #050B14; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; }
.privacy-btn::after { border: none; }
.privacy-cancel { text-align: center; margin-top: 24rpx; font-size: 28rpx; color: #94A3B8; }
/* —— 三步向导 —— */
.wizard-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-bottom: 20rpx;
padding: 0 16rpx;
}
.wizard-step {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
color: #64748b;
background: rgba(148, 163, 184, 0.15);
border: 2rpx solid rgba(148, 163, 184, 0.25);
}
.wizard-step-on {
color: #042f2e;
background: linear-gradient(135deg, #5eead4, #2dd4bf);
border-color: transparent;
}
.wizard-line {
width: 48rpx;
height: 4rpx;
border-radius: 4rpx;
background: rgba(148, 163, 184, 0.2);
}
.wizard-line-on {
background: linear-gradient(90deg, #5eead4, #2dd4bf);
}
.wizard-sub {
display: block;
font-size: 24rpx;
color: #94a3b8;
line-height: 1.5;
text-align: center;
margin-bottom: 32rpx;
padding: 0 12rpx;
}
.tip-card-wizard {
margin-bottom: 32rpx;
}
.section-wizard {
border-top: none;
padding-top: 0;
margin-bottom: 32rpx;
}
.wizard-actions {
display: flex;
flex-direction: row;
gap: 24rpx;
margin-top: 24rpx;
margin-bottom: 24rpx;
}
.wizard-btn-secondary {
flex: 1;
text-align: center;
padding: 28rpx 24rpx;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: 600;
color: #94a3b8;
border: 2rpx solid rgba(148, 163, 184, 0.35);
background: transparent;
}
.wizard-btn-primary {
flex: 1;
text-align: center;
padding: 28rpx 24rpx;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: 700;
color: #042f2e;
background: linear-gradient(135deg, #5eead4, #2dd4bf);
box-shadow: 0 8rpx 24rpx rgba(45, 212, 191, 0.25);
}
.wizard-btn-full {
flex: none;
width: 100%;
}

View File

@@ -3,6 +3,8 @@
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
*/
const app = getApp()
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
Page({
data: {
@@ -30,15 +32,27 @@ Page({
}
this.setData({ loading: true })
try {
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
if (res?.success && res.data) {
const d = res.data
const e = (v) => (v == null || v === undefined ? '' : (String(v).trim() === '' || String(v).trim() === '未填写' ? '' : String(v).trim()))
const phone = d.phone || ''
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
const av = d.avatar
const safeAv = isSafeImageSrc(av) ? String(av).trim() : ''
const displayAv = resolveAvatarWithMbti(
safeAv,
d.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
this.setData({
profile: {
...d,
avatar: displayAv,
industry: e(d.industry),
position: e(d.position),
businessScale: e(d.businessScale || d.business_scale),

View File

@@ -1,13 +1,92 @@
/**
* Soul创业实验 - 订单页
* Soul创业实验 - 订单页(已支付消费流水:章节/VIP/余额/代付等)
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
const PAID_STATUSES = new Set(['paid', 'completed', 'success'])
function parseOrderTimeMs(o) {
const raw = o.created_at || o.createdAt || o.pay_time || 0
const t = new Date(raw).getTime()
return Number.isFinite(t) ? t : 0
}
function formatShortDate(ms) {
if (!ms) return '--'
const d = new Date(ms)
const m = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
return `${m}-${day}`
}
function midForSection(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return row?.mid ?? row?.MID ?? 0
}
function classifyNav(productType, productId, mid) {
const pt = String(productType || '').toLowerCase()
if (pt === 'section' && productId) {
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
}
if (pt === 'fullbook') {
return { kind: 'switchTab', path: '/pages/chapters/chapters', label: '去目录' }
}
if (pt === 'vip') {
return { kind: 'page', path: '/pages/vip/vip', label: '会员中心' }
}
if (pt === 'match') {
return { kind: 'switchTab', path: '/pages/match/match', label: '找伙伴' }
}
if (pt === 'balance_recharge') {
return { kind: 'page', path: '/pages/wallet/wallet', label: '余额' }
}
if (pt === 'gift_pay' || pt === 'gift_pay_batch') {
return { kind: 'page', path: '/pages/gift-pay/list', label: '代付记录' }
}
if (productId && (/^\d+\.\d+/.test(productId) || productId.length > 0)) {
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
}
return { kind: 'none', label: '--' }
}
function mapApiOrderToRow(item, bookFlat) {
const status = String(item.status || '').toLowerCase()
if (!PAID_STATUSES.has(status)) return null
const pt = String(item.product_type || '').toLowerCase()
const productId = String(item.product_id || item.section_id || '').trim()
let mid = Number(item.section_mid ?? item.mid ?? item.MID ?? 0) || 0
if (pt === 'section' && productId && !mid) mid = midForSection(productId, bookFlat)
const titleRaw = cleanSingleLineField(item.product_name || '')
const title =
titleRaw ||
(pt === 'balance_recharge' ? '余额充值' : productId ? `订单 ${productId}` : '消费记录')
const amt = Number(item.amount)
const amountStr = Number.isFinite(amt) ? amt.toFixed(2) : '--'
const t = parseOrderTimeMs(item)
const nav = classifyNav(pt, productId, mid)
return {
rowKey: String(item.order_sn || item.id || `o_${t}`),
title,
subLine: `¥${amountStr} · ${formatShortDate(t)}`,
actionLabel: nav.label,
nav,
_sortMs: t
}
}
Page({
data: {
statusBarHeight: 44,
orders: [],
loading: true
loading: true,
allRows: [],
displayRows: [],
historyExpanded: false
},
onLoad() {
@@ -16,63 +95,95 @@ Page({
this.loadOrders()
},
onShow() {
if (!this._purchasesFirstOnShowSkipped) {
this._purchasesFirstOnShowSkipped = true
return
}
if (app.globalData.isLoggedIn) this.loadOrders()
},
applyDisplay(expanded) {
const all = this.data.allRows || []
const display = expanded || all.length <= 5 ? all : all.slice(0, 5)
this.setData({ displayRows: display, historyExpanded: !!expanded })
},
expandHistory() {
if (this.data.historyExpanded) return
this.applyDisplay(true)
},
async loadOrders() {
this.setData({ loading: true })
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const userId = app.globalData.userInfo?.id
try {
const userId = app.globalData.userInfo?.id
if (userId) {
const res = await app.request(`/api/miniprogram/orders?userId=${userId}`)
if (res && res.success && res.data) {
const raw = (res.data || []).map(item => ({
id: item.id || item.order_sn,
sectionId: item.product_id || item.section_id,
sectionMid: item.section_mid ?? item.mid ?? 0,
title: item.product_name || `章节 ${item.product_id || ''}`,
amount: item.amount || 0,
status: item.status || 'completed',
createTime: item.created_at ? new Date(item.created_at).toLocaleDateString() : '--',
_sortMs: new Date(item.created_at || item.pay_time || 0).getTime() || 0
}))
raw.sort((a, b) => b._sortMs - a._sortMs)
const orders = raw.map(({ _sortMs, ...rest }) => rest)
this.setData({ orders })
const res = await app.request({
url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (res && res.success && Array.isArray(res.data)) {
const rows = res.data
.map((item) => mapApiOrderToRow(item, bookFlat))
.filter(Boolean)
.sort((a, b) => b._sortMs - a._sortMs)
.map(({ _sortMs, ...rest }) => rest)
this.setData({ allRows: rows, loading: false })
this.applyDisplay(false)
return
}
}
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
const orders = purchasedSections.map((id, index) => ({
id: `order_${index}`,
sectionId: id,
sectionMid: 0,
title: `章节 ${id}`,
amount: 1,
status: 'completed',
createTime: new Date(Date.now() - index * 86400000).toLocaleDateString()
}))
this.setData({ orders })
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const rows = ids.map((id, index) => {
const mid = midForSection(id, bookFlat)
const row = bookFlat.find((s) => s.id === id)
const title =
cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
const t = Date.now() - index * 86400000
return {
rowKey: `p_${id}_${index}`,
title,
subLine: `已解锁 · ${formatShortDate(t)}`,
actionLabel: '阅读',
nav: { kind: 'read', id, mid, label: '阅读' }
}
})
this.setData({ allRows: rows, loading: false })
this.applyDisplay(false)
} catch (e) {
console.error('加载订单失败:', e)
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
this.setData({
orders: purchasedSections.map((id, i) => ({
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
}))
})
} finally {
this.setData({ loading: false })
this.setData({ allRows: [], loading: false })
this.applyDisplay(false)
}
},
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
if (!id) return
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
onOrderRowTap(e) {
const index = e.currentTarget.dataset.index
const row = (this.data.displayRows || [])[index]
if (!row || !row.nav) return
const { nav } = row
if (nav.kind === 'read' && nav.id) {
const q = nav.mid ? `mid=${nav.mid}` : `id=${nav.id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
return
}
if (nav.kind === 'page' && nav.path) {
wx.navigateTo({ url: nav.path })
return
}
if (nav.kind === 'switchTab' && nav.path) {
wx.switchTab({ url: nav.path })
}
},
goBack() { getApp().goBackOrToHome() },
goBack() {
getApp().goBackOrToHome()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()

View File

@@ -14,20 +14,37 @@
<view class="skeleton"></view>
</view>
<view class="orders-list" wx:elif="{{orders.length > 0}}">
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}" data-mid="{{item.sectionMid}}">
<view class="order-info">
<view class="order-title-row">
<text class="order-unlock-icon">🔓</text>
<text class="order-title">{{item.title}}</text>
<view class="order-history-card" wx:elif="{{displayRows.length > 0}}">
<view class="order-history-head">
<image class="order-history-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="oh-list">
<view
class="oh-row"
wx:for="{{displayRows}}"
wx:key="rowKey"
bindtap="onOrderRowTap"
data-index="{{index}}"
>
<view class="oh-left">
<text class="oh-index">{{index + 1}}</text>
<view class="oh-text-wrap">
<text class="oh-title">{{item.title}}</text>
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
</view>
</view>
<text class="order-time">{{item.createTime}}</text>
</view>
<view class="order-right">
<text class="order-amount">¥{{item.amount}}</text>
<text class="order-status">已完成</text>
<text class="oh-link">{{item.actionLabel}}</text>
</view>
</view>
<view
class="oh-expand"
wx:if="{{allRows.length > 5 && !historyExpanded}}"
bindtap="expandHistory"
hover-class="oh-expand-hover"
hover-stay-time="80"
>
<view class="oh-triangle"></view>
</view>
</view>
<view class="empty" wx:else>

View File

@@ -7,17 +7,29 @@
.loading { display: flex; flex-direction: column; gap: 24rpx; }
.skeleton { height: 120rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton 1.5s ease-in-out infinite; border-radius: 24rpx; }
@keyframes skeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.orders-list { display: flex; flex-direction: column; gap: 16rpx; }
.order-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx; background: #1c1c1e; border-radius: 24rpx; }
.order-item:active { opacity: 0.92; }
.order-info { flex: 1; min-width: 0; }
.order-title-row { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 8rpx; }
.order-unlock-icon { font-size: 26rpx; line-height: 1.35; opacity: 0.55; flex-shrink: 0; }
.order-title { font-size: 28rpx; color: #fff; flex: 1; min-width: 0; }
.order-time { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.order-right { text-align: right; }
.order-amount { font-size: 28rpx; font-weight: 600; color: #00CED1; display: block; margin-bottom: 4rpx; }
.order-status { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.order-history-card { background: #1c1c1e; border-radius: 24rpx; padding: 28rpx 24rpx 16rpx; }
.order-history-head { padding: 0 8rpx 16rpx 8rpx; }
.order-history-icon { width: 40rpx; height: 40rpx; opacity: 0.92; }
.oh-list { display: flex; flex-direction: column; gap: 16rpx; }
.oh-row {
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.oh-row:active { opacity: 0.92; }
.oh-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; flex: 1; }
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; }
.oh-index { font-size: 28rpx; color: #6B7280; font-family: monospace; flex-shrink: 0; }
.oh-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oh-sub { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.oh-link { font-size: 24rpx; color: #00CED1; font-weight: 500; flex-shrink: 0; margin-left: 16rpx; }
.oh-expand { display: flex; justify-content: center; align-items: center; padding: 8rpx 0 8rpx; margin-top: 8rpx; }
.oh-expand-hover { opacity: 0.65; }
.oh-triangle {
width: 0; height: 0;
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-top: 20rpx solid rgba(0, 206, 209, 0.85);
}
.empty { display: flex; flex-direction: column; align-items: center; padding: 96rpx; }
.empty-icon { font-size: 96rpx; margin-bottom: 24rpx; opacity: 0.5; }
.empty-text { font-size: 28rpx; color: rgba(255,255,255,0.4); }

View File

@@ -17,8 +17,8 @@ const accessManager = require('../../utils/chapterAccessManager')
const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
@@ -93,7 +93,7 @@ Page({
// 价格
sectionPrice: 1,
fullBookPrice: 9.9,
totalSections: 0, // 来自 app.getTotalSections() 或 book/parts
totalSections: 62,
// 弹窗
showShareModal: false,
@@ -116,21 +116,69 @@ Page({
// 余额(用于余额支付)
walletBalance: 0,
// 未解锁时显示的预览比例来自文章详情用于付费墙「已阅读X%」)
previewPercent: 20,
// 审核模式:隐藏购买按钮
auditMode: false,
// 分润比例(来自 config.shareRate用于分享提示文案
shareRate: 90,
// 好友从代付分享进入:待自动领取的 requestSn
pendingGiftRequestSn: '',
// 朋友圈单页模式scene 1154 / systemInfo.mode无法登录与支付仅引导「前往小程序」
readSinglePageMode: false,
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal
momentsPaywallExpanded: false,
},
/**
* 是否处于朋友圈等「单页预览」环境。
* 兼容:部分机型/基础库首帧 getSystemInfoSync().mode 未就绪,需结合 launch/enter scene 1154、getWindowInfo。
* 命中时同步 app.globalData.isSinglePageMode保证 ensureFullAppForAuth 与页内 wx:if 一致。
*/
_detectReadSinglePage() {
try {
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
if (launch && Number(launch.scene) === 1154) {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const enter = typeof wx.getEnterOptionsSync === 'function' ? wx.getEnterOptionsSync() : null
if (enter && Number(enter.scene) === 1154) {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const win = typeof wx.getWindowInfo === 'function' ? wx.getWindowInfo() : null
if (win && win.mode === 'singlePage') {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
return !!app.globalData.isSinglePageMode
},
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
onUnlockTapInSinglePage() {
trackClick('read', 'btn_click', '单页_解锁引导')
try {
wx.vibrateShort({ type: 'light' })
} catch (e) {}
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
const sp = this._detectReadSinglePage()
this.setData({
auditMode: app.globalData.auditMode || false,
readSinglePageMode: sp,
...(sp ? {} : { momentsPaywallExpanded: false }),
})
},
async onLoad(options) {
@@ -194,7 +242,8 @@ Page({
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || '',
totalSections: app.getTotalSections()
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
})
if (ref) {
@@ -205,12 +254,9 @@ Page({
try {
const config = await accessManager.fetchLatestConfig()
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9,
shareRate,
totalSections: app.getTotalSections()
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 统一:先拉章节数据,用 isFree/price===0 判断免费
@@ -245,9 +291,10 @@ Page({
}
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
} else {
app.touchRecentSection(id)
}
// 5. 导航:文章详情已带 prev/next
@@ -380,15 +427,13 @@ Page({
chapterTitle: res.chapterTitle || ''
}
if (res.mid) updates.sectionMid = res.mid
if (res.previewPercent != null && res.previewPercent >= 1 && res.previewPercent <= 100) {
updates.previewPercent = res.previewPercent
}
this.setData(updates)
// 写入本地缓存(存 displayContent供离线/重试降级使用)
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
app.touchRecentSection(id)
}
} catch (e) {
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
@@ -407,6 +452,7 @@ Page({
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
app.touchRecentSection(id)
console.log('[Read] 从本地缓存加载成功')
return
}
@@ -683,13 +729,71 @@ Page({
})
},
// 存客宝留资:统一 soulBridge.submitCkbLead与会员详情点头像同链路
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
async _doMentionAddFriend(targetUserId, targetNickname) {
await soulBridge.submitCkbLead(app, {
targetUserId,
targetNickname,
source: 'article_mention'
})
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
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?.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)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
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,
targetNickname: targetNickname || undefined,
source: 'article_mention'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
// 分享弹窗
@@ -740,11 +844,7 @@ Page({
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '朋友圈单页',
content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。',
showCancel: false
})
this.onUnlockTapInSinglePage()
return
}
} catch (e) {}
@@ -798,8 +898,24 @@ Page({
}
const payParams = payRes.data.payParams
const orderSn = payRes.data.orderSn
await soulBridge.requestWxJsapiPayment(payParams)
await soulBridge.syncOrderStatusQuery(app, orderSn)
await new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType || 'RSA',
paySign: payParams.paySign,
success: resolve,
fail: reject
})
})
// 3) 主动同步(与其他支付流程一致)
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
} catch (e) {}
}
wx.showToast({ title: '支付成功', icon: 'success' })
this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false })
@@ -817,7 +933,8 @@ Page({
// 复制链接
copyLink() {
const referralCode = app.getMyReferralCode() || ''
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || ''
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
wx.setClipboardData({
@@ -833,10 +950,9 @@ Page({
copyShareText() {
const { section } = this.data
const total = app.getTotalSections()
const shareText = `🔥 刚看完这篇《${section?.title || '卡若创业派对'}》,太上头了!
${total}个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
62个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
推荐给正在创业或想创业的朋友,搜"卡若创业派对"小程序就能看!
@@ -872,17 +988,9 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
return { title, path }
},
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户
onShareTimelineTap() {
wx.showToast({
title: '请点击右上角「...」→ 分享到朋友圈',
icon: 'none',
duration: 2500
})
},
// 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角)
// 分享到朋友圈:复制摘要 + 引导用户用右上角「···」发圈(无 open-type=shareTimeline
shareToMoments() {
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
@@ -901,7 +1009,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -928,21 +1036,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '请前往完整小程序',
content: '当前为朋友圈单页,仅支持部分浏览。想登录继续阅读,请点击底部「前往小程序」后再操作。',
showCancel: false,
confirmText: '我知道了',
})
return
}
} catch (e) {
console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e)
// 单页模式无法弹登录组件:页内已说明「前往小程序」,不再弹 Modal
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
try {
this.setData({ showLoginModal: true })
@@ -1026,6 +1123,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId)
console.log('[Pay] 点击购买章节按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1045,6 +1146,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1063,6 +1168,14 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 处理支付 - 调用真实微信支付接口
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
try {
wx.hideLoading()
} catch (e) {}
this.onUnlockTapInSinglePage()
return
}
const userInfo = app.globalData.userInfo
if (userInfo?.id) {
@@ -1072,10 +1185,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像昵称',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '支付订单会关联你的对外展示信息,请先设置头像昵称,避免账单与对方看到默认占位。',
confirmText: '去设置',
cancelText: '关闭',
success: resolve
})
})
@@ -1132,7 +1245,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
try {
// 0. 尝试余额支付(若余额足够)
const userId = app.globalData.userInfo?.id
const referralCode = soulBridge.getReferralCodeForPay(app)
const referralCode = wx.getStorageSync('referral_code') || ''
if (userId) {
try {
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
@@ -1196,9 +1309,14 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
let paymentData = null
try {
// 获取章节完整名称用于支付描述
const sectionTitle = this.data.section?.title || sectionId
const description = soulBridge.buildSectionPayDescription(type, sectionId, sectionTitle)
const referralCode = soulBridge.getReferralCodeForPay(app)
const description = type === 'fullbook'
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
const referralCode = wx.getStorageSync('referral_code') || ''
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -1229,7 +1347,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
cancelText: '关闭',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
@@ -1250,11 +1368,18 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
try {
await soulBridge.requestWxJsapiPayment(paymentData)
await this.callWechatPay(paymentData)
// 4. 【关键】主动向微信查询订单状态并同步到本地(不依赖回调,解决订单一直 created 的问题)
const orderSn = paymentData._orderSn || paymentData.orderSn
await soulBridge.syncOrderStatusQuery(app, orderSn)
if (orderSn) console.log('[Pay] 已主动同步订单状态:', orderSn)
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
console.log('[Pay] 已主动同步订单状态:', orderSn)
} catch (e) {
console.warn('[Pay] 主动同步订单失败,继续刷新购买状态:', e)
}
}
// 5. 【标准流程】刷新权限并解锁内容
console.log('[Pay] 微信支付成功!')
@@ -1342,6 +1467,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
checkAndExecute('after_pay', this)
} catch (e) {
wx.hideLoading()
@@ -1389,6 +1515,21 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
}
},
// 调用微信支付
callWechatPay(paymentData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'MD5',
paySign: paymentData.paySign,
success: resolve,
fail: reject
})
})
},
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
@@ -1412,6 +1553,25 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.navigateTo({ url: '/pages/referral/referral' })
},
/** 海报 canvas 在弹层渲染后偶现取不到 node多次重试 */
async _queryPosterCanvasNode(maxTry = 10, delayMs = 100) {
for (let i = 0; i < maxTry; i++) {
const node = await new Promise((resolve) => {
wx.createSelectorQuery()
.in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (res && res[0] && res[0].node) resolve(res[0])
else resolve(null)
})
})
if (node) return node
await new Promise((r) => setTimeout(r, delayMs))
}
return null
},
// 生成海报Canvas 2D API
async generatePoster() {
wx.showLoading({ title: '生成中...' })
@@ -1422,6 +1582,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
else setTimeout(resolve, 50)
})
await new Promise((r) => setTimeout(r, 120))
try {
const { section, contentParagraphs, sectionId, sectionMid } = this.data
@@ -1439,18 +1600,12 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (qrRes.success && qrRes.image) qrcodeImage = qrRes.image
} catch (_) {}
const canvasNode = await new Promise((resolve, reject) => {
wx.createSelectorQuery().in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (res && res[0] && res[0].node) resolve(res[0])
else reject(new Error('canvas node not found'))
})
})
const canvasNode = await this._queryPosterCanvasNode()
if (!canvasNode) {
throw new Error('canvas node not found')
}
const canvas = canvasNode.node
const ctx = canvas.getContext('2d')
let dpr = 2
try {
if (typeof wx.getWindowInfo === 'function') {
@@ -1461,73 +1616,100 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
} catch (_) {
dpr = 2
}
const width = 300
const height = 450
canvas.width = width * dpr
canvas.height = height * dpr
// 布局尺寸:优先用节点测量;为 0 时回退 300×450避免真机 query 过早得到 0 导致空白)
const layoutW = (canvasNode.width && canvasNode.width > 1) ? Math.round(canvasNode.width) : 300
const layoutH = (canvasNode.height && canvasNode.height > 1) ? Math.round(canvasNode.height) : 450
canvas.width = Math.max(1, Math.floor(layoutW * dpr))
canvas.height = Math.max(1, Math.floor(layoutH * dpr))
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d not supported')
ctx.scale(dpr, dpr)
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.fillStyle = grd
ctx.fillRect(0, 0, width, height)
const paintPoster = async () => {
const w = layoutW
const h = layoutH
const grd = ctx.createLinearGradient(0, 0, 0, h)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.fillStyle = grd
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, width, 4)
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, w, 4)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('📚 卡若创业派对', 20, 35)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('卡若创业派对', 20, 35)
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText2d(ctx, title, width - 40)
let y = 70
titleLines.forEach(line => { ctx.fillText(line, 20, y); y += 26 })
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText2d(ctx, title, w - 40)
let y = 70
titleLines.forEach((line) => { ctx.fillText(line, 20, y); y += 26 })
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(w - 20, y + 10)
ctx.stroke()
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
const summaryLines = this.wrapText2d(ctx, summary, width - 40)
summaryLines.slice(0, 6).forEach(line => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, height - 100, width, 100)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, width - 85, height - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, width, height)
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
let paras = Array.isArray(contentParagraphs) ? contentParagraphs.filter(Boolean) : []
if (!paras.length && this.data.content) {
const plain = String(this.data.content)
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (plain) paras = [plain.slice(0, 400)]
}
const rawSummary = paras.slice(0, 3).join(' ').trim() || '卡若创业派对 · 真实商业故事'
const summary = rawSummary.length > 160 ? rawSummary.slice(0, 157) + '...' : rawSummary
const summaryLines = this.wrapText2d(ctx, summary, w - 40)
summaryLines.slice(0, 6).forEach((line) => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, h - 100, w, 100)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, h - 60)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, h - 38)
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, w - 85, h - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, w, h)
}
} else {
this.drawQRPlaceholder2d(ctx, w, h)
}
}
if (typeof canvas.requestAnimationFrame === 'function') {
await new Promise((resolve, reject) => {
canvas.requestAnimationFrame(() => {
paintPoster().then(resolve).catch(reject)
})
})
} else {
this.drawQRPlaceholder2d(ctx, width, height)
await paintPoster()
}
wx.hideLoading()
@@ -1635,13 +1817,11 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
try {
const config = await accessManager.fetchLatestConfig()
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9,
shareRate
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 重新拉取章节,用 isFree/price 判断免费
const chapterRes = await app.request({
url: this._getChapterUrl({}),

View File

@@ -89,21 +89,21 @@
</view>
</view>
<!-- 分享操作区 -->
<!-- 分享区:仅好友/海报/代付;完整小程序发圈见右下角悬浮钮 -->
<view class="action-section">
<view class="action-row-inline">
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
<icon name="megaphone" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">分享到朋友圈</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<button plain class="action-share-native action-tile-unified" open-type="share" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="share" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">分享给好友</text>
</button>
<button plain class="action-share-native action-tile-unified" bindtap="generatePoster" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="image" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">生成海报</text>
</view>
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
</button>
<button plain class="action-share-native action-tile-unified" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">代付分享</text>
</view>
</button>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 {{shareRate || 90}}% 收益</text>
@@ -122,23 +122,36 @@
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 - 未登录:显示购买按钮(朋友圈/分享场景) -->
<view class="paywall">
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」点后再展开极简说明 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并购买后阅读全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">解锁全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
</block>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并支付 ¥1 后阅读全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
</view>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
</block>
</view>
<!-- 章节导航 -->
@@ -182,39 +195,47 @@
<view class="fade-mask"></view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall">
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<!-- 购买选项(审核模式隐藏) -->
<view class="purchase-options" wx:if="{{!auditMode}}">
<!-- 购买本章 - 直接调起支付 -->
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<!-- 解锁全书 - 只有购买超过3章才显示 -->
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice || 9.9}}</text>
<text class="btn-discount">省82%</text>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">解锁全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
</block>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<!-- 代付分享:帮好友购买(审核模式隐藏) -->
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
</block>
</view>
<!-- 章节导航 -->
@@ -270,9 +291,9 @@
</view>
</view>
<!-- 海报生成弹窗 -->
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content poster-modal" catchtap="stopPropagation">
<!-- 海报生成弹窗:居中 + z-index 高于右下角悬浮,避免「空白」错觉 -->
<view class="modal-overlay modal-overlay-center" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content modal-content-center poster-modal" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">生成海报</text>
<view class="modal-close" bindtap="closePosterModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
@@ -357,8 +378,12 @@
</view>
</view>
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<icon name="share" size="44" color="#0f172a" customClass="fab-share-icon"></icon>
<!-- 单页预览(朋友圈):指向底栏「前往小程序」,字少;完整小程序仍保留发圈悬浮钮 -->
<view class="singlepage-launch-pointer" wx:if="{{readSinglePageMode && momentsPaywallExpanded}}" aria-hidden="true">
<view class="singlepage-launch-pointer__arrow"></view>
</view>
<view class="fab-share-moments" wx:if="{{!readSinglePageMode}}" bindtap="shareToMoments" hover-class="fab-share-moments-hover" aria-label="分享到朋友圈">
<icon name="share" size="44" color="#ffffff" customClass="fab-share-moments-icon"></icon>
</view>
</view>

View File

@@ -280,6 +280,36 @@
margin-bottom: 16rpx;
}
/* 朋友圈单页:与完整小程序同款购买行,留白略紧 */
.paywall--single-preview {
padding-top: 40rpx;
padding-bottom: 40rpx;
}
.paywall--single-preview .paywall-icon {
margin-bottom: 24rpx;
}
.paywall--single-preview .paywall-title {
margin-bottom: 28rpx;
}
.paywall-desc--moments-expanded {
margin-top: 28rpx !important;
margin-bottom: 0 !important;
font-size: 26rpx !important;
line-height: 1.45;
padding: 0 8rpx;
}
/* 朋友圈单页:未点解锁前的一行轻提示 */
.paywall-hint-compact {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.48);
text-align: center;
display: block;
margin-bottom: 36rpx;
line-height: 1.55;
padding: 0 16rpx;
}
.paywall-desc {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
@@ -360,6 +390,33 @@
margin-left: 8rpx;
}
.paywall-singlepage-note {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
text-align: center;
line-height: 1.5;
}
/* 朋友圈单页付费墙底部:与正文文末「分享赚收益」文案一致 */
.paywall-share-earn-wrap {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.paywall-share-earn-wrap .share-tip-text {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.paywall-share-earn-sub {
margin-top: 12rpx !important;
display: block;
}
.paywall-tip {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
@@ -470,7 +527,14 @@
.action-row-inline {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
align-items: stretch;
gap: 12rpx;
}
/* 底部三按钮:同一底纹与描边(好友 / 海报 / 代付) */
.action-tile-unified {
background: rgba(255, 255, 255, 0.06) !important;
border: 2rpx solid rgba(0, 206, 209, 0.28) !important;
}
.action-btn-inline {
@@ -489,21 +553,38 @@
overflow: hidden;
}
.action-btn-inline::after {
/* 分享给好友:原生 button + open-type=share样式与 action-btn-inline 对齐 */
.action-share-native {
flex: 1 1 0;
min-width: 0;
min-height: 96rpx;
margin: 0;
padding: 24rpx 12rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
line-height: normal;
font-size: inherit;
box-sizing: border-box;
overflow: hidden;
}
.action-share-native::after {
border: none;
}
.btn-share-inline {
background: rgba(7, 193, 96, 0.15);
border: 2rpx solid rgba(7, 193, 96, 0.3);
button.action-share-native {
color: inherit;
}
.action-share-native-hover {
opacity: 0.85;
}
.btn-poster-inline {
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.3);
.action-btn-inline::after {
border: none;
}
.action-icon-small {
font-size: 28rpx;
flex-shrink: 0;
@@ -597,7 +678,8 @@
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
/* 高于右下角悬浮钮,避免弹层被盖住或 canvas 不可见 */
z-index: 10050;
}
.modal-overlay-center {
@@ -1201,6 +1283,9 @@
/* ===== 海报弹窗 ===== */
.poster-modal {
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
max-height: 85vh;
overflow-y: auto;
box-sizing: border-box;
}
.poster-preview {
@@ -1251,44 +1336,54 @@
display: block;
}
/* ===== 右下角悬浮分享按钮 ===== */
.fab-share {
/* ===== 右下角:分享到朋友圈(固定悬浮小圆钮,不在文末分享行) ===== */
.fab-share-moments {
position: fixed;
right: 32rpx;
width:70rpx!important;
bottom: calc(120rpx + env(safe-area-inset-bottom));
height: 70rpx;
border-radius: 60rpx;
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%);
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.4);
padding: 0;
margin: 0;
border: none;
z-index: 9999;
display:flex;
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.42);
z-index: 9980;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.fab-share::after {
border: none;
.fab-share-moments-hover {
opacity: 0.9;
}
.fab-share:active {
transform: scale(0.95);
box-shadow: 0 4rpx 20rpx rgba(0, 206, 209, 0.5);
.fab-share-moments:active {
transform: scale(0.94);
}
.fab-icon {
padding:16rpx;
width: 50rpx;
height: 50rpx;
display: block;
}
.fab-share-icon {
.fab-share-moments-icon {
font-size: 44rpx;
line-height: 1;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25));
}
/* 朋友圈单页:点「购买本章」后,箭头指向底栏「前往小程序」(字少,仅符号) */
.singlepage-launch-pointer {
position: fixed;
right: 48rpx;
bottom: calc(168rpx + env(safe-area-inset-bottom));
z-index: 99985;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.singlepage-launch-pointer__arrow {
font-size: 56rpx;
line-height: 1;
color: #00CED1;
text-shadow: 0 0 20rpx rgba(0, 206, 209, 0.55);
transform: rotate(0deg);
animation: singlepage-launch-pulse 1.25s ease-in-out infinite;
}
@keyframes singlepage-launch-pulse {
0%, 100% { opacity: 0.75; transform: translate(0, 0) scale(1); }
50% { opacity: 1; transform: translate(8rpx, 10rpx) scale(1.06); }
}

View File

@@ -0,0 +1,178 @@
/**
* 阅读记录:最近阅读 + 已读章节(与「我的」数据源一致)
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
function titleFromBookData(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${sectionId}`
}
function midFromBookData(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return row?.mid ?? row?.MID ?? 0
}
function mergeRecentFromLocal(apiList) {
const normalized = Array.isArray(apiList)
? apiList.map((item) => ({
id: item.id,
mid: item.mid,
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
}))
: []
if (normalized.length > 0) return normalized
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
return Object.keys(progressData)
.map((id) => ({
id,
ts: progressData[id]?.lastOpenAt || progressData[id]?.last_open_at || 0
}))
.filter((e) => e.id)
.sort((a, b) => b.ts - a.ts)
.slice(0, 20)
.map((e) => ({
id: e.id,
mid: midFromBookData(e.id, bookFlat),
title: titleFromBookData(e.id, bookFlat)
}))
} catch (e) {
return []
}
}
Page({
data: {
statusBarHeight: 44,
isLoggedIn: false,
focus: 'all',
recentList: [],
readAllList: [],
recentSectionTitle: '最近阅读',
readSectionTitle: '已读章节'
},
onLoad(options) {
const focus = (options.focus === 'recent' || options.focus === 'read') ? options.focus : 'all'
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
focus
})
this._applyMpUiTitles()
},
onShow() {
this.setData({ isLoggedIn: !!(app.globalData.isLoggedIn && app.globalData.userInfo?.id) })
this._applyMpUiTitles()
if (this.data.isLoggedIn) this.loadData()
},
_applyMpUiTitles() {
const my = app.globalData.configCache?.mpConfig?.mpUi?.myPage || {}
this.setData({
recentSectionTitle: my.recentReadTitle || '最近阅读',
readSectionTitle: my.readStatLabel || '已读章节'
})
},
async _ensureBookFlat() {
let flat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
if (flat.length) return flat
try {
const r = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const list = r?.data
if (Array.isArray(list) && list.length) {
app.globalData.bookData = list
return list
}
} catch (_) {}
return []
},
async loadData() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({
url: `/api/miniprogram/user/dashboard-stats?userId=${encodeURIComponent(userId)}`,
silent: true
})
const bookFlat = await this._ensureBookFlat()
let recent = []
let readIds = []
if (res?.success && res.data) {
const apiRecent = Array.isArray(res.data.recentChapters) ? res.data.recentChapters : []
recent = mergeRecentFromLocal(apiRecent)
readIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
} else {
recent = mergeRecentFromLocal([])
}
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const fromKeys = Object.keys(progressData).filter(Boolean)
const stored = wx.getStorageSync('readSectionIds')
const fromStored = Array.isArray(stored) ? stored.filter(Boolean) : []
const fromGlobal = Array.isArray(app.globalData.readSectionIds)
? app.globalData.readSectionIds.filter(Boolean)
: []
readIds = [...new Set([...readIds, ...fromGlobal, ...fromStored, ...fromKeys])]
} catch (_) {}
if (readIds.length === 0 && recent.length > 0) {
readIds = recent.map((r) => r.id)
}
const readAllList = readIds.map((id) => ({
id,
mid: midFromBookData(id, bookFlat),
title: titleFromBookData(id, bookFlat)
}))
this.setData({ recentList: recent, readAllList })
} catch (e) {
console.warn('[reading-records]', e)
try {
const bookFlat = await this._ensureBookFlat()
let readIds = Array.isArray(app.globalData.readSectionIds) ? [...app.globalData.readSectionIds] : []
if (!readIds.length) {
try {
const stored = wx.getStorageSync('readSectionIds')
if (Array.isArray(stored)) readIds = [...stored]
} catch (_) {}
}
const recent = mergeRecentFromLocal([])
if (!readIds.length && recent.length) readIds = recent.map((r) => r.id)
const readAllList = readIds.map((id) => ({
id,
mid: midFromBookData(id, bookFlat),
title: titleFromBookData(id, bookFlat)
}))
this.setData({ recentList: recent, readAllList })
} catch (_) {
this.setData({ recentList: [], readAllList: [] })
}
}
},
goRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
if (!id) return
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
goChapters() {
wx.switchTab({ url: '/pages/chapters/chapters' })
},
goLogin() {
wx.switchTab({ url: '/pages/my/my' })
},
goBack() {
getApp().goBackOrToHome()
}
})

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationStyle": "custom"
}

View File

@@ -0,0 +1,51 @@
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
<text class="nav-title">阅读记录</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content" wx:if="{{isLoggedIn}}">
<view class="section" wx:if="{{focus === 'all' || focus === 'recent'}}">
<view class="section-head">
<text class="section-title">{{recentSectionTitle}}</text>
<text class="section-count" wx:if="{{recentList.length > 0}}">{{recentList.length}} 条</text>
</view>
<view class="list" wx:if="{{recentList.length > 0}}">
<view class="row" wx:for="{{recentList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<text class="row-idx">{{index + 1}}</text>
<text class="row-title">{{item.title}}</text>
<text class="row-link">阅读</text>
</view>
</view>
<view class="empty" wx:else>
<text class="empty-t">暂无最近阅读</text>
<text class="empty-a" bindtap="goChapters">去目录逛逛</text>
</view>
</view>
<view class="section" wx:if="{{focus === 'all' || focus === 'read'}}">
<view class="section-head">
<text class="section-title">{{readSectionTitle}}</text>
<text class="section-count" wx:if="{{readAllList.length > 0}}">共 {{readAllList.length}} 章</text>
</view>
<view class="list" wx:if="{{readAllList.length > 0}}">
<view class="row" wx:for="{{readAllList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<text class="row-idx">{{index + 1}}</text>
<text class="row-title">{{item.title}}</text>
<text class="row-link">阅读</text>
</view>
</view>
<view class="empty" wx:else>
<text class="empty-t">暂无已读记录</text>
<text class="empty-a" bindtap="goChapters">去目录选章阅读</text>
</view>
</view>
</view>
<view class="guest" wx:else>
<text class="guest-t">登录后查看阅读记录</text>
<view class="guest-btn" bindtap="goLogin">去登录</view>
</view>
</view>

View File

@@ -0,0 +1,25 @@
.page { min-height: 100vh; background: #000; padding-bottom: 48rpx; }
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.92); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-back { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; flex: 1; text-align: center; }
.nav-placeholder { width: 72rpx; }
.content { padding: 32rpx; }
.section { margin-bottom: 48rpx; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24rpx; }
.section-title { font-size: 32rpx; font-weight: 600; color: #e5e7eb; }
.section-count { font-size: 24rpx; color: #6b7280; }
.list { display: flex; flex-direction: column; gap: 16rpx; }
.row {
display: flex; align-items: center; gap: 20rpx;
padding: 24rpx; background: #1c1c1e; border-radius: 20rpx;
}
.row:active { opacity: 0.9; }
.row-idx { font-size: 26rpx; color: #6b7280; font-family: monospace; flex-shrink: 0; }
.row-title { flex: 1; min-width: 0; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-link { font-size: 24rpx; color: #00CED1; flex-shrink: 0; }
.empty { padding: 48rpx 24rpx; text-align: center; }
.empty-t { display: block; font-size: 28rpx; color: #6b7280; margin-bottom: 24rpx; }
.empty-a { font-size: 28rpx; color: #00CED1; }
.guest { padding: 120rpx 48rpx; text-align: center; }
.guest-t { display: block; font-size: 30rpx; color: #9ca3af; margin-bottom: 32rpx; }
.guest-btn { display: inline-block; padding: 20rpx 48rpx; background: #00CED1; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }

View File

@@ -35,7 +35,7 @@ Page({
minWithdrawAmount: 10, // 最低提现金额,从 referral/data 获取
bindingDays: 30, // 绑定期天数,从 referral/data 获取
userDiscount: 5, // 好友购买优惠%,从 referral/data 获取
hasWechatId: false, // 是否已绑定微信号(未绑定时需引导去设置)
hasWechatId: false, // 是否已绑定微信号(未绑定时提示去设置
// === 统计数据 ===
referralCount: 0, // 总推荐人数
@@ -46,6 +46,8 @@ Page({
// 绑定用户列表
showBindingList: true,
showShareSection: false,
showEarningsDetails: false,
activeTab: 'active',
activeBindings: [],
convertedBindings: [],
@@ -63,10 +65,13 @@ Page({
posterReferralLink: '',
posterNickname: '',
posterNicknameInitial: '',
posterCaseCount: 0 // 来自 app.getTotalSections()initData 后更新
/** 推广海报推荐人头像:优先微信头像,否则按 MBTI 用后台映射 */
posterDisplayAvatar: '',
posterCaseCount: 62
},
onLoad() {
onLoad(options) {
this._focusTarget = (options && options.focus) || ""
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this.initData()
// 启用分享到朋友圈(需同时有 onShareAppMessage 和 onShareTimelinemenus 在 Android 支持iOS 为 Beta
@@ -174,7 +179,6 @@ Page({
minWithdrawAmount: minWithdrawAmount,
bindingDays: realData?.bindingDays ?? 30,
userDiscount: realData?.userDiscount ?? 5,
posterCaseCount: app.getTotalSections(),
// 统计
referralCount: realData?.referralCount || realData?.stats?.totalBindings || activeBindings.length + convertedBindings.length,
@@ -208,12 +212,37 @@ Page({
// 隐藏加载提示
wx.hideLoading()
this._applyFocusTarget()
} else {
// 未登录时也隐藏loading
this.setData({ isLoading: false })
}
},
toggleShareSection() {
this.setData({ showShareSection: !this.data.showShareSection })
},
toggleEarningsDetails() {
this.setData({ showEarningsDetails: !this.data.showEarningsDetails })
},
_applyFocusTarget() {
const t = this._focusTarget || ''
if (!t) return
if (t === 'bindings') {
this.setData({ showBindingList: true, activeTab: 'active', currentBindings: this.data.activeBindings })
setTimeout(() => wx.pageScrollTo({ selector: '#binding-card', duration: 220 }), 80)
this._focusTarget = ''
return
}
if (t === 'earnings') {
setTimeout(() => wx.pageScrollTo({ selector: '#earnings-card', duration: 220 }), 80)
this._focusTarget = ''
return
}
},
// 切换Tab
switchTab(e) {
const tab = e.currentTarget.dataset.tab
@@ -343,11 +372,20 @@ Page({
console.log('[Poster] 小程序码已保存到本地:', filePath)
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const u = this.data.userInfo || app.globalData.userInfo || {}
const posterDisplayAvatar = app.resolveAvatarWithMbti
? app.resolveAvatarWithMbti(u.avatar, u.mbti)
: ''
this.setData({
posterQrSrc: filePath,
posterReferralLink: '', // 小程序版本不再使用 H5 链接
posterNickname: nickname,
posterNicknameInitial: (nickname || '用').charAt(0),
posterDisplayAvatar,
isGeneratingPoster: false
})
wx.hideLoading()
@@ -355,7 +393,7 @@ Page({
console.error('[Poster] 生成二维码失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '' })
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '', posterDisplayAvatar: '' })
}
},
@@ -546,10 +584,9 @@ Page({
// 分享到朋友圈 - 随机文案
shareToMoments() {
const total = app.getTotalSections()
// 10条随机文案基于书的内容
const shareTexts = [
`🔥 在派对房里听到的真实故事比虚构的小说精彩100倍\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n${total}个真实案例,搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
`🔥 在派对房里听到的真实故事比虚构的小说精彩100倍\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n62个真实案例,搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
`💡 今天终于明白:会赚钱的人,都在用"流量杠杆"\n\n抖音、Soul、飞书...同一套内容,撬动不同平台的流量。\n\n《卡若创业派对》里的实战方法,受用终身!\n\n#流量 #副业 #创业派对`,
@@ -600,7 +637,7 @@ Page({
}
// 任意金额可提现,不再设最低限额
// 未绑定微信号时引导去设置
// 未绑定微信号:说明提现到账核对所需
if (!hasWechatId) {
wx.showModal({
title: '请先绑定微信号',
@@ -656,31 +693,37 @@ Page({
method: 'POST',
data: { userId, amount }
})
wx.hideLoading()
wx.showModal({
title: '提现申请已提交 ✅',
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
showCancel: false,
confirmText: '知道了'
})
this.initData()
if (res.success) {
wx.showModal({
title: '提现申请已提交 ✅',
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
showCancel: false,
confirmText: '知道了'
})
// 刷新数据(此时待审核金额会增加,可提现金额会减少)
this.initData()
} else {
if (res.needBind || res.needBindWechat) {
wx.showModal({
title: res.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: res.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
} else {
wx.showToast({ title: res.message || res.error || '提现失败', icon: 'none', duration: 3000 })
}
}
} catch (e) {
wx.hideLoading()
console.error('[Referral] 提现失败:', e)
const r = e.response || {}
if (r.needBind || r.needBindWechat) {
wx.showModal({
title: r.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: r.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
return
}
wx.showToast({ title: e.message || '提现失败,请重试', icon: 'none', duration: 3000 })
wx.showToast({ title: '提现失败,请重试', icon: 'none' })
}
},
@@ -856,9 +899,8 @@ Page({
onShareTimeline() {
const ref = this.data.referralCode || app.getMyReferralCode()
console.log('[Referral] 分享到朋友圈,推荐码:', ref)
const total = app.getTotalSections()
return {
title: `卡若创业派对 - ${total}个真实商业案例`,
title: `卡若创业派对 - 62个真实商业案例`,
query: ref ? `ref=${ref}` : ''
// 不设置 imageUrl使用小程序默认截图
}

View File

@@ -25,8 +25,7 @@
</view>
<!-- 收益卡片 - 对齐 Next.js -->
<view class="earnings-card">
<view class="earnings-bg"></view>
<view id="earnings-card" class="earnings-card">
<view class="earnings-main">
<view class="earnings-header">
<view class="earnings-left">
@@ -87,7 +86,7 @@
</view>
<!-- 绑定用户列表 -->
<view class="binding-card">
<view id="binding-card" class="binding-card">
<view class="binding-header" bindtap="toggleBindingList">
<view class="binding-title">
<image class="binding-icon-img" src="/assets/icons/users.svg" mode="aspectFit"></image>
@@ -161,9 +160,14 @@
</block>
</view>
<!-- 分享按钮 - 1:1 对齐 Next.js -->
<view class="share-section">
<view class="share-item" bindtap="generatePoster">
<!-- 分享按钮(可折叠,默认收起) -->
<view class="section-collapse-card">
<view class="section-collapse-header" bindtap="toggleShareSection">
<text class="section-collapse-title">分享推广</text>
<icon name="{{showShareSection ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
</view>
<view class="share-section" wx:if="{{showShareSection}}">
<view class="share-item" bindtap="generatePoster">
<view class="share-icon poster">
<image class="icon-share-btn" src="/assets/icons/image.svg" mode="aspectFit"></image>
</view>
@@ -196,13 +200,15 @@
<image class="share-arrow-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 收益明细 - 增强版 -->
<view class="earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
<view class="detail-header">
<text class="detail-title">收益明细</text>
<!-- 收益明细(可折叠,默认收起) -->
<view class="section-collapse-card earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
<view class="section-collapse-header" bindtap="toggleEarningsDetails">
<text class="section-collapse-title">收益明细</text>
<icon name="{{showEarningsDetails ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
</view>
<view class="detail-list">
<view class="detail-list" wx:if="{{showEarningsDetails}}">
<view class="detail-item" wx:for="{{earningsDetails}}" wx:key="id">
<!-- 买家头像 -->
<view class="detail-avatar-wrap">
@@ -297,7 +303,8 @@
<!-- 推荐人 -->
<view class="poster-recommender">
<view class="poster-avatar">
<text class="poster-avatar-text">{{posterNicknameInitial}}</text>
<image wx:if="{{posterDisplayAvatar}}" class="poster-avatar-img" src="{{posterDisplayAvatar}}" mode="aspectFill" />
<text wx:else class="poster-avatar-text">{{posterNicknameInitial}}</text>
</view>
<text class="poster-recommender-text">{{posterNickname}} 推荐你来读</text>
</view>

View File

@@ -1,28 +1,39 @@
/* ???????? - 1:1??Web?? */
@import './earnings-detail-styles.wxss';
/* 分销中心 - 主结构样式 */
.page { min-height: 100vh; background: #000; padding-bottom: 64rpx; }
/* ??? */
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.9); backdrop-filter: blur(40rpx); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(0,0,0,0.92);
display: flex; align-items: center; justify-content: space-between;
padding: 0 32rpx; height: 88rpx;
border-bottom: 1rpx solid rgba(255,255,255,0.06);
}
.nav-left { display: flex; gap: 16rpx; align-items: center; }
.nav-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(70%); }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; flex: 1; text-align: center; }
.nav-right-placeholder { width: 144rpx; }
.nav-btn { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.content { padding: 24rpx; width: 100%; box-sizing: border-box; }
/* ?????? */
.expiring-banner { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 24rpx; margin-bottom: 24rpx; }
.expiring-banner {
display: flex; align-items: center; gap: 24rpx; padding: 24rpx;
background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3);
border-radius: 24rpx; margin-bottom: 24rpx;
}
.banner-icon { width: 80rpx; height: 80rpx; background: rgba(255,165,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-bell-warning { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(64%) sepia(89%) saturate(1363%) hue-rotate(4deg) brightness(101%) contrast(102%); }
.banner-content { flex: 1; }
.banner-title { font-size: 28rpx; font-weight: 500; color: #fff; display: block; }
.banner-desc { font-size: 24rpx; color: rgba(255,165,0,0.8); margin-top: 4rpx; display: block; }
/* ???? - ?? Next.js */
.earnings-card { position: relative; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden; width: 100%; box-sizing: border-box; }
.earnings-bg { position: absolute; top: 0; right: 0; width: 256rpx; height: 256rpx; background: rgba(0,206,209,0.15); border-radius: 50%; filter: blur(100rpx); }
.earnings-card {
position: relative; background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1);
border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden;
width: 100%; box-sizing: border-box;
}
.earnings-main { position: relative; }
.earnings-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32rpx; }
.earnings-left { display: flex; align-items: center; gap: 16rpx; }
@@ -34,26 +45,17 @@
.earnings-right { text-align: right; }
.earnings-value { font-size: 60rpx; font-weight: 700; color: #fff; display: block; line-height: 1; }
.pending-text { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; display: block; }
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; box-shadow: 0 8rpx 24rpx rgba(0,206,209,0.3); }
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); box-shadow: none; }
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; }
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); }
.wechat-tip { display: block; font-size: 24rpx; color: rgba(255,165,0,0.9); margin-top: 16rpx; text-align: center; }
.withdraw-records-link { display: block; margin-top: 16rpx; text-align: center; font-size: 26rpx; color: #00CED1; }
/* ???? - ?? Next.js 4??? */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.stat-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
.stat-card { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
.stat-value { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.stat-value.orange { color: #FFA500; }
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.6); margin-top: 8rpx; display: block; }
/* ????? */
.visit-stat { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 20rpx 32rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 24rpx; }
.visit-label { font-size: 24rpx; color: rgba(255,255,255,0.5); }
.visit-value { font-size: 32rpx; font-weight: 700; color: #00CED1; }
.visit-tip { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* ???? - ?? Next.js */
.rules-card { background: rgba(0,206,209,0.05); border: 2rpx solid rgba(0,206,209,0.2); border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.rules-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.rules-icon { width: 64rpx; height: 64rpx; background: rgba(0,206,209,0.2); border-radius: 16rpx; display: flex; align-items: center; justify-content: center; }
@@ -63,240 +65,89 @@
.rule-item { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 2; display: block; margin-bottom: 4rpx; }
.rule-item .gold { color: #FFD700; font-weight: 500; }
.rule-item .brand { color: #00CED1; font-weight: 500; }
.rule-item .orange { color: #FFA500; font-weight: 500; }
/* ?????? - ?? Next.js */
.binding-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.binding-card { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.binding-header { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.binding-title { display: flex; align-items: center; gap: 12rpx; }
.binding-icon-img { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.title-text { font-size: 30rpx; font-weight: 600; color: #fff; }
.binding-count { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.toggle-icon { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* Tab?? */
.binding-tabs { display: flex; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.tab-item { flex: 1; padding: 24rpx 0; text-align: center; font-size: 26rpx; color: rgba(255,255,255,0.5); position: relative; }
.tab-item.tab-active { color: #00CED1; }
.tab-item.tab-active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 80rpx; height: 4rpx; background: #00CED1; border-radius: 4rpx; }
/* ???? */
.binding-list { max-height: 640rpx; overflow-y: auto; }
.empty-state { padding: 80rpx 0; text-align: center; }
.empty-icon { font-size: 64rpx; display: block; margin-bottom: 16rpx; }
.empty-text { font-size: 26rpx; color: rgba(255,255,255,0.5); }
/* ?????? - ??? */
.binding-item { display: flex; align-items: center; padding: 24rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); gap: 24rpx; }
.binding-item:last-child { border-bottom: none; }
/* ?? */
.user-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 600; color: #00CED1; flex-shrink: 0; }
.user-avatar.avatar-converted { background: rgba(76,175,80,0.2); color: #4CAF50; }
.user-avatar.avatar-expired { background: rgba(158,158,158,0.2); color: #9E9E9E; }
/* ???? */
.user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4rpx; }
.user-name { font-size: 28rpx; color: #fff; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-time { font-size: 22rpx; color: rgba(255,255,255,0.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ???? */
.user-status { flex-shrink: 0; text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; min-width: 100rpx; }
.status-amount { font-size: 28rpx; color: #4CAF50; font-weight: 600; white-space: nowrap; }
.status-order { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
.status-time { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
/* ???? */
.status-order, .status-time { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
.status-tag { font-size: 24rpx; font-weight: 600; padding: 6rpx 16rpx; border-radius: 16rpx; white-space: nowrap; }
.status-tag.tag-green { background: rgba(76,175,80,0.2); color: #4CAF50; }
.status-tag.tag-orange { background: rgba(255,165,0,0.2); color: #FFA500; }
.status-tag.tag-red { background: rgba(244,67,54,0.2); color: #F44336; }
.status-tag.tag-gray { background: rgba(158,158,158,0.2); color: #9E9E9E; }
/* ????? - ?? Next.js */
.invite-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 40rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.invite-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.invite-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.invite-code-box { background: rgba(0,206,209,0.2); padding: 12rpx 24rpx; border-radius: 16rpx; }
.invite-code { font-size: 28rpx; font-weight: 600; color: #00CED1; font-family: 'Courier New', monospace; letter-spacing: 2rpx; }
.invite-tip { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 1.5; display: block; }
.invite-tip .gold { color: #FFD700; }
.invite-tip .brand { color: #00CED1; }
/* ?????? - ??? */
.earnings-detail-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.detail-header { padding: 40rpx 40rpx 24rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.detail-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.detail-list { max-height: 480rpx; overflow-y: auto; padding: 16rpx 0; }
/* ??????? */
.earnings-detail-card .detail-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx 40rpx; background: transparent; border-bottom: 2rpx solid rgba(255,255,255,0.03); }
.earnings-detail-card .detail-item:last-child { border-bottom: none; }
.earnings-detail-card .detail-item:active { background: rgba(255, 255, 255, 0.05); }
/* ???? */
.earnings-detail-card .detail-avatar-wrap { width: 88rpx; height: 88rpx; flex-shrink: 0; }
.earnings-detail-card .detail-avatar { width: 100%; height: 100%; border-radius: 50%; border: 2rpx solid rgba(56, 189, 172, 0.2); }
.earnings-detail-card .detail-avatar-text { width: 100%; height: 100%; border-radius: 50%; background: linear-gradient(135deg, #38bdac 0%, #2da396 100%); display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; color: #ffffff; }
/* ???? */
.earnings-detail-card .detail-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.earnings-detail-card .detail-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.earnings-detail-card .detail-buyer { font-size: 28rpx; font-weight: 500; color: #ffffff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.earnings-detail-card .detail-amount { font-size: 32rpx; font-weight: 700; color: #38bdac; flex-shrink: 0; white-space: nowrap; }
/* ???? */
.earnings-detail-card .detail-product { display: flex; align-items: baseline; gap: 4rpx; font-size: 24rpx; color: rgba(255, 255, 255, 0.6); min-width: 0; overflow: hidden; }
.earnings-detail-card .detail-book { color: rgba(255, 255, 255, 0.7); font-weight: 500; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.earnings-detail-card .detail-chapter { color: rgba(255, 255, 255, 0.5); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.earnings-detail-card .detail-time { font-size: 22rpx; color: rgba(255, 255, 255, 0.4); }
/* ???? - ?? Next.js */
.share-section { display: flex; flex-direction: column; gap: 12rpx; width: 100%; margin-bottom: 24rpx; }
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; border: none; margin: 0; text-align: left; width: 100%; box-sizing: border-box; }
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; width: 100%; box-sizing: border-box; }
.share-item::after { border: none; }
.share-icon { width: 96rpx; height: 96rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.share-icon.poster { background: rgba(103,58,183,0.2); }
.share-icon.wechat { background: rgba(7,193,96,0.2); }
.share-icon.link { background: rgba(158,158,158,0.2); }
.icon-share-btn { width: 48rpx; height: 48rpx; display: block; }
.share-icon.poster .icon-share-btn { filter: brightness(0) saturate(100%) invert(37%) sepia(73%) saturate(2296%) hue-rotate(252deg) brightness(96%) contrast(92%); }
.share-icon.wechat .icon-share-btn { filter: brightness(0) saturate(100%) invert(58%) sepia(91%) saturate(1255%) hue-rotate(105deg) brightness(96%) contrast(97%); }
.share-icon.link .icon-share-btn { filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-info { flex: 1; text-align: left; }
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; text-align: left; }
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; text-align: left; }
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-btn-wechat { line-height: normal; font-size: inherit; padding: 24rpx 32rpx !important; margin: 0 !important; width: 100% !important; }
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%); }
.empty-earnings { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%); }
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
/* ?????????????? + ???? + ???????? */
/* ???????? backdrop-filter??????????????? */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 32rpx; box-sizing: border-box; }
.poster-dialog { width: 686rpx; border-radius: 24rpx; overflow: hidden; position: relative; background: transparent; }
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; z-index: 5; font-size: 28rpx; }
/* ???? */
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; z-index: 5; }
.poster-card { position: relative; background: linear-gradient(135deg, #0a1628 0%, #0f2137 50%, #1a3a5c 100%); color: #fff; padding: 44rpx 40rpx 36rpx; }
.poster-inner { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; text-align: center; }
/* ???? */
/* ???????? filter: blur ??????????????? + ???? */
.poster-glow { position: absolute; width: 320rpx; height: 320rpx; border-radius: 50%; opacity: 0.6; z-index: 1; }
.poster-glow-left { top: -120rpx; left: -160rpx; background: rgba(0,206,209,0.12); box-shadow: 0 0 140rpx 40rpx rgba(0,206,209,0.18); }
.poster-glow-right { bottom: -140rpx; right: -160rpx; background: rgba(255,215,0,0.10); box-shadow: 0 0 160rpx 50rpx rgba(255,215,0,0.14); }
.poster-ring { position: absolute; width: 520rpx; height: 520rpx; border-radius: 50%; border: 2rpx solid rgba(0,206,209,0.06); left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; }
/* ???? */
.poster-badges { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
.poster-badge { padding: 10rpx 22rpx; font-size: 20rpx; font-weight: 700; border-radius: 999rpx; border: 2rpx solid transparent; }
.poster-badge-gold { background: rgba(255,215,0,0.18); color: #FFD700; border-color: rgba(255,215,0,0.28); }
.poster-badge-brand { background: rgba(0,206,209,0.18); color: #00CED1; border-color: rgba(0,206,209,0.28); }
/* ?? */
.poster-title { margin-bottom: 8rpx; }
.poster-title-line1 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #00CED1; }
.poster-title-line2 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #fff; margin-top: 6rpx; }
.poster-subtitle { display: block; font-size: 22rpx; color: rgba(255,255,255,0.6); margin-bottom: 28rpx; }
/* ???? */
.poster-stats { width: 100%; display: flex; gap: 16rpx; justify-content: center; margin-bottom: 24rpx; }
.poster-stat { flex: 1; max-width: 190rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 16rpx; padding: 18rpx 10rpx; }
.poster-stat-value { display: block; font-size: 44rpx; font-weight: 900; line-height: 1; }
.poster-stat-label { display: block; font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; }
.poster-stat-gold { color: #FFD700; }
.poster-stat-brand { color: #00CED1; }
.poster-stat-pink { color: #E91E63; }
/* ?? */
.poster-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 10rpx; margin: 0 24rpx 26rpx; }
.poster-tag { font-size: 20rpx; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 12rpx; padding: 6rpx 14rpx; }
/* ??? */
.poster-recommender { display: flex; align-items: center; gap: 12rpx; background: rgba(0,206,209,0.10); border: 2rpx solid rgba(0,206,209,0.20); border-radius: 999rpx; padding: 12rpx 22rpx; margin-bottom: 22rpx; }
.poster-avatar { width: 44rpx; height: 44rpx; border-radius: 22rpx; background: rgba(0,206,209,0.30); display: flex; align-items: center; justify-content: center; }
.poster-avatar-text { font-size: 20rpx; font-weight: 800; color: #00CED1; }
.poster-recommender-text { font-size: 22rpx; color: #00CED1; }
/* ??? */
.poster-discount { width: 100%; background: linear-gradient(90deg, rgba(255,215,0,0.10) 0%, rgba(233,30,99,0.10) 100%); border: 2rpx solid rgba(255,215,0,0.20); border-radius: 18rpx; padding: 18rpx 18rpx; margin-bottom: 26rpx; }
.poster-discount-text { font-size: 22rpx; color: rgba(255,255,255,0.80); }
.poster-discount-highlight { color: #00CED1; font-weight: 800; }
/* ??? */
.poster-qr-wrap { background: #fff; padding: 14rpx; border-radius: 16rpx; margin-bottom: 12rpx; box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.35); }
.poster-qr-img { width: 240rpx; height: 240rpx; display: block; }
.poster-qr-tip { font-size: 20rpx; color: rgba(255,255,255,0.40); margin-bottom: 8rpx; }
.poster-code { font-size: 22rpx; font-family: monospace; letter-spacing: 2rpx; color: rgba(0,206,209,0.80); }
/* ??????? */
.poster-footer { background: #fff; padding: 28rpx 28rpx 32rpx; display: flex; flex-direction: column; gap: 18rpx; }
.poster-footer-tip { font-size: 22rpx; color: rgba(0,0,0,0.55); text-align: center; }
.poster-footer-btn { height: 72rpx; border-radius: 18rpx; border: 2rpx solid rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: rgba(0,0,0,0.75); background: #fff; }
/* ????- ?? Next.js */
.empty-earnings { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
.poster-avatar { position: relative; width: 72rpx; height: 72rpx; flex-shrink: 0; }
.poster-avatar-img {
width: 72rpx; height: 72rpx; border-radius: 50%;
border: 3rpx solid rgba(255, 215, 0, 0.88);
box-sizing: border-box; display: block; background: #111;
}
/* ===== Loading 遮罩(备用) ===== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(10rpx);
z-index: 999;
/* 分组折叠(用于优化页面长度) */
.section-collapse-card {
background: rgba(28, 28, 30, 0.8);
border: 2rpx solid rgba(255,255,255,0.1);
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
.section-collapse-header {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
padding: 24rpx 28rpx;
border-bottom: 1rpx solid rgba(255,255,255,0.06);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx solid rgba(56,189,172,0.2);
border-top-color: #38bdac;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
.section-collapse-title {
font-size: 28rpx;
color: rgba(255,255,255,0.8);
font-weight: 500;
color: #fff;
font-weight: 600;
}
.content-loading {
opacity: 0.3;
pointer-events: none;
}
/* ===== 收益明细独立块 ===== */
.detail-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,255,255,0.02); border-radius: 16rpx; margin-bottom: 16rpx; transition: all 0.3s; }
.detail-item:active { background: rgba(255,255,255,0.05); }
.detail-avatar-wrap { flex-shrink: 0; }
.detail-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; border: 2rpx solid rgba(56,189,172,0.2); }
.detail-avatar-text { width: 88rpx; height: 88rpx; border-radius: 50%; background: linear-gradient(135deg, #38bdac 0%, #2da396 100%); display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; color: #ffffff; }
.detail-content { flex: 1; display: flex; flex-direction: column; gap: 8rpx; min-width: 0; }
.detail-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.detail-buyer { font-size: 28rpx; font-weight: 500; color: #ffffff; flex-shrink: 0; }
.detail-amount { font-size: 32rpx; font-weight: 700; color: #38bdac; flex-shrink: 0; }
.detail-product { display: flex; align-items: center; font-size: 24rpx; color: rgba(255,255,255,0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-book { color: rgba(255,255,255,0.7); font-weight: 500; }
.detail-chapter { color: rgba(255,255,255,0.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-time { font-size: 22rpx; color: rgba(255,255,255,0.4); }

View File

@@ -34,7 +34,7 @@ Page({
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
(app.globalData.appDisplayVersion || '1.7.2')
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
isLoggedIn: app.globalData.isLoggedIn,
@@ -50,7 +50,7 @@ Page({
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
(app.globalData.appDisplayVersion || '1.7.2')
this.setData({ version: displayVersion })
this.loadBindingInfo()
},

View File

@@ -13,16 +13,16 @@ Page({
originalPrice: 6980,
/* 按 premium_membership_landing_v1 设计稿 */
contentRights: [
{ title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
{ title: '派对专属', desc: '创业派对房专享', icon: 'star' },
{ title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
{ title: '轮流置顶', desc: '首页获客曝光位', icon: 'arrow-up' }
{ key: 'match', title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
{ key: 'party', title: '派对专属', desc: '创业派对房专享', icon: 'star' },
{ key: 'rank', title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
{ key: 'top', title: '轮流置顶', desc: '首页获客曝光位', icon: 'chevron-up' }
],
socialRights: [
{ title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
{ title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
{ title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
{ key: 'cases', title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
{ key: 'fullbook', title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
{ key: 'daily', title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ key: 'leads', title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
],
purchasing: false
},
@@ -86,7 +86,7 @@ Page({
return
}
}
// VIP 购买后才引导完善资料:购买前不拦截,购买成功后跳转 profile-edit
// VIP 购买成功后再跳转资料:购买前不拦截
this.setData({ purchasing: true })
const amount = this.data.price
try {
@@ -167,9 +167,9 @@ Page({
// 超级个体购买后:弹窗提示,强制跳转资料编辑页
wx.hideLoading()
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请填写好资料',
confirmText: '去完善',
title: '补全 VIP 资料',
content: '补全资料后,找伙伴、提现与 VIP 群对接会更顺畅;手机号等为必填项。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit?from=vip' })
@@ -183,6 +183,33 @@ Page({
goBack() { getApp().goBackOrToHome() },
/**
* 权益卡片跳转:会员权利 / 派对权利 统一点击进对应能力页
*/
onBenefitTap(e) {
const key = e.currentTarget.dataset.key
if (!key) return
trackClick('vip', 'benefit_tap', key)
const tab = (path) => {
wx.switchTab({ url: path })
}
const nav = (path) => {
wx.navigateTo({ url: path })
}
const routes = {
match: () => tab('/pages/match/match'),
party: () => nav('/pages/mentors/mentors'),
rank: () => tab('/pages/index/index'),
top: () => tab('/pages/index/index'),
cases: () => tab('/pages/chapters/chapters'),
fullbook: () => tab('/pages/chapters/chapters'),
daily: () => tab('/pages/chapters/chapters'),
leads: () => nav('/pages/profile-edit/profile-edit')
}
const fn = routes[key]
if (typeof fn === 'function') fn()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {

View File

@@ -21,7 +21,7 @@
<text class="rights-dot rights-dot-teal"></text>
<text class="rights-col-title">会员权利</text>
</view>
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
<view class="benefit-card benefit-card-tap" wx:for="{{contentRights}}" wx:key="key" bindtap="onBenefitTap" data-key="{{item.key}}" hover-class="benefit-card-hover">
<icon name="{{item.icon || 'check'}}" size="40" color="#00CED1" customClass="benefit-icon"></icon>
<view class="benefit-info">
<text class="benefit-title">{{item.title}}</text>
@@ -34,7 +34,7 @@
<text class="rights-dot rights-dot-gold"></text>
<text class="rights-col-title rights-col-title-gold">派对权利</text>
</view>
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
<view class="benefit-card benefit-card-tap" wx:for="{{socialRights}}" wx:key="key" bindtap="onBenefitTap" data-key="{{item.key}}" hover-class="benefit-card-hover">
<icon name="{{item.icon || 'check'}}" size="40" color="#FFD700" customClass="benefit-icon benefit-icon-gold"></icon>
<view class="benefit-info">
<text class="benefit-title">{{item.title}}</text>

View File

@@ -22,6 +22,8 @@
.rights-col-title { font-size: 24rpx; font-weight: bold; color: #4FD1C5; letter-spacing: 2rpx; }
.rights-col-title-gold { color: #FFBD2E; }
.benefit-card { display: flex; flex-direction: column; gap: 16rpx; padding: 24rpx; margin-bottom: 16rpx; background: #141414; border: 1rpx solid rgba(255,255,255,0.05); border-radius: 24rpx; }
.benefit-card-tap { transition: opacity 0.15s; }
.benefit-card-hover { opacity: 0.88; background: #1a1a1a; }
.benefit-icon { font-size: 36rpx; color: #4FD1C5; }
.benefit-icon-gold { color: #FFBD2E; }
.benefit-info { display: flex; flex-direction: column; }

View File

@@ -0,0 +1,11 @@
{
"size": {
"total": 1579970,
"packages": [
{
"name": "TOTAL",
"size": 1579970
}
]
}
}

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,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)
}
@@ -177,16 +177,20 @@ class ReadingTracker {
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: this.activeTracker.totalDuration,
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}
data
})
if (isCompletion) {

View File

@@ -1,10 +1,12 @@
/**
* 卡若创业派对 - 用户旅程规则引擎
* 从后端 /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
@@ -34,9 +36,12 @@ 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',
@@ -45,6 +50,10 @@ 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 {
@@ -74,12 +83,44 @@ function getUserInfo() {
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()
@@ -92,36 +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专注头像+昵称,首次登录由 app.login 强制 redirect
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 引导到 profile-edit避免与主流程冲突
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
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 avatar = user.avatar || user.avatarUrl || ''
const nickname = user.nickname || ''
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) 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()
@@ -132,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'
}
@@ -169,45 +315,56 @@ function checkRule_ShareAfter5Chapters(rules) {
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
const app = getAppInstance()
if (!app || !(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
if (user.wechatId && user.address) 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'
}
@@ -217,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'
}
@@ -242,8 +402,11 @@ function checkRule_Withdraw(rules) {
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'
}
@@ -255,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:
@@ -277,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) {
@@ -288,6 +453,9 @@ function executeRule(rule, pageInstance) {
pageInstance.showPhoneBinding()
}
}
if (rule.serverRuleId) {
markRuleCompleted(rule.serverRuleId)
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
@@ -309,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

@@ -32,6 +32,19 @@ const formatMoney = (amount, decimals = 2) => {
return Number(amount).toFixed(decimals)
}
/** 「我的」等页统计数字展示非法值→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)
}
// 防抖函数
const debounce = (fn, delay = 300) => {
let timer = null
@@ -189,6 +202,7 @@ module.exports = {
formatTime,
formatDate,
formatMoney,
formatStatNum,
formatNumber,
debounce,
throttle,