Files
zt-closet-wxmini-frontend/miniprogram/pages/ai-recommend/ai-recommend.ts
T
2026-09-15 23:04:38 +08:00

370 lines
12 KiB
TypeScript

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 })
},
onSkipScene() {
this.setData({ showSceneSheet: false })
this.startGenerate(1, '')
},
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 })
},
// 取消生成:回到场景选择抽屉,避免停在无任何可渲染内容的空页
onConfirmCancel() {
this.setData({ confirmVisible: false, showSceneSheet: true })
},
onConfirmGenerate(e: WechatMiniprogram.CustomEvent) {
this.setData({ confirmVisible: false })
if (e.detail.value !== 'ok') {
// 兜底:非「开始生成」按钮同样回到场景抽屉(取消按钮已走 cancel 分支)
this.setData({ showSceneSheet: true })
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<string, any> = { scene_type: sceneType }
if (sceneType === 2 && scene) body.scene = scene
request<AiRecommendSubmitResult>({ 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<AiRecommendTaskStatus>({ 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<ClothesItem>({ 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),
})
},
})