Files
2026-09-15 23:04:38 +08:00

253 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
},
})