399 lines
13 KiB
TypeScript
399 lines
13 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 })
|
|
},
|
|
|
|
// 取消场景选择:退出 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<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),
|
|
})
|
|
},
|
|
})
|