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
@@ -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"
}
}
+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 })
},
})
@@ -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;
}