302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { request } from '../../utils/request'
|
||
import * as auth from '../../utils/auth'
|
||
import { PagedResult, OutfitItem, CommunityOutfitItem } from '../../types/models'
|
||
import { OutfitSource, STORAGE_KEYS } from '../../utils/config'
|
||
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
|
||
import { CoverCell, outfitCover } from '../../utils/outfit-cover'
|
||
import { OutfitStatusPill, isPendingSave, outfitStatusPill } from '../../utils/outfit-status'
|
||
import { setTabBarSelected } from '../../utils/tabbar'
|
||
import { formatRelativeTime } from '../../utils/format'
|
||
import { navScroll } from '../../utils/nav'
|
||
import { GUEST_OUTFIT, goLogin } from '../../utils/guest'
|
||
import { logger } from '../../utils/logger'
|
||
|
||
// 日志模块名
|
||
const LOG = 'outfit'
|
||
|
||
// AI 穿搭入口角标:只统计首页 source=2 里的待保存数量。
|
||
// 后端 GET /outfit 只支持 source / is_public 过滤,没有 status 过滤,故不做全量翻页统计。
|
||
const PENDING_COUNT_PAGE_SIZE = 20
|
||
|
||
// 列表行:我的穿搭/已收藏来自 OutfitItem;社区来自 CommunityOutfitItem(额外带作者与互动计数)。
|
||
// 统一为交叉类型,模板按 activeTab 条件渲染差异字段。
|
||
type OutfitRow = OutfitItem & Partial<CommunityOutfitItem> & {
|
||
_likeCount?: number
|
||
_collectCount?: number
|
||
// 状态胶囊(口径收敛在 utils/outfit-status.ts)
|
||
_status?: OutfitStatusPill
|
||
_time?: string
|
||
_covers?: CoverCell[]
|
||
_collageCls?: string
|
||
_moreCount?: number
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
activeTab: 0,
|
||
list: [] as OutfitRow[],
|
||
loading: true,
|
||
refreshing: false,
|
||
loadingMore: false,
|
||
page: 1,
|
||
pageSize: 20,
|
||
hasMore: true,
|
||
// AI 穿搭待保存数量(status=3):取不到时保持 0,入口只显示文案不显示数字
|
||
pendingCount: 0,
|
||
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
|
||
navScrolled: false,
|
||
// 游客态(未登录):只展示本地示例穿搭,不发需要授权的请求
|
||
guest: false,
|
||
guestOutfit: GUEST_OUTFIT,
|
||
// 游客态示例搭配抽屉:可完整浏览示例单品,浏览路径上不要求登录
|
||
guestSheetVisible: false,
|
||
},
|
||
|
||
// scroll-view 滚动 → 导航收缩为玻璃小标题
|
||
onNavScroll: navScroll,
|
||
|
||
onLoad() {
|
||
// 首帧渲染前判定游客态,避免首帧 setData 冲突
|
||
if (!auth.isLoggedIn()) {
|
||
this.setData({ guest: true, loading: false })
|
||
logger.info(LOG, '未登录进入穿搭页,渲染游客态示例内容')
|
||
}
|
||
},
|
||
|
||
onShow() {
|
||
this.syncTabBar()
|
||
// 未登录:游客态浏览,零请求,不再强制跳登录(微信审核合规要求)
|
||
if (!auth.isLoggedIn()) {
|
||
if (!this.data.guest) this.setData({ guest: true, list: [], loading: false })
|
||
return
|
||
}
|
||
// 登录成功回到本页:清空示例数据,切回真实列表
|
||
if (this.data.guest) {
|
||
logger.info(LOG, '检测到登录态,从游客态切换为真实穿搭列表')
|
||
;(this as any).firstShown = true
|
||
this.setData({ guest: false, guestSheetVisible: false, list: [], loading: true })
|
||
this.refresh()
|
||
this.fetchPendingCount()
|
||
return
|
||
}
|
||
const openCollect = wx.getStorageSync(STORAGE_KEYS.OPEN_COLLECT)
|
||
if (openCollect) {
|
||
logger.debug(LOG, '命中打开收藏夹标记,切换到收藏 tab')
|
||
wx.removeStorageSync(STORAGE_KEYS.OPEN_COLLECT)
|
||
if (this.data.activeTab !== 1) {
|
||
this.setData({ activeTab: 1, list: [], loading: true })
|
||
}
|
||
}
|
||
// 从 AI 穿搭页保存/删除后回到本页,列表与角标都要重取
|
||
// 首帧渲染未结束就 setData 会触发「Expected updated data but get first rendering data」,
|
||
// 整个 webview 渲染失败,用户视觉上仍看到上一个页面。首次 onShow 的刷新延迟到下一帧
|
||
if (!(this as any).firstShown) {
|
||
;(this as any).firstShown = true
|
||
wx.nextTick(() => {
|
||
this.refresh()
|
||
this.fetchPendingCount()
|
||
})
|
||
return
|
||
}
|
||
this.refresh()
|
||
this.fetchPendingCount()
|
||
},
|
||
|
||
syncTabBar() {
|
||
setTabBarSelected(1)
|
||
},
|
||
|
||
onTabChange(e: WechatMiniprogram.TouchEvent) {
|
||
const index = Number(e.currentTarget.dataset.index)
|
||
if (index === this.data.activeTab) return
|
||
// 游客态:分段仍可切换,示例内容用于体验浏览,不发请求
|
||
if (this.data.guest) {
|
||
this.setData({ activeTab: index })
|
||
return
|
||
}
|
||
this.setData({ activeTab: index, list: [], loading: true })
|
||
this.refresh()
|
||
},
|
||
|
||
// 游客态点击受限入口:进入登录页,由用户自行选择是否登录
|
||
onGuestLogin() {
|
||
logger.debug(LOG, '游客态点击受限入口,引导登录')
|
||
goLogin()
|
||
},
|
||
|
||
// 抽屉内容点击不穿透到遮罩
|
||
noop() {},
|
||
|
||
// 游客态点击示例搭配:打开只读示例抽屉(浏览路径上不要求登录)
|
||
onGuestOutfitTap() {
|
||
logger.debug(LOG, '游客态打开示例搭配抽屉')
|
||
this.setData({ guestSheetVisible: true })
|
||
},
|
||
|
||
onGuestSheetClose() {
|
||
this.setData({ guestSheetVisible: false })
|
||
},
|
||
|
||
// 抽屉底部入口:用户主动选择登录时才进入登录页
|
||
onGuestSheetLogin() {
|
||
logger.info(LOG, '示例搭配抽屉内主动登录')
|
||
this.setData({ guestSheetVisible: false })
|
||
goLogin()
|
||
},
|
||
|
||
refresh() {
|
||
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
|
||
this.fetchList(1)
|
||
},
|
||
|
||
onRefresh() {
|
||
// 游客态没有服务端数据可刷新,直接收起下拉动画
|
||
if (this.data.guest) {
|
||
this.setData({ refreshing: false })
|
||
return
|
||
}
|
||
this.setData({ refreshing: true })
|
||
Promise.all([this.fetchList(1), this.fetchPendingCount()]).finally(() =>
|
||
this.setData({ refreshing: false })
|
||
)
|
||
},
|
||
|
||
onReachBottom() {
|
||
if (this.data.guest) return
|
||
if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return
|
||
this.fetchList(this.data.page + 1)
|
||
},
|
||
|
||
// 我的穿搭只取用户手工创建的穿搭(source=1):AI 生成的穿搭一律走「AI 穿搭」页管理,
|
||
// 避免待保存(status=3)的 AI 结果混进我的搭配
|
||
fetchList(page: number): Promise<void> {
|
||
const tab = this.data.activeTab
|
||
const url = tab === 1 ? '/collect' : tab === 2 ? '/outfit/community' : '/outfit'
|
||
const data: Record<string, any> = { page, page_size: this.data.pageSize }
|
||
if (tab === 0) data.source = OutfitSource.USER
|
||
|
||
const seq = ((this as any).reqSeq || 0) + 1
|
||
;(this as any).reqSeq = seq
|
||
if (page > 1) this.setData({ loadingMore: true })
|
||
|
||
return request<PagedResult<OutfitRow>>({ url, data })
|
||
.then(async (res) => {
|
||
const { list: raw, total } = normalizePaged<OutfitRow>(res)
|
||
logger.debug(LOG, '列表加载成功, url=', url, 'page=', page, '条数=', raw && raw.length)
|
||
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: hasMorePaged(merged, total, this.data.pageSize),
|
||
loading: false,
|
||
loadingMore: false,
|
||
page,
|
||
})
|
||
})
|
||
.catch((e: any) => {
|
||
if ((this as any).reqSeq !== seq) return
|
||
logger.warn(LOG, '列表加载失败, url=', url, 'page=', page, 'code=', e && e.code)
|
||
this.setData({ loading: false, loadingMore: false })
|
||
})
|
||
},
|
||
|
||
// 入口角标:静默取一页 AI 穿搭统计待保存数量;失败降级为不显示数字,不打扰用户
|
||
fetchPendingCount(): Promise<void> {
|
||
// 入口只在「我的穿搭」分段展示,其它分段不必发这次请求
|
||
if (this.data.activeTab !== 0) return Promise.resolve()
|
||
return request<PagedResult<OutfitRow>>({
|
||
url: '/outfit',
|
||
data: { page: 1, page_size: PENDING_COUNT_PAGE_SIZE, source: OutfitSource.AI },
|
||
showError: false,
|
||
})
|
||
.then((res) => {
|
||
const { list } = normalizePaged<OutfitRow>(res)
|
||
const count = list.filter((it) => isPendingSave(it)).length
|
||
logger.debug(LOG, 'AI 穿搭待保存数量统计, count=', count)
|
||
this.setData({ pendingCount: count })
|
||
})
|
||
.catch((e: any) => logger.warn(LOG, 'AI 穿搭待保存数量统计失败(静默降级), code=', e && e.code))
|
||
},
|
||
|
||
decorateList(list: OutfitRow[]): Promise<OutfitRow[]> {
|
||
// 社区分段:作者与互动计数由后端一次性下发,本地仅补相对时间与拼图封面,
|
||
// 避免逐条请求点赞接口;封面必须与其它分段一致地组装,否则 collage 会渲染成空白方块
|
||
if (this.data.activeTab === 2) {
|
||
return Promise.resolve(
|
||
list.map((it) => ({
|
||
...it,
|
||
...outfitCover(it.items),
|
||
_likeCount: it.like_count || 0,
|
||
_collectCount: it.collect_count || 0,
|
||
_time: formatRelativeTime(it.created_at),
|
||
})),
|
||
)
|
||
}
|
||
return Promise.all(
|
||
list.map(async (it) => {
|
||
let likeCount = 0
|
||
try {
|
||
const like = await request<{ is_liked: boolean; like_count: number }>({
|
||
url: `/like/${it.outfit_uuid}`,
|
||
showError: false,
|
||
})
|
||
likeCount = like.like_count
|
||
} catch (e: any) {
|
||
logger.warn(LOG, '点赞数拉取失败, uuid=', it.outfit_uuid, 'code=', e && e.code)
|
||
}
|
||
return { ...it, ...outfitCover(it.items), _likeCount: likeCount, _status: outfitStatusPill(it) }
|
||
})
|
||
)
|
||
},
|
||
|
||
onCreate() {
|
||
if (this.data.guest) {
|
||
this.onGuestLogin()
|
||
return
|
||
}
|
||
wx.navigateTo({
|
||
url: '/pages/outfit-edit/outfit-edit',
|
||
fail: (e) => logger.warn(LOG, 'navigateTo 新建穿搭失败, err=', e && e.errMsg),
|
||
})
|
||
},
|
||
|
||
onAiRecommend() {
|
||
if (this.data.guest) {
|
||
this.onGuestLogin()
|
||
return
|
||
}
|
||
wx.navigateTo({
|
||
url: '/pages/ai-recommend/ai-recommend',
|
||
fail: (e) => logger.warn(LOG, 'navigateTo AI 推荐失败, err=', e && e.errMsg),
|
||
})
|
||
},
|
||
|
||
// 进入 AI 穿搭页:AI 生成结果的统一管理入口(待保存 / 已保存)
|
||
onAiPending() {
|
||
if (this.data.guest) {
|
||
this.onGuestLogin()
|
||
return
|
||
}
|
||
wx.navigateTo({
|
||
url: '/pages/ai-pending/ai-pending',
|
||
fail: (e) => logger.warn(LOG, 'navigateTo AI 穿搭失败, err=', e && e.errMsg),
|
||
})
|
||
},
|
||
|
||
onOutfitTap(e: WechatMiniprogram.TouchEvent) {
|
||
if (this.data.guest) {
|
||
this.onGuestLogin()
|
||
return
|
||
}
|
||
const uuid = e.currentTarget.dataset.uuid as string
|
||
const owner = this.data.activeTab === 0 ? '1' : '0'
|
||
logger.debug(LOG, '点击穿搭, uuid=', uuid, 'owner=', owner)
|
||
wx.navigateTo({
|
||
url: `/pages/outfit-detail/outfit-detail?uuid=${uuid}&owner=${owner}`,
|
||
fail: (err) => logger.warn(LOG, 'navigateTo 穿搭详情失败, err=', err && err.errMsg),
|
||
})
|
||
},
|
||
})
|