增强用户隐私保护,新增昵称授权功能。更新头像选择逻辑,用户可直接通过按钮选择微信头像或相册图片。优化个人资料页面,强化手机号必填提示,提升用户体验。调整多个页面以支持新隐私授权机制,确保符合最新隐私规范。

This commit is contained in:
Alex-larget
2026-03-19 18:26:45 +08:00
parent 35aecdcd8c
commit 588dad2518
124 changed files with 4093 additions and 38827 deletions

View File

@@ -80,10 +80,28 @@ App({
supportWechat: '',
// config 统一缓存5min减少重复请求
configCache: null,
configCacheExpires: 0
configCacheExpires: 0,
// VIP 联系方式检测上次检测时间戳onShow 节流 5 分钟
lastVipContactCheck: 0,
// 头像昵称检测上次检测时间戳onShow 节流 5 分钟
lastAvatarNicknameCheck: 0,
},
onLaunch(options) {
// 昵称等隐私组件需先授权input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用
if (typeof wx.onNeedPrivacyAuthorization === 'function') {
wx.onNeedPrivacyAuthorization((resolve) => {
this._privacyResolve = resolve
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
if (cur && typeof cur.setData === 'function' && cur.route && (cur.route.includes('avatar-nickname') || cur.route.includes('profile-edit'))) {
cur.setData({ showPrivacyModal: true })
} else {
resolve({ event: 'disagree' })
}
})
}
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 加载 iconfont字体图标。注意小程序不支持在 wxss 里用本地 @font-face 引用字体文件,
// 需使用 loadFontFace 动态加载(字体文件建议走 https CDN
@@ -103,6 +121,11 @@ App({
// 检查登录状态
this.checkLoginStatus()
// 每次进入:先获取 VIP 状态VIP 走 profile-edit非 VIP 走头像/昵称引导(由 checkVipContactRequiredAndGuide 内部链式调用)
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1500)
setTimeout(() => this.connectWsHeartbeat(), 2000)
}
// 加载书籍数据
this.loadBookData()
@@ -143,6 +166,23 @@ App({
this.globalData.lastMpConfigCheck = now
this.getAuditMode()
}
// 从后台切回:先 VIP 强制跳转,再头像/昵称,节流 5 分钟
const throttle = 5 * 60 * 1000
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
if (!this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > throttle) {
this.globalData.lastVipContactCheck = now
this.globalData.lastAvatarNicknameCheck = now
setTimeout(() => this.checkVipContactRequiredAndGuide(), 500)
}
// 从后台切回:若 WSS 已断开则重连(微信后台可能回收连接)
try {
const need = !this._wsSocketTask || (this._wsSocketTask.readyState !== 0 && this._wsSocketTask.readyState !== 1)
if (need) {
this.clearWsReconnect()
setTimeout(() => this.connectWsHeartbeat(), 1000)
}
} catch (_) {}
}
},
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与 utils/scene 解析闭环
@@ -322,6 +362,44 @@ App({
return false
},
/** 判断头像/昵称是否未完善(默认状态) */
_needsAvatarNickname(user) {
const u = user || this.globalData.userInfo || {}
const avatar = (u.avatar || u.avatarUrl || '').trim()
const nickname = (u.nickname || u.nickName || '').trim()
return !avatar || avatar.includes('default') || !nickname || nickname === '微信用户' || nickname.startsWith('微信用户')
},
/**
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit避免乱跳
*/
checkAvatarNicknameAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
if (this.globalData.isVip) return // VIP 统一走 profile-edit此处不触发
if (!this._needsAvatarNickname()) return
try {
const pages = getCurrentPages()
const last = pages[pages.length - 1]
const route = (last && last.route) || ''
if (route.indexOf('profile-edit') !== -1 || route.indexOf('avatar-nickname') !== -1) return
} catch (_) {}
// 老用户:弹窗提示后跳转
const today = new Date().toISOString().slice(0, 10)
const lastDate = wx.getStorageSync('lastAvatarGuideDate') || ''
if (lastDate === today) return
wx.setStorageSync('lastAvatarGuideDate', today)
wx.showModal({
title: '完善个人资料',
content: '请设置头像和昵称,让其他创业者更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
})
},
// 检查登录状态
checkLoginStatus() {
try {
@@ -341,6 +419,168 @@ App({
}
},
/**
* WSS 在线心跳(占位):登录后连接 ws发送 auth + 心跳,供管理端统计在线人数
* 容错任意异常均不向外抛出不影响登录、API 请求等核心功能
*/
clearWsReconnect() {
try {
if (this._wsReconnectTimerId) {
clearTimeout(this._wsReconnectTimerId)
this._wsReconnectTimerId = null
}
this._wsReconnectDelay = 3000
} catch (_) {}
},
scheduleWsReconnect() {
try {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
if (this._wsReconnectTimerId) return
const delay = this._wsReconnectDelay || 3000
this._wsReconnectTimerId = setTimeout(() => {
this._wsReconnectTimerId = null
this._wsReconnectDelay = Math.min(60000, (this._wsReconnectDelay || 3000) * 2)
this.connectWsHeartbeat()
}, delay)
} catch (_) {}
},
connectWsHeartbeat() {
try {
this.clearWsReconnect()
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
const userId = this.globalData.userInfo.id
const base = (this.globalData.baseUrl || '').replace(/\/$/, '')
if (!base) return
const wsUrl = base.replace(/^http/, 'ws') + '/ws/miniprogram'
if (this._wsHeartbeatTimer) {
clearInterval(this._wsHeartbeatTimer)
this._wsHeartbeatTimer = null
}
if (this._wsSocketTask) {
try { this._wsSocketTask.close() } catch (_) {}
this._wsSocketTask = null
}
let task
try {
task = wx.connectSocket({
url: wsUrl,
fail: () => { try { this.scheduleWsReconnect() } catch (_) {} }
})
} catch (e) {
if (typeof console !== 'undefined' && console.warn) console.warn('[WS] 连接失败(静默):', e?.message || e)
try { this.scheduleWsReconnect() } catch (_) {}
return
}
task.onOpen(() => {
try {
this.clearWsReconnect()
task.send({ data: JSON.stringify({ type: 'auth', userId }) })
this._wsHeartbeatTimer = setInterval(() => {
try {
if (task && task.readyState === 1) task.send({ data: JSON.stringify({ type: 'heartbeat' }) })
} catch (_) {}
}, 30000)
} catch (_) {}
})
task.onClose(() => {
try {
if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer); this._wsHeartbeatTimer = null }
this._wsSocketTask = null
this.scheduleWsReconnect()
} catch (_) {}
})
task.onError(() => {
try {
if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer); this._wsHeartbeatTimer = null }
this._wsSocketTask = null
this.scheduleWsReconnect()
} catch (_) {}
})
this._wsSocketTask = task
} catch (e) {
if (typeof console !== 'undefined' && console.warn) console.warn('[WS] 心跳异常(静默,不影响业务):', e?.message || e)
}
},
/**
* VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit避免与 avatar-nickname 乱跳。
* 旧数据VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
const now = Date.now()
if (this._lastVipGuideRun && now - this._lastVipGuideRun < 3000) return // 3 秒内不重复执行,避免 onLaunch+onShow 双重触发
this._lastVipGuideRun = now
const userId = this.globalData.userInfo.id
try {
const pages = getCurrentPages()
const last = pages[pages.length - 1]
const route = (last && last.route) || ''
if (route.indexOf('profile-edit') !== -1 || route.indexOf('avatar-nickname') !== -1) return
} catch (_) {}
try {
const [vipRes, profileRes] = await Promise.all([
this.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }).catch(() => null),
this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }).catch(() => null)
])
const isVip = vipRes?.data?.isVip || this.globalData.isVip || false
this.globalData.isVip = isVip
if (!isVip) {
this.checkAvatarNicknameAndGuide()
return
}
const profileData = profileRes?.data || this.globalData.userInfo || {}
const phone = (profileData.phone || this.globalData.userInfo?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
const needsAvatarNickname = this._needsAvatarNickname(profileData)
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit弹窗「为了更好服务请完善资料」
if (needsAvatarNickname) {
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请完善资料',
confirmText: '去完善',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
if (phone && wechatId) return
// VIP 无手机号:弹窗说明后跳转
if (!phone) {
wx.showModal({
title: '完善资料',
content: 'VIP会员需完善手机号以便使用找伙伴、提现等功能',
confirmText: '去完善',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
// 有手机号但缺微信号:弹窗引导(非强制)
wx.showModal({
title: '完善联系方式',
content: '请到资料页完善微信号,便于他人联系您',
confirmText: '去完善',
cancelText: '稍后',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
} catch (e) {
console.log('[App] checkVipContactRequiredAndGuide 失败:', e?.message)
}
},
// 加载书籍元数据totalSections不再预加载 all-chapters
async loadBookData() {
try {
@@ -659,8 +899,17 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料(规则引擎接管,完善头像吸收到规则引擎
checkAndExecute('after_login', null)
// 同步 isVip与 checkLoginStatus 一致
this.globalData.isVip = user.isVip || false
this.globalData.vipExpireDate = user.vipExpireDate || ''
// 首次登录注册:强制跳转 avatar-nickname 修改头像昵称(不弹窗)
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
setTimeout(() => this.connectWsHeartbeat(), 2000)
}
}
return res.data
@@ -721,8 +970,16 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料(规则引擎接管)
checkAndExecute('after_login', null)
// 同步 isVip
this.globalData.isVip = user.isVip || false
this.globalData.vipExpireDate = user.vipExpireDate || ''
// 首次登录注册:强制跳转 avatar-nickname
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
}
}
return res.data.openId
}
@@ -764,6 +1021,8 @@ App({
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
this.globalData.isVip = user.isVip || false
this.globalData.vipExpireDate = user.vipExpireDate || ''
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
@@ -775,9 +1034,14 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料(规则引擎接管)
checkAndExecute('after_login', null)
// 首次登录注册:强制跳转 avatar-nickname
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
}
return res.data
}
} catch (e) {

View File

@@ -0,0 +1,119 @@
# Soul 小程序 - 资料完善引导流程
> **入口统一**:仅 `checkVipContactRequiredAndGuide` 被 onLaunch/onShow/登录 调度;非 VIP 时内部链式调用 `checkAvatarNicknameAndGuide`。
---
## 一、整体流程(冷启动 / onShow
```mermaid
flowchart TD
Start([入口onLaunch 1.5s / onShow 0.5s]) --> CheckLogin{已登录?}
CheckLogin -->|否| End1([结束])
CheckLogin -->|是| VIPCheck[checkVipContactRequiredAndGuide]
VIPCheck --> CoolDown{3秒内已执行过?}
CoolDown -->|是| End0([结束,防重复])
CoolDown -->|否| RouteSkip{当前在 profile-edit<br/>或 avatar-nickname?}
RouteSkip -->|是| End0
RouteSkip -->|否| FetchAPI
VIPCheck --> FetchAPI[请求 APIVIP 状态 + 用户资料]
FetchAPI --> UpdateIsVip[更新 globalData.isVip]
UpdateIsVip --> IsVip{VIP?}
IsVip -->|否| AvatarCheck[checkAvatarNicknameAndGuide]
AvatarCheck --> End2([结束])
IsVip -->|是| SkipProfile{当前在 profile-edit?}
SkipProfile -->|是| End3([结束])
SkipProfile -->|否| NeedAvatar{头像/昵称未改?}
NeedAvatar -->|是| Modal1[弹窗:为了更好为您服务,请完善资料]
Modal1 --> Redirect1[redirectTo profile-edit]
Redirect1 --> End4([结束])
NeedAvatar -->|否| HasPhone{有手机号?}
HasPhone -->|否| Modal2[弹窗VIP会员需完善手机号...]
Modal2 --> Redirect2[redirectTo profile-edit]
Redirect2 --> End5([结束])
HasPhone -->|是| HasWechat{有微信号?}
HasWechat -->|是| End6([结束])
HasWechat -->|否| Modal3[弹窗:请到资料页完善微信号]
Modal3 --> Nav1[navigateTo profile-edit]
Nav1 --> End7([结束])
```
---
## 二、头像/昵称引导(非 VIP 用户)
```mermaid
flowchart TD
A([checkAvatarNicknameAndGuide]) --> B{globalData.isVip?}
B -->|是| X([跳过,由 VIP 检测处理])
B -->|否| C{头像/昵称已完善?}
C -->|是| Y([结束])
C -->|否| D{当前在 profile-edit<br/>或 avatar-nickname?}
D -->|是| Z([结束])
D -->|否| E{今日已提示过?}
E -->|是| W([结束])
E -->|否| F[弹窗:请设置头像和昵称]
F --> G{点击去完善?}
G -->|是| H[navigateTo avatar-nickname]
G -->|否 稍后| I([结束])
H --> I
```
---
## 三、首次登录(新注册用户)
```mermaid
flowchart TD
Login([登录成功]) --> NewUser{isNewUser?}
NewUser -->|否| VIPFlow[checkVipContactRequiredAndGuide]
VIPFlow --> Flow1([见流程图一])
NewUser -->|是| NeedAvatar{头像/昵称未改?}
NeedAvatar -->|否| VIPFlow
NeedAvatar -->|是| ForceRedirect[redirectTo avatar-nickname<br/>无弹窗,强制跳转]
ForceRedirect --> End([结束])
```
---
## 四、VIP 购买成功(超级个体)
```mermaid
flowchart TD
Pay([VIP 支付成功]) --> Sync[同步权益 / 拉取 VIP 状态]
Sync --> Modal[弹窗:为了更好为您服务,请填写好资料]
Modal --> Redirect[redirectTo profile-edit?from=vip]
Redirect --> End([结束])
```
---
## 五、页面职责
| 页面 | 用途 |
|------|------|
| **avatar-nickname** | 仅头像 + 昵称,专注引导新用户/非 VIP |
| **profile-edit** | 完整资料手机号、微信号、MBTI、行业等VIP 用户统一跳转 |
---
## 六、触发时机汇总
| 时机 | 执行的函数 | 延迟 |
|------|------------|------|
| onLaunch | checkVipContactRequiredAndGuide | 1500ms |
| onShow | checkVipContactRequiredAndGuide | 500ms节流 5min|
| login 成功 | checkVipContactRequiredAndGuide | 1200ms |
| getOpenId 返回 user | checkVipContactRequiredAndGuide | 1200ms |
| loginWithPhone 成功 | checkVipContactRequiredAndGuide | 1200ms |
| VIP 支付成功 | _onVipPaymentSuccess | 即时 |
---
*文档生成于资料完善引导逻辑调整后*

View File

@@ -10,7 +10,8 @@ Page({
avatar: '',
nickname: '',
saving: false,
showAvatarModal: false,
showPrivacyModal: false,
nicknameInputFocus: false,
},
onLoad() {
@@ -40,39 +41,38 @@ Page({
onNicknameChange(e) {
this.setData({ nickname: e.detail.value })
},
onAvatarTap() {
wx.showActionSheet({
itemList: ['使用微信头像', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
this.setData({ showAvatarModal: true })
} else if (res.tapIndex === 1) {
this.chooseAvatarFromAlbum()
}
onNicknameBlur() {
this.setData({ nicknameInputFocus: false })
},
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
success: () => {
this.setData({ nicknameInputFocus: true })
},
fail: () => {},
})
},
closeAvatarModal() {
this.setData({ showAvatarModal: false })
preventMove() {},
handleAgreePrivacy() {
const app = getApp()
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false, nicknameInputFocus: true })
},
chooseAvatarFromAlbum() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
await this.uploadAndSaveAvatar(tempPath)
},
})
handleDisagreePrivacy() {
const app = getApp()
if (app._privacyResolve) {
app._privacyResolve({ event: 'disagree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
await this.uploadAndSaveAvatar(tempAvatarUrl)
},
@@ -98,7 +98,10 @@ Page({
fail: reject,
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl })
await app.request({
url: '/api/miniprogram/user/profile',

View File

@@ -15,35 +15,48 @@
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
</view>
<!-- 头像 -->
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
<view class="avatar-section">
<view class="avatar-wrap" bindtap="onAvatarTap">
<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>
<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>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
<text class="avatar-change">点击更换头像</text>
</button>
</view>
<!-- 昵称 -->
<!-- 昵称:点击前先请求隐私授权,解决 errno:104 昵称选择器无法弹出 -->
<view class="form-section">
<text class="form-label">昵称</text>
<view class="form-input-wrap">
<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="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>
<text class="privacy-desc">为获取微信昵称,请先同意《用户隐私保护指引》</text>
<button id="agree-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacy">同意</button>
<view class="privacy-cancel" bindtap="handleDisagreePrivacy">拒绝</view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '完成'}}
</view>
@@ -54,14 +67,4 @@
</view>
</view>
<!-- 头像弹窗:使用微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<text class="avatar-modal-title">使用微信头像</text>
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
</view>
</view>
</view>

View File

@@ -72,10 +72,20 @@
.avatar-section {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 32rpx;
margin-bottom: 48rpx;
}
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;
padding: 0; margin: 0; background: transparent; border: none;
width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible;
flex-shrink: 0;
}
.avatar-wrap-btn::after { border: none; }
.avatar-wrap {
position: relative;
width: 192rpx;
@@ -126,7 +136,6 @@
font-size: 28rpx;
color: #5EEAD4;
font-weight: 500;
margin-top: 16rpx;
}
.form-section {
@@ -263,9 +272,79 @@
.btn-choose-avatar::after {
border: none;
}
.btn-choose-album {
width: 100%;
height: 88rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(94, 234, 212, 0.15);
color: #5EEAD4;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: 2rpx solid #5EEAD4;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}
/* 隐私授权弹窗(昵称选择器需先授权) */
.privacy-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.privacy-modal {
width: 100%;
max-width: 560rpx;
background: #17212F;
border-radius: 24rpx;
padding: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.privacy-title {
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 24rpx;
}
.privacy-desc {
font-size: 28rpx;
color: #94A3B8;
text-align: center;
line-height: 1.5;
margin-bottom: 40rpx;
}
.privacy-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: #5EEAD4;
color: #050B14;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
margin: 0;
}
.privacy-btn::after { border: none; }
.privacy-cancel {
margin-top: 24rpx;
font-size: 28rpx;
color: #94A3B8;
}

View File

@@ -28,6 +28,9 @@ Page({
onLoad(options) {
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
if (options?.ref || options?.referralCode) {
app.handleReferralCode({ query: { ref: options.ref || options.referralCode } })
}
const requestSn = (options.requestSn || '').trim()
const sectionId = (options.sectionId || '').trim()
const isSinglePage = (wx.getSystemInfoSync?.()?.mode === 'singlePage') || app.globalData.isSinglePageMode
@@ -323,10 +326,10 @@ Page({
const { requestSn } = this.data
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
let path = '/pages/gift-pay/detail'
if (requestSn) {
path = `/pages/gift-pay/detail?requestSn=${requestSn}`
if (ref) path += `&ref=${encodeURIComponent(ref)}`
}
const params = []
if (requestSn) params.push(`requestSn=${encodeURIComponent(requestSn)}`)
if (ref) params.push(`ref=${encodeURIComponent(ref)}`)
if (params.length) path += '?' + params.join('&')
return {
title: '好友送你一篇好文 - Soul创业派对',
path
@@ -336,14 +339,12 @@ Page({
onShareTimeline() {
const { requestSn } = this.data
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
let query = ''
if (requestSn) {
query = `requestSn=${requestSn}`
if (ref) query += `&ref=${encodeURIComponent(ref)}`
}
const params = []
if (requestSn) params.push(`requestSn=${encodeURIComponent(requestSn)}`)
if (ref) params.push(`ref=${encodeURIComponent(ref)}`)
return {
title: '好友送你一篇好文 - Soul创业派对',
query: query || ''
query: params.length ? params.join('&') : ''
}
}
})

View File

@@ -10,8 +10,11 @@ Page({
loading: false
},
onLoad() {
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
if (options && (options.ref || options.referralCode)) {
app.handleReferralCode({ query: { ref: options.ref || options.referralCode } })
}
this.loadData()
},
@@ -82,6 +85,8 @@ Page({
},
onShareAppMessage() {
return { title: '我的代付 - Soul创业派对', path: '/pages/gift-pay/list' }
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
const path = ref ? `/pages/gift-pay/list?ref=${encodeURIComponent(ref)}` : '/pages/gift-pay/list'
return { title: '我的代付 - Soul创业派对', path }
}
})

View File

@@ -226,9 +226,9 @@ Page({
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const phone = (res?.data?.phone || '').trim()
const wechat = (res?.data?.wechatId || '').trim()
if (phone || wechat) {
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()
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
callback()
return
}
@@ -252,10 +252,10 @@ Page({
onContactWechatInput(e) { this.setData({ contactWechat: e.detail.value }) },
async saveContactInfo() {
const phone = (this.data.contactPhone || '').trim()
const phone = (this.data.contactPhone || '').trim().replace(/\s/g, '')
const wechat = (this.data.contactWechat || '').trim()
if (!phone && !wechat) {
wx.showToast({ title: '请至少填写手机号或微信号', icon: 'none' })
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showToast({ title: '请输入正确的11位手机号必填', icon: 'none' })
return
}
this.setData({ contactSaving: true })

View File

@@ -274,7 +274,7 @@
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
<view class="contact-modal" catchtap="preventBubble">
<text class="contact-modal-title">请完善联系方式</text>
<view class="contact-modal-hint">需完善手机号或微信号才能使用找伙伴功能</view>
<view class="contact-modal-hint">手机号必填,微信号建议填写,以便使用找伙伴功能</view>
<view class="form-input-wrap">
<text class="form-label">手机号</text>
<view class="form-input-inner">

View File

@@ -68,9 +68,6 @@ Page({
showNicknameModal: false,
editingNickname: '',
// 头像弹窗(含 chooseAvatar 按钮,必须用户点击才可获取微信头像)
showAvatarModal: false,
// 手机/微信号弹窗stitch_soul comprehensive_profile_editor_v1_2
showContactModal: false,
contactPhone: '',
@@ -458,10 +455,9 @@ Page({
wx.showToast({ title: '已刷新', icon: 'success' })
},
// 微信原生获取头像button open-type="chooseAvatar" 回调,真正获取微信头像
// 微信原生获取头像button open-type="chooseAvatar" 回调,点击头像直接唤起选择器
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
@@ -495,8 +491,11 @@ Page({
})
})
// 2. 获取上传后的完整URL
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
// 2. 获取上传后的完整URL(显示用);保存时只传路径
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
console.log('[My] 头像上传成功:', avatarUrl)
// 3. 更新本地头像
@@ -506,7 +505,7 @@ Page({
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 4. 同步到服务器数据库
// 4. 同步到服务器数据库(只保存路径,不含域名)
await app.request('/api/miniprogram/user/update', {
method: 'POST',
data: { userId: userInfo.id, avatar: avatarUrl }
@@ -548,12 +547,9 @@ Page({
}
},
// 打开昵称修改弹窗
// 点击昵称跳转资料编辑页type="nickname" 在弹窗内无法触发微信昵称选择器,需在主页面)
editNickname() {
this.setData({
showNicknameModal: true,
editingNickname: this.data.userInfo?.nickname || ''
})
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 关闭昵称弹窗
@@ -875,62 +871,6 @@ Page({
} catch (e) { console.log('[My] 余额查询失败', e) }
},
// 头像点击:已登录弹出选项(微信头像 / 相册)
onAvatarTap() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.showActionSheet({
itemList: ['获取微信头像', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) this.setData({ showAvatarModal: true })
if (res.tapIndex === 1) this.chooseAvatarFromAlbum()
}
})
},
closeAvatarModal() {
this.setData({ showAvatarModal: false })
},
// 从相册/相机选择(自定义图片)
chooseAvatarFromAlbum() {
wx.chooseMedia({
count: 1, mediaType: ['image'], sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempPath,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
data.success ? resolve(data) : reject(new Error(data.error || '上传失败'))
} catch (e) { reject(new Error('解析失败')) }
},
fail: (e) => reject(e)
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
await app.request('/api/miniprogram/user/update', { method: 'POST', data: { userId: userInfo.id, avatar: avatarUrl } })
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败,请重试', icon: 'none' })
}
}
})
},
goToVip() {
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
@@ -982,18 +922,19 @@ Page({
})
},
// 提现/找伙伴前检查手机或微信号未填则弹窗stitch_soul
// 提现/找伙伴前检查联系方式:手机号必填(与 profile-edit 规则一致
async ensureContactInfo(callback) {
const userId = app.globalData.userInfo?.id
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const phone = (res?.data?.phone || '').trim()
const wechat = (res?.data?.wechatId || '').trim()
if (phone || wechat) {
const phone = (res?.data?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const hasValidPhone = !!phone && /^1[3-9]\d{9}$/.test(phone)
if (hasValidPhone) {
callback()
return
}
const wechat = (res?.data?.wechatId || res?.data?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
this.setData({
showContactModal: true,
contactPhone: phone || '',
@@ -1015,10 +956,14 @@ Page({
onContactWechatInput(e) { this.setData({ contactWechat: e.detail.value }) },
async saveContactInfo() {
const phone = (this.data.contactPhone || '').trim()
const phone = (this.data.contactPhone || '').trim().replace(/\s/g, '')
const wechat = (this.data.contactWechat || '').trim()
if (!phone && !wechat) {
wx.showToast({ title: '请至少填写手机号或微信号', icon: 'none' })
if (!phone) {
wx.showToast({ title: '请输入手机号(必填)', icon: 'none' })
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
wx.showToast({ title: '请输入正确的11位手机号', icon: 'none' })
return
}
this.setData({ contactSaving: true })

View File

@@ -23,14 +23,16 @@
<view class="profile-card" wx:else>
<view class="profile-card-inner">
<view class="profile-top-row">
<view class="avatar-wrap" bindtap="onAvatarTap">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar}}" 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>
</view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
<view class="vip-badge vip-badge-gray" wx:else>VIP</view>
</view>
</button>
<view class="profile-meta">
<view class="profile-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
@@ -199,7 +201,7 @@
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
<view class="contact-modal" catchtap="stopPropagation">
<text class="contact-modal-title">请完善联系方式</text>
<view class="contact-modal-hint">需完善手机号或微信号才能使用提现和找伙伴功能</view>
<view class="contact-modal-hint">手机号必填,微信号建议填写,以便使用提现和找伙伴功能</view>
<view class="form-input-wrap">
<text class="form-label">手机号</text>
<view class="form-input-inner">
@@ -219,17 +221,6 @@
</view>
</view>
<!-- 头像弹窗:必须点击 button 才能获取微信头像(隐私规范) -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<text class="avatar-modal-title">获取微信头像</text>
<text class="avatar-modal-desc">点击下方按钮使用你的微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
</view>
</view>
<!-- 修改昵称弹窗 -->
<view class="modal-overlay" wx:if="{{showNicknameModal}}" bindtap="closeNicknameModal">
<view class="modal-content nickname-modal" catchtap="stopPropagation">

View File

@@ -41,6 +41,14 @@
border: 1rpx solid rgba(75,85,99,0.5);
}
.profile-top-row { display: flex; align-items: flex-start; gap: 32rpx; }
/* 头像按钮:透明无边框,宽高与头像一致,点击直接唤起微信选择器(微信头像/相册/拍照) */
.avatar-wrap-btn {
width: 40rpx; height: 130rpx;
padding: 0; margin: 0; background: transparent; border: none;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.avatar-wrap-btn::after { border: none; }
.avatar-wrap { position: relative; flex-shrink: 0; }
.avatar-inner {
width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden;

View File

@@ -9,6 +9,7 @@
* 表单展示:普通用户仅展示 温馨提示、头像、昵称、MBTI、地区、行业、业务体量、职位、核心联系方式VIP 展示全部
*/
const app = getApp()
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']
@@ -38,11 +39,15 @@ Page({
showMbtiPicker: false,
saving: false,
loading: true,
showAvatarModal: false,
showPrivacyModal: false,
nicknameInputFocus: false,
},
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
fromVip: options?.from === 'vip',
})
wx.showShareMenu({ withShareTimeline: true })
// 从朋友圈/分享打开且带 id跳转到名片详情member-detail
if (options?.id) {
@@ -104,6 +109,30 @@ Page({
goBack() { getApp().goBackOrToHome() },
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
success: () => { this.setData({ nicknameInputFocus: true }) },
fail: () => {},
})
},
onNicknameBlur() { this.setData({ nicknameInputFocus: false }) },
preventMove() {},
handleAgreePrivacy() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false, nicknameInputFocus: true })
},
handleDisagreePrivacy() {
if (app._privacyResolve) {
app._privacyResolve({ event: 'disagree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 生成分享名片封面图(参考:头像左+昵称右,分隔线,四栏信息 5:4
async generateShareCard() {
const { avatar, nickname, region, mbti, industry, position } = this.data
@@ -286,76 +315,9 @@ Page({
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
},
// 点击头像:选择微信头像从相册选择
onAvatarTap() {
wx.showActionSheet({
itemList: ['使用微信头像', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
this.setData({ showAvatarModal: true })
} else if (res.tapIndex === 1) {
this.chooseAvatarFromAlbum()
}
},
})
},
closeAvatarModal() {
this.setData({ showAvatarModal: false })
},
// 从相册/相机选择头像
chooseAvatarFromAlbum() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempPath,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch { reject(new Error('解析失败')) }
},
fail: reject,
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
this.setData({ avatar: avatarUrl })
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
}
},
})
},
// 微信原生 chooseAvatar 回调:使用当前微信头像
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
@@ -379,12 +341,16 @@ Page({
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl })
const avatarToSave = toAvatarPath(avatarUrl)
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
data: { userId: app.globalData.userInfo?.id, avatar: avatarToSave },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.avatar = avatarUrl
@@ -405,20 +371,32 @@ Page({
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
// 手机号必填,格式校验(支持带空格/连字符输入)
const phoneRaw = s(this.data.phone)
if (!phoneRaw) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
return
}
const phoneNum = phoneRaw.replace(/\D/g, '')
if (!/^1[3-9]\d{9}$/.test(phoneNum)) {
wx.showToast({ title: '请输入正确的11位手机号', icon: 'none' })
return
}
const phoneToSave = phoneNum
this.setData({ saving: true })
try {
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
const payload = {
userId,
avatar: s(this.data.avatar),
avatar: toAvatarPath(s(this.data.avatar)),
nickname: s(this.data.nickname),
mbti: s(this.data.mbti),
region: s(this.data.region),
industry: s(this.data.industry),
businessScale: s(this.data.businessScale),
position: s(this.data.position),
phone: s(this.data.phone),
phone: phoneToSave,
wechatId: s(this.data.wechatId),
}
const showHelp = isVip || this.data.helpOffer || this.data.helpNeed
@@ -441,7 +419,7 @@ Page({
this.setData({ saving: false })
return
}
await app.request({
const res = await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: payload,
@@ -449,7 +427,7 @@ Page({
wx.showToast({ title: '保存成功', icon: 'success' })
if (app.globalData.userInfo) {
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
if (payload.avatar) app.globalData.userInfo.avatar = payload.avatar
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
setTimeout(() => getApp().goBackOrToHome(), 800)

View File

@@ -9,36 +9,39 @@
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 温馨提示 -->
<view class="tip-card">
<!-- 温馨提示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">温馨提示:需完善手机号和微信号才能使用提现和找伙伴功能</text>
<text class="tip-text">{{fromVip ? '恭喜成为VIP完善资料后即可使用找伙伴、提现等功能手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}}</text>
</view>
<!-- 头像 -->
<!-- 头像:点击直接弹出微信原生选择器(用微信头像/从相册选择/拍照) -->
<view class="avatar-section">
<view class="avatar-wrap" bindtap="onAvatarTap">
<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>
<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>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
<text class="avatar-change">更换头像</text>
</button>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap">
<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>
@@ -84,8 +87,8 @@
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号</text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="请输入手机号" value="{{phone}}" bindinput="onPhoneInput"/></view>
<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>
@@ -149,14 +152,13 @@
<!-- 分享名片 canvas隐藏用于生成分享图 5:4 -->
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
<!-- 头像弹窗:通过 button 获取微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<text class="avatar-modal-title">使用微信头像</text>
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
<!-- 隐私授权弹窗(昵称需授权后方可唤起微信昵称选择器) -->
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>
<text class="privacy-desc">为获取微信昵称,请先同意《用户隐私保护指引》</text>
<button id="agree-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacy">同意</button>
<view class="privacy-cancel" bindtap="handleDisagreePrivacy">拒绝</view>
</view>
</view>
</view>

View File

@@ -31,10 +31,20 @@
background: rgba(94,234,212,0.08); border: 1rpx solid rgba(94,234,212,0.25);
border-radius: 24rpx; margin-bottom: 48rpx;
}
.tip-card-highlight {
background: rgba(94,234,212,0.12); border-color: rgba(94,234,212,0.4);
}
.tip-icon { font-size: 40rpx; color: #5EEAD4; flex-shrink: 0; }
.tip-text { font-size: 26rpx; color: rgba(94,234,212,0.95); line-height: 1.6; }
.avatar-section { display: flex; flex-direction: column; align-items: center; margin-bottom: 48rpx; }
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;
padding: 0; margin: 0; background: transparent; border: none;
width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible;
}
.avatar-wrap-btn::after { border: none; }
.avatar-wrap {
position: relative; width: 192rpx; height: 192rpx; border-radius: 50%;
border: 4rpx solid #5EEAD4; box-shadow: 0 0 30rpx rgba(94,234,212,0.3);
@@ -67,6 +77,7 @@
.form-row-2 { display: flex; gap: 24rpx; }
.form-row-2 .form-item { flex: 1; min-width: 0; }
.form-label { display: block; font-size: 24rpx; color: #94A3B8; margin-bottom: 12rpx; margin-left: 8rpx; }
.required-mark { color: #F87171; margin-left: 4rpx; }
/* input/textarea 用 view 包裹padding 写在 view 上 */
.form-input-wrap {
@@ -75,10 +86,37 @@
border-radius: 24rpx;
box-sizing: border-box; min-width: 0; width: 100%;
}
.form-input-suffix { position: relative; padding-right: 64rpx; }
/* 地区等带后缀图标的输入框:与 MBTI 同高,图标垂直居中 */
.form-input-suffix {
position: relative;
padding-right: 64rpx;
display: flex;
align-items: center;
min-height: 88rpx;
}
.form-input-suffix .form-input-inner {
flex: 1;
min-height: 40rpx;
line-height: 40rpx;
}
.form-input-suffix .form-suffix {
position: absolute; right: 24rpx; top: 50%; transform: translateY(-50%);
font-size: 32rpx; color: #94A3B8;
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 32rpx;
color: #94A3B8;
pointer-events: none;
}
/* form-row-2 下两列统一最小高度,与 MBTI 一致 */
.form-row-2 .form-input-wrap,
.form-row-2 .form-picker {
min-height: 88rpx;
display: flex;
align-items: center;
}
.form-row-2 .form-picker {
color: #fff;
}
.form-input-inner {
width: 100%; max-width: 100%; font-size: 28rpx; color: #fff; background: transparent;
@@ -183,9 +221,123 @@
.btn-choose-avatar::after {
border: none;
}
.btn-choose-album {
width: 100%;
height: 88rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(94, 234, 212, 0.15);
color: #5EEAD4;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: 2rpx solid #5EEAD4;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}
/* 昵称隐私弹窗 */
.privacy-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-modal {
width: 560rpx;
padding: 48rpx 40rpx;
background: #1E293B;
border-radius: 24rpx;
text-align: center;
}
.privacy-modal .privacy-title { font-size: 34rpx; font-weight: 600; color: #fff; margin-bottom: 24rpx; }
.privacy-modal .privacy-desc { font-size: 28rpx; color: #94A3B8; line-height: 1.6; margin-bottom: 40rpx; }
.privacy-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #5EEAD4;
color: #0F172A;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
/* 隐私授权弹窗 */
.privacy-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-modal {
width: 580rpx;
background: #1E293B;
border-radius: 24rpx;
padding: 40rpx 32rpx 32rpx;
}
.privacy-modal-title {
font-size: 34rpx;
font-weight: 600;
color: #F8FAFC;
margin-bottom: 24rpx;
text-align: center;
}
.privacy-modal-desc {
font-size: 28rpx;
color: #94A3B8;
line-height: 1.5;
margin-bottom: 32rpx;
}
.privacy-btn {
width: 100%;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #5EEAD4;
color: #0F172A;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
margin-bottom: 16rpx;
}
.privacy-btn:last-of-type {
margin-bottom: 0;
background: transparent;
color: #94A3B8;
border: 2rpx solid #475569;
}
/* 昵称隐私授权弹窗(解决 errno:104 */
.privacy-mask {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 9999;
display: flex; align-items: center; justify-content: center; padding: 48rpx;
}
.privacy-modal {
width: 100%; max-width: 600rpx; background: #1E293B; border-radius: 24rpx; padding: 48rpx;
}
.privacy-title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 16rpx; }
.privacy-desc { font-size: 28rpx; color: #94A3B8; line-height: 1.5; display: block; margin-bottom: 32rpx; }
.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; }

View File

@@ -651,21 +651,21 @@ Page({
return
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone && !wechatId) {
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '完善资料',
content: '请先填写手机号或微信号,以便对方联系您',
content: '请先填写手机号(必填),以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {

View File

@@ -3,6 +3,7 @@
* 账号绑定功能
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
Page({
data: {
@@ -286,8 +287,11 @@ Page({
})
})
// 2. 获取上传后的完整URL
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
// 2. 获取上传后的完整URL(显示用);保存时只传路径
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
console.log('[Settings] 头像上传成功:', avatarUrl)
// 3. 更新本地
@@ -299,12 +303,12 @@ Page({
}
})
// 4. 同步到服务器数据库
// 4. 同步到服务器数据库(只保存路径,不含域名)
const userId = app.globalData.userInfo?.id
if (userId) {
await app.request('/api/miniprogram/user/profile', {
method: 'POST',
data: { userId, nickname: nickName, avatar: avatarUrl }
data: { userId, nickname: nickName, avatar: toAvatarPath(avatarUrl) }
})
}

View File

@@ -85,20 +85,7 @@ Page({
return
}
}
// 支付前:若头像/昵称仍为默认值,引导完善(仅头像+昵称)
if (this._shouldGuideAvatarNickname()) {
wx.showModal({
title: '完善资料',
content: '开通超级个体前,请先设置头像和昵称,让他人更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
})
return
}
// VIP 购买后才引导完善资料:购买前不拦截,购买成功后跳转 profile-edit
this.setData({ purchasing: true })
const amount = this.data.price
try {
@@ -173,23 +160,21 @@ Page({
else if (typeof p.updateUserStatus === 'function') p.updateUserStatus()
})
// 开通成功后兜底:仍为默认头像/昵称则引导完善
if (this._shouldGuideAvatarNickname()) {
wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
// 超级个体购买后:弹窗提示,强制跳转资料编辑页
wx.hideLoading()
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请填写好资料',
confirmText: '去完善',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit?from=vip' })
}
})
} catch (e) {
console.error('[VIP] 支付后同步失败:', e)
wx.hideLoading()
}
wx.hideLoading()
},
_shouldGuideAvatarNickname() {
const user = app.globalData.userInfo || {}
const avatar = (user.avatar || user.avatarUrl || '').trim()
const nickname = (user.nickname || user.nickName || '').trim()
// 与 ruleEngine.checkRule_FillAvatar 保持同口径(允许前端兜底)
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return false
return true
},
goBack() { getApp().goBackOrToHome() },

View File

@@ -24,12 +24,26 @@
"miniprogram": {
"list": [
{
"name": "pages/gift-pay/list",
"pathName": "pages/gift-pay/list",
"name": "pages/my/my",
"pathName": "pages/my/my",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "个人资料",
"pathName": "pages/avatar-nickname/avatar-nickname",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "pages/gift-pay/list",
"pathName": "pages/gift-pay/list",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "代付",
"pathName": "pages/gift-pay/detail",

View File

@@ -86,9 +86,11 @@ function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
}
// 稳定版:跳转 avatar-nickname与 _ensureProfileCompletedAfterLogin 一致
// 稳定版:跳转 avatar-nickname专注头像+昵称,首次登录由 app.login 强制 redirect
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 引导到 profile-edit避免与主流程冲突
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) return null
if (app.globalData.isVip) return null
const user = getUserInfo()
if (!user.id) return null
const avatar = user.avatar || user.avatarUrl || ''

View File

@@ -174,6 +174,20 @@ const showConfirm = (title, content) => {
})
}
/**
* 从头像 URL 提取路径部分(不含域名),用于保存到后端
* 例如https://xxx.com/uploads/avatars/1.jpg → /uploads/avatars/1.jpg
* @param {string} url - 完整 URL 或路径
* @returns {string}
*/
const toAvatarPath = url => {
if (!url || typeof url !== 'string') return url || ''
const idx = url.indexOf('/uploads/')
if (idx >= 0) return url.substring(idx)
if (url.startsWith('/')) return url
return url
}
module.exports = {
formatTime,
formatDate,
@@ -191,5 +205,6 @@ module.exports = {
showToast,
showLoading,
hideLoading,
showConfirm
showConfirm,
toAvatarPath
}

View File

@@ -0,0 +1,94 @@
# Soul 小程序 - 模拟测试清单
在**微信开发者工具**中打开 `miniprogram` 项目,按以下步骤逐项验证。
---
## 一、前置准备
1. 启动微信开发者工具,打开项目 `e:\Gongsi\Mycontent\miniprogram`
2. 确认后端 soul-api 已启动baseUrl 指向正确,如 `http://localhost:8080`
3. 准备测试账号:① 普通用户(未完善) ② VIP 用户(无手机号) ③ 已完善用户
---
## 二、VIP 无手机号 / 头像未改(先判断 VIP
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 2.1 | 用 **VIP 且无手机号** 的账号登录 | 登录成功 |
| 2.2 | 完全关闭小程序,重新编译并启动 | 约 1.5s 后弹窗「VIP会员需完善手机号...」→ 点击「去完善」→ redirectTo profile-edit |
| 2.3 | 用 **VIP 有手机号但头像/昵称未改**(旧数据) | 弹窗「为了更好为您服务,请完善资料」→ redirectTo profile-edit |
| 2.4 | 在资料页填写并保存手机号 | 保存成功,可返回首页 |
| 2.5 | 再次关闭并重新启动 | 不再弹窗或跳转,正常进入首页 |
---
## 三、头像/昵称引导(非 VIP 老用户)
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 3.1 | 用 **非 VIP、头像/昵称仍为默认** 的账号冷启动 | 先获取 VIP 状态 → 非 VIP → 弹窗「请设置头像和昵称」,每日最多 1 次 |
| 3.2 | 点击「去完善」 | navigateTo avatar-nickname |
| 3.3 | 点击「稍后」 | 弹窗关闭,可正常使用 |
| 3.4 | **新注册用户**isNewUser且头像未改 | 无弹窗,直接 redirectTo avatar-nickname |
| 3.5 | 当前已在 profile-edit 或 avatar-nickname 页 | 不再重复弹窗或跳转 |
---
## 四、检测顺序VIP 优先)
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 4.1 | 用 **VIP 无手机号 + 头像默认** 账号登录 | 先执行 VIP 检测 → 直接 redirectTo profile-edit |
| 4.2 | 验证不会先到头像页 | VIP 优先,直接进 profile-edit不会先到 avatar-nickname |
---
## 五、VIP 购买流程
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 5.1 | 进入 VIP 页点击「开通」并完成购买 | 购买前不拦截;购买成功后弹窗说明,确认后跳转 profile-edit?from=vip |
| 5.2 | VIP 购买成功后点击「去完善」 | 跳转到 profile-edit含手机号必填 |
| 5.3 | 在 profile-edit 不填手机号直接保存VIP 账号) | 提示「请输入手机号VIP会员必填」 |
| 5.4 | 填写正确手机号后保存 | 保存成功 |
---
## 六、微信号引导(非强制)
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 6.1 | 用 **VIP 有手机号但无微信号** 账号登录 | 弹窗「请到资料页完善微信号」 |
| 6.2 | 点击「去完善」或「稍后」 | 去完善→跳转;稍后→关闭 |
---
## 七、快捷自检命令(开发者工具控制台)
在**调试器 → Console** 中可粘贴以下代码快速模拟状态:
```javascript
// 查看当前 userInfo
getApp().globalData.userInfo
// 手动触发检测(需已登录)
getApp().checkVipContactRequiredAndGuide()
getApp().checkAvatarNicknameAndGuide()
```
---
## 八、常见问题排查
| 现象 | 可能原因 |
|------|----------|
| 未触发 VIP redirect | 1) 非 VIP 2) 已填手机号 3) 当前已在 profile-edit 页 |
| 头像未改却未强制跳转 | 检查当前页是否为 profile-edit 或 avatar-nickname会跳过 |
| 后端请求失败 | 确认 soul-api 已启动baseUrl 正确 |
| 登录态丢失 | 检查 token、userInfo 是否持久化 |
---
测试完成后,可在本文件末尾记录:✅ 通过 / ❌ 失败及原因。