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) { 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({ 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({ 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() }, })