import { request, uploadFile, redirectToLogin } 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 { 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, }, // scroll-view 滚动 → 导航收缩为玻璃小标题 onNavScroll: navScroll, onLoad() { this.setToday() }, // 跨零点后回到本页时,生日选择器上限需重算,不能停留在昨天 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) // 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求 if (!auth.isLoggedIn()) { redirectToLogin() return } this.consumeOpenEditMark() this.fetchUser() this.fetchQuota() this.fetchUnread() }, // 离开本页时恢复标签栏:此时抽屉不再可见,标签栏不能残留隐藏 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({ 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() { 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 = {} // 只提交真正变更的字段,避免无意义请求与误清空 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() { wx.navigateTo({ url: '/pages/mall/mall', fail: (e) => logger.warn(LOG, 'navigateTo 商城失败, err=', e && e.errMsg), }) }, onTapOrder() { wx.navigateTo({ url: '/pages/order/order', fail: (e) => logger.warn(LOG, 'navigateTo 订单失败, err=', e && e.errMsg), }) }, onTapNotification() { wx.navigateTo({ url: '/pages/notification/notification', fail: (e) => logger.warn(LOG, 'navigateTo 通知失败, err=', e && e.errMsg), }) }, onTapCollect() { wx.setStorageSync(STORAGE_KEYS.OPEN_COLLECT, 1) wx.switchTab({ url: '/pages/outfit/outfit', fail: (e) => logger.warn(LOG, 'switchTab 穿搭失败, err=', e && e.errMsg), }) }, onTapFlow() { 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), }) }, })