feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本

- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
This commit is contained in:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

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()

View File

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

View File

@@ -1,18 +1,17 @@
<!--卡若创业派对 - 头像昵称引导页,仅头像+昵称-->
<!--卡若创业派对 - 头像昵称设置页-->
<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">头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
@@ -62,7 +61,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;

View File

@@ -26,6 +26,7 @@ Page({
// 展开状态
expandedPart: null,
bookCollapsed: false,
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
@@ -47,7 +48,11 @@ Page({
partsLoading: true,
// 功能配置(搜索开关)
searchEnabled: true
searchEnabled: true,
// mp_config.mpUi.chaptersPage
chaptersBookTitle: '一场SOUL的创业实验场',
chaptersBookSubtitle: '来自Soul派对房的真实商业故事'
},
onLoad() {
@@ -62,10 +67,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 +89,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 +109,6 @@ Page({
totalSections = res.totalSections ?? 0
fixedSections = res.fixedSections || []
}
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
const fixedMap = {}
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
const appendixList = [
@@ -100,13 +116,14 @@ Page({
{ id: 'appendix-2', title: '附录2创业者自检清单', mid: fixedMap['appendix-2'] },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源', mid: fixedMap['appendix-3'] }
]
const bookData = parts.map((p, idx) => ({
const bookData = parts.map((p) => ({
id: p.id,
number: numbers[idx] || String(idx + 1),
icon: p.icon || '',
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [] // 展开时懒加载
chapters: [],
alwaysShow: (p.title || '').indexOf('每日派对干货') > -1
}))
app.globalData.totalSections = totalSections
this.setData({
@@ -186,6 +203,7 @@ Page({
},
onShow() {
this._applyChaptersMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
@@ -225,7 +243,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,12 @@
<!-- 篇章列表 -->
<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="aspectFit"/>
<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,9 @@
color: #ffffff;
flex-shrink: 0;
}
.part-icon-img {
width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0;
}
.part-info {
display: flex;

View File

@@ -7,6 +7,7 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
function isKaruoHostDuplicateName(displayName) {
@@ -85,7 +86,19 @@ Page({
searchEnabled: true,
// 审核模式:隐藏支付相关入口
auditMode: false
auditMode: false,
// mp_config.mpUi.homePage后台系统设置 mpUi
mpUiLogoTitle: '卡若创业派对',
mpUiLogoSubtitle: '来自派对房的真实故事',
mpUiLinkKaruoText: '点击链接卡若',
mpUiSearchPlaceholder: '搜索章节标题或内容...',
mpUiBannerTag: '推荐',
mpUiBannerReadMore: '点击阅读',
mpUiSuperTitle: '超级个体',
mpUiSuperLinkText: '获客入口',
mpUiPickTitle: '精选推荐',
mpUiLatestTitle: '最新新增'
},
onLoad(options) {
@@ -111,6 +124,7 @@ Page({
onShow() {
console.log('[Index] onShow 触发')
this.setData({ auditMode: app.globalData.auditMode || false })
this._applyHomeMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
@@ -305,6 +319,30 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
_applyHomeMpUi() {
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
this.setData({
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若',
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读',
mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体',
mpUiSuperLinkText: String(h.superSectionLinkText || '获客入口').trim() || '获客入口',
mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐',
mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增'
})
},
/** 超级个体右侧文案:默认跳转找伙伴 Tab路径可由 homePage.superSectionLinkPath 配置) */
goSuperSectionLink() {
const p = String(
app.globalData.configCache?.mpConfig?.mpUi?.homePage?.superSectionLinkPath || '/pages/match/match'
).trim()
if (p) navigateMpPath(p)
},
async loadFeatureConfig() {
try {
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
@@ -313,6 +351,7 @@ Page({
searchEnabled: app.globalData.features.searchEnabled,
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
return
}
const res = await app.getConfig()
@@ -324,8 +363,10 @@ Page({
app.globalData.features.searchEnabled = searchEnabled
app.globalData.auditMode = auditMode
this.setData({ searchEnabled, auditMode })
this._applyHomeMpUi()
} catch (e) {
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
this._applyHomeMpUi()
}
},

View File

@@ -12,16 +12,11 @@
<text class="logo-text">派</text>
</view>
<view class="logo-info">
<text class="logo-title-text">卡若创业派对</text>
<text class="logo-subtitle">来自派对房的真实故事</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>
<text class="logo-title-text">{{mpUiLogoTitle}}</text>
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
</view>
</view>
<view class="header-right"></view>
</view>
<!-- 搜索栏(根据配置显示) -->
@@ -29,7 +24,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 +33,26 @@
<!-- 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>
<text class="section-subtitle" bindtap="goSuperSectionLink">{{mpUiSuperLinkText}}</text>
</view>
<!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -100,7 +95,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 +129,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,25 +160,4 @@
<!-- 底部留白 -->
<view class="bottom-space"></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>
<text class="lead-divider">或手动输入</text>
<view class="lead-input-wrap">
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>
</view>
<view class="lead-actions">
<button class="lead-btn lead-btn-cancel" bindtap="closeLeadModal">取消</button>
<button class="lead-btn lead-btn-submit" bindtap="submitLead">提交</button>
</view>
</view>
</view>
</view>

View File

@@ -8,6 +8,18 @@ const app = getApp()
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项信息我能帮到你什么、我需要什么帮助
@@ -226,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
@@ -413,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,
@@ -424,23 +448,39 @@ Page({
this.setData({ matchAttempts: this.data.matchAttempts + 1 })
}, 1000)
// 从数据库获取真实用户匹配
// 从数据库获取真实用户匹配(带上手机/微信写入 match_records与流量池运营对齐
let matchedUser = null
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)
matchFailHint = (e && e.message) ? String(e.message) : '网络异常,请稍后重试'
}
// 延迟显示结果(模拟匹配过程)
@@ -453,7 +493,7 @@ Page({
this.setData({ isMatching: false })
wx.showModal({
title: '暂无匹配',
content: '当前暂无合适的匹配用户,请稍后再试',
content: matchFailHint || '当前暂无合适的匹配用户,请稍后再试',
showCancel: false,
confirmText: '知道了'
})
@@ -498,7 +538,7 @@ Page({
}
}
})
// 匹配后规则:引导填写 MBTI/行业信息
// 匹配后规则:资料未齐时提示补全(服务端 profile 合并,见 ruleEngine
checkAndExecute('after_match', this)
} catch (e) {
console.log('上报匹配失败:', e)

View File

@@ -5,21 +5,70 @@
* 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 { trackClick } = require('../../utils/trackClick')
const { isSafeImageSrc } = require('../../utils/imageUrl.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) {
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) {
@@ -64,10 +113,11 @@ Page({
enrichAndFormat(raw) {
const e = (v) => this._emptyIfPlaceholder(v)
const rawAv = raw.avatar || raw.vipAvatar || raw.vip_avatar || ''
const merged = {
id: raw.id,
name: raw.nickname || raw.name || raw.vipName || raw.vip_name || '创业者',
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
avatar: isSafeImageSrc(rawAv) ? String(rawAv).trim() : '',
isVip: !!(raw.isVip || raw.is_vip),
mbti: e(raw.mbti),
region: e(raw.region),
@@ -152,55 +202,167 @@ Page({
return false
},
/**
* 点头像:有存客宝人物 token 时优先 POST /api/miniprogram/ckb/lead与阅读页 @ 同链路,匹配 persons.ckb_api_key 计划)
* 否则:解锁后复制微信/手机号并引导
*/
startLinkFlow() {
const member = this.data.member
if (!member) return
const leadTok = (member.ckbLeadToken || '').trim()
if (leadTok) {
const nickname = ((member.name || 'TA').trim() || 'TA')
wx.showModal({
title: '添加好友',
content: `是否通过获客计划联系 ${nickname}?提交后将按对方在存客宝后台配置的计划执行。`,
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) this._doCkbLeadSubmit(leadTok, nickname)
}
})
return
}
if (member.wechatRaw || member.wechatDisplay) {
if (!this._ensureUnlockedForLink('wechat')) return
const m = this.data.member
if (m.wechatFull) this._copyAndGuideWechat(m.wechatFull)
return
}
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' })
/** 链接前:头像需非空且非默认图;微信号需已填写(与 app._needsAvatarNickname 中头像规则一致) */
_hasCustomAvatarForLink(u) {
const avatar = (u && (u.avatar || u.avatarUrl) || '').trim()
return !!avatar && !avatar.includes('default')
},
/** 与 read 页 _doMentionAddFriend 一致targetUserId = Person.token */
async _doCkbLeadSubmit(targetUserId, targetNickname) {
_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
},
/**
* 点头像:登录 → 头像+微信号 → 手机号 均校验通过后弹窗说明;确认后 POST ckb/lead。
* 有 ckbLeadToken 走人物计划;无 token 走全局留资。对方已公开联系方式时可取消后在下方自行添加。
*/
async startLinkFlow() {
if (this.data.isOwnProfile) return
const member = this.data.member
if (!member) return
const nickname = (member.name || 'TA').trim() || 'TA'
trackClick('member_detail', 'btn_click', '链接头像_' + (member.id || ''))
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
title: `链接「${nickname}`,
content: '请先登录后再发起链接。',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
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 (!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)
}
})
},
/** 无人物 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)) {
@@ -212,10 +374,27 @@ Page({
}
} 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: '请填写手机号(必填),便对方通过获客计划联系您',
title: '补全手机号',
content: '请填写手机号(必填),便对方通过获客计划联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
@@ -234,6 +413,8 @@ Page({
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source: 'member_detail_avatar'
}
})
@@ -372,11 +553,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'
}
},
@@ -384,7 +610,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,21 +8,8 @@ const app = getApp()
const { formatStatNum } = require('../../utils/util.js')
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
/** 是否视为「单章解锁」类订单(排除全书/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: {
@@ -97,10 +84,12 @@ Page({
// 我的余额
walletBalanceText: '--',
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
// mp_config.mpUi.myPage后台可改文案/跳转
mpUiCardLabel: '名片',
mpUiVipLabelVip: '会员中心',
mpUiVipLabelGuest: '成为会员',
mpUiReadStatLabel: '已读章节',
mpUiRecentTitle: '最近阅读',
},
onLoad() {
@@ -130,6 +119,27 @@ Page({
}
}
this.initUserStatus()
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 loadFeatureConfig() {
@@ -144,9 +154,11 @@ Page({
app.globalData.auditMode = auditMode
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()
}
},
@@ -158,11 +170,17 @@ 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,
userIdShort,
userWechat,
readCount: 0,
@@ -182,9 +200,9 @@ Page({
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
this.loadUnlockedChapters()
} else {
const guestReadCount = app.getReadCount()
const guestRecent = this._mergeRecentChaptersFromLocal([])
this.setData({
isLoggedIn: false,
userInfo: null,
@@ -195,10 +213,7 @@ Page({
earnings: '-',
pendingEarnings: '-',
earningsLoading: false,
recentChapters: [],
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
recentChapters: guestRecent,
totalReadTime: 0,
matchHistory: 0,
totalReadTimeText: '0',
@@ -207,88 +222,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
})
},
@@ -302,21 +312,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)
@@ -334,6 +352,7 @@ Page({
})
} catch (e) {
console.log('[My] 拉取阅读统计失败:', e && e.message)
this._hydrateReadStatsFromLocal()
}
},
@@ -574,7 +593,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
@@ -856,9 +879,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' })
},
@@ -932,10 +979,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 }

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,34 +20,28 @@
<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="{{userInfo.avatar && userInfo.avatar.length > 5}}" class="avatar-img" src="{{userInfo.avatar}}" 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" 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">
<text class="profile-stat-val">{{referralCount}}</text>
@@ -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,49 @@
}
.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; }
/* 名片 / 会员中心:统一品牌青,与 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 +124,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 +198,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 +205,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,17 @@ Page({
loading: true,
showPrivacyModal: false,
nicknameInputFocus: false,
/** 首次完善:分 3 步full=1 或未达标资料时为单页 */
wizardMode: false,
wizardStep: 1,
totalWizardSteps: 3,
},
onLoad(options) {
this._wizardRouteOpts = {
full: options?.full === '1',
wizardOff: options?.wizard === '0',
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
fromVip: options?.from === 'vip',
@@ -98,6 +109,7 @@ Page({
projectIntro: v('projectIntro'),
loading: false,
})
this._applyWizardModeFromProfile(d)
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
@@ -109,6 +121,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({
@@ -365,7 +420,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 +432,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 +486,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="{{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 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="{{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>
</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,7 @@
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
*/
const app = getApp()
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
Page({
data: {
@@ -36,9 +37,11 @@ Page({
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
this.setData({
profile: {
...d,
avatar: isSafeImageSrc(av) ? String(av).trim() : '',
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

@@ -18,6 +18,7 @@ const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
@@ -120,10 +121,64 @@ Page({
// 好友从代付分享进入:待自动领取的 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) {
@@ -186,7 +241,9 @@ Page({
sectionMid: mid || null,
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || ''
pendingGiftRequestSn: giftRequestSn || '',
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
})
if (ref) {
@@ -234,9 +291,10 @@ Page({
}
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
} else {
app.touchRecentSection(id)
}
// 5. 导航:文章详情已带 prev/next
@@ -375,6 +433,7 @@ Page({
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
app.touchRecentSection(id)
}
} catch (e) {
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
@@ -393,6 +452,7 @@ Page({
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
app.touchRecentSection(id)
console.log('[Read] 从本地缓存加载成功')
return
}
@@ -698,8 +758,8 @@ Page({
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '完善资料',
content: '请填写手机号(必填),便对方联系您',
title: '补全手机号',
content: '请填写手机号(必填),便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
@@ -784,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) {}
@@ -932,17 +988,9 @@ Page({
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')
@@ -961,7 +1009,7 @@ Page({
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -988,21 +1036,10 @@ Page({
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
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 })
@@ -1086,6 +1123,10 @@ Page({
// 购买章节 - 直接调起支付
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 })
@@ -1105,6 +1146,10 @@ Page({
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1123,6 +1168,14 @@ Page({
// 处理支付 - 调用真实微信支付接口
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) {
@@ -1132,10 +1185,10 @@ Page({
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像昵称',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '支付订单会关联你的对外展示信息,请先设置头像昵称,避免账单与对方看到默认占位。',
confirmText: '去设置',
cancelText: '关闭',
success: resolve
})
})
@@ -1294,7 +1347,7 @@ Page({
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
cancelText: '关闭',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
@@ -1414,6 +1467,7 @@ Page({
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
checkAndExecute('after_pay', this)
} catch (e) {
wx.hideLoading()
@@ -1499,6 +1553,25 @@ Page({
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: '生成中...' })
@@ -1509,6 +1582,7 @@ Page({
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
@@ -1526,18 +1600,12 @@ Page({
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') {
@@ -1548,73 +1616,100 @@ Page({
} 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()

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">分享后好友购买,你可获得 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, // 总推荐人数
@@ -598,7 +598,7 @@ Page({
}
// 任意金额可提现,不再设最低限额
// 未绑定微信号时引导去设置
// 未绑定微信号:说明提现到账核对所需
if (!hasWechatId) {
wx.showModal({
title: '请先绑定微信号',

View File

@@ -12,16 +12,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
},
@@ -85,7 +85,7 @@ Page({
return
}
}
// VIP 购买后才引导完善资料:购买前不拦截,购买成功后跳转 profile-edit
// VIP 购买成功后再跳转资料:购买前不拦截
this.setData({ purchasing: true })
const amount = this.data.price
try {
@@ -163,9 +163,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' })
@@ -179,6 +179,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; }