bigfix
This commit is contained in:
+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,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;
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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>) {
|
||||
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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+122
-45
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user