Files
zt-closet-wxmini-frontend/miniprogram/pages/outfit/outfit.ts
T
zhengchiliu f0503c7ad7 feat(guest): 支持游客态浏览示例衣物与搭配
游客可点开示例衣物详情与搭配抽屉,不再在浏览路径上要求登录。
2026-09-17 11:41:42 +08:00

302 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
})
},
})