Files
zhengchiliu fb92782c92 feat(穿搭详情): 支持社区公开穿搭只读访问单品
非属主查看社区公开穿搭时携带 outfit_uuid 凭证请求单品详情,
放行后端只读读取,避免名称退化为「未命名」;单品详情页同步
支持 outfit_uuid 只读凭证。点赞增加防连点,来源胶囊与状态
胶囊文案重复时只保留一个。
2026-09-16 02:28:58 +08:00

499 lines
17 KiB
TypeScript

import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { OutfitItem, ClothesItem } from '../../types/models'
import { IsPublic } from '../../utils/config'
import { OUTFIT_STATUS, OutfitStatusPill, isBanned, isPendingSave, outfitStatusPill } from '../../utils/outfit-status'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'outfit-detail'
// 接收者打开「分享者额度不足」链接后的自动返回倒计时(秒)
const SHARE_FAIL_BACK_SECONDS = 10
// 提示弹层按钮(type 缺省为非主按钮)
type OverlayButton = { text: string; value: string; type?: string }
const TIP_BUTTONS_ACK: OverlayButton[] = [{ text: '我知道了', value: 'ok', type: 'main' }]
const TIP_BUTTONS_TO_PENDING: OverlayButton[] = [
{ text: '知道了', value: '' },
{ text: '去 AI 穿搭', value: 'pending', type: 'main' },
]
Page({
data: {
uuid: '',
// 仅显式 owner=1 视为属主:无参数链接(如微信默认分享路径)安全降级为非属主视角
isOwner: false,
outfit: {} as OutfitItem,
clothes: [] as ClothesItem[],
isLiked: false,
likeCount: 0,
isCollected: false,
loading: true,
// 状态派生标记(WXML 不能调用方法,取数后一次算好):
// pendingSave=待保存的 AI 推荐(status=3,可直接保存);banned=已封禁(status=2,只读)
pendingSave: false,
banned: false,
statePill: { text: '仅自己可见', cls: '' } as OutfitStatusPill,
saving: false,
// 页面状态:normal 正常;quota 接收者命中「分享者额度不足」;invalid 加载失败
pageState: 'normal' as 'normal' | 'quota' | 'invalid',
invalidTitle: '',
invalidDesc: '',
needLogin: false,
countdown: SHARE_FAIL_BACK_SECONDS,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
tipVisible: false,
tipTitle: '',
tipText: '',
tipButtons: TIP_BUTTONS_ACK,
pubVisible: false,
pubButtons: [
{ text: '取消', value: '' },
{ text: '确认公开', value: 'ok', type: 'main' },
],
delVisible: false,
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
moreVisible: false,
moreItems: [
{ text: '编辑穿搭', value: 'edit' },
{ text: '删除穿搭', value: 'delete' },
],
// 额度不足引导:v2 玻璃弹窗
quotaVisible: false,
quotaTitle: '',
quotaText: '',
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const isOwner = query.owner === '1'
logger.debug(LOG, 'onLoad, uuid=', uuid, 'isOwner=', isOwner, 'shareFail=', query.share_fail)
this.setData({ uuid, isOwner })
// 分享者额度不足时分享出去的链接:不请求后端(也不依赖登录),直接进入提示态
if (query.share_fail === 'quota') {
this.showShareFailQuota()
return
}
if (!uuid) {
logger.warn(LOG, 'onLoad 缺少 uuid 参数')
this.setInvalid('链接不完整', '缺少穿搭标识,无法打开')
return
}
this.fetchDetail()
},
// 接收者命中「分享者额度不足」:弹提醒 + 倒计时后返回「我的」页
showShareFailQuota() {
this.setData({
pageState: 'quota',
loading: false,
countdown: SHARE_FAIL_BACK_SECONDS,
})
this.showTip('提示', '给您分享的好友额度不足哦')
this.startBackCountdown()
},
// 统一提示出口:标题 + 文案 +(可选)去 AI 穿搭的出口
showTip(title: string, text: string, toPending = false) {
this.setData({
tipVisible: true,
tipTitle: title,
tipText: text,
tipButtons: toPending ? TIP_BUTTONS_TO_PENDING : TIP_BUTTONS_ACK,
})
},
onTipConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ tipVisible: false })
if (e && e.detail && e.detail.value === 'pending') this.onAiPending()
},
onAiPending() {
wx.navigateTo({
url: '/pages/ai-pending/ai-pending',
fail: (err) => logger.warn(LOG, 'navigateTo AI 穿搭失败, err=', err && err.errMsg),
})
},
startBackCountdown() {
this.clearBackTimer()
;(this as any).backTimer = setInterval(() => {
const next = this.data.countdown - 1
if (next > 0) {
this.setData({ countdown: next })
return
}
this.clearBackTimer()
this.onBackProfile()
}, 1000)
},
clearBackTimer() {
const timer = (this as any).backTimer
if (timer) {
clearInterval(timer)
;(this as any).backTimer = null
}
},
onBackProfile() {
logger.info(LOG, '额度不足提示结束,返回「我的」页')
wx.switchTab({
url: '/pages/profile/profile',
fail: (e) => logger.warn(LOG, 'switchTab 我的失败, err=', e && e.errMsg),
})
},
onBackHome() {
wx.switchTab({
url: '/pages/wardrobe/wardrobe',
fail: (e) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e && e.errMsg),
})
},
onGoLogin() {
// 透传视角参数:属主登录失效后重登,仍回到属主视角
const owner = this.data.isOwner ? '1' : '0'
const redirect = `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=${owner}`
const loginUrl = `/pages/login/login?redirect=${encodeURIComponent(redirect)}`
wx.navigateTo({
url: loginUrl,
fail: () => {
wx.redirectTo({
url: loginUrl,
fail: () => wx.switchTab({ url: '/pages/profile/profile' }),
})
},
})
},
setInvalid(title: string, desc: string, needLogin = false) {
this.setData({
loading: false,
pageState: 'invalid',
invalidTitle: title,
invalidDesc: desc,
needLogin,
})
},
async fetchDetail() {
let outfit: OutfitItem
try {
outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}`, showError: false })
} catch (e: any) {
const code = e && e.code
logger.warn(LOG, '穿搭详情加载失败, code=', code)
if (AUTH_EXPIRED_CODES.indexOf(code) >= 0) {
// 未登录:给「去登录」出口,登录成功后回到本页
this.setInvalid('请先登录', '登录后即可查看该穿搭', true)
} else if (code === 40004 || code === 40000) {
// 他人未公开 / 已删除:不暴露具体原因,统一提示内容不可见
this.setInvalid('内容不可见', '该穿搭可能已被删除或未公开')
} else {
this.setInvalid('加载失败', '请稍后重试')
}
return
}
logger.debug(
LOG,
'穿搭详情加载成功, 单品数=',
outfit.items && outfit.items.length,
'status=',
outfit.status
)
// 状态口径统一由 utils/outfit-status.ts 决定:待保存可直接保存、封禁只读
this.setData({
outfit,
loading: false,
pendingSave: isPendingSave(outfit),
banned: isBanned(outfit),
statePill: outfitStatusPill(outfit),
})
// 封面与衣物缩略图先直接使用穿搭明细里的抠图(processed_image),
// 即使后续单品详情拉取失败,图片也不会丢。
const baseClothes = (outfit.items || []).map(
(it) => ({ clothes_uuid: it.clothes_uuid, processed_image: it.processed_image }) as ClothesItem,
)
this.setData({ clothes: baseClothes })
// 拉取单品详情补充名称与属性:属主直接读;非属主(社区公开穿搭 / 他人分享)携带本穿搭 uuid,
// 由后端只读放行,避免被拒(50005)导致名称全部退化成「未命名」。
const clothesQuery = this.data.isOwner ? '' : `?outfit_uuid=${this.data.uuid}`
const clothes = await Promise.all(
(outfit.items || []).map(async (it) => {
try {
return await request<ClothesItem>({
url: `/clothes/${it.clothes_uuid}${clothesQuery}`,
showError: false,
})
} catch (e: any) {
logger.warn(LOG, '单品详情拉取失败, uuid=', it.clothes_uuid, 'code=', e && e.code)
return { clothes_uuid: it.clothes_uuid, processed_image: it.processed_image } as ClothesItem
}
})
)
this.setData({ clothes })
// 点赞状态:失败静默保持默认,不影响详情展示
request<{ is_liked: boolean; like_count: number }>({
url: `/like/${this.data.uuid}`,
showError: false,
})
.then((like) => this.setData({ isLiked: like.is_liked, likeCount: like.like_count }))
.catch((e: any) => logger.warn(LOG, '点赞状态拉取失败(保持默认), code=', e && e.code))
// 收藏状态:调用后端 /collect/:targetUUID 初始化,失败静默保持未收藏
request<{ is_collected: boolean; collect_count: number }>({
url: `/collect/${this.data.uuid}`,
showError: false,
})
.then((collect) => this.setData({ isCollected: collect.is_collected }))
.catch((e: any) => logger.warn(LOG, '收藏状态拉取失败(保持未收藏), code=', e && e.code))
},
onLike() {
// 防连点:请求进行中忽略后续点击,避免误触/连点并发发出多条点赞、取消请求
if ((this as any).liking) {
logger.debug(LOG, '点赞请求进行中,忽略重复点击')
return
}
;(this as any).liking = true
const action = this.data.isLiked ? 2 : 1
request({ url: '/like', method: 'POST', data: { target_uuid: this.data.uuid, action } })
.then(() => {
logger.debug(LOG, '点赞操作成功, action=', action)
this.setData({
isLiked: action === 1,
likeCount: this.data.likeCount + (action === 1 ? 1 : -1),
})
})
.catch((e: any) => logger.warn(LOG, '点赞操作失败, action=', action, 'code=', e && e.code))
.finally(() => {
;(this as any).liking = false
})
},
onCollect() {
const action = this.data.isCollected ? 2 : 1
request({ url: '/collect', method: 'POST', data: { target_uuid: this.data.uuid, action } })
.then(() => this.setData({ isCollected: action === 1 }))
.catch((e: any) => logger.warn(LOG, '收藏操作失败, action=', action, 'code=', e && e.code))
},
// 待保存的 AI 推荐:详情页直接保存为正式穿搭(status 3 → 1),不必回 AI 穿搭页
onSaveAsOutfit() {
if (this.data.saving) return
this.setData({ saving: true })
logger.info(LOG, '保存待保存穿搭, uuid=', this.data.uuid)
request({
url: '/ai-recommend/save',
method: 'POST',
data: { outfit_uuid: this.data.uuid },
showError: false,
})
.then(() => {
const outfit = { ...this.data.outfit, status: OUTFIT_STATUS.NORMAL }
logger.info(LOG, '待保存穿搭保存成功, uuid=', this.data.uuid)
this.setData({ saving: false, outfit, pendingSave: false, statePill: outfitStatusPill(outfit) })
wx.showToast({ title: '已保存', icon: 'success' })
})
.catch((e: any) => {
this.setData({ saving: false })
const code = e && e.code
logger.warn(LOG, '待保存穿搭保存失败, code=', code)
if (code === 40000) {
// 超过 7 天被后端定时清理:给出明确出口,不让用户反复点必然失败的按钮
this.showTip('保存失败', '这套搭配已被清理,请重新生成。', true)
return
}
const detail = (e && e.details && e.details[0]) || (e && e.message) || ''
this.showTip('保存失败', detail || '请稍后重试')
})
},
onTogglePublic() {
const isPublic = this.data.outfit.is_public === IsPublic.PUBLIC
if (isPublic) {
this.doPublic(IsPublic.PRIVATE)
} else {
this.setData({ pubVisible: true })
}
},
onPubCancel() {
this.setData({ pubVisible: false })
},
onPubConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ pubVisible: false })
if (e.detail.value !== 'ok') return
this.doPublic(IsPublic.PUBLIC)
},
doPublic(isPublic: number) {
// 后端 OutfitPublicRequest.OutfitUUID 为必填(binding required),请求体需携带
request({
url: `/outfit/${this.data.uuid}/public`,
method: 'PUT',
data: { outfit_uuid: this.data.uuid, is_public: isPublic },
})
.then(() => {
logger.info(LOG, '公开状态变更成功, isPublic=', isPublic)
const outfit = { ...this.data.outfit, is_public: isPublic }
this.setData({ outfit, statePill: outfitStatusPill(outfit) })
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
})
.catch((e: any) => {
logger.warn(LOG, '公开状态变更失败, code=', e && e.code)
// 社区分享额度不足(后端已回滚为私有):弹窗引导购买
if (e && e.code === 80000) {
this.setData({
quotaVisible: true,
quotaTitle: '社区分享额度不足',
quotaText: '购买额度包后可公开到社区',
})
}
})
},
onMore() {
this.setData({ moreVisible: true })
},
onMoreCancel() {
this.setData({ moreVisible: false })
},
onMoreSelect(e: WechatMiniprogram.CustomEvent) {
this.setData({ moreVisible: false })
const value = e.detail.value
if (value === 'edit') this.onEdit()
else if (value === 'delete') this.onDelete()
},
onEdit() {
wx.navigateTo({
url: `/pages/outfit-edit/outfit-edit?uuid=${this.data.uuid}`,
fail: (e) => logger.warn(LOG, 'navigateTo 编辑穿搭失败, err=', e && e.errMsg),
})
},
onDelete() {
this.setData({ delVisible: true })
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE', showError: false })
.then(() => {
logger.info(LOG, '穿搭删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e2: any) => {
logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code)
const detail = (e2 && e2.details && e2.details[0]) || (e2 && e2.message) || ''
this.showTip('删除失败', e2 && e2.code === 40000 ? '这套搭配已不存在' : detail || '请稍后重试')
})
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
// 非属主(社区公开穿搭 / 他人分享)携带本穿搭 uuid 进入只读详情,
// 后端据此放行;不携带时后端会按无权限拒绝(50005)。
const query = this.data.isOwner ? '' : `&outfit_uuid=${this.data.uuid}`
logger.debug(LOG, '点击单品, uuid=', uuid, 'isOwner=', this.data.isOwner)
wx.navigateTo({
url: `/pages/clothes-detail/clothes-detail?uuid=${uuid}${query}`,
fail: (err) => logger.warn(LOG, 'navigateTo 单品详情失败, err=', err && err.errMsg),
})
},
// 分享采用微信官方「同步返回 + promise 字段」异步分享模式(基础库 2.12.0+):
// 同步返回兜底链接(3 秒内未 resolve 时生效),promise resolve 后以结果为准。
// 非属主不创建分享记录(对他人穿搭创建会被拒),直接转发普通详情链接。
onShareAppMessage(): any {
const name = this.data.outfit.outfit_name || '我的穿搭'
const fallback = `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`
if (!this.data.isOwner) {
return { title: name, path: fallback }
}
const promise = request<{ share_uuid: string }>({
url: '/outfit-share',
method: 'POST',
data: { outfit_uuid: this.data.uuid },
showError: false,
})
.then((res) => {
logger.info(LOG, '穿搭分享创建成功, share_uuid=', res.share_uuid)
return {
title: name,
path: `/pages/outfit-share-detail/outfit-share-detail?uuid=${res.share_uuid}`,
}
})
.catch((e: any) => {
logger.warn(LOG, '穿搭分享创建失败, code=', e && e.code)
if (e && e.code === 80000) {
// 额度不足:提示去购买,链接指向「额度不足」提示页(接收者侧提示后自动返回)
this.setData({
quotaVisible: true,
quotaTitle: '分享额度不足',
quotaText: '购买额度包后可继续分享',
})
return { title: name, path: `${fallback}&share_fail=quota` }
}
// 其他失败:回退普通详情链接(接收者侧展示通用「内容不可见」空态)
return { title: name, path: fallback }
})
return { title: name, path: fallback, promise }
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
onUnload() {
this.clearBackTimer()
},
})