bigfix
This commit is contained in:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,27 +10,83 @@ 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,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
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}}">
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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>) {
|
||||
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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
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() {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
// 衣物图片处理状态
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+104
-27
@@ -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,22 +233,53 @@ 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) => {
|
||||
// 未登录:直接失败,不发请求
|
||||
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 {
|
||||
@@ -232,24 +287,46 @@ export function uploadFile<T = any>(filePath: string, name = 'file', url = '/clo
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
if (body && typeof body.code === 'number') {
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 || '上传失败'
|
||||
showToast(msg)
|
||||
if (showError) showToast(msg)
|
||||
reject(new ApiError(body.code, body.msg || '上传失败', body.details))
|
||||
}
|
||||
} else {
|
||||
showToast('上传失败,请稍后重试')
|
||||
reject(new Error('upload fail'))
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showToast('上传失败,请检查网络后重试')
|
||||
if (showError) showToast('上传失败,请检查网络后重试')
|
||||
reject(new Error('upload fail'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
doUpload()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+1
-3
@@ -1,9 +1,7 @@
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
interface IAppOption {
|
||||
globalData: {
|
||||
userInfo?: Record<string, any>,
|
||||
}
|
||||
globalData: Record<string, any>
|
||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user