Files
zt-closet-wxmini-frontend/miniprogram/pages/profile/profile.ts
T
zhengchiliu 45222317af feat: 支持游客免登录浏览核心页面
未登录用户可先浏览衣橱、穿搭、运势、分享等内容,登录改为用户主动选择,并在登录页增加协议勾选确认与暂不登录入口。
2026-09-16 21:44:04 +08:00

381 lines
13 KiB
TypeScript

import { request, uploadFile } from '../../utils/request'
import * as auth from '../../utils/auth'
import { UserInfo, QuotaItem } from '../../types/models'
import { QuotaType, QUOTA_NAMES, VIP_TYPE_NAMES, STORAGE_KEYS } from '../../utils/config'
import { formatDate } from '../../utils/format'
import { setTabBarSelected, setTabBarUnread, setTabBarHidden } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { goLogin } from '../../utils/guest'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'profile'
function getZodiac(birthDay: number): string {
if (!birthDay) return ''
const d = new Date(birthDay * 1000)
const m = d.getMonth() + 1
const day = d.getDate()
const arr = [
[120, '摩羯座'], [218, '水瓶座'], [320, '双鱼座'], [419, '白羊座'],
[520, '金牛座'], [621, '双子座'], [722, '巨蟹座'], [822, '狮子座'],
[922, '处女座'], [1023, '天秤座'], [1122, '天蝎座'], [1221, '射手座'],
]
const val = m * 100 + day
for (let i = 0; i < arr.length; i++) {
if (val <= (arr[i][0] as number)) return arr[i][1] as string
}
return '摩羯座'
}
Page({
data: {
userInfo: {} as UserInfo,
// 会员标签(vip_type 2/3/4 → 月度/年度/体验会员;非会员为空不展示)
vipLabel: '',
uploadRemain: 0,
zodiac: '',
unread: 0,
editVisible: false,
avatarUploading: false,
avatarChanged: false,
saving: false,
editForm: {
username: '',
sex: 0,
birth_day: 0,
avatar: '',
},
birthDateStr: '',
today: '',
// 四栏额度条:上传 / AI 推荐 / 社区分享 / 衣柜分享
quotaStats: [] as Array<{ key: number; label: string; remain: number; total: number }>,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 游客态(未登录):展示资料引导卡,只读入口仍可访问,不发请求
guest: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad() {
this.setToday()
// 首帧渲染前判定游客态,避免首帧 setData 冲突
if (!auth.isLoggedIn()) {
this.setData({ guest: true })
logger.info(LOG, '未登录进入我的页,渲染游客态资料卡')
}
},
// 跨零点后回到本页时,生日选择器上限需重算,不能停留在昨天
setToday() {
const d = new Date()
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`)
this.setData({ today: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` })
},
onShow() {
this.syncTabBar()
this.setToday()
// 回到本页时按抽屉状态校准标签栏,防止切前后台/切 tab 后残留隐藏
setTabBarHidden(!!this.data.editVisible)
// 未登录:游客态展示引导卡,零请求,不再强制跳登录(微信审核合规要求)
if (!auth.isLoggedIn()) {
if (!this.data.guest) {
// 退出登录后回到本页时清掉残留资料,避免展示上一账号信息
this.setData({
guest: true,
userInfo: {} as UserInfo,
vipLabel: '',
zodiac: '',
quotaStats: [],
uploadRemain: 0,
unread: 0,
})
}
return
}
// 登录成功回到本页:清掉游客态,拉取真实资料
if (this.data.guest) {
logger.info(LOG, '检测到登录态,从游客态切回真实资料')
this.setData({ guest: false })
}
this.consumeOpenEditMark()
this.fetchUser()
this.fetchQuota()
this.fetchUnread()
},
// 未登录时的统一拦截:进入登录页,由用户自行选择是否登录
needLogin(): boolean {
if (auth.isLoggedIn()) return false
logger.debug(LOG, '游客态点击受限入口,引导登录')
goLogin()
return true
},
// 游客态点击资料卡 / 登录引导
onGuestLogin() {
this.needLogin()
},
// 离开本页时恢复标签栏:此时抽屉不再可见,标签栏不能残留隐藏
onHide() {
setTabBarHidden(false)
},
// 拉取未读数:与 tabBar 红点同源(/notification/unread),保证两处数量一致
fetchUnread() {
request<{ unread_count: number }>({ url: '/notification/unread', showError: false, silentAuth: true })
.then((res) => {
const unread = res.unread_count || 0
logger.debug(LOG, '未读数加载成功, unread=', unread)
// 写回缓存并同步 tabBar 红点(setUnread 内部只更新,不再发请求)
wx.setStorageSync(STORAGE_KEYS.UNREAD, unread)
this.setData({ unread })
setTabBarUnread(unread)
})
.catch((e: any) => logger.warn(LOG, '未读数加载失败(静默降级), code=', e && e.code))
},
// 消费「引导完善资料」标记:命中则自动弹出编辑弹窗(标记一次性,先清除再打开)
consumeOpenEditMark() {
if (!wx.getStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT)) return
wx.removeStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT)
logger.debug(LOG, '命中打开编辑弹窗标记,自动弹出资料编辑')
// 首帧渲染未结束就 setData 会触发「Expected updated data but get first rendering data」,
// 导致 webview 渲染失败(视觉上仍停留在上一页),故延迟到下一帧再打开
wx.nextTick(() => this.openEditFromGuide())
},
// 引导入口进入时自动打开编辑弹窗:fetchUser 未返回时用本地缓存兜底,避免表单为空
openEditFromGuide() {
if (!Object.keys(this.data.userInfo || {}).length) {
const cached = wx.getStorageSync(STORAGE_KEYS.USER_INFO)
if (cached && Object.keys(cached).length) {
logger.debug(LOG, '用户信息未就绪,使用本地缓存预填编辑表单')
this.setData({
userInfo: cached,
zodiac: getZodiac(cached.birth_day),
vipLabel: VIP_TYPE_NAMES[cached.vip_type] || '',
})
}
}
this.onEdit()
},
syncTabBar() {
// 红点由 tabBar 自身在登录态下同步,这里只更新选中态
setTabBarSelected(4)
},
fetchUser() {
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
logger.debug(LOG, '用户信息加载成功, vipType=', user.vip_type)
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
// 会员标签按 vip_type 判定:2 月度 / 3 年度 / 4 体验(新用户默认赠送),非会员不展示
this.setData({
userInfo: user,
zodiac: getZodiac(user.birth_day),
vipLabel: VIP_TYPE_NAMES[user.vip_type] || '',
})
})
.catch((e: any) => logger.warn(LOG, '用户信息加载失败, code=', e && e.code))
},
fetchQuota() {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false, silentAuth: true })
.then((res) => {
const quotas = res && Array.isArray(res.quotas) ? res.quotas : []
const upload = quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
if (upload) this.setData({ uploadRemain: upload.remain })
// 四栏额度条按固定顺序取数,缺失类型补 0,保证栏位不塌陷
const order = [QuotaType.UPLOAD, QuotaType.AI, QuotaType.OUTFIT_SHARE, QuotaType.CLOSET_SHARE]
const quotaStats = order.map((type) => {
const hit = quotas.find((q) => q.quota_type === type)
return {
key: type,
label: QUOTA_NAMES[type] || '额度',
remain: hit ? hit.remain : 0,
total: hit ? hit.total : 0,
}
})
this.setData({ quotaStats })
logger.debug(LOG, '额度加载成功, 上传剩余=', upload ? upload.remain : 0, '栏位=', quotaStats.length)
})
.catch((e: any) => logger.warn(LOG, '额度加载失败(静默降级), code=', e && e.code))
},
onEdit() {
if (this.needLogin()) return
const u = this.data.userInfo
// 抽屉打开期间隐藏悬浮标签栏:tabBar 由框架渲染在页面之上,否则会压住抽屉底部按钮
setTabBarHidden(true)
this.setData({
editVisible: true,
avatarChanged: false,
editForm: {
username: u.username || '',
sex: u.sex || 0,
birth_day: u.birth_day || 0,
avatar: u.avatar || '',
},
birthDateStr: u.birth_day ? formatDate(u.birth_day) : '',
})
},
onCloseEdit() {
this.setData({ editVisible: false })
setTabBarHidden(false)
},
noop() {},
onChooseAvatar(e: WechatMiniprogram.CustomEvent) {
this.setData({ 'editForm.avatar': e.detail.avatarUrl, avatarChanged: true })
},
onNameInput(e: WechatMiniprogram.Input) {
this.setData({ 'editForm.username': e.detail.value })
},
// 性别为两选一字段:抽屉内联胶囊直接选中,不再弹动作表(避免两层玻璃叠加)
onSexTap(e: WechatMiniprogram.TouchEvent) {
const sex = Number((e.currentTarget.dataset as any).value) || 0
logger.debug(LOG, '选择性别, sex=', sex)
this.setData({ 'editForm.sex': sex })
},
onBirthChange(e: WechatMiniprogram.CustomEvent) {
const dateStr = e.detail.value as string
const ts = Math.floor(new Date(dateStr.replace(/-/g, '/')).getTime() / 1000)
this.setData({ 'editForm.birth_day': ts, birthDateStr: dateStr })
},
onSaveProfile() {
if (this.data.avatarUploading || this.data.saving) return
const f = this.data.editForm
const avatar = f.avatar || ''
const username = (f.username || '').trim()
if (!username) {
wx.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
const finish = () => {
this.setData({
editVisible: false,
avatarUploading: false,
saving: false,
avatarChanged: false,
})
// 抽屉关闭,恢复悬浮标签栏
setTabBarHidden(false)
this.fetchUser()
}
const saveProfile = () => {
const u = this.data.userInfo
const body: Record<string, any> = {}
// 只提交真正变更的字段,避免无意义请求与误清空
if (username !== (u.username || '')) body.username = username
if (f.sex && f.sex !== u.sex) body.sex = f.sex
if (f.birth_day && f.birth_day !== u.birth_day) body.birth_day = f.birth_day
if (!Object.keys(body).length && !this.data.avatarChanged) {
// 无变更直接关闭:同样要恢复标签栏,否则会残留隐藏
this.setData({ editVisible: false })
setTabBarHidden(false)
return
}
this.setData({ saving: true })
logger.debug(LOG, '提交资料变更, 字段=', Object.keys(body).join(','))
request({ url: '/user/profile', method: 'PUT', data: body })
.then(() => {
logger.info(LOG, '资料保存成功')
wx.showToast({ title: '已保存', icon: 'success' })
finish()
})
.catch((e: any) => {
logger.warn(LOG, '资料保存失败, code=', e && e.code)
this.setData({ avatarUploading: false, saving: false })
})
}
if (this.data.avatarChanged && avatar) {
this.setData({ avatarUploading: true })
uploadFile<{ avatar: string }>(avatar, 'file', '/user/avatar/upload')
.then(() => {
logger.info(LOG, '头像上传成功')
saveProfile()
})
.catch((e: any) => {
logger.warn(LOG, '头像上传失败, code=', e && e.code)
this.setData({ avatarUploading: false, saving: false })
})
} else {
saveProfile()
}
},
onTapMall() {
if (this.needLogin()) return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (e) => logger.warn(LOG, 'navigateTo 商城失败, err=', e && e.errMsg),
})
},
onTapOrder() {
if (this.needLogin()) return
wx.navigateTo({
url: '/pages/order/order',
fail: (e) => logger.warn(LOG, 'navigateTo 订单失败, err=', e && e.errMsg),
})
},
onTapNotification() {
if (this.needLogin()) return
wx.navigateTo({
url: '/pages/notification/notification',
fail: (e) => logger.warn(LOG, 'navigateTo 通知失败, err=', e && e.errMsg),
})
},
onTapCollect() {
if (this.needLogin()) return
wx.setStorageSync(STORAGE_KEYS.OPEN_COLLECT, 1)
wx.switchTab({
url: '/pages/outfit/outfit',
fail: (e) => logger.warn(LOG, 'switchTab 穿搭失败, err=', e && e.errMsg),
})
},
onTapFlow() {
if (this.needLogin()) return
wx.navigateTo({
url: '/pages/mall/mall?tab=flow',
fail: (e) => logger.warn(LOG, 'navigateTo 流水失败, err=', e && e.errMsg),
})
},
onTapService() {
wx.showToast({ title: '客服与反馈敬请期待', icon: 'none' })
},
onTapAbout() {
wx.showToast({ title: '纸条AI衣橱 v1.0.0', icon: 'none' })
},
// 协议入口常驻设置分组,随时可查阅(协议页无网络请求,登录态异常也可打开)
onTapUserAgreement() {
this.openAgreement('user')
},
onTapPrivacy() {
this.openAgreement('privacy')
},
openAgreement(type: 'user' | 'privacy') {
logger.debug(LOG, '打开协议, type=', type)
wx.navigateTo({
url: `/pages/agreement/agreement?type=${type}`,
fail: (e) => logger.warn(LOG, 'navigateTo 协议页失败, err=', e && e.errMsg),
})
},
})