This commit is contained in:
2026-09-15 23:04:38 +08:00
parent 6be3f083cc
commit 592fa9be64
21 changed files with 1323 additions and 77 deletions
+226 -28
View File
@@ -1,11 +1,33 @@
import { request } from '../../utils/request'
import { AiRecommendResult, ClothesItem } from '../../types/models'
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',
@@ -15,6 +37,8 @@ Page({
result: null as AiRecommendResult | null,
clothes: [] as ClothesItem[],
generating: false,
// 生成中的已等待秒数:慢生成时有心跳反馈,避免误以为卡死
genElapsed: 0,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
@@ -28,6 +52,11 @@ Page({
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
// 错误提示(提交失败 / 生成失败 / 保存失败 / 超时,必须有可见反馈)
errVisible: false,
errTitle: '生成失败',
errText: '',
errButtons: ERR_BUTTONS_ACK,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
@@ -35,6 +64,25 @@ Page({
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 })
},
@@ -80,6 +128,7 @@ Page({
this.doGenerate(pending.sceneType, pending.scene)
},
// 生成为异步任务:先提交拿 task_id,再轮询状态接口取结果
doGenerate(sceneType: number, scene: string) {
if (this.data.generating) return
this.setData({ state: 'generating', generating: true })
@@ -87,38 +136,162 @@ Page({
const body: Record<string, any> = { scene_type: sceneType }
if (sceneType === 2 && scene) body.scene = scene
request<AiRecommendResult>({ url: '/ai-recommend', method: 'POST', data: body, showError: false })
.then(async (res) => {
logger.info(LOG, 'AI 推荐生成成功, 单品数=', res.outfit_item && res.outfit_item.length)
const clothes = await 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
}
})
)
this.setData({ result: res, clothes, state: 'result', generating: false })
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) => {
this.setData({ generating: false })
const code = e && e.code
logger.warn(LOG, 'AI 推荐生成失败, code=', code)
logger.warn(LOG, 'AI 推荐任务提交失败, code=', code, 'msg=', e && e.message)
if (code === 90400) {
this.setData({ state: 'empty' })
} else if (code === 90401 || code === 80000) {
// 90401=AI 推荐次数上限;80000=额度不足兜底,统一弹窗引导购买
this.setData({ state: 'scene', showSceneSheet: true })
// 衣橱暂无可用衣物等:走空态引导上传
this.stopElapsed()
this.setData({ generating: false, state: 'empty', showSceneSheet: false })
return
}
if (code === 90401 || code === 80000) {
// 额度不足:提交时就已校验,弹窗引导购买
this.failBackToScene()
this.showLimitModal()
} else if (code === 10003) {
wx.showToast({ title: '正在生成中,请稍候', icon: 'none' })
this.setData({ state: 'scene', showSceneSheet: true })
} else {
this.setData({ state: 'scene', showSceneSheet: true })
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() {
@@ -138,16 +311,31 @@ Page({
})
},
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' })
wx.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => {
wx.redirectTo({
url: `/pages/outfit-detail/outfit-detail?uuid=${this.data.result!.outfit_uuid}&owner=1`,
@@ -155,7 +343,17 @@ Page({
})
}, 500)
})
.catch((e: any) => logger.warn(LOG, 'AI 穿搭保存失败, code=', e && e.code))
.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() {
@@ -19,7 +19,8 @@ module.exports = { cls: cls }
</view>
</view>
<view class="ai-gen-t">AI 正在为你搭配…</view>
<view class="ai-gen-d">结合今日运势与你的衣橱</view>
<view class="ai-gen-d">结合今日运势与你的衣橱,生成通常需要 10~30 秒</view>
<view class="ai-gen-elapsed num">已等待 {{genElapsed}} 秒</view>
</view>
<!-- 衣橱为空 -->
@@ -86,9 +87,10 @@ module.exports = { cls: cls }
<!-- 悬浮底部操作条 -->
<view class="dock" wx:if="{{state === 'result'}}">
<view class="ai-reroll-tip">换一套将再消耗 1 次推荐额度</view>
<view class="ai-reroll-tip ai-reroll-sub">未保存的推荐会留在「AI 穿搭」里,可随时找回</view>
<view class="dock-row">
<view class="btn btn--ghost" bindtap="onReroll">换一套</view>
<view class="btn btn--ink" bindtap="onSave">保存到我的穿搭</view>
<view class="btn btn--ink" bindtap="onSave">保存</view>
</view>
</view>
@@ -125,6 +127,10 @@ module.exports = { cls: cls }
</view>
<view class="sheet-foot">
<view class="btn btn--ink btn--block" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
<view class="sheet-more" bindtap="onAiPending">
<text>查看我的 AI 穿搭</text>
<view class="icon icon-chevron-right"></view>
</view>
</view>
</view>
</view>
@@ -134,7 +140,7 @@ module.exports = { cls: cls }
visible="{{confirmVisible}}"
type="modal"
title="AI 穿搭推荐"
content="本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭"
content="本次将消耗 1 次 AI 推荐额度,生成结果可在「AI 穿搭」里保存与管理"
buttons="{{confirmButtons}}"
bind:confirm="onConfirmGenerate"
bind:cancel="onConfirmCancel"
@@ -150,4 +156,14 @@ module.exports = { cls: cls }
bind:confirm="onLimitConfirm"
bind:cancel="onLimitCancel"
/>
<!-- 错误提示(提交/生成/保存失败、超时、结果过期) -->
<v2-overlay
visible="{{errVisible}}"
type="modal"
title="{{errTitle}}"
content="{{errText}}"
buttons="{{errButtons}}"
bind:confirm="onErrConfirm"
/>
</view>
@@ -67,6 +67,14 @@
color: var(--ink-2);
}
/* 异步轮询期间的心跳:让慢生成看得见进度,避免误以为卡死 */
.ai-gen-elapsed {
margin-top: 16rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .02em;
}
/* 结果 */
.ai-cover {
padding: 40rpx var(--pad) 0;
@@ -145,6 +153,34 @@
color: var(--ink-3);
}
/* 第二行说明:换一套后旧结果仍可在「AI 穿搭」找回 */
.ai-reroll-sub {
margin-top: -10rpx;
color: var(--ink-4);
}
/* 场景抽屉底部:进入 AI 穿搭管理页的文字入口 */
.sheet-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding-top: 30rpx;
font-size: 25rpx;
color: var(--ink-2);
transition: opacity .18s var(--ease);
}
.sheet-more:active {
opacity: .55;
}
.sheet-more .icon {
width: 24rpx;
height: 24rpx;
color: var(--ink-4);
}
.dock-row .btn {
flex: 1;
}