import { request } from '../../utils/request' import { AiRecommendResult, AiRecommendSubmitResult, AiRecommendTaskStatus, ClothesItem, } from '../../types/models' import { STORAGE_KEYS } from '../../utils/config' import { navScroll } from '../../utils/nav' import { logger } from '../../utils/logger' // 日志模块名 const LOG = 'ai-recommend' // 异步任务的轮询节奏:后端建议 2 秒一次、上限 90 次(≈3 分钟,与后端任务超时一致) const POLL_INTERVAL = 2000 const POLL_MAX = 90 const TASK_DONE = 2 const TASK_FAIL = 3 // 玻璃弹层按钮(type 缺省为非主按钮) type OverlayButton = { text: string; value: string; type?: string } // 失败提示按钮:任务快照过期时结果可能已生成落库,多给一个去「AI 穿搭」找回的出口 const ERR_BUTTONS_ACK: OverlayButton[] = [{ text: '知道了', value: 'ok', type: 'main' }] const ERR_BUTTONS_WITH_PENDING: OverlayButton[] = [ { text: '知道了', value: '' }, { text: '去 AI 穿搭', value: 'pending', type: 'main' }, ] Page({ data: { state: 'scene' as 'scene' | 'generating' | 'result' | 'empty', showSceneSheet: true, sceneType: 1, scene: '', result: null as AiRecommendResult | null, clothes: [] as ClothesItem[], generating: false, // 生成中的已等待秒数:慢生成时有心跳反馈,避免误以为卡死 genElapsed: 0, // 滚动收缩:由 utils/nav.ts 的共享 handler 驱动 navScrolled: false, // v2 玻璃弹层 confirmVisible: false, confirmButtons: [ { text: '取消', value: '' }, { text: '开始生成', value: 'ok', type: 'main' }, ], limitVisible: false, limitButtons: [ { text: '取消', value: '' }, { text: '去购买', value: 'ok', type: 'main' }, ], // 错误提示(提交失败 / 生成失败 / 保存失败 / 超时,必须有可见反馈) errVisible: false, errTitle: '生成失败', errText: '', errButtons: ERR_BUTTONS_ACK, }, // scroll-view 滚动 → 导航收缩为玻璃小标题 onNavScroll: navScroll, noop() {}, // 进入页面:若上次离开时还有未完成的生成任务,续接轮询(后端异步不中断) onShow() { const taskId = wx.getStorageSync(STORAGE_KEYS.AI_TASK) if (!taskId || this.data.state === 'result') return logger.info(LOG, '发现未完成的 AI 推荐任务,续接轮询, taskId=', taskId) this.setData({ state: 'generating', generating: true, showSceneSheet: false }) this.startPoll(taskId) }, // 离开页面只停本地轮询:后端任务继续跑,回来续接,中间可去「AI 穿搭」找结果 onHide() { this.stopPoll() }, onUnload() { this.stopPoll() this.stopElapsed() }, selectDaily() { this.setData({ sceneType: 1 }) }, selectCustom() { this.setData({ sceneType: 2 }) }, onSceneInput(e: WechatMiniprogram.Input) { this.setData({ scene: e.detail.value }) }, // 取消场景选择:退出 AI 推荐页返回来源页(不再用「每日推荐」自动生成) onCancelScene() { logger.info(LOG, '取消场景选择,返回来源页') this.backToSource() }, // 取消的统一出口:优先返回来源页(从运势进回运势、从穿搭进回穿搭); // 页面栈不足(分享卡片/深链直达)时兜底 switchTab 回运势页 backToSource() { const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] if (pages.length > 1) { wx.navigateBack({ delta: 1, fail: (e) => { logger.warn(LOG, 'navigateBack 失败,兜底回运势页, err=', e && e.errMsg) this.switchToHoroscope() }, }) return } logger.warn(LOG, '页面栈不足,兜底回运势页, 栈深=', pages.length) this.switchToHoroscope() }, switchToHoroscope() { wx.switchTab({ url: '/pages/horoscope/horoscope', fail: (e) => logger.warn(LOG, 'switchTab 运势失败, err=', e && e.errMsg), }) }, onStartGenerate() { this.setData({ showSceneSheet: false }) this.startGenerate(this.data.sceneType, this.data.scene) }, startGenerate(sceneType: number, scene: string) { if (this.data.generating) return // 待生成的场景参数暂存到实例,确认后再发起 ;(this as any).pendingScene = { sceneType, scene } this.setData({ confirmVisible: true }) }, // 取消生成:退出 AI 推荐页返回来源页(与抽屉「取消」语义一致,避免停在空页) onConfirmCancel() { logger.info(LOG, '确认弹窗取消,返回来源页') this.setData({ confirmVisible: false }) this.backToSource() }, onConfirmGenerate(e: WechatMiniprogram.CustomEvent) { this.setData({ confirmVisible: false }) if (e.detail.value !== 'ok') { // 兜底:非「开始生成」按钮同样按取消处理(取消按钮已走 cancel 分支) logger.info(LOG, '确认弹窗非主按钮,按取消处理,返回来源页') this.backToSource() return } const pending = (this as any).pendingScene || { sceneType: 1, scene: '' } this.doGenerate(pending.sceneType, pending.scene) }, // 生成为异步任务:先提交拿 task_id,再轮询状态接口取结果 doGenerate(sceneType: number, scene: string) { if (this.data.generating) return this.setData({ state: 'generating', generating: true }) const body: Record = { scene_type: sceneType } if (sceneType === 2 && scene) body.scene = scene request({ url: '/ai-recommend', method: 'POST', data: body, showError: false }) .then((res) => { logger.info(LOG, 'AI 推荐任务已提交, taskId=', res.task_id) // 落 storage:中途退出页面再进来可续接轮询(后端生成不中断) wx.setStorageSync(STORAGE_KEYS.AI_TASK, res.task_id) this.startPoll(res.task_id) }) .catch((e: any) => { const code = e && e.code logger.warn(LOG, 'AI 推荐任务提交失败, code=', code, 'msg=', e && e.message) if (code === 90400) { // 衣橱暂无可用衣物等:走空态引导上传 this.stopElapsed() this.setData({ generating: false, state: 'empty', showSceneSheet: false }) return } if (code === 90401 || code === 80000) { // 额度不足:提交时就已校验,弹窗引导购买 this.failBackToScene() this.showLimitModal() return } if (code === 10003) { this.failBackToScene() wx.showToast({ title: '操作过于频繁,请稍后重试', icon: 'none' }) return } const detail = (e && e.details && e.details[0]) || (e && e.message) || '' this.failBackToScene(code ? detail || '提交失败,请稍后重试' : '网络异常,请检查网络后重试') }) }, // 轮询任务状态:2 秒一次、上限 90 次 startPoll(taskId: string) { this.stopPoll() ;(this as any).pollTaskId = taskId ;(this as any).pollCount = 0 this.startElapsed() const tick = () => { const count = ((this as any).pollCount || 0) + 1 ;(this as any).pollCount = count if (count > POLL_MAX) { this.stopPoll() logger.warn(LOG, 'AI 推荐轮询超时, taskId=', taskId, '查询次数=', count) this.failBackToScene('生成超时,请稍后重试', true) return } this.queryTaskStatus(taskId) } ;(this as any).pollTimer = setInterval(tick, POLL_INTERVAL) // 立即查一次,避免白等一个轮询间隔 tick() }, queryTaskStatus(taskId: string) { request({ url: '/ai-recommend/status', data: { task_id: taskId }, showError: false }) .then((res) => { if (res.status === TASK_DONE && res.result) { this.stopPoll() logger.info(LOG, 'AI 推荐生成完成, taskId=', taskId) this.renderResult(res.result) return } if (res.status === TASK_FAIL) { this.stopPoll() logger.warn( LOG, 'AI 推荐生成失败, taskId=', taskId, 'errorCode=', res.error_code, 'errorMsg=', res.error_msg ) this.failBackToScene(res.error_msg || '生成失败,请稍后重试') return } // status === 1:继续等待,等待时长由 genElapsed 体现 }) .catch((e: any) => { const code = e && e.code logger.warn(LOG, 'AI 推荐状态查询失败, taskId=', taskId, 'code=', code) if (code === 90402) { // 任务快照 15 分钟过期:生成结果已落库为「待保存」,引导去 AI 穿搭找回 this.stopPoll() this.failBackToScene('本次生成结果已过期。若已生成完成,可在「AI 穿搭」里查看。', true) } // 其它错误(网络抖动)不打断等待,交给轮询上限兜底 }) }, // 成功:拉齐单品详情后渲染结果页 renderResult(res: AiRecommendResult) { this.setData({ genElapsed: 0 }) this.stopElapsed() Promise.all( res.outfit_item.map(async (it) => { try { return await request({ url: `/clothes/${it.clothes_uuid}`, showError: false }) } catch (e: any) { logger.warn(LOG, '单品详情拉取失败, uuid=', it.clothes_uuid, 'code=', e && e.code) return { clothes_uuid: it.clothes_uuid } as ClothesItem } }) ).then((clothes) => { wx.removeStorageSync(STORAGE_KEYS.AI_TASK) logger.info(LOG, 'AI 推荐结果渲染完成, 单品数=', clothes.length) this.setData({ result: res, clothes, state: 'result', generating: false }) }) }, // 失败:回场景抽屉;带文案时弹提示(绝不静默返回) failBackToScene(errText?: string, toPending = false) { this.stopElapsed() wx.removeStorageSync(STORAGE_KEYS.AI_TASK) this.setData({ state: 'scene', generating: false, showSceneSheet: true, }) if (errText) this.showError('生成失败', errText, toPending) }, // 统一错误出口:标题 + 文案 + 可选「去 AI 穿搭」按钮 showError(title: string, text: string, toPending = false) { this.setData({ errTitle: title, errText: text, errButtons: toPending ? ERR_BUTTONS_WITH_PENDING : ERR_BUTTONS_ACK, errVisible: true, }) }, stopPoll() { const timer = (this as any).pollTimer if (timer) { clearInterval(timer) ;(this as any).pollTimer = null } }, // 生成中的秒级心跳 startElapsed() { this.stopElapsed() this.setData({ genElapsed: 0 }) ;(this as any).elapsedTimer = setInterval(() => { this.setData({ genElapsed: (this.data.genElapsed || 0) + 1 }) }, 1000) }, stopElapsed() { const timer = (this as any).elapsedTimer if (timer) { clearInterval(timer) ;(this as any).elapsedTimer = null } }, showLimitModal() { this.setData({ limitVisible: true }) }, onLimitCancel() { this.setData({ limitVisible: false }) }, onLimitConfirm(e: WechatMiniprogram.CustomEvent) { this.setData({ limitVisible: false }) if (e.detail.value !== 'ok') return wx.navigateTo({ url: '/pages/mall/mall', fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg), }) }, onErrConfirm(e: WechatMiniprogram.CustomEvent) { this.setData({ errVisible: false }) // 任务过期 / 超时:结果可能已经在「AI 穿搭」里落库,给一条直达路径 if (e.detail.value === 'pending') this.onAiPending() }, // 去 AI 穿搭页:查看/保存历史生成结果(待保存 / 已保存) onAiPending() { wx.navigateTo({ url: '/pages/ai-pending/ai-pending', fail: (e) => logger.warn(LOG, 'navigateTo AI 穿搭失败, err=', e && e.errMsg), }) }, onSave() { if (!this.data.result) return request({ url: '/ai-recommend/save', method: 'POST', data: { outfit_uuid: this.data.result.outfit_uuid }, showError: false, }) .then(() => { logger.info(LOG, 'AI 穿搭保存成功') wx.showToast({ title: '已保存', icon: 'success' }) setTimeout(() => { wx.redirectTo({ url: `/pages/outfit-detail/outfit-detail?uuid=${this.data.result!.outfit_uuid}&owner=1`, fail: (e) => logger.warn(LOG, 'redirectTo 穿搭详情失败, err=', e && e.errMsg), }) }, 500) }) .catch((e: any) => { const code = e && e.code logger.warn(LOG, 'AI 穿搭保存失败, code=', code) // 结果仍在页面上,给出可见反馈让用户直接重试 if (code === 40000) { this.showError('保存失败', '这套搭配已被清理,请重新生成。', true) return } const detail = (e && e.details && e.details[0]) || (e && e.message) || '' this.showError('保存失败', detail || '请稍后重试') }) }, onReroll() { this.startGenerate(this.data.sceneType, this.data.scene) }, onGoUpload() { wx.navigateTo({ url: '/pages/upload/upload', fail: (e) => logger.warn(LOG, 'navigateTo 上传页失败, err=', e && e.errMsg), }) }, })