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
+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)
}