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
+252
View File
@@ -0,0 +1,252 @@
import { request } from '../../utils/request'
import { PagedResult, OutfitItem } from '../../types/models'
import { OutfitSource } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { CoverCell, outfitCover } from '../../utils/outfit-cover'
import { OUTFIT_STATUS, OutfitStatusPill, isPendingSave, outfitStatusPill } from '../../utils/outfit-status'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'ai-pending'
// 列表行:AI 生成结果 + 预计算的封面与状态胶囊(WXML 不能调用方法)
type AiRow = OutfitItem & {
_status: OutfitStatusPill
_covers: CoverCell[]
_collageCls: string
_moreCount: number
}
Page({
data: {
// 0 待保存(status=3) / 1 已保存(status=1)
activeTab: 0,
pendingList: [] as AiRow[],
savedList: [] as AiRow[],
// 当前分段渲染的数据(切段时同步切换,避免模板里写两套列表)
list: [] as AiRow[],
loading: true,
refreshing: false,
loadingMore: false,
page: 1,
pageSize: 20,
hasMore: true,
// 保存/删除防抖:同一时刻只允许一个写操作
saving: false,
// 正在保存的那一条:按钮显示「保存中…」,避免重复点击
busyUuid: '',
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 删除确认(v2 玻璃弹窗)
delVisible: false,
delText: '',
delUuid: '',
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
// 轻量反馈(v2 深色玻璃 toast)
toastVisible: false,
toastText: '',
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
// 操作行空白处吞掉点击,避免穿透到卡片进详情
noop() {},
onLoad() {
// 首帧渲染后再发请求,避免 setData 早于首次渲染导致 webview 渲染失败
wx.nextTick(() => this.fetchList(1))
},
onShow() {
// 从详情页返回(可能在详情页保存过)需要重取,保证分段与状态一致
if ((this as any).loaded) this.fetchList(1)
;(this as any).loaded = true
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeTab) return
logger.debug(LOG, '切换分段, index=', index)
this.setData({ activeTab: index, list: index === 0 ? this.data.pendingList : this.data.savedList })
},
onRefresh() {
this.setData({ refreshing: true })
this.fetchList(1).finally(() => this.setData({ refreshing: false }))
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return
this.fetchList(this.data.page + 1)
},
// 拉取 AI 穿搭:后端 GET /outfit 只支持 source 过滤(没有 status),
// 因此一次拉全量再本地按 status 拆成「待保存 / 已保存」两段,分页仍按原始数据推进
fetchList(page: number): Promise<void> {
const seq = ((this as any).reqSeq || 0) + 1
;(this as any).reqSeq = seq
if (page > 1) {
this.setData({ loadingMore: true })
} else {
this.setData({ loading: this.data.pendingList.length === 0 && this.data.savedList.length === 0 })
}
return request<PagedResult<OutfitItem>>({
url: '/outfit',
data: { page, page_size: this.data.pageSize, source: OutfitSource.AI },
})
.then((res) => {
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<OutfitItem>(res)
const rows: AiRow[] = raw.map((it) => ({
...it,
...outfitCover(it.items),
_status: outfitStatusPill(it),
}))
// 累积数组放实例而非 data:避免每次 setData 传输全量原始数据
const all = mergePaged<AiRow>(((this as any).allRows || []) as AiRow[], rows, page === 1)
;(this as any).allRows = all
logger.debug(LOG, 'AI 穿搭加载成功, page=', page, '条数=', rows.length, '累计=', all.length)
this.setData({
hasMore: hasMorePaged(all, total, this.data.pageSize),
loading: false,
loadingMore: false,
page,
})
this.applySplit(all)
})
.catch((e: any) => {
if ((this as any).reqSeq !== seq) return
logger.warn(LOG, 'AI 穿搭加载失败, page=', page, 'code=', e && e.code)
this.setData({ loading: false, loadingMore: false })
})
},
// 按 status 拆段并同步当前分段渲染数据
applySplit(all: AiRow[]) {
const pendingList = all.filter((it) => isPendingSave(it))
const savedList = all.filter((it) => !isPendingSave(it))
this.setData({
pendingList,
savedList,
list: this.data.activeTab === 0 ? pendingList : savedList,
})
},
// 保存为正式穿搭(status 3 → 1):成功后本地迁移段落,不重新请求以免闪屏
onSave(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
if (this.data.saving) return
this.setData({ saving: true, busyUuid: uuid })
logger.info(LOG, '保存 AI 穿搭, uuid=', uuid)
request({ url: '/ai-recommend/save', method: 'POST', data: { outfit_uuid: uuid }, showError: false })
.then(() => {
const all = (((this as any).allRows || []) as AiRow[]).map((it) =>
it.outfit_uuid === uuid
? { ...it, status: OUTFIT_STATUS.NORMAL, _status: outfitStatusPill({ ...it, status: OUTFIT_STATUS.NORMAL }) }
: it
)
;(this as any).allRows = all
this.applySplit(all)
this.setData({ saving: false, busyUuid: '' })
this.toast('已保存,可在「已保存」查看')
})
.catch((err: any) => {
this.setData({ saving: false, busyUuid: '' })
const code = err && err.code
logger.warn(LOG, '保存 AI 穿搭失败, uuid=', uuid, 'code=', code)
if (code === 40000) {
// 超过 7 天被后端定时清理:本地同步移除,避免用户反复点必然失败的按钮
this.removeLocal(uuid)
this.toast('这套搭配已被清理,请重新生成')
return
}
const detail = (err && err.details && err.details[0]) || (err && err.message) || ''
this.toast(detail || '保存失败,请稍后重试')
})
},
// 删除入口:弹 v2 玻璃确认层
onDelete(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
this.setData({
delVisible: true,
delUuid: uuid,
delText: '删除后不可恢复,这套 AI 生成的搭配会从列表中移除。',
})
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
const uuid = this.data.delUuid
if (!uuid || this.data.saving) return
this.setData({ saving: true })
request({ url: `/outfit/${uuid}`, method: 'DELETE', showError: false })
.then(() => {
logger.info(LOG, '删除 AI 穿搭成功, uuid=', uuid)
this.setData({ saving: false })
this.removeLocal(uuid)
this.toast('已删除')
})
.catch((err: any) => {
this.setData({ saving: false })
const code = err && err.code
logger.warn(LOG, '删除 AI 穿搭失败, uuid=', uuid, 'code=', code)
if (code === 40000) {
this.removeLocal(uuid)
this.toast('这套搭配已不存在')
return
}
const detail = (err && err.details && err.details[0]) || (err && err.message) || ''
this.toast(detail || '删除失败,请稍后重试')
})
},
// 本地移除(删除成功,或后端已清理)
removeLocal(uuid: string) {
const all = (((this as any).allRows || []) as AiRow[]).filter((it) => it.outfit_uuid !== uuid)
;(this as any).allRows = all
this.applySplit(all)
},
// 重新生成:回到 AI 推荐流程(会再消耗 1 次推荐额度)
onRegen() {
wx.navigateTo({
url: '/pages/ai-recommend/ai-recommend',
fail: (e) => logger.warn(LOG, 'navigateTo AI 推荐失败, err=', e && e.errMsg),
})
},
onDetail(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
logger.debug(LOG, '点击 AI 穿搭, uuid=', uuid)
wx.navigateTo({
url: `/pages/outfit-detail/outfit-detail?uuid=${uuid}&owner=1`,
fail: (err) => logger.warn(LOG, 'navigateTo 穿搭详情失败, err=', err && err.errMsg),
})
},
onGoGenerate() {
this.onRegen()
},
// 轻量反馈:先置 false 再置 true,确保连续提示也能重新触发自动关闭
toast(text: string) {
this.setData({ toastVisible: false, toastText: text })
wx.nextTick(() => this.setData({ toastVisible: true }))
},
onToastClose() {
this.setData({ toastVisible: false })
},
})