This commit is contained in:
2026-09-13 01:51:09 +08:00
parent b1a664a768
commit afd0973ce5
32 changed files with 935 additions and 171 deletions
@@ -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<T> { list: T[]; total: number }
export function normalizePaged<T>(res: any): NormalizedPaged<T>
// 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<T = any>(filePath: string, name?: string, url?: string, options?: UploadOptions): Promise<T>
```
+1 -3
View File
@@ -3,9 +3,7 @@ import * as auth from './utils/auth'
import { setAuthExpiredHandler } from './utils/request'
App<IAppOption>({
globalData: {
userInfo: undefined,
},
globalData: {},
onLaunch() {
// 登录失效 / 账号禁用时统一清登录态。
@@ -55,7 +55,11 @@ Component({
* 组件的初始数据
*/
data: {
displayStyle: ''
displayStyle: '',
ios: false,
innerPaddingRight: '',
leftWidth: '',
safeAreaTop: ''
},
lifetimes: {
attached() {
+4 -1
View File
@@ -54,7 +54,10 @@ Component({
silentAuth: true,
})
.then((res) => this.setUnread(res.unread_count || 0))
.catch(() => {})
.catch(() => {
// 请求失败时回滚节流时间戳,否则要白等 30s 才能重试
lastSyncAt = 0
})
},
switchTab(e: WechatMiniprogram.TouchEvent) {
@@ -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<string, any> = { page: 1, page_size: 50 }
const params: Record<string, any> = { 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<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<ClothesItem>(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<string, any> = { 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() {
@@ -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<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) this.fetchDetail()
if (!uuid) {
// uuid 缺失时若不兜底,loading 会永久为 true,页面白屏且无法退出
this.setInvalid('链接不完整', '缺少分享标识,无法打开')
return
}
this.fetchDetail()
},
fetchDetail() {
request<ClosetShareDetail>({ 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
}
},
})
@@ -2,10 +2,19 @@
<navigation-bar title="分享详情" />
<empty-state
wx:if="{{invalid}}"
wx:if="{{invalid && needLogin}}"
icon="icon-user"
title="{{invalidTitle}}"
desc="{{invalidDesc}}"
buttonText="去登录"
bind:action="onGoLogin"
/>
<empty-state
wx:elif="{{invalid}}"
icon="icon-share"
title="该分享已过期或已被取消"
desc="分享链接已失效"
title="{{invalidTitle}}"
desc="{{invalidDesc}}"
buttonText="返回首页"
bind:action="onBackHome"
/>
@@ -35,6 +44,9 @@
</view>
</view>
<!-- 转发出口 -->
<button class="share-btn" open-type="share">分享给好友</button>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
@@ -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;
+31 -11
View File
@@ -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<PagedResult<ClosetShareItem>>({ 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<ClosetShareItem>(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,
}
},
@@ -52,7 +52,7 @@
<view class="tag tag-gray share-type">{{item._typeText}}</view>
<view class="share-expire">{{item._expireText}}</view>
</view>
<view wx:if="{{item._invalid}}" class="share-badge">{{item._status === 2 ? '已取消' : '已过期'}}</view>
<view wx:if="{{item._invalid}}" class="share-badge">{{item._status === 2 ? '已取消' : (item._status === 4 ? '已封禁' : '已过期')}}</view>
</view>
</view>
</scroll-view>
@@ -39,15 +39,23 @@ Page({
failReason: '',
loading: true,
editMode: false,
notFound: false,
},
onLoad(query: Record<string, string>) {
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<void> {
@@ -9,8 +9,18 @@
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 缺少衣物标识 -->
<empty-state
wx:if="{{notFound}}"
icon="icon-hanger"
title="衣物不存在"
desc="缺少衣物标识,无法查看详情"
buttonText="返回衣橱"
bind:action="onBackWardrobe"
/>
<!-- 处理中:显示步骤进度 -->
<block wx:if="{{imageStatus === 1 || imageStatus === 2 || imageStatus === 3}}">
<block wx:elif="{{imageStatus === 1 || imageStatus === 2 || imageStatus === 3}}">
<view class="card processing-card">
<view class="proc-icon"><view class="icon icon-hanger"></view></view>
<view class="steps">
@@ -118,7 +128,7 @@
<!-- 底部操作:处理中隐藏,其他显示 -->
<view
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
wx:if="{{!notFound && imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
class="footer"
>
<block wx:if="{{editMode}}">
+40 -4
View File
@@ -15,6 +15,13 @@ const COLOR_MAP: Record<string, string> = {
灰色: '#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<HoroscopeData>({ 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()
},
})
+11 -1
View File
@@ -21,8 +21,18 @@
bind:action="onGoProfile"
/>
<!-- 加载失败 -->
<empty-state
wx:elif="{{error}}"
icon="icon-sparkles"
title="运势加载失败"
desc="{{errorText}}"
buttonText="重试"
bind:action="onRetry"
/>
<!-- 运势内容 -->
<view wx:else class="content">
<view wx:elif="{{horoscope}}" class="content">
<!-- 星座大卡 -->
<view class="card horo-card">
<view class="horo-top">
+20 -5
View File
@@ -13,17 +13,32 @@ interface GiftItem {
Page({
data: {
loading: false,
redirect: '', // 登录后要回跳的页面(如从好友分享链接进入)
userInfo: {} as UserInfo,
giftVisible: false,
giftItems: [] as GiftItem[],
},
onLoad() {
onLoad(query: Record<string, string>) {
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()
},
})
},
+31 -12
View File
@@ -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<number, string> = {
[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<PagedResult<GoodsItem>>({ url: '/goods', data: params, auth: false })
.then((res) => {
const goods = res.list.map((g) => ({
const goods = normalizePaged<GoodsItem>(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<PagedResult<QuotaFlowItem>>({ url: '/quota/flow', data: { page: 1, page_size: 50 } })
.then((res) => {
const list = res.list.map((it) => ({
const list = normalizePaged<QuotaFlowItem>(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<VirtualPayResult>({
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<PagedResult<OrderItem>>({ 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<OrderItem>(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<typeof setInterval>) {
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()
},
})
+1 -1
View File
@@ -89,7 +89,7 @@
<view class="order-goods">{{orderGoods.goods_name}}</view>
<view class="order-desc">{{orderGoods.description}}</view>
<view class="order-amount">实付 <text class="order-price num">{{orderGoods._priceText}}</text></view>
<view class="btn btn-primary" bindtap="confirmPay">立即支付</view>
<view class="btn btn-primary {{submitting ? 'disabled' : ''}}" bindtap="confirmPay">{{submitting ? '下单中…' : '立即支付'}}</view>
</view>
</view>
</view>
@@ -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(() => {})
}
},
+21 -6
View File
@@ -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<PagedResult<NotificationItem>>({ 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<NotificationItem>(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) {
+57 -5
View File
@@ -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<string, any> = { 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<PagedResult<OrderItem>>({ url: '/order', data: params })
.then((res) => {
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<OrderItem>(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) {
@@ -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: {
+70 -20
View File
@@ -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<string, string>) {
@@ -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<string, ClothesItem> {
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<string, any> = { page: 1, page_size: 50 }
const params: Record<string, any> = { 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<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<ClothesItem>(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() {
+22 -8
View File
@@ -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<void> {
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<PagedResult<OutfitItem>>({ 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<OutfitItem>(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<OutfitItem[]> {
+40 -9
View File
@@ -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<string, any> = {}
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() {
+10 -2
View File
@@ -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' })
}
})
},
+17 -6
View File
@@ -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<string, any> = { 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<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<ClothesItem>(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 })
})
},
+16
View File
@@ -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)
}
+1
View File
@@ -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',
}
// 衣物图片处理状态
+24
View File
@@ -0,0 +1,24 @@
// 分页响应统一兜底:后端 code === 0 时仍可能返回 null / 缺字段,
// 直接 res.list.map 会抛错并被页面空的 catch 吞掉,表现为「静默空态」。
export interface NormalizedPaged<T> {
list: T[]
total: number
}
export function normalizePaged<T>(res: any): NormalizedPaged<T> {
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<T>(oldList: T[], list: T[], refresh: boolean): T[] {
return refresh ? list : oldList.concat(list)
}
// 是否还有下一页:total 缺失时退化为「本页满额则假设还有」
export function hasMorePaged<T>(list: T[], total: number, pageSize: number): boolean {
if (total > 0) return list.length < total
return list.length > 0 && list.length % pageSize === 0
}
+122 -45
View File
@@ -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<boolean> {
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<T = any>(options: RequestOptions): Promise<T> {
return
}
const doCall = (): void => {
const doCall = (retried = false): void => {
const finalHeader: Record<string, any> = {
'Content-Type': 'application/json',
...header,
@@ -146,6 +173,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
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<T = any>(options: RequestOptions): Promise<T> {
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<T = any>(options: RequestOptions): Promise<T> {
},
})
}
// 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<T = any>(filePath: string, name = 'file', url = '/clothes/upload'): Promise<T> {
const token = auth.getAccessToken()
const header: Record<string, any> = {}
if (token) header['Authorization'] = `Bearer ${token}`
export function uploadFile<T = any>(
filePath: string,
name = 'file',
url = '/clothes/upload',
options: UploadOptions = {},
): Promise<T> {
const { showError = true, timeout = UPLOAD_TIMEOUT, silentAuth = false } = options
return new Promise<T>((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<string, any> = {}
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()
})
}
+18
View File
@@ -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)
}
+1 -3
View File
@@ -1,9 +1,7 @@
/// <reference path="./types/index.d.ts" />
interface IAppOption {
globalData: {
userInfo?: Record<string, any>,
}
globalData: Record<string, any>
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
}