import { request, uploadFile, NOT_LOGIN_CODE } from '../../utils/request' import { ProcessStatus } from '../../utils/config' import { addPendingClothes, removePendingClothes } from '../../utils/pending-clothes' import { navPageScroll } from '../../utils/nav' import { logger } from '../../utils/logger' // 日志模块名 const LOG = 'upload' // 状态轮询:2s 一次、最多 90 次 ≈ 3 分钟,与后端单任务 3 分钟上限对齐。 // 预算耗尽只停轮询并转中性提示,绝不判定失败:任务可能仍在排队/执行中, // 由后端恢复调度器兜底,最终结果由衣橱页核对补齐。 const POLL_INTERVAL_MS = 2000 const POLL_MAX_COUNT = 90 Page({ data: { state: 'idle' as 'idle' | 'processing' | 'failed', imagePath: '', clothesUuid: '', failReason: '', step: 0, // 轮询预算耗尽:仍在处理中,只做中性提示,不算失败 slowHint: false, // 失败原因是否伴随额度退回(仅后端确认失败才为真) quotaRefunded: false, // 滚动收缩:由 utils/nav.ts 的共享 handler 驱动 navScrolled: false, // v2 玻璃弹层 srcVisible: false, srcItems: [ { text: '拍照', value: 'camera' }, { text: '从相册选择', value: 'album' }, ], doneVisible: false, doneButtons: [ { text: '回到衣橱', value: 'back' }, { text: '继续上传', value: 'again', type: 'main' }, ], quotaVisible: false, quotaButtons: [ { text: '取消', value: '' }, { text: '去购买', value: 'ok', type: 'main' }, ], }, // 页面级滚动 → 导航收缩为玻璃小标题 onPageScroll: navPageScroll, onChoose() { this.setData({ srcVisible: true }) }, onSourceCancel() { this.setData({ srcVisible: false }) }, onSourceSelect(e: WechatMiniprogram.CustomEvent) { const value = e.detail.value as string this.setData({ srcVisible: false }) if (value !== 'camera' && value !== 'album') return this.chooseImage([value]) }, chooseImage(sourceType: Array<'camera' | 'album'>) { wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType, success: (res) => { const file = res.tempFiles[0] logger.debug(LOG, '已选择图片, 来源=', sourceType.join(',')) this.startUpload(file.tempFilePath) }, fail: (e) => logger.warn(LOG, '选择图片失败, err=', e && e.errMsg), }) }, startUpload(filePath: string) { this.setData({ state: 'processing', imagePath: filePath, step: 0, failReason: '', slowHint: false, quotaRefunded: false, }) uploadFile<{ clothes_uuid: string; image_process_status: number }>(filePath) .then((res) => { logger.info(LOG, '衣物上传成功, uuid=', res.clothes_uuid) // 登记待核对:即使用户立刻退出小程序,回到衣橱时也会补齐最终处理结果 addPendingClothes(res.clothes_uuid) this.setData({ clothesUuid: res.clothes_uuid }) this.askNextAction() }) .catch((e: any) => { this.setData({ state: 'idle' }) logger.warn(LOG, '衣物上传失败, code=', e && e.code) if (e && e.code === 80000) { this.showQuotaModal() return } // uploadFile 已统一 toast 业务错误与网络错误;未登录时请求并未发出,需补一次提示 if (e && e.code === NOT_LOGIN_CODE) { wx.showToast({ title: '请先登录', icon: 'none' }) } }) }, askNextAction() { // 后台继续轮询,仅用于更新步骤进度(不再自动跳详情) this.pollStatus(this.data.clothesUuid) // 立即弹窗让用户选择下一步,避免干等 this.setData({ doneVisible: true }) }, onDoneCancel() { // 回到衣橱 this.setData({ doneVisible: false }) this.clearTimer() wx.navigateBack({ delta: 1, fail: (e) => { logger.warn(LOG, 'navigateBack 失败,改跳衣橱, err=', e && e.errMsg) wx.switchTab({ url: '/pages/wardrobe/wardrobe', fail: (e2) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e2 && e2.errMsg), }) }, }) }, onDoneConfirm(e: WechatMiniprogram.CustomEvent) { this.setData({ doneVisible: false }) this.clearTimer() if (e.detail.value === 'again') { logger.debug(LOG, '选择继续上传,重置状态') this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '', slowHint: false, quotaRefunded: false, }) return } this.onDoneCancel() }, pollStatus(uuid: string) { // 先清旧定时器:onShow 续接时避免叠加成多份轮询 this.clearTimer() let count = 0 ;(this as any).pollTimer = setInterval(() => { count++ request<{ image_process_status: number; fail_reason: string }>({ url: `/clothes/${uuid}/status`, showError: false, silentAuth: true, }) .then((res) => { const status = res.image_process_status if (status === ProcessStatus.MATTING) { this.setData({ step: 1 }) } else if (status === ProcessStatus.CLASSIFYING) { this.setData({ step: 2 }) } else if (status === ProcessStatus.SUCCESS) { // 处理成功不再自动跳转,由用户自行从衣橱进入详情 this.clearTimer() removePendingClothes(uuid) } else if (status === ProcessStatus.FAILED) { // 仅后端确认失败才进入失败态:该路径额度已由后端自动退回 this.clearTimer() removePendingClothes(uuid) logger.warn(LOG, 'AI 识别失败, 原因=', res.fail_reason) this.setData({ state: 'failed', failReason: res.fail_reason || '识别失败', quotaRefunded: true, }) } }) .catch((e: any) => logger.warn(LOG, '状态轮询失败, code=', e && e.code)) if (count >= POLL_MAX_COUNT) { // 只停轮询,不判失败:任务可能仍在队列/执行中,由后端兜底、衣橱页核对 this.clearTimer() logger.warn(LOG, '状态轮询预算耗尽,转中性提示, uuid=', uuid) this.setData({ slowHint: true }) } }, POLL_INTERVAL_MS) }, clearTimer() { const t = (this as any).pollTimer if (t) { clearInterval(t) ;(this as any).pollTimer = null } }, // 离开页面只停本地轮询:后端处理不中断,最终结果由衣橱页核对补齐 onHide() { this.clearTimer() }, // 回到前台:仍在处理中则续接轮询,恢复进度展示 onShow() { if (this.data.state === 'processing' && this.data.clothesUuid) { logger.debug(LOG, 'onShow 续接状态轮询, uuid=', this.data.clothesUuid) this.pollStatus(this.data.clothesUuid) } }, onUnload() { this.clearTimer() }, showQuotaModal() { this.setData({ quotaVisible: true }) }, 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), }) }, onRetry() { this.setData({ state: 'idle', imagePath: '', clothesUuid: '', failReason: '', step: 0, slowHint: false, quotaRefunded: false, }) }, onManualEdit() { if (!this.data.clothesUuid) return wx.redirectTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${this.data.clothesUuid}`, fail: (e) => logger.warn(LOG, 'redirectTo 衣物详情失败, err=', e && e.errMsg), }) }, })