bugfix
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="AI 穿搭" scrolled="{{navScrolled}}" />
|
||||
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="scroll-body"
|
||||
refresher-enabled
|
||||
refresher-triggered="{{refreshing}}"
|
||||
bindrefresherrefresh="onRefresh"
|
||||
bindscrolltolower="onReachBottom"
|
||||
bindscroll="onNavScroll"
|
||||
>
|
||||
<!-- 大标题页头 -->
|
||||
<view class="head">
|
||||
<view class="eyebrow">AI Styling</view>
|
||||
<view class="display">AI 穿搭</view>
|
||||
</view>
|
||||
|
||||
<!-- 下划线式文字分段 -->
|
||||
<view class="segbar">
|
||||
<view class="seg">
|
||||
<view class="seg-item {{activeTab === 0 ? 'is-on' : ''}}" data-index="0" bindtap="onTabChange">待保存</view>
|
||||
<view class="seg-item {{activeTab === 1 ? 'is-on' : ''}}" data-index="1" bindtap="onTabChange">已保存</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 待保存说明:把保存语义与 7 天清理规则前置说清 -->
|
||||
<view wx:if="{{activeTab === 0}}" class="ap-note">
|
||||
保存后归入「已保存」。超过 7 天未保存的推荐会被自动清理。
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view wx:if="{{loading}}" class="ap-skel">
|
||||
<view class="ap-skel-item" wx:for="{{[1, 2]}}" wx:key="*this">
|
||||
<view class="sk sk-img sk-cover"></view>
|
||||
<view class="sk sk-ln" style="width: 46%;"></view>
|
||||
<view class="sk sk-ln" style="width: 68%;"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 该分段在本页数据里为空但还有下一页:给显式加载入口,避免误以为真的没有 -->
|
||||
<view wx:elif="{{!list.length && hasMore}}" class="ap-more" bindtap="onReachBottom">继续加载更多</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<empty-state
|
||||
wx:elif="{{!list.length}}"
|
||||
icon="icon-sparkles"
|
||||
title="{{activeTab === 0 ? '没有待保存的推荐' : '还没有保存的 AI 穿搭'}}"
|
||||
desc="{{activeTab === 0 ? '让 AI 结合今日运势与你的衣橱搭一套' : '把满意的推荐保存下来,就会出现在这里'}}"
|
||||
buttonText="{{activeTab === 0 ? '去 AI 推荐' : ''}}"
|
||||
bind:action="onGoGenerate"
|
||||
/>
|
||||
|
||||
<!-- 卡片流 -->
|
||||
<view wx:else class="ap-list">
|
||||
<view
|
||||
class="ap-card"
|
||||
wx:for="{{list}}"
|
||||
wx:key="outfit_uuid"
|
||||
data-uuid="{{item.outfit_uuid}}"
|
||||
bindtap="onDetail"
|
||||
>
|
||||
<!-- 无缝拼图封面 -->
|
||||
<view class="collage {{item._collageCls}}">
|
||||
<view class="cg" wx:for="{{item._covers}}" wx:for-item="c" wx:key="k">
|
||||
<image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{item._moreCount > 0}}" class="cg-more">+{{item._moreCount}}</view>
|
||||
<text class="cg-badge num">{{item.items.length}} 件</text>
|
||||
</view>
|
||||
|
||||
<view class="ap-trow">
|
||||
<text class="ap-title">{{item.outfit_name || 'AI 推荐搭配'}}</text>
|
||||
<text class="pill {{item._status.cls}}">{{item._status.text}}</text>
|
||||
</view>
|
||||
<view class="ap-desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
|
||||
<view class="ap-meta">
|
||||
<text class="pill pill--line" wx:if="{{item.scene}}">{{item.scene}}</text>
|
||||
<text class="ap-m">{{item.items.length}} 件衣物</text>
|
||||
</view>
|
||||
|
||||
<!-- 待保存才给内联操作;已保存保持只读,点击进详情 -->
|
||||
<view class="ap-acts" wx:if="{{activeTab === 0}}" catchtap="noop">
|
||||
<view class="btn btn--ink btn--sm ap-save" data-uuid="{{item.outfit_uuid}}" catchtap="onSave">
|
||||
{{busyUuid === item.outfit_uuid ? '保存中…' : '保存'}}
|
||||
</view>
|
||||
<view class="btn btn--text" catchtap="onRegen">重新生成</view>
|
||||
<view class="btn btn--text" data-uuid="{{item.outfit_uuid}}" catchtap="onDelete">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
|
||||
<view class="gap-64"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<v2-overlay
|
||||
visible="{{delVisible}}"
|
||||
type="modal"
|
||||
title="删除这套搭配"
|
||||
content="{{delText}}"
|
||||
buttons="{{delButtons}}"
|
||||
bind:confirm="onDelConfirm"
|
||||
bind:cancel="onDelCancel"
|
||||
/>
|
||||
|
||||
<!-- 轻量反馈 -->
|
||||
<v2-overlay visible="{{toastVisible}}" type="toast" content="{{toastText}}" bind:close="onToastClose" />
|
||||
</view>
|
||||
@@ -0,0 +1,141 @@
|
||||
/** AI 穿搭:待保存 / 已保存两段,图卡与胶囊复用全局类 **/
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 分段条:吸顶玻璃 */
|
||||
.segbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
margin-top: 36rpx;
|
||||
background: var(--glass);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 1rpx solid var(--hairline);
|
||||
}
|
||||
|
||||
/* 待保存说明行:保存语义 + 7 天清理规则 */
|
||||
.ap-note {
|
||||
margin: 32rpx var(--pad) 0;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.ap-list {
|
||||
padding: 40rpx var(--pad) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 56rpx;
|
||||
}
|
||||
|
||||
.ap-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
transition: transform .2s var(--ease);
|
||||
}
|
||||
|
||||
.ap-card:active {
|
||||
transform: scale(.985);
|
||||
}
|
||||
|
||||
.ap-skel {
|
||||
padding: 40rpx var(--pad) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 56rpx;
|
||||
}
|
||||
|
||||
.ap-skel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.sk-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.ap-trow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.ap-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 34rpx;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-desc {
|
||||
margin-top: 14rpx;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.62;
|
||||
color: var(--ink-2);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.ap-m {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 待保存操作行:实心黑主按钮 + 下划线文字按钮 */
|
||||
.ap-acts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.ap-save {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 分段在本页数据里为空但仍有下一页时的显式加载入口 */
|
||||
.ap-more {
|
||||
margin: 56rpx var(--pad) 0;
|
||||
text-align: center;
|
||||
font-size: 25rpx;
|
||||
color: var(--ink-2);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 7rpx;
|
||||
text-decoration-thickness: 1rpx;
|
||||
text-decoration-color: var(--ink-4);
|
||||
transition: opacity .18s var(--ease);
|
||||
}
|
||||
|
||||
.ap-more:active {
|
||||
opacity: .55;
|
||||
}
|
||||
Reference in New Issue
Block a user