Refactor user profile handling and navigation logic in the mini program. Introduce functions to ensure user profile completeness after login, update avatar selection process, and enhance navigation between chapters based on backend data. Update API endpoints for user data synchronization and improve user experience with new UI elements for profile editing.

This commit is contained in:
Alex-larget
2026-03-12 11:36:50 +08:00
parent da6d2c0852
commit d3b67681d7
27 changed files with 1464 additions and 393 deletions

View File

@@ -8,9 +8,9 @@ const { parseScene } = require('./utils/scene.js')
App({
globalData: {
// API基础地址 - 连接真实后端
baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'http://localhost:8080',
baseUrl: 'http://localhost:8080',
// 小程序配置 - 真实AppID
@@ -207,6 +207,31 @@ App({
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
},
// 判断用户资料是否完善(昵称 + 头像)
_isProfileIncomplete(user) {
if (!user) return true
const nickname = (user.nickname || '').trim()
const avatar = (user.avatar || '').trim()
const isDefaultNickname = !nickname || nickname === '微信用户'
const noAvatar = !avatar
return isDefaultNickname || noAvatar
},
// 登录后若资料未完善,引导跳转到资料编辑页
_ensureProfileCompletedAfterLogin(user) {
try {
if (!user || !this._isProfileIncomplete(user)) return
const pages = getCurrentPages()
const current = pages[pages.length - 1]
// 避免在资料页内重复跳转
if (current && current.route === 'pages/profile-edit/profile-edit') return
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
} catch (e) {
console.warn('[App] 跳转资料编辑页失败:', e)
}
},
// 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) {
const list = this.globalData.bookData || []
@@ -479,12 +504,13 @@ App({
// 保存用户信息
if (res.data.user) {
this.globalData.userInfo = res.data.user
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = res.data.user.purchasedSections || []
this.globalData.hasFullBook = res.data.user.hasFullBook || false
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', res.data.user)
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token || '')
// 登录成功后,检查待绑定的推荐码并执行绑定
@@ -493,6 +519,9 @@ App({
console.log('[App] 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
}
return res.data
@@ -540,17 +569,21 @@ App({
wx.setStorageSync('openId', res.data.openId)
// 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码
if (res.data.user) {
this.globalData.userInfo = res.data.user
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = res.data.user.purchasedSections || []
this.globalData.hasFullBook = res.data.user.hasFullBook || false
wx.setStorageSync('userInfo', res.data.user)
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token || '')
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
if (pendingRef) {
console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
}
return res.data.openId
}
@@ -587,12 +620,13 @@ App({
})
if (res.success && res.data) {
this.globalData.userInfo = res.data.user
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = res.data.user.purchasedSections || []
this.globalData.hasFullBook = res.data.user.hasFullBook || false
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', res.data.user)
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
// 登录成功后绑定推荐码
@@ -601,6 +635,9 @@ App({
console.log('[App] 手机号登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
return res.data
}

View File

@@ -5,6 +5,7 @@
"pages/match/match",
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
"pages/about/about",
"pages/agreement/agreement",
"pages/privacy/privacy",
@@ -16,7 +17,11 @@
"pages/addresses/edit",
"pages/withdraw-records/withdraw-records",
"pages/vip/vip",
"pages/member-detail/member-detail","pages/mentors/mentors","pages/mentor-detail/mentor-detail","pages/profile-show/profile-show","pages/profile-edit/profile-edit"
"pages/member-detail/member-detail",
"pages/mentors/mentors",
"pages/mentor-detail/mentor-detail",
"pages/profile-show/profile-show",
"pages/profile-edit/profile-edit"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -205,10 +205,14 @@ Page({
}
}
// 2. 最新更新:用 book/latest-chapters 取第1条
// 2. 最新更新:用 book/latest-chapters 取第1条(排除「序言」「尾声」「附录」)
try {
const latestRes = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
const latestList = (latestRes && latestRes.data) ? latestRes.data : []
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
const latestList = rawList.filter(l => {
const pt = (l.part_title || l.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
if (latestList.length > 0) {
const l = latestList[0]
this.setData({
@@ -341,7 +345,7 @@ Page({
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
@@ -426,25 +430,25 @@ Page({
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
phone: p,
name: (app.globalData.userInfo?.nickname || '').trim() || undefined
}
name: (app.globalData.userInfo?.nickname || '').trim() || undefined,
},
})
wx.hideLoading()
this.setData({ showLeadModal: false, leadPhone: '' })
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
// 同步手机号到用户资料
try {
const currentPhone = (app.globalData.userInfo?.phone || '').trim()
if (!currentPhone && userId) {
if (userId) {
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId, phone: p }
data: { userId, phone: p },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.phone = p

View File

@@ -0,0 +1,43 @@
const app = getApp()
Page({
data: {
url: '',
title: '链接预览',
statusBarHeight: 44,
navBarHeight: 88,
},
onLoad(options) {
const url = decodeURIComponent(options.url || '')
const title = options.title ? decodeURIComponent(options.title) : '链接预览'
this.setData({
url,
title,
statusBarHeight: app.globalData.statusBarHeight || 44,
navBarHeight: app.globalData.navBarHeight || 88,
})
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
wx.navigateBack()
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
copyLink() {
const url = (this.data.url || '').trim()
if (!url) {
wx.showToast({ title: '暂无可复制链接', icon: 'none' })
return
}
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: '链接已复制', icon: 'none' }),
})
},
})

View File

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

View File

@@ -0,0 +1,28 @@
<view class="page">
<!-- 简单自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content" style="height: {{navBarHeight - statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<text class="back-arrow">←</text>
</view>
<view class="nav-title">
<text class="nav-title-text">{{title}}</text>
</view>
<view class="nav-actions">
<view class="copy-btn" bindtap="copyLink">
<text class="copy-text">复制链接</text>
</view>
</view>
</view>
</view>
<view class="nav-placeholder" style="height: {{navBarHeight}}px;"></view>
<!-- 链接预览区域 -->
<view class="webview-wrap" wx:if="{{url}}">
<web-view src="{{url}}"></web-view>
</view>
<view class="empty-wrap" wx:else>
<text class="empty-text">暂无链接地址</text>
</view>
</view>

View File

@@ -0,0 +1,83 @@
.page {
width: 100%;
height: 100%;
background-color: #000;
color: #fff;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: #000;
}
.nav-content {
display: flex;
align-items: center;
padding: 0 12px;
}
.nav-back {
width: 40rpx;
justify-content: center;
align-items: center;
}
.back-arrow {
font-size: 32rpx;
}
.nav-title {
flex: 1;
text-align: center;
}
.nav-title-text {
font-size: 28rpx;
font-weight: 600;
}
.nav-actions {
min-width: 120rpx;
display: flex;
justify-content: flex-end;
}
.copy-btn {
padding: 6rpx 10rpx;
border-radius: 999rpx;
border: 1px solid #444;
}
.copy-text {
font-size: 22rpx;
color: #ccc;
}
.nav-placeholder {
width: 100%;
}
.webview-wrap {
width: 100%;
height: 100%;
}
web-view {
width: 100%;
height: 100%;
}
.empty-wrap {
padding: 40rpx;
text-align: center;
color: #888;
}
.empty-text {
font-size: 26rpx;
}

View File

@@ -596,8 +596,19 @@ Page({
}
},
// 复制用户ID
// 复制联系方式:优先复制微信号,其次复制用户ID
copyUserId() {
const userWechat = (this.data.userWechat || '').trim()
if (userWechat) {
wx.setClipboardData({
data: userWechat,
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
})
return
}
const userId = this.data.userInfo?.id || ''
if (!userId) {
wx.showToast({ title: '暂无ID', icon: 'none' })

View File

@@ -41,7 +41,7 @@
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
</view>
<text class="user-wechat" bindtap="copyUserId">微信号: {{userWechat || userIdShort || '--'}}</text>
<text class="user-wechat" wx:if="{{userWechat}}" bindtap="copyUserId">微信号: {{userWechat}}</text>
</view>
</view>
<view class="profile-stats-row">

View File

@@ -37,6 +37,7 @@ Page({
showMbtiPicker: false,
saving: false,
loading: true,
showAvatarModal: false,
},
onLoad() {
@@ -95,6 +96,7 @@ Page({
goBack() { getApp().goBackOrToHome() },
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
onNicknameChange(e) { this.setData({ nickname: e.detail.value }) },
onRegionInput(e) { this.setData({ region: e.detail.value }) },
onIndustryInput(e) { this.setData({ industry: e.detail.value }) },
onBusinessScaleInput(e) { this.setData({ businessScale: e.detail.value }) },
@@ -114,7 +116,26 @@ Page({
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
},
chooseAvatar() {
// 点击头像:选择微信头像或从相册选择
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'],
@@ -160,6 +181,52 @@ Page({
})
},
// 微信原生 chooseAvatar 回调:使用当前微信头像
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
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' })
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })
}
},
async saveProfile() {
const userId = app.globalData.userInfo?.id
if (!userId) {

View File

@@ -17,7 +17,7 @@
<!-- 头像 -->
<view class="avatar-section">
<view class="avatar-wrap" bindtap="chooseAvatar">
<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>
@@ -31,7 +31,18 @@
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入昵称" value="{{nickname}}" bindinput="onNicknameInput"/></view>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
maxlength="20"
/>
</view>
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
</view>
<view class="form-row form-row-2">
<view class="form-item">
@@ -134,4 +145,15 @@
</view>
<view class="bottom-space"></view>
</scroll-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">✕</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

@@ -101,3 +101,83 @@
}
.save-btn[disabled] { opacity: 0.6; }
.bottom-space { height: 120rpx; }
/* 昵称提示文案 */
.input-tip {
margin-top: 8rpx;
font-size: 22rpx;
color: #94A3B8;
margin-left: 8rpx;
}
/* 头像弹窗样式,复用我的页风格 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(16rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 48rpx;
box-sizing: border-box;
}
.modal-content {
width: 100%;
max-width: 640rpx;
background: #0b1220;
border-radius: 32rpx;
padding: 48rpx;
position: relative;
box-sizing: border-box;
}
.modal-close {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255,255,255,0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: rgba(255,255,255,0.7);
}
.avatar-modal-title {
display: block;
font-size: 36rpx;
font-weight: 700;
text-align: center;
margin-bottom: 12rpx;
}
.avatar-modal-desc {
display: block;
font-size: 26rpx;
color: #94A3B8;
text-align: center;
margin-bottom: 32rpx;
}
.btn-choose-avatar {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: #5EEAD4;
color: #050B14;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
.btn-choose-avatar::after {
border: none;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}

View File

@@ -421,31 +421,47 @@ Page({
},
// 加载导航
loadNavigation(id) {
const sectionOrder = [
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
'2.1', '2.2', '2.3', '2.4', '2.5',
'3.1', '3.2', '3.3', '3.4',
'4.1', '4.2', '4.3', '4.4', '4.5',
'5.1', '5.2', '5.3', '5.4', '5.5',
'6.1', '6.2', '6.3', '6.4',
'7.1', '7.2', '7.3', '7.4', '7.5',
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
'10.1', '10.2', '10.3', '10.4',
'11.1', '11.2', '11.3', '11.4', '11.5',
'epilogue'
]
const currentIndex = sectionOrder.indexOf(id)
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
this.setData({
prevSection: prevId ? { id: prevId, title: this.getSectionTitle(prevId) } : null,
nextSection: nextId ? { id: nextId, title: this.getSectionTitle(nextId) } : null
})
// 加载导航:基于后端章节顺序计算上一篇/下一篇
async loadNavigation(id) {
try {
// 优先使用全局缓存的 bookData
let chapters = app.globalData.bookData || []
if (!chapters || !Array.isArray(chapters) || chapters.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
chapters = (res && (res.data || res.chapters)) || []
}
if (!chapters || chapters.length === 0) {
this.setData({ prevSection: null, nextSection: null })
return
}
// 过滤掉没有 id 的记录,并按 sort_order + id 排序
const ordered = chapters
.filter(c => c.id)
.sort((a, b) => {
const soA = typeof a.sort_order === 'number' ? a.sort_order : (typeof a.sortOrder === 'number' ? a.sortOrder : 0)
const soB = typeof b.sort_order === 'number' ? b.sort_order : (typeof b.sortOrder === 'number' ? b.sortOrder : 0)
if (soA !== soB) return soA - soB
return String(a.id).localeCompare(String(b.id), 'zh-Hans-CN')
})
const index = ordered.findIndex(c => String(c.id) === String(id))
const prev = index > 0 ? ordered[index - 1] : null
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null
this.setData({
prevSection: prev ? {
id: prev.id,
mid: prev.mid ?? prev.MID ?? null,
title: prev.section_title || prev.sectionTitle || prev.title || this.getSectionTitle(prev.id),
} : null,
nextSection: next ? {
id: next.id,
mid: next.mid ?? next.MID ?? null,
title: next.section_title || next.sectionTitle || next.title || this.getSectionTitle(next.id),
} : null,
})
} catch (e) {
console.warn('[Read] loadNavigation failed:', e)
this.setData({ prevSection: null, nextSection: null })
}
},
// 返回(从分享进入无栈时回首页)
@@ -453,7 +469,7 @@ Page({
getApp().goBackOrToHome()
},
// 点击正文中的 #链接标签:外链复制到剪贴板,小程序内页直接跳转
// 点击正文中的 #链接标签:小程序内页/预览页跳转
onLinkTagTap(e) {
let url = (e.currentTarget.dataset.url || '').trim()
const label = (e.currentTarget.dataset.label || '').trim()
@@ -484,25 +500,13 @@ Page({
return
}
// 外部 URL优先用 wx.openLink 在浏览器打开,旧版微信降级复制
// 外部 URL跳转到内置预览页,由 web-view 打开
if (url) {
if (typeof wx.openLink === 'function') {
wx.openLink({
url,
fail: () => {
// openLink 不支持(如不在微信内),降级复制
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
})
}
})
} else {
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: `链接已复制`, icon: 'none', duration: 2000 })
})
}
const encodedUrl = encodeURIComponent(url)
const encodedTitle = encodeURIComponent(label || '链接预览')
wx.navigateTo({
url: `/pages/link-preview/link-preview?url=${encodedUrl}&title=${encodedTitle}`,
})
return
}
@@ -1141,14 +1145,18 @@ Page({
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.prevSection.id}` })
const { id, mid } = this.data.prevSection
const query = mid ? `mid=${mid}` : `id=${id}`
wx.redirectTo({ url: `/pages/read/read?${query}` })
}
},
// 跳转到下一篇
goToNext() {
if (this.data.nextSection) {
wx.redirectTo({ url: `/pages/read/read?id=${this.data.nextSection.id}` })
const { id, mid } = this.data.nextSection
const query = mid ? `mid=${mid}` : `id=${id}`
wx.redirectTo({ url: `/pages/read/read?${query}` })
}
},

View File

@@ -85,9 +85,9 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="share">
<text class="action-icon-small">💬</text>
<text class="action-text-small">推荐给好友</text>
<button class="action-btn-inline btn-share-inline" open-type="shareTimeline">
<text class="action-icon-small">📣</text>
<text class="action-text-small">分享到朋友圈</text>
</button>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>