feat: 支持退出后补告衣物处理结果

This commit is contained in:
2026-09-16 02:01:26 +08:00
parent a9f2659c42
commit 7be561500b
12 changed files with 539 additions and 22 deletions
@@ -1,6 +1,8 @@
import { request } from '../../utils/request'
import { ClothesItem } from '../../types/models'
import { ProcessStatus } from '../../utils/config'
import { isProcessTerminal } from '../../utils/clothes-status'
import { removePendingClothes } from '../../utils/pending-clothes'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
@@ -152,9 +154,13 @@ Page({
this.setData({ loading: false, notFound: true })
return
}
this.fetchDetail()
// 只读(来自穿搭分享)场景无需轮询 AI 处理状态
if (!readonly) this.pollStatus()
this.fetchDetail().then(() => {
// 只读(来自穿搭分享)场景无需轮询 AI 处理状态
if (readonly) return
// 详情响应已带处理状态:成功/失败等终态直接定态,仅处理中才轮询
if (isProcessTerminal(this.data.detail)) return
this.pollStatus()
})
},
onBackWardrobe() {
@@ -172,7 +178,19 @@ Page({
return request<ClothesItem>({ url })
.then((detail) => {
logger.debug(LOG, '详情加载成功, name=', detail && detail.name)
this.setData({ detail, loading: false })
const patch: Record<string, any> = { detail, loading: false }
const status = Number(detail && detail.image_process_status)
if (!this.data.readonly && status) {
// 详情响应带处理状态:进入页面即渲染正确视图,避免先闪正常详情再切回处理中
patch.imageStatus = status
patch.step = statusToStep(status)
if (status === ProcessStatus.FAILED) {
patch.failReason = (detail && detail.fail_reason) || '识别失败'
}
// 终态即可清掉待核对记录(幂等,未登记时为空操作)
if (isProcessTerminal(detail)) removePendingClothes(this.data.uuid)
}
this.setData(patch)
})
.catch((e: any) => {
logger.warn(LOG, '详情加载失败, code=', e && e.code)
@@ -181,22 +199,27 @@ Page({
},
pollStatus() {
// 先清旧定时器,避免重复进入时叠加成多份轮询
this.clearTimer()
let count = 0
;(this as any).pollTimer = setInterval(() => {
count++
request<{ image_process_status: number; fail_reason?: string }>({
url: `/clothes/${this.data.uuid}/status`,
showError: false,
silentAuth: true,
})
.then((res) => {
const status = res.image_process_status
if (status === ProcessStatus.SUCCESS) {
// 成功:先拉最新详情,再切换为详情视图,避免空属性闪烁
this.clearTimer()
removePendingClothes(this.data.uuid)
logger.info(LOG, 'AI 处理完成')
this.fetchDetail().then(() => this.setData({ imageStatus: status, step: 2 }))
} else if (status === ProcessStatus.FAILED) {
this.clearTimer()
removePendingClothes(this.data.uuid)
logger.warn(LOG, 'AI 识别失败, 原因=', res.fail_reason)
this.setData({ imageStatus: status, failReason: res.fail_reason || '识别失败' })
} else {
@@ -205,8 +228,9 @@ Page({
})
.catch((e: any) => logger.warn(LOG, '状态轮询失败, code=', e && e.code))
if (count >= 120) {
// 只停轮询,保持处理中视图(后端有恢复调度器兜底),不误报失败
this.clearTimer()
logger.warn(LOG, '状态轮询超时(240s),停止轮询')
logger.warn(LOG, '状态轮询超时(240s),保持处理中视图并停止轮询')
}
}, 2000)
},
@@ -70,7 +70,7 @@ module.exports = { hex: hex, tags: tags }
<view class="head">
<view class="eyebrow">Processing</view>
<view class="display">AI 正在识别你的衣物…</view>
<view class="cd-note">通常需要 10 秒左右,请稍候</view>
<view class="cd-note">通常需要 10 秒左右;处理较慢时可先返回,稍后再来查看</view>
</view>
<view class="up-preview">
<view class="ph icon icon-hanger"></view>
+68 -8
View File
@@ -1,11 +1,18 @@
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',
@@ -13,6 +20,10 @@ Page({
clothesUuid: '',
failReason: '',
step: 0,
// 轮询预算耗尽:仍在处理中,只做中性提示,不算失败
slowHint: false,
// 失败原因是否伴随额度退回(仅后端确认失败才为真)
quotaRefunded: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
@@ -66,11 +77,20 @@ Page({
},
startUpload(filePath: string) {
this.setData({ state: 'processing', imagePath: filePath, step: 0, failReason: '' })
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()
})
@@ -116,19 +136,30 @@ Page({
this.clearTimer()
if (e.detail.value === 'again') {
logger.debug(LOG, '选择继续上传,重置状态')
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
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
@@ -139,20 +170,28 @@ Page({
} 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 || '识别失败' })
this.setData({
state: 'failed',
failReason: res.fail_reason || '识别失败',
quotaRefunded: true,
})
}
})
.catch((e: any) => logger.warn(LOG, '状态轮询失败, code=', e && e.code))
if (count >= 60) {
if (count >= POLL_MAX_COUNT) {
// 只停轮询,不判失败:任务可能仍在队列/执行中,由后端兜底、衣橱页核对
this.clearTimer()
logger.warn(LOG, 'AI 处理超时(90s)')
this.setData({ state: 'failed', failReason: '处理超时,请重试' })
logger.warn(LOG, '状态轮询预算耗尽,转中性提示, uuid=', uuid)
this.setData({ slowHint: true })
}
}, 1500)
}, POLL_INTERVAL_MS)
},
clearTimer() {
@@ -163,6 +202,19 @@ Page({
}
},
// 离开页面只停本地轮询:后端处理不中断,最终结果由衣橱页核对补齐
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()
},
@@ -185,7 +237,15 @@ Page({
},
onRetry() {
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', failReason: '' })
this.setData({
state: 'idle',
imagePath: '',
clothesUuid: '',
failReason: '',
step: 0,
slowHint: false,
quotaRefunded: false,
})
},
onManualEdit() {
+3 -2
View File
@@ -23,7 +23,8 @@
<view class="head">
<view class="eyebrow">Processing</view>
<view class="display">AI 正在识别你的衣物…</view>
<view class="up-note-left">通常需要 10 秒左右,请稍候</view>
<view wx:if="{{slowHint}}" class="up-note-left">处理时间较长,可先返回衣橱,结果会自动更新</view>
<view wx:else class="up-note-left">通常需要 10 秒左右,请稍候</view>
</view>
<view class="up-preview">
@@ -52,7 +53,7 @@
<view class="up-bang">!</view>
<view class="head">
<view class="display">识别失败</view>
<view class="up-note-left">{{failReason || '未知原因'}} · 本次上传额度已自动退回</view>
<view class="up-note-left">{{failReason || '未知原因'}}{{quotaRefunded ? ' · 本次上传额度已自动退回' : ''}}</view>
</view>
<view class="up-duo">
<view class="btn btn--ghost" bindtap="onRetry">重新上传</view>
+2 -1
View File
@@ -2,6 +2,7 @@
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+60 -5
View File
@@ -3,6 +3,8 @@ import * as auth from '../../utils/auth'
import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
import { QuotaType } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { clothesStatusFlag } from '../../utils/clothes-status'
import { resolvePendingClothes } from '../../utils/pending-clothes'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
@@ -35,6 +37,14 @@ Page({
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 「上传后退出小程序」的失败结果回归告知(v2 玻璃弹窗)
failVisible: false,
failText: '',
failUuid: '',
failButtons: [
{ text: '知道了', value: '' },
{ text: '查看衣物', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
@@ -47,6 +57,8 @@ Page({
redirectToLogin()
return
}
// 核对「上传后退出小程序」的衣物最终处理结果(pending 为空时零请求)
this.resolvePending()
// 首帧渲染未结束就 setData 会触发「Expected updated data but get first rendering data」,
// 整个 webview 渲染失败,用户视觉上仍看到上一个页面(从 login switchTab 过来时表现为
// "卡在登录页")。把首次 onShow 的刷新延迟到下一帧,规避首帧冲突。
@@ -96,11 +108,11 @@ Page({
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<ClothesItem>(res)
logger.debug(LOG, '列表加载成功, 分类=', cat || '全部', 'page=', page, '条数=', raw && raw.length)
const list = raw.map((it) => ({
...it,
_statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '',
_statusType: 'plain',
}))
// 状态标识收敛到 clothes-status:处理中(玻璃)/ 处理失败(实心)/ 无
const list = raw.map((it) => {
const flag = clothesStatusFlag(it)
return { ...it, _statusText: flag.text, _statusType: flag.type }
})
const merged = mergePaged(this.data.list, list, page === 1)
this.setData({
list: merged,
@@ -128,6 +140,49 @@ Page({
.catch((e: any) => logger.warn(LOG, '额度加载失败(静默降级), code=', e && e.code))
},
// 核对待处理衣物:成功静默、失败弹窗告知;有终结态时重刷列表对齐状态
resolvePending() {
if ((this as any).resolvingPending) return
;(this as any).resolvingPending = true
resolvePendingClothes()
.then((res) => {
if (!res.finished.length && !res.failed.length) return
logger.info(LOG, '待核对衣物已终结, 成功=', res.finished.length, '失败=', res.failed.length)
if (res.failed.length) this.showFailModal(res.failed)
this.refresh()
})
.catch((e: any) => logger.warn(LOG, '待核对衣物解析异常, code=', e && e.code))
.finally(() => {
;(this as any).resolvingPending = false
})
},
showFailModal(failed: Array<{ uuid: string; reason: string }>) {
const first = (failed[0] || ({} as { uuid: string; reason: string })) as {
uuid: string
reason: string
}
const content =
failed.length === 1
? `1 件衣物识别失败:${first.reason},额度已自动退回`
: `${failed.length} 件衣物识别失败,额度已自动退回,可进入详情查看原因`
this.setData({ failVisible: true, failText: content, failUuid: first.uuid || '' })
},
onFailCancel() {
this.setData({ failVisible: false })
},
onFailConfirm(e: WechatMiniprogram.CustomEvent) {
const uuid = this.data.failUuid
this.setData({ failVisible: false })
if (e.detail.value !== 'ok' || !uuid) return
wx.navigateTo({
url: `/pages/clothes-detail/clothes-detail?uuid=${uuid}`,
fail: (err) => logger.warn(LOG, 'navigateTo 衣物详情失败, err=', err && err.errMsg),
})
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
+11
View File
@@ -70,4 +70,15 @@
<view class="fab" bindtap="onUpload">
<view class="icon icon-plus"></view>
</view>
<!-- 处理失败提示:「上传后退出小程序」的失败结果回归告知 -->
<v2-overlay
visible="{{failVisible}}"
type="modal"
title="AI 识别失败"
content="{{failText}}"
buttons="{{failButtons}}"
bind:confirm="onFailConfirm"
bind:cancel="onFailCancel"
/>
</view>