diff --git a/.codebuddy/plans/fix-full-audit-issues-p0-p2_3b34adcf.md b/.codebuddy/plans/fix-full-audit-issues-p0-p2_3b34adcf.md new file mode 100644 index 0000000..6704ee2 --- /dev/null +++ b/.codebuddy/plans/fix-full-audit-issues-p0-p2_3b34adcf.md @@ -0,0 +1,178 @@ +--- +name: fix-full-audit-issues-p0-p2 +overview: 按审查报告修复小程序全部已确认缺陷:5 项阻断(支付重复下单、支付轮询定时器泄漏、401 无限重试、uploadFile 无 401 处理、衣柜分享无出口且失效时白屏)、12 项一般(通知红点不同步、运势空卡与定时器、列表竞态与判空、11000 未处理、token 过期判断、请求无超时、uuid 缺失卡 loading 等)、8 项轻微(提交锁、死代码、硬编码 key、状态枚举等)。 +todos: + - id: request-core + content: 加固 request.ts 与 auth.ts:重试上限、11000、超时、过期预刷新、uploadFile 登录校验 + status: completed + - id: paged-util + content: 新增 utils/paged.ts 的 normalizePaged 并接入全部列表判空 + status: completed + - id: mall-pay + content: 修复商城支付:confirmPay 提交锁、轮询定时器随页面卸载清理、按钮 loading + status: completed + dependencies: + - request-core + - id: share-detail + content: 修复分享详情:uuid 兜底、错误态与去登录、过期校验、转发出口、定时器清理 + status: completed + dependencies: + - request-core + - id: list-race + content: 为衣橱、穿搭、分享、订单、通知、商城加请求竞态保护与 loadingMore 拆分 + status: completed + dependencies: + - paged-util + - id: state-fix + content: 修复运势错误态与重试、定时器与跨天、通知红点同步、衣物详情空态 + status: completed + dependencies: + - request-core + - id: submit-locks + content: 修复穿搭编辑与分享创建:跨分类已选不丢、选衣分页、提交防重复与失败提示 + status: completed + dependencies: + - paged-util + - id: p2-cleanup + content: 清理轻微项:资料保存锁与校验、跨零点、storage key、红点节流、倒计时、枚举、组件 data、死代码 + status: completed + dependencies: + - request-core + - id: verify-all + content: 执行 tsc 类型检查并逐条复核修复效果 + status: completed + dependencies: + - request-core + - mall-pay + - share-detail + - list-race + - state-fix + - submit-locks + - p2-cleanup +--- + +## 产品概述 + +微信小程序「纸条AI衣橱」。上一轮全链路审查输出了分级缺陷清单(阻断 5 项、一般 12 项、轻微 8 项),用户明确要求「全部修复」,即 P0 + P1 + P2 全量处理,不做取舍。 + +## 核心修复项 + +- 支付与上传:商城下单加提交锁、支付轮询定时器随页面卸载清理、uploadFile 补齐登录校验与 401 处理、上传失败给明确提示 +- 请求内核:401 重试加上限防请求风暴、补 11000 登录失效码、全请求超时兜底、token 过期主动预刷新、静默请求不清登录态(已修,保持) +- 衣柜分享闭环:分享详情页补 uuid 空值兜底、错误态、本地过期/封禁校验、转发出口(onShareAppMessage + 分享按钮)、定时器清理 +- 列表健壮性:统一分页空值兜底与请求竞态保护、拆分 loading 与 loadingMore,覆盖衣橱/穿搭/分享/订单/通知/商城 +- 选择页与提交:穿搭编辑、衣柜分享创建的衣物选择跨分类不丢、列表分页、提交防重复 +- 体验修复:运势页错误态与重试、定时器清理与跨天重算、通知已读后红点同步、衣物详情 uuid 缺失空态 +- 轻微清理:资料保存锁与校验、生日选择器跨零点、硬编码 storage key 收编、红点节流失败回滚、订单倒计时走动、分享状态枚举化、组件 data 补齐、死代码移除 + +## 效果验收 + +开发者工具清缓存重新编译后:无重复下单、无页面卸载后残留请求、弱网有超时提示、分享页可转发且无白屏、列表快速切换不串数据、运势失败可见可重试、通知已读后红点即时更新。 + +## 技术栈 + +微信小程序原生框架 + TypeScript(`useCompilerPlugins: typescript`,基础库 3.17.2,渲染引擎已切回 webview),无测试框架,通过微信开发者工具编译调试。类型检查命令:`cd /root/zt-closet-wxmini-frontend && npx --yes -p typescript@5.4.5 tsc --noEmit -p tsconfig.json`。 + +## 实现思路 + +分四层收敛,全部复用项目既有写法(Page 对象字面量 + setData + request 封装),不引入新模式: + +1. **请求内核层(最优先,其余修复依赖它)** + +- 登录失效码收敛为常量集合 `11000/11001/11002/11004` 或 HTTP 401(`utils/config.ts:118-121` 已定义这些文案)。 +- `doCall(retried)` 加重试标记:刷新成功后仅允许重试一次,第二次仍命中失效码则直接清 token + `handleAuthExpired()` + reject,杜绝无限「刷新→重试」风暴。 +- `auth.ts` 新增 `isTokenExpired()`:单位自动兼容(`expireTime > 1e12` 视为毫秒,否则秒),留 30s 提前量;`isLoggedIn()` 语义保持不变(仅判断 token 存在),避免误杀。`request` 在 needAuth 且 token 已过期时先 `await doRefresh()` 再发请求,省掉一次 401 往返。 +- 统一超时:业务请求与 refresh 15s,uploadFile 30s。 +- `uploadFile` 补齐:未登录快速失败(复用 `NOT_LOGIN_CODE`)、登录失效码 → 清 token + `handleAuthExpired()`、支持 `showError`/`timeout` 参数。 + +2. **页面状态机层** + +- 新增 `utils/paged.ts` 提供 `normalizePaged`,统一 `(res && res.list) || []` 与 `(res && res.total) || 0` 兜底,消除 7 处重复且易漏的 `res.list.map`。 +- 列表页统一三件套:`reqSeq`(响应序号不匹配即丢弃)、`loading`(首次/刷新)与 `loadingMore`(上拉)分离、`onUnload` 定时器清理。涉及 wardrobe、outfit、closet-share、order、notification、mall、closet-share-create、outfit-edit。 + +3. **防重复提交层** + +- 商城 `confirmPay`、衣柜分享创建 `onConfirm`、穿搭编辑 `onSave`、资料保存 `onSaveProfile` 统一加 `submitting`/`saving` 布尔闸门,入口处 return,catch 与成功分支复位,按钮文案联动。 + +4. **错误可见层** + +- 运势页新增 `error` 态 + 重试按钮(wxml 内容区由 `wx:else` 改为 `wx:elif` 并补错误分支);衣物详情 uuid 缺失走空态;分享详情页 `invalid` 支持标题/描述变量与「去登录」出口;upload 失败对非 80000 错误给 toast。 + +## 实现要点(防回归) + +- 不得回退已有加固:`redirectToLogin` 幂等锁与「已在登录页跳过」、`silentAuth` 不清 token、`request` 未登录快速失败、`attached` 首帧 setData 延到 nextTick。 +- `分享详情 /closet-share/:uuid` 是否免登录需后端确认,**保守保持 `auth: true`**,仅把 11001 单独映射为「去登录」按钮,避免擅自放开权限。 +- `expire_time` 单位在 `types/models.ts` 注释标注为秒,本次用「大于 1e12 视为毫秒」的兼容判断兜底,无需后端确认即可安全上线。 +- 定时器一律挂 `this` 并在 `onUnload` 清理;倒计时 tick 在 `onHide` 也要停,避免后台空转。 +- 空态统一复用 `empty-state` 组件(项目已在 horoscope/mall 使用,属性:icon/title/desc/buttonText/bind:action),不新增组件。 +- 日志与提示:不打印 token 等敏感信息;toast 文案沿用 `utils/config.ts` 的 `ERROR_MESSAGES` 风格。 + +## 架构设计 + +```mermaid +flowchart TD + A[页面/组件发起请求] --> B{needAuth 且无 token?} + B -- 是 --> C[reject NOT_LOGIN 不发请求] + B -- 否 --> D{token 已过期?} + D -- 是 --> E[doRefresh 预刷新] + D -- 否 --> F[wx.request timeout 15s] + E -- 成功 --> F + E -- 失败 --> G[清 token + handleAuthExpired + 幂等跳登录] + F -- 命中登录失效码 --> H{已重试过?} + H -- 否 --> I[doRefresh 后重试一次] + H -- 是 --> G + F -- 其它业务码 --> J[toast + reject] + K[uploadFile] --> B +``` + +## 目录结构 + +``` +zt-closet-wxmini-frontend/ +├── miniprogram/ +│ ├── utils/ +│ │ ├── request.ts # [MODIFY] 登录失效码集合常量;doCall(retried) 重试上限;11000 并入;统一 timeout;needAuth 过期预刷新;uploadFile 补登录校验/401/timeout/showError +│ │ ├── auth.ts # [MODIFY] 新增 isTokenExpired()(秒/毫秒兼容 + 30s 提前量),isLoggedIn 语义不变 +│ │ ├── paged.ts # [NEW] normalizePaged(res) 统一 list/total 空值兜底,供 7 处列表复用 +│ │ └── config.ts # [MODIFY] STORAGE_KEYS 新增 OPEN_COLLECT,收编硬编码 'zt_open_collect' +│ ├── custom-tab-bar/index.ts # [MODIFY] 节流打点改为请求成功后记录、失败回滚 lastSyncAt=0 +│ ├── components/navigation-bar/navigation-bar.ts # [MODIFY] data 补齐 ios/innerPaddingRight/leftWidth/safeAreaTop +│ ├── pages/ +│ │ ├── mall/mall.ts # [MODIFY] confirmPay 加 submitting 锁;pollOrder timer 挂 this + onUnload 清理;fetchGoods/fetchFlow 用 normalizePaged +│ │ ├── mall/mall.wxml # [MODIFY] 支付按钮 loading 文案与禁止连点 +│ │ ├── upload/upload.ts # [MODIFY] catch 分支对非 80000 错误给 toast,保留额度弹窗 +│ │ ├── closet-share-detail/closet-share-detail.ts # [MODIFY] uuid 空值兜底;本地过期/封禁校验;非 901xx 与 11001 分别落错误态/去登录态;onShareAppMessage;取消分享 timer 挂 this + onUnload 清理 +│ │ ├── closet-share-detail/closet-share-detail.wxml # [MODIFY] empty-state 标题/描述变量化 + 去登录按钮 + open-type=share 分享按钮 +│ │ ├── horoscope/horoscope.ts # [MODIFY] 新增 error 态与 onRetry;重试 timer 挂 this + onUnload 清理;跨天(stat_date 比对今日)重拉 +│ │ ├── horoscope/horoscope.wxml # [MODIFY] 内容区改 wx:elif 并补错误态分支与重试按钮 +│ │ ├── wardrobe/wardrobe.ts # [MODIFY] reqSeq + loadingMore + normalizePaged +│ │ ├── outfit/outfit.ts # [MODIFY] 同上;storage key 改用 STORAGE_KEYS.OPEN_COLLECT +│ │ ├── closet-share/closet-share.ts # [MODIFY] 同上;decorate 改用 ShareStatus 枚举 +│ │ ├── closet-share/closet-share.wxml # [MODIFY] 已封禁状态文案分支 +│ │ ├── order/order.ts # [MODIFY] 同上;待支付倒计时 1s tick + onUnload/onHide 清理 +│ │ ├── notification/notification.ts # [MODIFY] 红点同步改为从 getCurrentPages 首个 tab 页取 getTabBar(),取不到仅写 storage;列表用 normalizePaged +│ │ ├── clothes-detail/clothes-detail.ts # [MODIFY] uuid 为空直接 notFound 态,不再永久 loading +│ │ ├── clothes-detail/clothes-detail.wxml # [MODIFY] 增加 notFound 空态 +│ │ ├── outfit-edit/outfit-edit.ts # [MODIFY] selectedMap 累积跨分类已选;选衣列表触底分页;onSave 加 saving 锁 +│ │ ├── closet-share-create/closet-share-create.ts # [MODIFY] 选衣列表触底分页;onConfirm 加 submitting 锁 + 失败 toast +│ │ ├── profile/profile.ts # [MODIFY] showActionSheet 补 fail;today 改 onShow 重算;onSaveProfile 加 saving 锁 + trim + 无变更关闭 + 复位 avatarChanged;storage key 收编 +│ │ └── outfit-detail/outfit-detail.ts # [MODIFY] 移除未使用的 OutfitSource 导入 +│ └── app.ts # [MODIFY] 移除零引用的 globalData.userInfo(与 IAppOption 类型同步精简) +``` + +## 关键代码结构 + +```ts +// miniprogram/utils/paged.ts —— 统一分页兜底,杜绝 list 为 null 时抛错被空 catch 吞掉 +export interface NormalizedPaged { list: T[]; total: number } +export function normalizePaged(res: any): NormalizedPaged + +// miniprogram/utils/auth.ts —— token 过期判断(秒/毫秒兼容,30s 提前量) +export function isTokenExpired(): boolean +// isLoggedIn() 语义保持不变:仅判断 access token 是否存在 + +// miniprogram/utils/request.ts —— 登录失效码收敛 + 重试上限 +export const AUTH_EXPIRED_CODES: number[] // 11000 / 11001 / 11002 / 11004 +export interface UploadOptions { showError?: boolean; timeout?: number } +export function uploadFile(filePath: string, name?: string, url?: string, options?: UploadOptions): Promise +``` \ No newline at end of file diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 49c544e..18f8fe9 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -3,9 +3,7 @@ import * as auth from './utils/auth' import { setAuthExpiredHandler } from './utils/request' App({ - globalData: { - userInfo: undefined, - }, + globalData: {}, onLaunch() { // 登录失效 / 账号禁用时统一清登录态。 diff --git a/miniprogram/components/navigation-bar/navigation-bar.ts b/miniprogram/components/navigation-bar/navigation-bar.ts index 4466e8e..2a704d9 100644 --- a/miniprogram/components/navigation-bar/navigation-bar.ts +++ b/miniprogram/components/navigation-bar/navigation-bar.ts @@ -55,7 +55,11 @@ Component({ * 组件的初始数据 */ data: { - displayStyle: '' + displayStyle: '', + ios: false, + innerPaddingRight: '', + leftWidth: '', + safeAreaTop: '' }, lifetimes: { attached() { diff --git a/miniprogram/custom-tab-bar/index.ts b/miniprogram/custom-tab-bar/index.ts index 707ddbf..a0ff075 100644 --- a/miniprogram/custom-tab-bar/index.ts +++ b/miniprogram/custom-tab-bar/index.ts @@ -54,7 +54,10 @@ Component({ silentAuth: true, }) .then((res) => this.setUnread(res.unread_count || 0)) - .catch(() => {}) + .catch(() => { + // 请求失败时回滚节流时间戳,否则要白等 30s 才能重试 + lastSyncAt = 0 + }) }, switchTab(e: WechatMiniprogram.TouchEvent) { diff --git a/miniprogram/pages/closet-share-create/closet-share-create.ts b/miniprogram/pages/closet-share-create/closet-share-create.ts index b282926..e89da52 100644 --- a/miniprogram/pages/closet-share-create/closet-share-create.ts +++ b/miniprogram/pages/closet-share-create/closet-share-create.ts @@ -1,6 +1,7 @@ import { request } from '../../utils/request' import { ClothesItem, PagedResult, UserInfo } from '../../types/models' import { STORAGE_KEYS } from '../../utils/config' +import { normalizePaged, hasMorePaged } from '../../utils/paged' const CATEGORIES = [ { name: '全部', value: '' }, @@ -19,6 +20,8 @@ const EXPIRE_OPTIONS = [ { label: '长期有效', days: 0 }, ] +const PAGE_SIZE = 50 + Page({ data: { view: 'config' as 'config' | 'select' | 'success', @@ -30,6 +33,10 @@ Page({ activeCategory: 0, allClothes: [] as ClothesItem[], selectLoading: true, + loadingMore: false, + hasMore: true, + page: 1, + submitting: false, shareUuid: '', nickname: '微信用户', avatar: '', @@ -66,30 +73,54 @@ Page({ }, openSelect() { - this.setData({ view: 'select', selectLoading: true }) - this.fetchAllClothes() + this.setData({ view: 'select', selectLoading: true, page: 1, hasMore: true }) + this.fetchAllClothes(1) }, - fetchAllClothes() { + fetchAllClothes(page = 1) { const cat = CATEGORIES[this.data.activeCategory].value - const params: Record = { page: 1, page_size: 50 } + const params: Record = { page, page_size: PAGE_SIZE } if (cat) params.category_main = cat + + // 请求序号:切换分类时旧响应可能后到,序号不匹配直接丢弃 + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + request>({ url: '/clothes', data: params }) .then((res) => { - const list = res.list.map((it) => ({ + if ((this as any).reqSeq !== seq) return + const { list: raw, total } = normalizePaged(res) + const list = raw.map((it) => ({ ...it, _selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0, })) - this.setData({ allClothes: list, selectLoading: false }) + const merged = page === 1 ? list : this.data.allClothes.concat(list) + this.setData({ + allClothes: merged, + selectLoading: false, + loadingMore: false, + page, + hasMore: hasMorePaged(merged, total, PAGE_SIZE), + }) + }) + .catch(() => { + if ((this as any).reqSeq !== seq) return + this.setData({ selectLoading: false, loadingMore: false }) }) - .catch(() => this.setData({ selectLoading: false })) }, onCategoryTap(e: WechatMiniprogram.TouchEvent) { const index = Number(e.currentTarget.dataset.index) if (index === this.data.activeCategory) return - this.setData({ activeCategory: index, selectLoading: true }) - this.fetchAllClothes() + this.setData({ activeCategory: index, selectLoading: true, page: 1, hasMore: true }) + this.fetchAllClothes(1) + }, + + onReachBottom() { + if (this.data.view !== 'select') return + if (!this.data.hasMore || this.data.selectLoading || this.data.loadingMore) return + this.fetchAllClothes(this.data.page + 1) }, onToggleSelect(e: WechatMiniprogram.CustomEvent) { @@ -122,6 +153,7 @@ Page({ }, onConfirm() { + if (this.data.submitting) return const body: Record = { share_type: this.data.shareType } if (this.data.shareType === 1) { if (!this.data.selectedUuids.length) { @@ -133,9 +165,11 @@ Page({ const days = this.data.expireDays body.expire_time = days > 0 ? Math.floor(Date.now() / 1000) + days * 86400 : 0 + // 提交加锁:连点会重复创建分享、重复扣额度 + this.setData({ submitting: true }) request<{ share_uuid: string }>({ url: '/closet-share', method: 'POST', data: body }) - .then((res) => this.setData({ shareUuid: res.share_uuid, view: 'success' })) - .catch(() => {}) + .then((res) => this.setData({ shareUuid: res.share_uuid, view: 'success', submitting: false })) + .catch(() => this.setData({ submitting: false })) }, onViewShare() { diff --git a/miniprogram/pages/closet-share-detail/closet-share-detail.ts b/miniprogram/pages/closet-share-detail/closet-share-detail.ts index 3ef9d2c..5dc05e9 100644 --- a/miniprogram/pages/closet-share-detail/closet-share-detail.ts +++ b/miniprogram/pages/closet-share-detail/closet-share-detail.ts @@ -1,5 +1,6 @@ -import { request } from '../../utils/request' +import { request, AUTH_EXPIRED_CODES } from '../../utils/request' import { ClosetShareDetail } from '../../types/models' +import { ShareStatus } from '../../utils/config' import { formatDateTime } from '../../utils/format' Page({ @@ -9,30 +10,86 @@ Page({ expireText: '', loading: true, invalid: false, + invalidTitle: '该分享已过期或已被取消', + invalidDesc: '分享链接已失效', + needLogin: false, }, onLoad(query: Record) { const uuid = query.uuid || '' this.setData({ uuid }) - if (uuid) this.fetchDetail() + if (!uuid) { + // uuid 缺失时若不兜底,loading 会永久为 true,页面白屏且无法退出 + this.setInvalid('链接不完整', '缺少分享标识,无法打开') + return + } + this.fetchDetail() }, fetchDetail() { request({ url: `/closet-share/${this.data.uuid}`, showError: false }) .then((detail) => { + // 后端可能惰性过期:本地再校验一次状态与有效期,避免展示已失效内容 + if (this.checkInvalid(detail)) return + const expireText = detail.expire_time === 0 ? '长期有效' : `有效期至 ${formatDateTime(detail.expire_time)}` this.setData({ detail, expireText, loading: false }) }) .catch((e: any) => { - this.setData({ loading: false }) const code = e && e.code - if (code === 90100 || code === 90101 || code === 90102) { - this.setData({ invalid: true }) + if (AUTH_EXPIRED_CODES.indexOf(code) >= 0) { + // 查看分享需登录:给「去登录」出口,登录成功后回到本页 + this.setData({ + loading: false, + invalid: true, + needLogin: true, + invalidTitle: '请先登录', + invalidDesc: '登录后即可查看好友分享的衣柜', + }) + return + } + if (code === 90101) { + this.setInvalid('分享已过期', '该分享链接已超过有效期') + } else if (code === 90102) { + this.setInvalid('分享已取消', '该分享已被创建者取消') + } else if (code === 90100) { + this.setInvalid('分享不存在', '该分享链接无效') + } else { + // 兜底:任何未知错误都不能停在空白页 + this.setInvalid('加载失败', '请稍后重试') } }) }, + // 本地状态/有效期校验,返回 true 表示已置为失效态 + checkInvalid(detail: ClosetShareDetail): boolean { + if (detail.status === ShareStatus.CANCELLED) { + this.setInvalid('分享已取消', '该分享已被创建者取消') + return true + } + if (detail.status === ShareStatus.BANNED) { + this.setInvalid('分享已封禁', '该分享内容违规,已停止展示') + return true + } + const now = Math.floor(Date.now() / 1000) + if (detail.status === ShareStatus.EXPIRED || (detail.expire_time > 0 && detail.expire_time < now)) { + this.setInvalid('分享已过期', '该分享链接已超过有效期') + return true + } + return false + }, + + setInvalid(title: string, desc: string) { + this.setData({ + loading: false, + invalid: true, + needLogin: false, + invalidTitle: title, + invalidDesc: desc, + }) + }, + onCancel() { wx.showModal({ title: '取消分享', @@ -44,7 +101,7 @@ Page({ request({ url: `/closet-share/${this.data.uuid}/cancel`, method: 'POST' }) .then(() => { wx.showToast({ title: '已取消', icon: 'success' }) - setTimeout(() => wx.navigateBack(), 500) + ;(this as any).backTimer = setTimeout(() => wx.navigateBack(), 500) }) .catch(() => {}) }, @@ -54,4 +111,28 @@ Page({ onBackHome() { wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) }, + + onGoLogin() { + const redirect = `/pages/closet-share-detail/closet-share-detail?uuid=${this.data.uuid}` + wx.navigateTo({ + url: `/pages/login/login?redirect=${encodeURIComponent(redirect)}`, + fail: () => wx.switchTab({ url: '/pages/profile/profile' }), + }) + }, + + onShareAppMessage() { + const name = (this.data.detail && this.data.detail.owner_name) || '好友' + return { + title: `${name} 向你分享了 TA 的衣柜`, + path: `/pages/closet-share-detail/closet-share-detail?uuid=${this.data.uuid}`, + } + }, + + onUnload() { + const timer = (this as any).backTimer + if (timer) { + clearTimeout(timer) + ;(this as any).backTimer = null + } + }, }) diff --git a/miniprogram/pages/closet-share-detail/closet-share-detail.wxml b/miniprogram/pages/closet-share-detail/closet-share-detail.wxml index a78649b..2a3f1e5 100644 --- a/miniprogram/pages/closet-share-detail/closet-share-detail.wxml +++ b/miniprogram/pages/closet-share-detail/closet-share-detail.wxml @@ -2,10 +2,19 @@ + + @@ -35,6 +44,9 @@ + + + 取消分享 diff --git a/miniprogram/pages/closet-share-detail/closet-share-detail.wxss b/miniprogram/pages/closet-share-detail/closet-share-detail.wxss index 0ef2209..fd02652 100644 --- a/miniprogram/pages/closet-share-detail/closet-share-detail.wxss +++ b/miniprogram/pages/closet-share-detail/closet-share-detail.wxss @@ -112,6 +112,21 @@ margin-top: 4rpx; } +.share-btn { + margin: 32rpx 24rpx 0; + height: 88rpx; + line-height: 88rpx; + font-size: 30rpx; + font-weight: 500; + color: var(--color-primary, #000); + background: #fff; + border-radius: 24rpx; +} + +.share-btn::after { + border: none; +} + .cancel-btn { text-align: center; padding: 32rpx 0; diff --git a/miniprogram/pages/closet-share/closet-share.ts b/miniprogram/pages/closet-share/closet-share.ts index 2662cee..27340c0 100644 --- a/miniprogram/pages/closet-share/closet-share.ts +++ b/miniprogram/pages/closet-share/closet-share.ts @@ -1,6 +1,8 @@ import { request, redirectToLogin } from '../../utils/request' import * as auth from '../../utils/auth' import { PagedResult, ClosetShareItem } from '../../types/models' +import { ShareStatus, ShareType } from '../../utils/config' +import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged' Page({ data: { @@ -11,6 +13,7 @@ Page({ activeTab: 0, // 0 我的分享 1 分享给我的 list: [] as ClosetShareItem[], loading: true, + loadingMore: false, page: 1, pageSize: 20, hasMore: true, @@ -44,35 +47,49 @@ Page({ }, onReachBottom() { - if (!this.data.hasMore || this.data.loading) return + if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return this.fetchList(this.data.page + 1) }, fetchList(page: number) { const url = this.data.activeTab === 0 ? '/closet-share' : '/closet-share/received' + + // 请求序号:切 tab 时旧响应可能后到,序号不匹配直接丢弃 + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + request>({ url, data: { page, page_size: this.data.pageSize } }) .then((res) => { - const list = res.list.map((it) => this.decorate(it)) - const merged = page === 1 ? list : this.data.list.concat(list) + if ((this as any).reqSeq !== seq) return + const { list: raw, total } = normalizePaged(res) + const list = raw.map((it) => this.decorate(it)) + const merged = mergePaged(this.data.list, list, page === 1) this.setData({ list: merged, - hasMore: merged.length < res.total, + hasMore: hasMorePaged(merged, total, this.data.pageSize), loading: false, + loadingMore: false, page, }) }) - .catch(() => this.setData({ loading: false })) + .catch(() => { + if ((this as any).reqSeq !== seq) return + this.setData({ loading: false, loadingMore: false }) + }) }, decorate(it: ClosetShareItem) { const now = Math.floor(Date.now() / 1000) let status = it.status - if (status === 1 && it.expire_time > 0 && it.expire_time < now) status = 3 + if (status === ShareStatus.NORMAL && it.expire_time > 0 && it.expire_time < now) { + status = ShareStatus.EXPIRED + } let expireText = '长期有效' - if (status === 2) expireText = '已取消' - else if (status === 3) expireText = '已过期' - else if (status === 4) expireText = '已封禁' + if (status === ShareStatus.CANCELLED) expireText = '已取消' + else if (status === ShareStatus.EXPIRED) expireText = '已过期' + else if (status === ShareStatus.BANNED) expireText = '已封禁' else if (it.expire_time > 0) { const days = Math.ceil((it.expire_time - now) / 86400) expireText = days > 0 ? `${days} 天后过期` : '已过期' @@ -82,8 +99,11 @@ Page({ ...it, _status: status, _expireText: expireText, - _typeText: it.share_type === 1 ? '部分衣物' : '全量衣橱', - _invalid: status === 2 || status === 3 || status === 4, + _typeText: it.share_type === ShareType.PARTIAL ? '部分衣物' : '全量衣橱', + _invalid: + status === ShareStatus.CANCELLED || + status === ShareStatus.EXPIRED || + status === ShareStatus.BANNED, } }, diff --git a/miniprogram/pages/closet-share/closet-share.wxml b/miniprogram/pages/closet-share/closet-share.wxml index 3d457d9..fc1db51 100644 --- a/miniprogram/pages/closet-share/closet-share.wxml +++ b/miniprogram/pages/closet-share/closet-share.wxml @@ -52,7 +52,7 @@ - + diff --git a/miniprogram/pages/clothes-detail/clothes-detail.ts b/miniprogram/pages/clothes-detail/clothes-detail.ts index 4f76190..2740ccb 100644 --- a/miniprogram/pages/clothes-detail/clothes-detail.ts +++ b/miniprogram/pages/clothes-detail/clothes-detail.ts @@ -39,15 +39,23 @@ Page({ failReason: '', loading: true, editMode: false, + notFound: false, }, onLoad(query: Record) { const uuid = query.uuid || '' this.setData({ uuid }) - if (uuid) { - this.fetchDetail() - this.pollStatus() + if (!uuid) { + // uuid 缺失时不请求不轮询,否则会停在空白页且无法退出 + this.setData({ loading: false, notFound: true }) + return } + this.fetchDetail() + this.pollStatus() + }, + + onBackWardrobe() { + wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) }, fetchDetail(): Promise { diff --git a/miniprogram/pages/clothes-detail/clothes-detail.wxml b/miniprogram/pages/clothes-detail/clothes-detail.wxml index 1dcb3d8..da030df 100644 --- a/miniprogram/pages/clothes-detail/clothes-detail.wxml +++ b/miniprogram/pages/clothes-detail/clothes-detail.wxml @@ -9,8 +9,18 @@ + + + - + @@ -118,7 +128,7 @@ diff --git a/miniprogram/pages/horoscope/horoscope.ts b/miniprogram/pages/horoscope/horoscope.ts index 62349a2..8e9a8b5 100644 --- a/miniprogram/pages/horoscope/horoscope.ts +++ b/miniprogram/pages/horoscope/horoscope.ts @@ -15,6 +15,13 @@ const COLOR_MAP: Record = { 灰色: '#8E8E93', } +// 今日日期(YYYYMMDD),用于判断运势数据是否跨天 +function todayStatDate(): number { + const d = new Date() + const pad = (n: number) => (n < 10 ? `0${n}` : String(n)) + return Number(`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`) +} + Page({ data: { horoscope: null as HoroscopeData | null, @@ -22,6 +29,8 @@ Page({ luckyColorHex: '#007AFF', loading: true, noBirth: false, + error: false, + errorText: '', }, onShow() { @@ -31,7 +40,10 @@ Page({ redirectToLogin() return } - if (!this.data.horoscope) this.fetchHoroscope(0) + // 跨天回到本页时旧数据仍是昨天的,需重新拉取 + const stale = + !this.data.horoscope || Number(this.data.horoscope.stat_date) !== todayStatDate() + if (stale) this.fetchHoroscope(0) }, syncTabBar() { @@ -40,7 +52,8 @@ Page({ }, fetchHoroscope(retry: number) { - this.setData({ loading: true }) + this.clearRetryTimer() + this.setData({ loading: true, error: false, errorText: '' }) request({ url: '/horoscope/today', showError: false }) .then((res) => { this.setData({ @@ -49,6 +62,7 @@ Page({ luckyColorHex: COLOR_MAP[res.lucky_color] || '#007AFF', loading: false, noBirth: false, + error: false, }) }) .catch((e: any) => { @@ -59,13 +73,31 @@ Page({ this.setData({ noBirth: true, loading: false }) } else if (code === 90300 && retry < 3) { wx.showToast({ title: '运势生成中,请稍后重试', icon: 'none' }) - setTimeout(() => this.fetchHoroscope(retry + 1), 4000) + // 定时器挂实例并在页面卸载时清理,避免离开页面后继续请求 + ;(this as any).retryTimer = setTimeout(() => this.fetchHoroscope(retry + 1), 4000) } else { - this.setData({ loading: false }) + // 失败必须有可见反馈:否则会渲染一张数据为 null 的空卡 + this.setData({ + loading: false, + error: true, + errorText: (e && e.message) || '运势加载失败,请重试', + }) } }) }, + clearRetryTimer() { + const timer = (this as any).retryTimer + if (timer) { + clearTimeout(timer) + ;(this as any).retryTimer = null + } + }, + + onRetry() { + this.fetchHoroscope(0) + }, + formatStatDate(d: number): string { const s = String(d) return `${s.slice(0, 4)}.${s.slice(4, 6)}.${s.slice(6, 8)}` @@ -78,4 +110,8 @@ Page({ onGenerate() { wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' }) }, + + onUnload() { + this.clearRetryTimer() + }, }) diff --git a/miniprogram/pages/horoscope/horoscope.wxml b/miniprogram/pages/horoscope/horoscope.wxml index d24f8af..3201bf3 100644 --- a/miniprogram/pages/horoscope/horoscope.wxml +++ b/miniprogram/pages/horoscope/horoscope.wxml @@ -21,8 +21,18 @@ bind:action="onGoProfile" /> + + + - + diff --git a/miniprogram/pages/login/login.ts b/miniprogram/pages/login/login.ts index c6747f0..f9994b5 100644 --- a/miniprogram/pages/login/login.ts +++ b/miniprogram/pages/login/login.ts @@ -13,17 +13,32 @@ interface GiftItem { Page({ data: { loading: false, + redirect: '', // 登录后要回跳的页面(如从好友分享链接进入) userInfo: {} as UserInfo, giftVisible: false, giftItems: [] as GiftItem[], }, - onLoad() { + onLoad(query: Record) { + if (query.redirect) this.setData({ redirect: query.redirect }) if (auth.isLoggedIn()) { - wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) + this.goAfterLogin() } }, + // 登录完成后的落地页:带 redirect 时回跳来源页(分享详情等),否则进衣橱 + goAfterLogin(fallback = false) { + const redirect = this.data.redirect + if (redirect && !fallback) { + wx.redirectTo({ + url: redirect, + fail: () => this.goAfterLogin(true), + }) + return + } + wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) + }, + onLogin() { if (this.data.loading) return this.setData({ loading: true }) @@ -56,7 +71,7 @@ Page({ } else if (!data.user_info.birth_day) { this.showBirthGuide() } else { - wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) + this.goAfterLogin() } }) .catch(() => {}) @@ -100,7 +115,7 @@ Page({ if (!this.data.userInfo.birth_day) { this.showBirthGuide() } else { - wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) + this.goAfterLogin() } }, @@ -112,7 +127,7 @@ Page({ cancelText: '稍后再说', success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/profile/profile' }) - else wx.switchTab({ url: '/pages/wardrobe/wardrobe' }) + else this.goAfterLogin() }, }) }, diff --git a/miniprogram/pages/mall/mall.ts b/miniprogram/pages/mall/mall.ts index 4d88b03..508a840 100644 --- a/miniprogram/pages/mall/mall.ts +++ b/miniprogram/pages/mall/mall.ts @@ -2,6 +2,7 @@ import { request } from '../../utils/request' import { QuotaItem, UserInfo, GoodsItem, QuotaFlowItem, PagedResult, VirtualPayResult, OrderItem } from '../../types/models' import { QUOTA_NAMES, GoodsType, GOODS_TYPE_NAMES } from '../../utils/config' import { fenToYuan, formatDate, formatDateTime } from '../../utils/format' +import { normalizePaged } from '../../utils/paged' const GOODS_ICONS: Record = { [GoodsType.MEMBER]: 'icon-crown', @@ -48,6 +49,7 @@ Page({ goodsIcons: GOODS_ICONS, orderVisible: false, orderGoods: null as GoodsItem | null, + submitting: false, // 下单防连点:连点「立即支付」会重复创建虚拟支付订单 loading: true, }, @@ -94,7 +96,7 @@ Page({ if (filter > 0) params.goods_type = filter request>({ url: '/goods', data: params, auth: false }) .then((res) => { - const goods = res.list.map((g) => ({ + const goods = normalizePaged(res).list.map((g) => ({ ...g, _icon: GOODS_ICONS[g.goods_type] || 'icon-crown', _typeName: GOODS_TYPE_NAMES[g.goods_type] || '', @@ -109,7 +111,7 @@ Page({ fetchFlow() { request>({ url: '/quota/flow', data: { page: 1, page_size: 50 } }) .then((res) => { - const list = res.list.map((it) => ({ + const list = normalizePaged(res).list.map((it) => ({ ...it, _time: formatDateTime(it.created_at), _sign: it.change_num > 0 ? `+${it.change_num}` : `${it.change_num}`, @@ -151,7 +153,10 @@ Page({ noop() {}, confirmPay() { + if (this.data.submitting) return if (!this.data.orderGoods) return + + this.setData({ submitting: true }) request({ url: '/order/virtual', method: 'POST', @@ -161,10 +166,10 @@ Page({ }, }) .then((res) => { - this.setData({ orderVisible: false }) + this.setData({ orderVisible: false, submitting: false }) this.doPayment(res) }) - .catch(() => {}) + .catch(() => this.setData({ submitting: false })) }, doPayment(res: VirtualPayResult) { @@ -182,35 +187,49 @@ Page({ }, pollOrder(orderNo: string) { + this.clearPayTimer() let count = 0 - const timer = setInterval(() => { + ;(this as any).payTimer = setInterval(() => { count++ request>({ url: '/order', data: { page: 1, page_size: 50 }, showError: false }) .then((res) => { - const order = res.list.find((it) => it.order_no === orderNo) + const order = normalizePaged(res).list.find((it) => it.order_no === orderNo) if (order && order.order_status === 2) { - clearInterval(timer) + this.clearPayTimer() wx.showToast({ title: '支付成功', icon: 'success' }) this.fetchQuota() this.fetchUser() } else if (order && (order.order_status === 3 || order.order_status === 4 || order.order_status === 5)) { - clearInterval(timer) + this.clearPayTimer() const msg = order.order_status === 5 ? '订单已退款' : '订单未支付成功' wx.showToast({ title: msg, icon: 'none' }) } else if (count >= 10) { - this.finishPayTimeout(timer) + this.finishPayTimeout() } }) .catch(() => { - if (count >= 10) this.finishPayTimeout(timer) + if (count >= 10) this.finishPayTimeout() }) }, 1500) }, - finishPayTimeout(timer: ReturnType) { - clearInterval(timer) + clearPayTimer() { + const timer = (this as any).payTimer + if (timer) { + clearInterval(timer) + ;(this as any).payTimer = null + } + }, + + finishPayTimeout() { + this.clearPayTimer() wx.showToast({ title: '到账可能有延迟,请稍后查看', icon: 'none' }) this.fetchQuota() this.fetchUser() }, + + onUnload() { + // 离开商城后停止支付轮询,避免对已卸载页面 setData + this.clearPayTimer() + }, }) diff --git a/miniprogram/pages/mall/mall.wxml b/miniprogram/pages/mall/mall.wxml index a73a33e..f31fc70 100644 --- a/miniprogram/pages/mall/mall.wxml +++ b/miniprogram/pages/mall/mall.wxml @@ -89,7 +89,7 @@ {{orderGoods.goods_name}} {{orderGoods.description}} 实付 {{orderGoods._priceText}} - 立即支付 + {{submitting ? '下单中…' : '立即支付'}} diff --git a/miniprogram/pages/notification-detail/notification-detail.ts b/miniprogram/pages/notification-detail/notification-detail.ts index 340954b..b6dfa58 100644 --- a/miniprogram/pages/notification-detail/notification-detail.ts +++ b/miniprogram/pages/notification-detail/notification-detail.ts @@ -1,6 +1,7 @@ import { request } from '../../utils/request' import { NotificationItem } from '../../types/models' import { formatDateTime } from '../../utils/format' +import { syncTabBarUnread } from '../../utils/tabbar' Page({ data: { @@ -14,7 +15,12 @@ Page({ this.setData({ uuid }) if (uuid) { this.fetchDetail() - request({ url: `/notification/${uuid}/read`, method: 'POST', showError: false }).catch(() => {}) + request({ url: `/notification/${uuid}/read`, method: 'POST', showError: false }) + .then(() => { + // 标记已读后同步红点:本页不是 tab 页,红点不会自动刷新 + syncTabBarUnread(true) + }) + .catch(() => {}) } }, diff --git a/miniprogram/pages/notification/notification.ts b/miniprogram/pages/notification/notification.ts index 054e3f7..9483a69 100644 --- a/miniprogram/pages/notification/notification.ts +++ b/miniprogram/pages/notification/notification.ts @@ -3,6 +3,8 @@ import * as auth from '../../utils/auth' import { PagedResult, NotificationItem } from '../../types/models' import { STORAGE_KEYS } from '../../utils/config' import { formatRelativeTime } from '../../utils/format' +import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged' +import { getTabBarInstance } from '../../utils/tabbar' const FILTERS = [ { name: '全部', value: 0 }, @@ -17,6 +19,7 @@ Page({ activeFilter: 0, list: [] as NotificationItem[], loading: true, + loadingMore: false, page: 1, pageSize: 20, hasMore: true, @@ -41,7 +44,8 @@ Page({ .then((res) => { const unread = res.unread_count || 0 wx.setStorageSync(STORAGE_KEYS.UNREAD, unread) - const tabBar = (this as any).getTabBar && (this as any).getTabBar() + // 本页不是 tab 页,getTabBar() 恒为 undefined,需从页面栈里取 tab 页的 tabBar + const tabBar = getTabBarInstance() // 直接更新红点,避免 tabBar 内部再发一次请求 if (tabBar && tabBar.setUnread) tabBar.setUnread(unread) }) @@ -54,7 +58,7 @@ Page({ }, onReachBottom() { - if (!this.data.hasMore || this.data.loading) return + if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return this.fetchList(this.data.page + 1) }, @@ -63,18 +67,29 @@ Page({ const filter = this.data.activeFilter if (filter > 0) params.msg_type = filter + // 请求序号:切换筛选时旧响应可能后到,序号不匹配直接丢弃 + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + request>({ url: '/notification', data: params }) .then((res) => { - const list = res.list.map((it) => ({ ...it, _time: formatRelativeTime(it.created_at) })) - const merged = page === 1 ? list : this.data.list.concat(list) + if ((this as any).reqSeq !== seq) return + const { list: raw, total } = normalizePaged(res) + const list = raw.map((it) => ({ ...it, _time: formatRelativeTime(it.created_at) })) + const merged = mergePaged(this.data.list, list, page === 1) this.setData({ list: merged, - hasMore: merged.length < res.total, + hasMore: hasMorePaged(merged, total, this.data.pageSize), loading: false, + loadingMore: false, page, }) }) - .catch(() => this.setData({ loading: false })) + .catch(() => { + if ((this as any).reqSeq !== seq) return + this.setData({ loading: false, loadingMore: false }) + }) }, onFilterTap(e: WechatMiniprogram.TouchEvent) { diff --git a/miniprogram/pages/order/order.ts b/miniprogram/pages/order/order.ts index d47b5ae..999d2b5 100644 --- a/miniprogram/pages/order/order.ts +++ b/miniprogram/pages/order/order.ts @@ -1,6 +1,7 @@ import { request } from '../../utils/request' import { PagedResult, OrderItem } from '../../types/models' import { fenToYuan, formatDateTime, formatCountdown } from '../../utils/format' +import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged' const STATUS_FILTERS = [ { name: '全部', value: 0 }, @@ -20,6 +21,7 @@ Page({ activeFilter: 0, list: [] as OrderItem[], loading: true, + loadingMore: false, page: 1, pageSize: 20, hasMore: true, @@ -27,6 +29,15 @@ Page({ onShow() { this.refresh() + this.startCountdown() + }, + + onHide() { + this.stopCountdown() + }, + + onUnload() { + this.stopCountdown() }, refresh() { @@ -35,19 +46,56 @@ Page({ }, onReachBottom() { - if (!this.data.hasMore || this.data.loading) return + if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return this.fetchList(this.data.page + 1) }, + // 待支付倒计时需要每秒走动,仅在有倒计时项时才 setData + startCountdown() { + this.stopCountdown() + ;(this as any).tickTimer = setInterval(() => this.tickCountdown(), 1000) + }, + + stopCountdown() { + const timer = (this as any).tickTimer + if (timer) { + clearInterval(timer) + ;(this as any).tickTimer = null + } + }, + + tickCountdown() { + const list = this.data.list + if (!list.length) return + const now = Math.floor(Date.now() / 1000) + let changed = false + const next = list.map((it: any) => { + if (it.order_status !== 1 || !it.expire_time) return it + const left = it.expire_time - now + const text = left > 0 ? formatCountdown(left) : '' + if (text === it._countdown) return it + changed = true + return { ...it, _countdown: text } + }) + if (changed) this.setData({ list: next }) + }, + fetchList(page: number) { const params: Record = { page, page_size: this.data.pageSize } const filter = this.data.activeFilter if (filter > 0) params.status = filter + // 请求序号:切换筛选时旧响应可能后到,序号不匹配直接丢弃 + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + request>({ url: '/order', data: params }) .then((res) => { + if ((this as any).reqSeq !== seq) return + const { list: raw, total } = normalizePaged(res) const now = Math.floor(Date.now() / 1000) - const list = res.list.map((it) => ({ + const list = raw.map((it) => ({ ...it, _statusText: STATUS_TEXT[it.order_status] || '', _statusType: STATUS_TYPE[it.order_status] || 'gray', @@ -59,15 +107,19 @@ Page({ _canCancel: it.order_status === 1 && it.pay_channel !== 2, _payChannelText: it.pay_channel === 2 ? '虚拟支付' : '', })) - const merged = page === 1 ? list : this.data.list.concat(list) + const merged = mergePaged(this.data.list, list, page === 1) this.setData({ list: merged, - hasMore: merged.length < res.total, + hasMore: hasMorePaged(merged, total, this.data.pageSize), loading: false, + loadingMore: false, page, }) }) - .catch(() => this.setData({ loading: false })) + .catch(() => { + if ((this as any).reqSeq !== seq) return + this.setData({ loading: false, loadingMore: false }) + }) }, onFilterTap(e: WechatMiniprogram.TouchEvent) { diff --git a/miniprogram/pages/outfit-detail/outfit-detail.ts b/miniprogram/pages/outfit-detail/outfit-detail.ts index eafd699..5b65222 100644 --- a/miniprogram/pages/outfit-detail/outfit-detail.ts +++ b/miniprogram/pages/outfit-detail/outfit-detail.ts @@ -1,6 +1,6 @@ import { request } from '../../utils/request' import { OutfitItem, ClothesItem } from '../../types/models' -import { OutfitSource, IsPublic } from '../../utils/config' +import { IsPublic } from '../../utils/config' Page({ data: { diff --git a/miniprogram/pages/outfit-edit/outfit-edit.ts b/miniprogram/pages/outfit-edit/outfit-edit.ts index f25c559..cbf4296 100644 --- a/miniprogram/pages/outfit-edit/outfit-edit.ts +++ b/miniprogram/pages/outfit-edit/outfit-edit.ts @@ -1,5 +1,6 @@ import { request } from '../../utils/request' import { OutfitItem, ClothesItem, PagedResult } from '../../types/models' +import { normalizePaged, hasMorePaged } from '../../utils/paged' const CATEGORIES = [ { name: '全部', value: '' }, @@ -11,6 +12,8 @@ const CATEGORIES = [ { name: '配饰', value: '配饰' }, ] +const PAGE_SIZE = 50 + Page({ data: { uuid: '', @@ -25,6 +28,10 @@ Page({ activeCategory: 0, allClothes: [] as ClothesItem[], selectLoading: true, + loadingMore: false, + hasMore: true, + page: 1, + saving: false, }, onLoad(query: Record) { @@ -48,6 +55,11 @@ Page({ } }) ) + // 编辑态下也要填充已选快照,避免确认选衣时跨分类数据丢失 + const map = this.getSelectedMap() + clothes.forEach((it) => { + map[it.clothes_uuid] = it + }) this.setData({ outfitName: outfit.outfit_name, outfitDesc: outfit.outfit_desc, @@ -58,6 +70,12 @@ Page({ } catch (e) {} }, + // 已选衣物快照:跨分类切换时列表只保留当前分类,快照保留全部已选项 + getSelectedMap(): Record { + if (!(this as any).selectedMap) (this as any).selectedMap = {} + return (this as any).selectedMap + }, + onNameInput(e: WechatMiniprogram.Input) { this.setData({ outfitName: e.detail.value }) }, @@ -69,49 +87,75 @@ Page({ }, openSelect() { - this.setData({ view: 'select', selectLoading: true }) - this.fetchAllClothes() + this.setData({ view: 'select', selectLoading: true, page: 1, hasMore: true }) + this.fetchAllClothes(1) }, backToForm() { this.setData({ view: 'form' }) }, - fetchAllClothes() { + fetchAllClothes(page = 1) { const cat = CATEGORIES[this.data.activeCategory].value - const params: Record = { page: 1, page_size: 50 } + const params: Record = { page, page_size: PAGE_SIZE } if (cat) params.category_main = cat + + // 请求序号:切换分类时旧响应可能后到,序号不匹配直接丢弃 + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + request>({ url: '/clothes', data: params }) .then((res) => { - const list = res.list.map((it) => ({ + if ((this as any).reqSeq !== seq) return + const { list: raw, total } = normalizePaged(res) + const list = raw.map((it) => ({ ...it, _selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0, })) - this.setData({ allClothes: list, selectLoading: false }) + const merged = page === 1 ? list : this.data.allClothes.concat(list) + this.setData({ + allClothes: merged, + selectLoading: false, + loadingMore: false, + page, + hasMore: hasMorePaged(merged, total, PAGE_SIZE), + }) + }) + .catch(() => { + if ((this as any).reqSeq !== seq) return + this.setData({ selectLoading: false, loadingMore: false }) }) - .catch(() => this.setData({ selectLoading: false })) }, onCategoryTap(e: WechatMiniprogram.TouchEvent) { const index = Number(e.currentTarget.dataset.index) if (index === this.data.activeCategory) return - this.setData({ activeCategory: index, selectLoading: true }) - this.fetchAllClothes() + this.setData({ activeCategory: index, selectLoading: true, page: 1, hasMore: true }) + this.fetchAllClothes(1) + }, + + onReachBottom() { + if (this.data.view !== 'select') return + if (!this.data.hasMore || this.data.selectLoading || this.data.loadingMore) return + this.fetchAllClothes(this.data.page + 1) }, onToggleSelect(e: WechatMiniprogram.CustomEvent) { const item = e.detail.item as ClothesItem const uuids = this.data.selectedUuids - const idx = uuids.indexOf(item.clothes_uuid) - if (idx >= 0) { + const map = this.getSelectedMap() + if (uuids.indexOf(item.clothes_uuid) >= 0) { + delete map[item.clothes_uuid] this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid)) - } else { - if (uuids.length >= 10) { - wx.showToast({ title: '最多选择 10 件衣物', icon: 'none' }) - return - } - this.syncSelect([...uuids, item.clothes_uuid]) + return } + if (uuids.length >= 10) { + wx.showToast({ title: '最多选择 10 件衣物', icon: 'none' }) + return + } + map[item.clothes_uuid] = item + this.syncSelect([...uuids, item.clothes_uuid]) }, syncSelect(uuids: string[]) { @@ -125,13 +169,16 @@ Page({ }, confirmSelect() { - const selectedItems = this.data.allClothes.filter( - (it) => this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0 + const map = this.getSelectedMap() + // 以快照为准:只从当前分类列表里过滤会丢掉其它分类已选的衣物 + const selectedItems = this.data.selectedUuids.map( + (u) => map[u] || ({ clothes_uuid: u } as ClothesItem) ) this.setData({ view: 'form', selectedItems }) }, onSave() { + if (this.data.saving) return const name = this.data.outfitName.trim() if (!name) { wx.showToast({ title: '请填写穿搭名称', icon: 'none' }) @@ -148,15 +195,18 @@ Page({ scene: this.data.scene, items: this.data.selectedUuids.map((u) => ({ clothes_uuid: u })), } + + this.setData({ saving: true }) const req = this.data.isEdit ? request({ url: `/outfit/${this.data.uuid}`, method: 'PUT', data: body }) : request({ url: '/outfit', method: 'POST', data: body }) req .then(() => { + this.setData({ saving: false }) wx.showToast({ title: '已保存', icon: 'success' }) setTimeout(() => wx.navigateBack(), 500) }) - .catch(() => {}) + .catch(() => this.setData({ saving: false })) }, onDelete() { diff --git a/miniprogram/pages/outfit/outfit.ts b/miniprogram/pages/outfit/outfit.ts index 0c1915e..c603e09 100644 --- a/miniprogram/pages/outfit/outfit.ts +++ b/miniprogram/pages/outfit/outfit.ts @@ -1,7 +1,8 @@ import { request, redirectToLogin } from '../../utils/request' import * as auth from '../../utils/auth' import { PagedResult, OutfitItem } from '../../types/models' -import { OutfitSource, IsPublic } from '../../utils/config' +import { OutfitSource, IsPublic, STORAGE_KEYS } from '../../utils/config' +import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged' Page({ data: { @@ -9,6 +10,7 @@ Page({ list: [] as OutfitItem[], loading: true, refreshing: false, + loadingMore: false, page: 1, pageSize: 20, hasMore: true, @@ -21,9 +23,9 @@ Page({ redirectToLogin() return } - const openCollect = wx.getStorageSync('zt_open_collect') + const openCollect = wx.getStorageSync(STORAGE_KEYS.OPEN_COLLECT) if (openCollect) { - wx.removeStorageSync('zt_open_collect') + wx.removeStorageSync(STORAGE_KEYS.OPEN_COLLECT) if (this.data.activeTab !== 1) { this.setData({ activeTab: 1, list: [], loading: true }) } @@ -54,25 +56,37 @@ Page({ }, onReachBottom() { - if (!this.data.hasMore || this.data.loading) return + if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return this.fetchList(this.data.page + 1) }, fetchList(page: number): Promise { const isCollect = this.data.activeTab === 1 const url = isCollect ? '/collect' : '/outfit' + + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + return request>({ url, data: { page, page_size: this.data.pageSize } }) .then(async (res) => { - const items = await this.decorateList(res.list) - const merged = page === 1 ? items : this.data.list.concat(items) + const { list: raw, total } = normalizePaged(res) + const items = await this.decorateList(raw) + // decorateList 是异步的,期间可能已切换 tab:序号不匹配则丢弃 + if ((this as any).reqSeq !== seq) return + const merged = mergePaged(this.data.list, items, page === 1) this.setData({ list: merged, - hasMore: merged.length < res.total, + hasMore: hasMorePaged(merged, total, this.data.pageSize), loading: false, + loadingMore: false, page, }) }) - .catch(() => this.setData({ loading: false })) + .catch(() => { + if ((this as any).reqSeq !== seq) return + this.setData({ loading: false, loadingMore: false }) + }) }, decorateList(list: OutfitItem[]): Promise { diff --git a/miniprogram/pages/profile/profile.ts b/miniprogram/pages/profile/profile.ts index 2a610a6..14ec333 100644 --- a/miniprogram/pages/profile/profile.ts +++ b/miniprogram/pages/profile/profile.ts @@ -29,6 +29,7 @@ Page({ editVisible: false, avatarUploading: false, avatarChanged: false, + saving: false, editForm: { username: '', sex: 0, @@ -40,6 +41,11 @@ Page({ }, 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())}` }) @@ -47,6 +53,7 @@ Page({ onShow() { this.syncTabBar() + this.setToday() // 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求 if (!auth.isLoggedIn()) { redirectToLogin() @@ -113,6 +120,7 @@ Page({ wx.showActionSheet({ itemList: ['男', '女'], success: (res) => this.setData({ 'editForm.sex': res.tapIndex + 1 }), + fail: () => {}, // 取消选择属正常操作,不必抛错日志 }) }, @@ -123,29 +131,52 @@ Page({ }, onSaveProfile() { - if (this.data.avatarUploading) return + 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, + }) + this.fetchUser() + } const saveProfile = () => { + const u = this.data.userInfo const body: Record = {} - if (f.username) body.username = f.username - if (f.sex) body.sex = f.sex - if (f.birth_day) body.birth_day = f.birth_day + // 只提交真正变更的字段,避免无意义请求与误清空 + 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 }) + return + } + + this.setData({ saving: true }) request({ url: '/user/profile', method: 'PUT', data: body }) .then(() => { wx.showToast({ title: '已保存', icon: 'success' }) - this.setData({ editVisible: false, avatarUploading: false }) - this.fetchUser() + finish() }) - .catch(() => this.setData({ avatarUploading: false })) + .catch(() => this.setData({ avatarUploading: false, saving: false })) } if (this.data.avatarChanged && avatar) { this.setData({ avatarUploading: true }) uploadFile<{ avatar: string }>(avatar, 'file', '/user/avatar/upload') .then(() => saveProfile()) - .catch(() => this.setData({ avatarUploading: false })) + .catch(() => this.setData({ avatarUploading: false, saving: false })) } else { saveProfile() } @@ -161,7 +192,7 @@ Page({ wx.navigateTo({ url: '/pages/notification/notification' }) }, onTapCollect() { - wx.setStorageSync('zt_open_collect', 1) + wx.setStorageSync(STORAGE_KEYS.OPEN_COLLECT, 1) wx.switchTab({ url: '/pages/outfit/outfit' }) }, onTapFlow() { diff --git a/miniprogram/pages/upload/upload.ts b/miniprogram/pages/upload/upload.ts index 879a6da..3782cd4 100644 --- a/miniprogram/pages/upload/upload.ts +++ b/miniprogram/pages/upload/upload.ts @@ -1,4 +1,4 @@ -import { request, uploadFile } from '../../utils/request' +import { request, uploadFile, NOT_LOGIN_CODE } from '../../utils/request' import { ProcessStatus } from '../../utils/config' Page({ @@ -17,6 +17,7 @@ Page({ const sourceType: Array<'camera' | 'album'> = res.tapIndex === 0 ? ['camera'] : ['album'] this.chooseImage(sourceType) }, + fail: () => {}, // 取消选择属正常操作,不必抛错日志 }) }, @@ -42,7 +43,14 @@ Page({ }) .catch((e: any) => { this.setData({ state: 'idle' }) - if (e && e.code === 80000) this.showQuotaModal() + if (e && e.code === 80000) { + this.showQuotaModal() + return + } + // uploadFile 已统一 toast 业务错误与网络错误;未登录时请求并未发出,需补一次提示 + if (e && e.code === NOT_LOGIN_CODE) { + wx.showToast({ title: '请先登录', icon: 'none' }) + } }) }, diff --git a/miniprogram/pages/wardrobe/wardrobe.ts b/miniprogram/pages/wardrobe/wardrobe.ts index c623558..0c6f180 100644 --- a/miniprogram/pages/wardrobe/wardrobe.ts +++ b/miniprogram/pages/wardrobe/wardrobe.ts @@ -2,6 +2,7 @@ import { request, redirectToLogin } from '../../utils/request' import * as auth from '../../utils/auth' import { PagedResult, ClothesItem, QuotaItem } from '../../types/models' import { QuotaType } from '../../utils/config' +import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged' const CATEGORIES = [ { name: '全部', value: '' }, @@ -22,6 +23,7 @@ Page({ uploadRemain: 0, loading: true, refreshing: false, + loadingMore: false, // 上拉加载独立闸门,避免与首次 loading 互相干扰 page: 1, pageSize: 20, hasMore: true, @@ -56,7 +58,7 @@ Page({ }, onReachBottom() { - if (!this.data.hasMore || this.data.loading) return + if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return this.fetchList(this.data.page + 1) }, @@ -65,24 +67,33 @@ Page({ const params: Record = { page, page_size: this.data.pageSize } if (cat) params.category_main = cat + // 请求序号:快速切分类/下拉刷新时旧响应可能后到,序号不匹配的响应直接丢弃 + const seq = ((this as any).reqSeq || 0) + 1 + ;(this as any).reqSeq = seq + if (page > 1) this.setData({ loadingMore: true }) + return request>({ url: '/clothes', data: params }) .then((res) => { - const list = res.list.map((it) => ({ + if ((this as any).reqSeq !== seq) return + const { list: raw, total } = normalizePaged(res) + const list = raw.map((it) => ({ ...it, _statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '', _statusType: 'blue', })) - const merged = page === 1 ? list : this.data.list.concat(list) + const merged = mergePaged(this.data.list, list, page === 1) this.setData({ list: merged, - total: res.total, - hasMore: merged.length < res.total, + total, + hasMore: hasMorePaged(merged, total, this.data.pageSize), loading: false, + loadingMore: false, page, }) }) .catch(() => { - this.setData({ loading: false }) + if ((this as any).reqSeq !== seq) return + this.setData({ loading: false, loadingMore: false }) }) }, diff --git a/miniprogram/utils/auth.ts b/miniprogram/utils/auth.ts index 43b316e..c2cedb0 100644 --- a/miniprogram/utils/auth.ts +++ b/miniprogram/utils/auth.ts @@ -34,6 +34,22 @@ export function isLoggedIn(): boolean { return !!getAccessToken() } +// token 过期提前量(秒):提前 30s 视为过期,避免请求在途时刚好失效 +const EXPIRE_LEAD_SECONDS = 30 + +/** + * access token 是否已过期(仅用于请求前预刷新,不参与登录态判定) + * expire_time 单位兼容秒与毫秒:数值大于 1e12 视为毫秒时间戳。 + */ +export function isTokenExpired(): boolean { + const expireTime = getExpireTime() + if (!expireTime) return false // 未记录过期时间时不臆测,交给后端判定 + + const isMillisecond = expireTime > 1e12 + const expireMs = isMillisecond ? expireTime : expireTime * 1000 + return Date.now() + EXPIRE_LEAD_SECONDS * 1000 >= expireMs +} + export function clearUserInfo(): void { wx.removeStorageSync(STORAGE_KEYS.USER_INFO) } diff --git a/miniprogram/utils/config.ts b/miniprogram/utils/config.ts index c072960..9223da7 100644 --- a/miniprogram/utils/config.ts +++ b/miniprogram/utils/config.ts @@ -13,6 +13,7 @@ export const STORAGE_KEYS = { EXPIRE_TIME: 'zt_closet_expire_time', USER_INFO: 'zt_closet_user_info', UNREAD: 'zt_closet_unread', + OPEN_COLLECT: 'zt_open_collect', } // 衣物图片处理状态 diff --git a/miniprogram/utils/paged.ts b/miniprogram/utils/paged.ts new file mode 100644 index 0000000..21ce1dc --- /dev/null +++ b/miniprogram/utils/paged.ts @@ -0,0 +1,24 @@ +// 分页响应统一兜底:后端 code === 0 时仍可能返回 null / 缺字段, +// 直接 res.list.map 会抛错并被页面空的 catch 吞掉,表现为「静默空态」。 + +export interface NormalizedPaged { + list: T[] + total: number +} + +export function normalizePaged(res: any): NormalizedPaged { + const list = res && Array.isArray(res.list) ? (res.list as T[]) : [] + const total = res && typeof res.total === 'number' ? res.total : list.length + return { list, total } +} + +// 追加/替换分页数据:refresh 为 true 时用新一页覆盖,否则追加 +export function mergePaged(oldList: T[], list: T[], refresh: boolean): T[] { + return refresh ? list : oldList.concat(list) +} + +// 是否还有下一页:total 缺失时退化为「本页满额则假设还有」 +export function hasMorePaged(list: T[], total: number, pageSize: number): boolean { + if (total > 0) return list.length < total + return list.length > 0 && list.length % pageSize === 0 +} diff --git a/miniprogram/utils/request.ts b/miniprogram/utils/request.ts index abeebb6..76e7d5e 100644 --- a/miniprogram/utils/request.ts +++ b/miniprogram/utils/request.ts @@ -22,9 +22,35 @@ export interface RequestOptions { silentAuth?: boolean // 非关键请求(如未读红点轮询):401 只失败,不触发全局跳登录 } -// 未登录错误码:调用方可通过 e.code === 11001 区分 +// 未登录错误码:调用方可通过 e.code === NOT_LOGIN_CODE 区分 export const NOT_LOGIN_CODE = 11001 +// 登录失效错误码集合:命中后先尝试刷新 token,刷新失败才清理登录态并跳转登录页 +export const AUTH_EXPIRED_CODES: number[] = [ + 11000, // 请先登录 + 11001, // 登录状态已失效 + 11002, // 登录状态已过期 + 11004, // 请先登录 +] + +// 请求超时兜底(毫秒):弱网下 wx.request 默认 60s,用户体验极差 +const REQUEST_TIMEOUT = 15000 +const UPLOAD_TIMEOUT = 30000 + +function isAuthExpiredCode(code: number, statusCode?: number): boolean { + return AUTH_EXPIRED_CODES.indexOf(code) >= 0 || statusCode === 401 +} + +// 登录失效后的收尾:非关键请求只 reject,不动登录态 +function failAuth(code: number, msg: string, silentAuth: boolean, showError: boolean) { + if (!silentAuth) { + auth.clearTokens() + handleAuthExpired() + if (showError) showToast('登录已过期,请重新登录') + } + return new ApiError(code, msg || '登录已过期') +} + // 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖) let onAuthExpired: (() => void) | null = null export function setAuthExpiredHandler(handler: () => void): void { @@ -48,6 +74,7 @@ function doRefresh(): Promise { method: 'POST', header: { 'Content-Type': 'application/json' }, data: { refresh_token: refreshToken, access_token: accessToken }, + timeout: REQUEST_TIMEOUT, success: (res) => { const body = res.data as { code: number; data?: LoginTokenResponse } if (body && body.code === 0 && body.data) { @@ -131,7 +158,7 @@ export function request(options: RequestOptions): Promise { return } - const doCall = (): void => { + const doCall = (retried = false): void => { const finalHeader: Record = { 'Content-Type': 'application/json', ...header, @@ -146,6 +173,7 @@ export function request(options: RequestOptions): Promise { method, data, header: finalHeader, + timeout: REQUEST_TIMEOUT, success: (res) => { const body = res.data as { code: number; msg: string; data?: T; details?: string[] } @@ -164,24 +192,20 @@ export function request(options: RequestOptions): Promise { return } - // Token 失效:尝试刷新后重试一次 - if ( - needAuth && - (code === 11001 || code === 11002 || code === 11004 || res.statusCode === 401) - ) { + // Token 失效:刷新后最多重试一次,避免「刷新成功但资源接口持续 401」引发请求风暴 + if (needAuth && isAuthExpiredCode(code, res.statusCode)) { + if (retried) { + reject(failAuth(code, body.msg, silentAuth, showError)) + return + } doRefresh().then((ok) => { if (ok) { - doCall() + doCall(true) return } // 非关键请求(红点轮询、额度展示等)静默失败: // 只 reject,不动登录态、不跳转,避免把已登录用户悄悄踢成未登录 - if (!silentAuth) { - auth.clearTokens() - handleAuthExpired() - if (showError) showToast('登录已过期,请重新登录') - } - reject(new ApiError(code, body.msg || '登录已过期')) + reject(failAuth(code, body.msg, silentAuth, showError)) }) return } @@ -209,47 +233,100 @@ export function request(options: RequestOptions): Promise { }, }) } + + // token 已过期:先静默刷新再发请求,省掉一次必然失败的 401 往返 + // (刷新失败也不直接拒绝:仍发一次,由后端判定,避免网络抖动就把用户踢下线) + if (needAuth && auth.isTokenExpired()) { + doRefresh().then(() => { + doCall() + }) + return + } + doCall() }) } +export interface UploadOptions { + showError?: boolean // 默认 true,业务错误自动 toast + timeout?: number // 默认 30s + silentAuth?: boolean // 非关键上传:401 只失败,不触发全局跳登录 +} + // 文件上传(multipart,字段名 file) -export function uploadFile(filePath: string, name = 'file', url = '/clothes/upload'): Promise { - const token = auth.getAccessToken() - const header: Record = {} - if (token) header['Authorization'] = `Bearer ${token}` +export function uploadFile( + filePath: string, + name = 'file', + url = '/clothes/upload', + options: UploadOptions = {}, +): Promise { + const { showError = true, timeout = UPLOAD_TIMEOUT, silentAuth = false } = options return new Promise((resolve, reject) => { - wx.uploadFile({ - url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`, - filePath, - name, - header, - success: (res) => { - let body: { code: number; msg: string; data?: T; details?: string[] } | null = null - try { - body = JSON.parse(res.data) - } catch (e) { - body = null - } - if (body && typeof body.code === 'number') { + // 未登录:直接失败,不发请求 + if (!auth.isLoggedIn()) { + reject(new ApiError(NOT_LOGIN_CODE, '未登录')) + return + } + + const doUpload = (retried = false): void => { + const header: Record = {} + const token = auth.getAccessToken() + if (token) header['Authorization'] = `Bearer ${token}` + + wx.uploadFile({ + url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`, + filePath, + name, + header, + timeout, + success: (res) => { + let body: { code: number; msg: string; data?: T; details?: string[] } | null = null + try { + body = JSON.parse(res.data) + } catch (e) { + body = null + } + + if (!body || typeof body.code !== 'number') { + if (showError) showToast('上传失败,请稍后重试') + reject(new Error('upload fail')) + return + } + if (body.code === 0) { resolve(body.data as T) - } else { - const msg = - (body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败' - showToast(msg) - reject(new ApiError(body.code, body.msg || '上传失败', body.details)) + return } - } else { - showToast('上传失败,请稍后重试') + + // Token 失效:刷新后最多重试一次 + if (isAuthExpiredCode(body.code, res.statusCode)) { + if (retried) { + reject(failAuth(body.code, body.msg, silentAuth, showError)) + return + } + doRefresh().then((ok) => { + if (ok) { + doUpload(true) + return + } + reject(failAuth(body.code, body.msg, silentAuth, showError)) + }) + return + } + + const msg = + (body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败' + if (showError) showToast(msg) + reject(new ApiError(body.code, body.msg || '上传失败', body.details)) + }, + fail: () => { + if (showError) showToast('上传失败,请检查网络后重试') reject(new Error('upload fail')) - } - }, - fail: () => { - showToast('上传失败,请检查网络后重试') - reject(new Error('upload fail')) - }, - }) + }, + }) + } + + doUpload() }) } diff --git a/miniprogram/utils/tabbar.ts b/miniprogram/utils/tabbar.ts new file mode 100644 index 0000000..7db3b0b --- /dev/null +++ b/miniprogram/utils/tabbar.ts @@ -0,0 +1,18 @@ +// 自定义 tabBar 实例获取:只有 tab 页自身才有 getTabBar(), +// 通知页等非 tab 页调用会拿到 undefined,需从页面栈中查找仍在栈内的 tab 页。 + +export function getTabBarInstance(): any { + const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] + for (let i = pages.length - 1; i >= 0; i--) { + const page = pages[i] as any + const tabBar = page && page.getTabBar && page.getTabBar() + if (tabBar) return tabBar + } + return null +} + +// 同步未读红点:优先让 tabBar 重新拉取真实数量,取不到实例时静默跳过 +export function syncTabBarUnread(force = true): void { + const tabBar = getTabBarInstance() + if (tabBar && typeof tabBar.syncUnread === 'function') tabBar.syncUnread(force) +} diff --git a/typings/index.d.ts b/typings/index.d.ts index 10561cb..65ebca5 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,9 +1,7 @@ /// interface IAppOption { - globalData: { - userInfo?: Record, - } + globalData: Record userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback, }