bugfix
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
---
|
||||
name: 前端同步后端AI穿搭待保存改造
|
||||
overview: 跟进后端本轮改动:穿搭对象新增 status(1正常/2封禁/3待保存)、未保存 AI 穿搭 7 天自动清理、任务快照 15 分钟。前端新增 pages/ai-pending 管理页(待保存/已保存分段,支持保存、删除、重新生成、查看详情),我的穿搭只保留手工创建(source=1)并在顶部加入口,同步统一 status 展示口径与超期/清理兜底文案。
|
||||
design:
|
||||
styleKeywords:
|
||||
- 零彩色灰阶
|
||||
- 杂志排版
|
||||
- 发丝线分层
|
||||
- 毛玻璃悬浮层
|
||||
- 极简克制
|
||||
fontSystem:
|
||||
fontFamily: Songti-SC, PingFang-SC
|
||||
heading:
|
||||
size: 68rpx
|
||||
weight: 400
|
||||
subheading:
|
||||
size: 34rpx
|
||||
weight: 600
|
||||
body:
|
||||
size: 30rpx
|
||||
weight: 400
|
||||
colorSystem:
|
||||
primary:
|
||||
- "#0A0A0A"
|
||||
- "#1C1C1E"
|
||||
- "#6E6E73"
|
||||
background:
|
||||
- "#FFFFFF"
|
||||
- "#F7F7F5"
|
||||
- "#EFEFED"
|
||||
text:
|
||||
- "#0A0A0A"
|
||||
- "#6E6E73"
|
||||
- "#AEAEB2"
|
||||
functional:
|
||||
- "#C9C9CE"
|
||||
- "#B9B9BE"
|
||||
- "#D2D2D7"
|
||||
todos:
|
||||
- id: status-contract
|
||||
content: 补 OutfitItem.status 类型,新增 utils/outfit-status.ts 与 utils/outfit-cover.ts 收敛状态口径与封面组装
|
||||
status: completed
|
||||
- id: my-outfits-entry
|
||||
content: 我的穿搭改为只取 source=1,改用统一状态胶囊并新增 AI 穿搭入口卡片与待保存角标
|
||||
status: completed
|
||||
dependencies:
|
||||
- status-contract
|
||||
- id: pending-page
|
||||
content: 新增 pages/ai-pending 页面:待保存/已保存分段、保存/删除/重新生成/详情与 7 天清理提示
|
||||
status: completed
|
||||
dependencies:
|
||||
- status-contract
|
||||
- id: ai-recommend-entry
|
||||
content: AI 推荐页接入 AI 穿搭入口,90402 文案改为引导并补换一套时的未保存提示
|
||||
status: completed
|
||||
- id: detail-status
|
||||
content: 穿搭详情页支持待保存直接保存、封禁态只读并隐藏管理操作
|
||||
status: completed
|
||||
dependencies:
|
||||
- status-contract
|
||||
- id: qa-verify
|
||||
content: 用 [subagent:code-explorer] 与 [skill:lsp-code-analysis] 核对引用点,用 [skill:playwright-cli] 对照参考稿,跑 tsc 与静态审计并输出真机验收清单
|
||||
status: completed
|
||||
dependencies:
|
||||
- my-outfits-entry
|
||||
- pending-page
|
||||
- ai-recommend-entry
|
||||
- detail-status
|
||||
---
|
||||
|
||||
## 产品概述
|
||||
|
||||
后端已完成 AI 推荐异步化与穿搭状态体系改造(穿搭对象带 `status`:正常 / 封禁 / 待保存,且超过 7 天未保存的 AI 推荐由后端自动清理)。前端本轮跟进:把 AI 生成的穿搭从「我的穿搭」中完全独立出来,收进新页面「AI 穿搭」统一管理,让用户可随时找回、保存或清理 AI 生成结果。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **新增「AI 穿搭」页**:分「待保存 / 已保存」两段;待保存项支持 保存到我的穿搭、查看详情、删除、重新生成;已保存项仅查看(删除走详情页既有入口)
|
||||
- **待保存说明与超期兜底**:页面顶部说明「保存后进入「我的穿搭」;超过 7 天未保存将自动清理」;保存时若已被清理,给出「这套搭配已被清理,请重新生成」的明确提示并移出列表
|
||||
- **我的穿搭只留手工创建**:列表请求按来源过滤,AI 穿搭不再混入;顶部新增「AI 穿搭」入口卡片,存在待保存项时显示数量角标
|
||||
- **AI 推荐页打通入口**:「场景抽屉」底部可进入「AI 穿搭」;生成结果过期提示里引导用户去「AI 穿搭」查看是否已生成;重新生成前提示上一套未保存的推荐去哪找
|
||||
- **穿搭详情页状态化**:待保存的 AI 穿搭在详情页可直接保存;已封禁的穿搭只读展示,隐藏全部管理操作
|
||||
- **删除二次确认**:删除 AI 生成结果前弹玻璃确认层,说明该操作不可恢复
|
||||
|
||||
## 视觉效果
|
||||
|
||||
延续现有零彩色灰阶杂志风:衬线大标题 + 小字号大写 eyebrow、下划线式文字分段、发丝线分隔、直角卡片配无缝拼图封面、悬浮玻璃操作条与玻璃弹层。新的操作行以「实心黑主按钮 + 无衬线文字按钮」排布,待保存用实心黑胶囊、已封禁以降透明度呈现,保持全站只有白与灰阶。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 原生微信小程序(TypeScript + WXML + WXSS),无构建脚本,沿用 `miniprogram-api-typings`
|
||||
- 复用现有 v2 设计系统:`app.wxss` 令牌与通用类、`styles/icons.wxss`(CSS mask 单色图标)、`components/{navigation-bar, empty-state, v2-overlay}`
|
||||
- 复用现有请求与工具层:`utils/request`、`utils/logger`、`utils/paged`(`normalizePaged / mergePaged / hasMorePaged`)、`utils/nav`(`navScroll`)、`utils/tabbar`
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 契约对齐(与后端已确认的真实行为)
|
||||
|
||||
- `GET /outfit` 请求参数为 `{ page, page_size, is_public?, source? }` —— **只支持 source 过滤,没有 status 过滤**;DAO 不会过滤 `status=3 / status=2`
|
||||
- `OutfitCreateResponse` 已带 `status`(`1` 正常 / `2` 封禁 / `3` 待保存),列表、详情、创建响应都有
|
||||
- `GET /outfit/community` 固定 `is_public=2 AND audit_status=2 AND status=1`,社区天然不含待保存与封禁
|
||||
- `POST /ai-recommend/save`:`status=3` → 置 `1`;已为 `1` → 幂等返回 nil;已被清理 → `40000`;封禁 → `40003`
|
||||
- `DELETE /outfit/:uuid` 可用(事务删明细 + 主表)
|
||||
- 待保存 AI 穿搭 7 天后由后端每小时清理一次(软删除,列表与详情立即不可见)
|
||||
|
||||
### 关键决策
|
||||
|
||||
1. **我的穿搭用服务端过滤**:请求带 `source=1`(后端现成参数),分页 `total` 正确、无需客户端剔除,彻底避免「客户端过滤导致某页全被剔除、列表越翻越空」的分页塌陷问题。
|
||||
2. **AI 穿搭页用单分页流 + 客户端拆段**:一次 `source=2` 的累积数组,渲染时派生成「待保存(status=3)/ 已保存(status=1)」两段;`hasMore` 只看原始分页是否满额(`hasMorePaged(all, total, pageSize)`)。保存成功后**本地迁移**(把该项从待保存段移入已保存段并置本地 status=1),不重新请求,避免闪屏与流量。
|
||||
3. **状态口径收敛为单一事实来源**:新增 `utils/outfit-status.ts`,输出胶囊文案 + 类名、以及 `isPendingSave / isBanned` 判定,供 outfit / ai-pending / outfit-detail 三处共用,杜绝同一条语义在三个文件各写一遍 if-else。
|
||||
4. **复用优先、最小改面**:新页面结构直接套用已上线验证的 outfit 页模式(`.head / .segbar / 骨架 / empty-state / 卡片 / float-duo`);把两页共用的无缝拼图封面(`.collage` 系列样式 + 封面组装函数)上提为共享资产,避免复制粘贴后视觉分叉。
|
||||
5. **入口计数不追求全量准确**:我的穿搭页 `onShow` 静默拉一页 `source=2&page_size=20` 统计 `status===3` 数量用于角标引导;请求失败或超页则只显示入口不显示数字,避免为角标做 N 次翻页。
|
||||
6. **口径提示(需在实现中保持一致)**:按已确认口径,所有 `source=2` 穿搭只在「AI 穿搭」页查看,因此「保存到我的穿搭」按钮语义为「保存为正式穿搭(status 3→1)」,保存后该项进入 AI 穿搭页的「已保存」段而**不进入「我的穿搭」列表**;若后续希望保存后的 AI 穿搭回流,只需把 `source=1` 过滤改为「source=1 或 (source=2 且 status=1)」,改动集中在一处。
|
||||
|
||||
### 状态流转
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[AI 推荐生成] --> B[落库 status=3 待保存]
|
||||
B --> C[AI 穿搭页 · 待保存段]
|
||||
C -->|POST /ai-recommend/save| D[status=1 已保存]
|
||||
D --> E[AI 穿搭页 · 已保存段]
|
||||
C -->|DELETE /outfit/:uuid| F[本地移除]
|
||||
C -.->|超过 7 天被后端清理后 save| G[40000 提示已被清理]
|
||||
```
|
||||
|
||||
### 性能与可靠性
|
||||
|
||||
- 列表渲染只对累积数组做一次 `filter`,复杂度 O(n),n 为已加载条数(每页 20),无额外遍历
|
||||
- 待保存计数为单次静默请求(失败降级不打扰用户),不阻塞首屏
|
||||
- 保存/删除均加 loading 防抖,避免重复提交;定时器与请求回调统一由 `reqSeq` 防串页
|
||||
- 所有错误路径必须有可见出口:`40000`(已被清理)、`40002`(删除失败)、网络异常,均弹层或 toast 明示,禁止静默
|
||||
|
||||
## 目录结构(仅列受影响文件)
|
||||
|
||||
```
|
||||
miniprogram/
|
||||
├── app.json # [MODIFY] pages 数组末尾注册 pages/ai-pending/ai-pending
|
||||
├── app.wxss # [MODIFY] 上移共用「无缝拼图封面」样式(.collage/.cg/.pic/.ph/.cg-more/.cg-badge),供穿搭页与 AI 穿搭页共用
|
||||
├── types/models.ts # [MODIFY] OutfitItem 补 status: number(1 正常 2 封禁 3 待保存),与后端 OutfitCreateResponse 对齐
|
||||
├── utils/outfit-status.ts # [NEW] 状态口径单一事实来源:胶囊文案+类名、isPendingSave/isBanned
|
||||
├── utils/outfit-cover.ts # [NEW] 拼图封面组装:由 items 取前 4 件图片、生成 4/3/2/1 分格类名与 +N 角标、空数据补线稿格
|
||||
├── pages/ai-pending/ai-pending.json # [NEW] usingComponents 声明 navigation-bar / empty-state / v2-overlay(相对路径 ../../components/...)
|
||||
├── pages/ai-pending/ai-pending.wxml # [NEW] 页头 + 下划线分段(待保存/已保存)+ 说明行 + 卡片列表(封面/名称/场景胶囊/件数/时间)+ 操作行 + 骨架 + 空态 + 删除确认弹层
|
||||
├── pages/ai-pending/ai-pending.wxss # [NEW] 页面级样式:入口说明行、卡片操作行(ink 主按钮 + 文字按钮)、角标;拼图卡与全局类一律复用 app.wxss
|
||||
├── pages/ai-pending/ai-pending.ts # [NEW] 分页拉取 source=2 + 按 status 拆段 + 保存/删除/重新生成/详情 + 下拉刷新与触底加载
|
||||
├── pages/outfit/outfit.ts # [MODIFY] 我的穿搭请求带 source=1;getStatusText 收敛到共享口径;新增待保存计数与跳转 AI 穿搭页
|
||||
├── pages/outfit/outfit.wxml # [MODIFY] 我的穿搭分段下新增「AI 穿搭」入口卡片(含待保存角标)
|
||||
├── pages/outfit/outfit.wxss # [MODIFY] 入口卡片样式;删除已上移到 app.wxss 的拼图封面重复定义
|
||||
├── pages/ai-recommend/ai-recommend.ts # [MODIFY] 新增进入 AI 穿搭页;90402 文案引导 + 弹层加「去看看」;换一套确认文案补未保存提示
|
||||
├── pages/ai-recommend/ai-recommend.wxml # [MODIFY] 场景抽屉底部新增「查看我的 AI 穿搭」文字入口
|
||||
├── pages/outfit-detail/outfit-detail.ts # [MODIFY] 待保存态保存逻辑(含防抖与 40000 兜底)
|
||||
└── pages/outfit-detail/outfit-detail.wxml # [MODIFY] 待保存显示「保存到我的穿搭」并隐藏公开;封禁态只读、隐藏更多入口与状态提示行
|
||||
```
|
||||
|
||||
## 关键结构(供三个模块共用的状态口径)
|
||||
|
||||
```ts
|
||||
export type OutfitStatusPill = { text: string; cls: string }
|
||||
export type OutfitStatusLike = { status?: number; source?: number; is_public?: number }
|
||||
|
||||
// 待保存(3) 优先于来源判定;封禁(2) 降透明度;AI 已保存(1 且 source=2) 用实心黑胶囊
|
||||
export function outfitStatusPill(o: OutfitStatusLike): OutfitStatusPill
|
||||
export function isPendingSave(o: OutfitStatusLike): boolean
|
||||
export function isBanned(o: OutfitStatusLike): boolean
|
||||
```
|
||||
|
||||
## 实现要点(执行注意)
|
||||
|
||||
- 单位统一 rpx(v2 的 px×2);只允许使用 `app.wxss` 令牌与既有通用类,禁止新增色值(零彩色审计会拦截)
|
||||
- `empty-state` / `v2-overlay` 等组件为 `styleIsolation: apply-shared`,页面样式不要反向覆盖组件内部类
|
||||
- 日志统一 `logger` + 模块名常量(新页 `LOG = 'ai-pending'`),失败一律 warn 级并带 `code`,不打日志敏感信息
|
||||
- 工作区已有未提交改动(outfit / ai-recommend / outfit-detail / clothes-detail 等),本轮改动必须叠加在其之上,不回溯、不重构无关逻辑
|
||||
- 本环境无法启动微信开发者工具,视觉只做静态核对,最终以真机预览为准
|
||||
|
||||
## 设计说明
|
||||
|
||||
新增页面严格沿用既有 v2 零彩色灰阶杂志风设计系统,不引入任何新风格要素:颜色只取灰阶令牌,衬线字体用于大标题与数字,无衬线用于正文与按钮,0.5px 发丝线替代边框,仅胶囊/抽屉/小控件使用圆角,统一 `cubic-bezier(.32,.72,0,1)` 动效与 `scale(.97)` 按压反馈。
|
||||
|
||||
## 页面区块设计(AI 穿搭页,自上而下)
|
||||
|
||||
1. **顶部导航**:透明起点为 34px 左对齐衬线大标题,向下滚动收缩为居中玻璃小标题,右侧无操作项。
|
||||
2. **页头**:10px 大写字距 eyebrow「AI Styling」+ 衬线 display「AI 穿搭」,左右 20px 内边距。
|
||||
3. **下划线式文字分段**:待保存 / 已保存,选中项黑色加粗 + 2px 实心黑下划线,未选中为中性灰,左右滑动不折行。
|
||||
4. **待保存段**:首行 13px 中性灰说明「保存后进入「我的穿搭」;超过 7 天未保存将自动清理」;卡片为直角无缝拼图封面(4/3/2/1 分格,超出显示 +N 角标,件数胶囊叠右下),下方名称(17px/600)+ 场景描边胶囊 + 生成时间;卡片底部操作行:实心黑主按钮「保存到我的穿搭」占满剩余宽度,「重新生成」「删除」为 14px 中性灰文字按钮。
|
||||
5. **已保存段**:同款卡片但仅展示与点击进入详情,无内联操作按钮,保持信息密度一致。
|
||||
6. **状态视图**:骨架为灰阶占位条 + 图片块;空态用线稿图标 + 标题 + 说明 + 主按钮;触底显示「没有更多了」小字。
|
||||
7. **玻璃弹层**:删除使用居中玻璃 modal(标题 + 说明「删除后不可恢复」+ 取消 / 确认删除),点击遮罩取消,主按钮为实心黑。
|
||||
8. **动效与响应**:列表项入场 40ms 递进淡入上移,分段切换 320ms 交叉淡入,按钮按压 `scale(.97)` 并降至 60% 透明度;375px 基准宽度自适应,底部预留安全区与 64px 触底间距。
|
||||
|
||||
## Agent Extensions
|
||||
|
||||
### SubAgent
|
||||
|
||||
- **code-explorer**
|
||||
- Purpose: 跨文件盘点状态口径相关的全部引用点(`OutfitItem` / `getStatusText` / `source` 判定 / `outfit-status` 使用处)与旧口径残留,确保「我的穿搭只显示手工创建」与状态胶囊改造无遗漏、无死代码
|
||||
- Expected outcome: 给出引用点清单与残留清单,作为状态口径收敛与最终走查的核对依据
|
||||
|
||||
### Skill
|
||||
|
||||
- **lsp-code-analysis**
|
||||
- Purpose: 用语义分析确认 `OutfitItem`、`OutfitSource`、新增 `utils/outfit-status` 的定义与全部引用关系(含类型错误定位),避免仅靠文本搜索漏掉动态引用与类型不匹配
|
||||
- Expected outcome: 得到准确的引用/定义清单与类型诊断,确认 `status` 字段补齐后无类型错误、无未使用符号
|
||||
|
||||
- **playwright-cli**
|
||||
- Purpose: 打开 `stitch_ai_ui_v2/index.html` 的穿搭相关参考屏(outfit / outfit-detail / spec 规范页),抓取分段条、卡片、胶囊、发丝线、间距与字号的真实样式值,与新页面 WXSS 逐项核对
|
||||
- Expected outcome: 输出一份「参考稿样式值 vs 新页面实现值」的逐项对照结论,修正偏差项,保证新页面与既有 v2 视觉零漂移
|
||||
@@ -11,6 +11,7 @@
|
||||
"pages/outfit-detail/outfit-detail",
|
||||
"pages/outfit-edit/outfit-edit",
|
||||
"pages/ai-recommend/ai-recommend",
|
||||
"pages/ai-pending/ai-pending",
|
||||
"pages/notification-detail/notification-detail",
|
||||
"pages/closet-share/closet-share",
|
||||
"pages/closet-share-create/closet-share-create",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,33 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { AiRecommendResult, ClothesItem } from '../../types/models'
|
||||
import {
|
||||
AiRecommendResult,
|
||||
AiRecommendSubmitResult,
|
||||
AiRecommendTaskStatus,
|
||||
ClothesItem,
|
||||
} from '../../types/models'
|
||||
import { STORAGE_KEYS } from '../../utils/config'
|
||||
import { navScroll } from '../../utils/nav'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
// 日志模块名
|
||||
const LOG = 'ai-recommend'
|
||||
|
||||
// 异步任务的轮询节奏:后端建议 2 秒一次、上限 90 次(≈3 分钟,与后端任务超时一致)
|
||||
const POLL_INTERVAL = 2000
|
||||
const POLL_MAX = 90
|
||||
const TASK_DONE = 2
|
||||
const TASK_FAIL = 3
|
||||
|
||||
// 玻璃弹层按钮(type 缺省为非主按钮)
|
||||
type OverlayButton = { text: string; value: string; type?: string }
|
||||
|
||||
// 失败提示按钮:任务快照过期时结果可能已生成落库,多给一个去「AI 穿搭」找回的出口
|
||||
const ERR_BUTTONS_ACK: OverlayButton[] = [{ text: '知道了', value: 'ok', type: 'main' }]
|
||||
const ERR_BUTTONS_WITH_PENDING: OverlayButton[] = [
|
||||
{ text: '知道了', value: '' },
|
||||
{ text: '去 AI 穿搭', value: 'pending', type: 'main' },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
state: 'scene' as 'scene' | 'generating' | 'result' | 'empty',
|
||||
@@ -15,6 +37,8 @@ Page({
|
||||
result: null as AiRecommendResult | null,
|
||||
clothes: [] as ClothesItem[],
|
||||
generating: false,
|
||||
// 生成中的已等待秒数:慢生成时有心跳反馈,避免误以为卡死
|
||||
genElapsed: 0,
|
||||
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
|
||||
navScrolled: false,
|
||||
// v2 玻璃弹层
|
||||
@@ -28,6 +52,11 @@ Page({
|
||||
{ text: '取消', value: '' },
|
||||
{ text: '去购买', value: 'ok', type: 'main' },
|
||||
],
|
||||
// 错误提示(提交失败 / 生成失败 / 保存失败 / 超时,必须有可见反馈)
|
||||
errVisible: false,
|
||||
errTitle: '生成失败',
|
||||
errText: '',
|
||||
errButtons: ERR_BUTTONS_ACK,
|
||||
},
|
||||
|
||||
// scroll-view 滚动 → 导航收缩为玻璃小标题
|
||||
@@ -35,6 +64,25 @@ Page({
|
||||
|
||||
noop() {},
|
||||
|
||||
// 进入页面:若上次离开时还有未完成的生成任务,续接轮询(后端异步不中断)
|
||||
onShow() {
|
||||
const taskId = wx.getStorageSync(STORAGE_KEYS.AI_TASK)
|
||||
if (!taskId || this.data.state === 'result') return
|
||||
logger.info(LOG, '发现未完成的 AI 推荐任务,续接轮询, taskId=', taskId)
|
||||
this.setData({ state: 'generating', generating: true, showSceneSheet: false })
|
||||
this.startPoll(taskId)
|
||||
},
|
||||
|
||||
// 离开页面只停本地轮询:后端任务继续跑,回来续接,中间可去「AI 穿搭」找结果
|
||||
onHide() {
|
||||
this.stopPoll()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.stopPoll()
|
||||
this.stopElapsed()
|
||||
},
|
||||
|
||||
selectDaily() {
|
||||
this.setData({ sceneType: 1 })
|
||||
},
|
||||
@@ -80,6 +128,7 @@ Page({
|
||||
this.doGenerate(pending.sceneType, pending.scene)
|
||||
},
|
||||
|
||||
// 生成为异步任务:先提交拿 task_id,再轮询状态接口取结果
|
||||
doGenerate(sceneType: number, scene: string) {
|
||||
if (this.data.generating) return
|
||||
this.setData({ state: 'generating', generating: true })
|
||||
@@ -87,10 +136,102 @@ Page({
|
||||
const body: Record<string, any> = { scene_type: sceneType }
|
||||
if (sceneType === 2 && scene) body.scene = scene
|
||||
|
||||
request<AiRecommendResult>({ url: '/ai-recommend', method: 'POST', data: body, showError: false })
|
||||
.then(async (res) => {
|
||||
logger.info(LOG, 'AI 推荐生成成功, 单品数=', res.outfit_item && res.outfit_item.length)
|
||||
const clothes = await Promise.all(
|
||||
request<AiRecommendSubmitResult>({ url: '/ai-recommend', method: 'POST', data: body, showError: false })
|
||||
.then((res) => {
|
||||
logger.info(LOG, 'AI 推荐任务已提交, taskId=', res.task_id)
|
||||
// 落 storage:中途退出页面再进来可续接轮询(后端生成不中断)
|
||||
wx.setStorageSync(STORAGE_KEYS.AI_TASK, res.task_id)
|
||||
this.startPoll(res.task_id)
|
||||
})
|
||||
.catch((e: any) => {
|
||||
const code = e && e.code
|
||||
logger.warn(LOG, 'AI 推荐任务提交失败, code=', code, 'msg=', e && e.message)
|
||||
if (code === 90400) {
|
||||
// 衣橱暂无可用衣物等:走空态引导上传
|
||||
this.stopElapsed()
|
||||
this.setData({ generating: false, state: 'empty', showSceneSheet: false })
|
||||
return
|
||||
}
|
||||
if (code === 90401 || code === 80000) {
|
||||
// 额度不足:提交时就已校验,弹窗引导购买
|
||||
this.failBackToScene()
|
||||
this.showLimitModal()
|
||||
return
|
||||
}
|
||||
if (code === 10003) {
|
||||
this.failBackToScene()
|
||||
wx.showToast({ title: '操作过于频繁,请稍后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const detail = (e && e.details && e.details[0]) || (e && e.message) || ''
|
||||
this.failBackToScene(code ? detail || '提交失败,请稍后重试' : '网络异常,请检查网络后重试')
|
||||
})
|
||||
},
|
||||
|
||||
// 轮询任务状态:2 秒一次、上限 90 次
|
||||
startPoll(taskId: string) {
|
||||
this.stopPoll()
|
||||
;(this as any).pollTaskId = taskId
|
||||
;(this as any).pollCount = 0
|
||||
this.startElapsed()
|
||||
const tick = () => {
|
||||
const count = ((this as any).pollCount || 0) + 1
|
||||
;(this as any).pollCount = count
|
||||
if (count > POLL_MAX) {
|
||||
this.stopPoll()
|
||||
logger.warn(LOG, 'AI 推荐轮询超时, taskId=', taskId, '查询次数=', count)
|
||||
this.failBackToScene('生成超时,请稍后重试', true)
|
||||
return
|
||||
}
|
||||
this.queryTaskStatus(taskId)
|
||||
}
|
||||
;(this as any).pollTimer = setInterval(tick, POLL_INTERVAL)
|
||||
// 立即查一次,避免白等一个轮询间隔
|
||||
tick()
|
||||
},
|
||||
|
||||
queryTaskStatus(taskId: string) {
|
||||
request<AiRecommendTaskStatus>({ url: '/ai-recommend/status', data: { task_id: taskId }, showError: false })
|
||||
.then((res) => {
|
||||
if (res.status === TASK_DONE && res.result) {
|
||||
this.stopPoll()
|
||||
logger.info(LOG, 'AI 推荐生成完成, taskId=', taskId)
|
||||
this.renderResult(res.result)
|
||||
return
|
||||
}
|
||||
if (res.status === TASK_FAIL) {
|
||||
this.stopPoll()
|
||||
logger.warn(
|
||||
LOG,
|
||||
'AI 推荐生成失败, taskId=',
|
||||
taskId,
|
||||
'errorCode=',
|
||||
res.error_code,
|
||||
'errorMsg=',
|
||||
res.error_msg
|
||||
)
|
||||
this.failBackToScene(res.error_msg || '生成失败,请稍后重试')
|
||||
return
|
||||
}
|
||||
// status === 1:继续等待,等待时长由 genElapsed 体现
|
||||
})
|
||||
.catch((e: any) => {
|
||||
const code = e && e.code
|
||||
logger.warn(LOG, 'AI 推荐状态查询失败, taskId=', taskId, 'code=', code)
|
||||
if (code === 90402) {
|
||||
// 任务快照 15 分钟过期:生成结果已落库为「待保存」,引导去 AI 穿搭找回
|
||||
this.stopPoll()
|
||||
this.failBackToScene('本次生成结果已过期。若已生成完成,可在「AI 穿搭」里查看。', true)
|
||||
}
|
||||
// 其它错误(网络抖动)不打断等待,交给轮询上限兜底
|
||||
})
|
||||
},
|
||||
|
||||
// 成功:拉齐单品详情后渲染结果页
|
||||
renderResult(res: AiRecommendResult) {
|
||||
this.setData({ genElapsed: 0 })
|
||||
this.stopElapsed()
|
||||
Promise.all(
|
||||
res.outfit_item.map(async (it) => {
|
||||
try {
|
||||
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
|
||||
@@ -99,26 +240,58 @@ Page({
|
||||
return { clothes_uuid: it.clothes_uuid } as ClothesItem
|
||||
}
|
||||
})
|
||||
)
|
||||
).then((clothes) => {
|
||||
wx.removeStorageSync(STORAGE_KEYS.AI_TASK)
|
||||
logger.info(LOG, 'AI 推荐结果渲染完成, 单品数=', clothes.length)
|
||||
this.setData({ result: res, clothes, state: 'result', generating: false })
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this.setData({ generating: false })
|
||||
const code = e && e.code
|
||||
logger.warn(LOG, 'AI 推荐生成失败, code=', code)
|
||||
if (code === 90400) {
|
||||
this.setData({ state: 'empty' })
|
||||
} else if (code === 90401 || code === 80000) {
|
||||
// 90401=AI 推荐次数上限;80000=额度不足兜底,统一弹窗引导购买
|
||||
this.setData({ state: 'scene', showSceneSheet: true })
|
||||
this.showLimitModal()
|
||||
} else if (code === 10003) {
|
||||
wx.showToast({ title: '正在生成中,请稍候', icon: 'none' })
|
||||
this.setData({ state: 'scene', showSceneSheet: true })
|
||||
} else {
|
||||
this.setData({ state: 'scene', showSceneSheet: true })
|
||||
}
|
||||
},
|
||||
|
||||
// 失败:回场景抽屉;带文案时弹提示(绝不静默返回)
|
||||
failBackToScene(errText?: string, toPending = false) {
|
||||
this.stopElapsed()
|
||||
wx.removeStorageSync(STORAGE_KEYS.AI_TASK)
|
||||
this.setData({
|
||||
state: 'scene',
|
||||
generating: false,
|
||||
showSceneSheet: true,
|
||||
})
|
||||
if (errText) this.showError('生成失败', errText, toPending)
|
||||
},
|
||||
|
||||
// 统一错误出口:标题 + 文案 + 可选「去 AI 穿搭」按钮
|
||||
showError(title: string, text: string, toPending = false) {
|
||||
this.setData({
|
||||
errTitle: title,
|
||||
errText: text,
|
||||
errButtons: toPending ? ERR_BUTTONS_WITH_PENDING : ERR_BUTTONS_ACK,
|
||||
errVisible: true,
|
||||
})
|
||||
},
|
||||
|
||||
stopPoll() {
|
||||
const timer = (this as any).pollTimer
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
;(this as any).pollTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
// 生成中的秒级心跳
|
||||
startElapsed() {
|
||||
this.stopElapsed()
|
||||
this.setData({ genElapsed: 0 })
|
||||
;(this as any).elapsedTimer = setInterval(() => {
|
||||
this.setData({ genElapsed: (this.data.genElapsed || 0) + 1 })
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
stopElapsed() {
|
||||
const timer = (this as any).elapsedTimer
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
;(this as any).elapsedTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
showLimitModal() {
|
||||
@@ -138,16 +311,31 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
onErrConfirm(e: WechatMiniprogram.CustomEvent) {
|
||||
this.setData({ errVisible: false })
|
||||
// 任务过期 / 超时:结果可能已经在「AI 穿搭」里落库,给一条直达路径
|
||||
if (e.detail.value === 'pending') this.onAiPending()
|
||||
},
|
||||
|
||||
// 去 AI 穿搭页:查看/保存历史生成结果(待保存 / 已保存)
|
||||
onAiPending() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/ai-pending/ai-pending',
|
||||
fail: (e) => logger.warn(LOG, 'navigateTo AI 穿搭失败, err=', e && e.errMsg),
|
||||
})
|
||||
},
|
||||
|
||||
onSave() {
|
||||
if (!this.data.result) return
|
||||
request({
|
||||
url: '/ai-recommend/save',
|
||||
method: 'POST',
|
||||
data: { outfit_uuid: this.data.result.outfit_uuid },
|
||||
showError: false,
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(LOG, 'AI 穿搭保存成功')
|
||||
wx.showToast({ title: '已保存到我的穿搭', icon: 'success' })
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
wx.redirectTo({
|
||||
url: `/pages/outfit-detail/outfit-detail?uuid=${this.data.result!.outfit_uuid}&owner=1`,
|
||||
@@ -155,7 +343,17 @@ Page({
|
||||
})
|
||||
}, 500)
|
||||
})
|
||||
.catch((e: any) => logger.warn(LOG, 'AI 穿搭保存失败, code=', e && e.code))
|
||||
.catch((e: any) => {
|
||||
const code = e && e.code
|
||||
logger.warn(LOG, 'AI 穿搭保存失败, code=', code)
|
||||
// 结果仍在页面上,给出可见反馈让用户直接重试
|
||||
if (code === 40000) {
|
||||
this.showError('保存失败', '这套搭配已被清理,请重新生成。', true)
|
||||
return
|
||||
}
|
||||
const detail = (e && e.details && e.details[0]) || (e && e.message) || ''
|
||||
this.showError('保存失败', detail || '请稍后重试')
|
||||
})
|
||||
},
|
||||
|
||||
onReroll() {
|
||||
|
||||
@@ -19,7 +19,8 @@ module.exports = { cls: cls }
|
||||
</view>
|
||||
</view>
|
||||
<view class="ai-gen-t">AI 正在为你搭配…</view>
|
||||
<view class="ai-gen-d">结合今日运势与你的衣橱</view>
|
||||
<view class="ai-gen-d">结合今日运势与你的衣橱,生成通常需要 10~30 秒</view>
|
||||
<view class="ai-gen-elapsed num">已等待 {{genElapsed}} 秒</view>
|
||||
</view>
|
||||
|
||||
<!-- 衣橱为空 -->
|
||||
@@ -86,9 +87,10 @@ module.exports = { cls: cls }
|
||||
<!-- 悬浮底部操作条 -->
|
||||
<view class="dock" wx:if="{{state === 'result'}}">
|
||||
<view class="ai-reroll-tip">换一套将再消耗 1 次推荐额度</view>
|
||||
<view class="ai-reroll-tip ai-reroll-sub">未保存的推荐会留在「AI 穿搭」里,可随时找回</view>
|
||||
<view class="dock-row">
|
||||
<view class="btn btn--ghost" bindtap="onReroll">换一套</view>
|
||||
<view class="btn btn--ink" bindtap="onSave">保存到我的穿搭</view>
|
||||
<view class="btn btn--ink" bindtap="onSave">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -125,6 +127,10 @@ module.exports = { cls: cls }
|
||||
</view>
|
||||
<view class="sheet-foot">
|
||||
<view class="btn btn--ink btn--block" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
|
||||
<view class="sheet-more" bindtap="onAiPending">
|
||||
<text>查看我的 AI 穿搭</text>
|
||||
<view class="icon icon-chevron-right"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -134,7 +140,7 @@ module.exports = { cls: cls }
|
||||
visible="{{confirmVisible}}"
|
||||
type="modal"
|
||||
title="AI 穿搭推荐"
|
||||
content="本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭"
|
||||
content="本次将消耗 1 次 AI 推荐额度,生成结果可在「AI 穿搭」里保存与管理"
|
||||
buttons="{{confirmButtons}}"
|
||||
bind:confirm="onConfirmGenerate"
|
||||
bind:cancel="onConfirmCancel"
|
||||
@@ -150,4 +156,14 @@ module.exports = { cls: cls }
|
||||
bind:confirm="onLimitConfirm"
|
||||
bind:cancel="onLimitCancel"
|
||||
/>
|
||||
|
||||
<!-- 错误提示(提交/生成/保存失败、超时、结果过期) -->
|
||||
<v2-overlay
|
||||
visible="{{errVisible}}"
|
||||
type="modal"
|
||||
title="{{errTitle}}"
|
||||
content="{{errText}}"
|
||||
buttons="{{errButtons}}"
|
||||
bind:confirm="onErrConfirm"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -67,6 +67,14 @@
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* 异步轮询期间的心跳:让慢生成看得见进度,避免误以为卡死 */
|
||||
.ai-gen-elapsed {
|
||||
margin-top: 16rpx;
|
||||
font-size: 23rpx;
|
||||
color: var(--ink-3);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
/* 结果 */
|
||||
.ai-cover {
|
||||
padding: 40rpx var(--pad) 0;
|
||||
@@ -145,6 +153,34 @@
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* 第二行说明:换一套后旧结果仍可在「AI 穿搭」找回 */
|
||||
.ai-reroll-sub {
|
||||
margin-top: -10rpx;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
/* 场景抽屉底部:进入 AI 穿搭管理页的文字入口 */
|
||||
.sheet-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding-top: 30rpx;
|
||||
font-size: 25rpx;
|
||||
color: var(--ink-2);
|
||||
transition: opacity .18s var(--ease);
|
||||
}
|
||||
|
||||
.sheet-more:active {
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.sheet-more .icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.dock-row .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
|
||||
import { OutfitItem, ClothesItem } from '../../types/models'
|
||||
import { IsPublic } from '../../utils/config'
|
||||
import { OUTFIT_STATUS, OutfitStatusPill, isBanned, isPendingSave, outfitStatusPill } from '../../utils/outfit-status'
|
||||
import { navScroll } from '../../utils/nav'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
@@ -10,6 +11,14 @@ const LOG = 'outfit-detail'
|
||||
// 接收者打开「分享者额度不足」链接后的自动返回倒计时(秒)
|
||||
const SHARE_FAIL_BACK_SECONDS = 10
|
||||
|
||||
// 提示弹层按钮(type 缺省为非主按钮)
|
||||
type OverlayButton = { text: string; value: string; type?: string }
|
||||
const TIP_BUTTONS_ACK: OverlayButton[] = [{ text: '我知道了', value: 'ok', type: 'main' }]
|
||||
const TIP_BUTTONS_TO_PENDING: OverlayButton[] = [
|
||||
{ text: '知道了', value: '' },
|
||||
{ text: '去 AI 穿搭', value: 'pending', type: 'main' },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
uuid: '',
|
||||
@@ -21,6 +30,12 @@ Page({
|
||||
likeCount: 0,
|
||||
isCollected: false,
|
||||
loading: true,
|
||||
// 状态派生标记(WXML 不能调用方法,取数后一次算好):
|
||||
// pendingSave=待保存的 AI 推荐(status=3,可直接保存);banned=已封禁(status=2,只读)
|
||||
pendingSave: false,
|
||||
banned: false,
|
||||
statePill: { text: '仅自己可见', cls: '' } as OutfitStatusPill,
|
||||
saving: false,
|
||||
// 页面状态:normal 正常;quota 接收者命中「分享者额度不足」;invalid 加载失败
|
||||
pageState: 'normal' as 'normal' | 'quota' | 'invalid',
|
||||
invalidTitle: '',
|
||||
@@ -33,7 +48,7 @@ Page({
|
||||
tipVisible: false,
|
||||
tipTitle: '',
|
||||
tipText: '',
|
||||
tipButtons: [{ text: '我知道了', value: 'ok', type: 'main' }],
|
||||
tipButtons: TIP_BUTTONS_ACK,
|
||||
pubVisible: false,
|
||||
pubButtons: [
|
||||
{ text: '取消', value: '' },
|
||||
@@ -88,15 +103,31 @@ Page({
|
||||
pageState: 'quota',
|
||||
loading: false,
|
||||
countdown: SHARE_FAIL_BACK_SECONDS,
|
||||
tipVisible: true,
|
||||
tipTitle: '提示',
|
||||
tipText: '给您分享的好友额度不足哦',
|
||||
})
|
||||
this.showTip('提示', '给您分享的好友额度不足哦')
|
||||
this.startBackCountdown()
|
||||
},
|
||||
|
||||
onTipConfirm() {
|
||||
// 统一提示出口:标题 + 文案 +(可选)去 AI 穿搭的出口
|
||||
showTip(title: string, text: string, toPending = false) {
|
||||
this.setData({
|
||||
tipVisible: true,
|
||||
tipTitle: title,
|
||||
tipText: text,
|
||||
tipButtons: toPending ? TIP_BUTTONS_TO_PENDING : TIP_BUTTONS_ACK,
|
||||
})
|
||||
},
|
||||
|
||||
onTipConfirm(e: WechatMiniprogram.CustomEvent) {
|
||||
this.setData({ tipVisible: false })
|
||||
if (e && e.detail && e.detail.value === 'pending') this.onAiPending()
|
||||
},
|
||||
|
||||
onAiPending() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/ai-pending/ai-pending',
|
||||
fail: (err) => logger.warn(LOG, 'navigateTo AI 穿搭失败, err=', err && err.errMsg),
|
||||
})
|
||||
},
|
||||
|
||||
startBackCountdown() {
|
||||
@@ -180,8 +211,21 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug(LOG, '穿搭详情加载成功, 单品数=', outfit.items && outfit.items.length)
|
||||
this.setData({ outfit, loading: false })
|
||||
logger.debug(
|
||||
LOG,
|
||||
'穿搭详情加载成功, 单品数=',
|
||||
outfit.items && outfit.items.length,
|
||||
'status=',
|
||||
outfit.status
|
||||
)
|
||||
// 状态口径统一由 utils/outfit-status.ts 决定:待保存可直接保存、封禁只读
|
||||
this.setData({
|
||||
outfit,
|
||||
loading: false,
|
||||
pendingSave: isPendingSave(outfit),
|
||||
banned: isBanned(outfit),
|
||||
statePill: outfitStatusPill(outfit),
|
||||
})
|
||||
|
||||
// 封面与衣物缩略图直接使用穿搭明细里的抠图(processed_image),
|
||||
// 避免非属主逐件请求 /clothes/:uuid 被拒(50005)导致图片与名称全部丢失。
|
||||
@@ -242,6 +286,37 @@ Page({
|
||||
.catch((e: any) => logger.warn(LOG, '收藏操作失败, action=', action, 'code=', e && e.code))
|
||||
},
|
||||
|
||||
// 待保存的 AI 推荐:详情页直接保存为正式穿搭(status 3 → 1),不必回 AI 穿搭页
|
||||
onSaveAsOutfit() {
|
||||
if (this.data.saving) return
|
||||
this.setData({ saving: true })
|
||||
logger.info(LOG, '保存待保存穿搭, uuid=', this.data.uuid)
|
||||
request({
|
||||
url: '/ai-recommend/save',
|
||||
method: 'POST',
|
||||
data: { outfit_uuid: this.data.uuid },
|
||||
showError: false,
|
||||
})
|
||||
.then(() => {
|
||||
const outfit = { ...this.data.outfit, status: OUTFIT_STATUS.NORMAL }
|
||||
logger.info(LOG, '待保存穿搭保存成功, uuid=', this.data.uuid)
|
||||
this.setData({ saving: false, outfit, pendingSave: false, statePill: outfitStatusPill(outfit) })
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this.setData({ saving: false })
|
||||
const code = e && e.code
|
||||
logger.warn(LOG, '待保存穿搭保存失败, code=', code)
|
||||
if (code === 40000) {
|
||||
// 超过 7 天被后端定时清理:给出明确出口,不让用户反复点必然失败的按钮
|
||||
this.showTip('保存失败', '这套搭配已被清理,请重新生成。', true)
|
||||
return
|
||||
}
|
||||
const detail = (e && e.details && e.details[0]) || (e && e.message) || ''
|
||||
this.showTip('保存失败', detail || '请稍后重试')
|
||||
})
|
||||
},
|
||||
|
||||
onTogglePublic() {
|
||||
const isPublic = this.data.outfit.is_public === IsPublic.PUBLIC
|
||||
if (isPublic) {
|
||||
@@ -270,7 +345,8 @@ Page({
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(LOG, '公开状态变更成功, isPublic=', isPublic)
|
||||
this.setData({ 'outfit.is_public': isPublic })
|
||||
const outfit = { ...this.data.outfit, is_public: isPublic }
|
||||
this.setData({ outfit, statePill: outfitStatusPill(outfit) })
|
||||
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
|
||||
})
|
||||
.catch((e: any) => {
|
||||
@@ -319,7 +395,7 @@ Page({
|
||||
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
|
||||
this.setData({ delVisible: false })
|
||||
if (e.detail.value !== 'ok') return
|
||||
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
|
||||
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE', showError: false })
|
||||
.then(() => {
|
||||
logger.info(LOG, '穿搭删除成功')
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
@@ -331,7 +407,11 @@ Page({
|
||||
500,
|
||||
)
|
||||
})
|
||||
.catch((e2: any) => logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code))
|
||||
.catch((e2: any) => {
|
||||
logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code)
|
||||
const detail = (e2 && e2.details && e2.details[0]) || (e2 && e2.message) || ''
|
||||
this.showTip('删除失败', e2 && e2.code === 40000 ? '这套搭配已不存在' : detail || '请稍后重试')
|
||||
})
|
||||
},
|
||||
|
||||
onItemTap(e: WechatMiniprogram.TouchEvent) {
|
||||
|
||||
@@ -10,8 +10,9 @@ module.exports = { cls: cls }
|
||||
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}" scrolled="{{navScrolled}}">
|
||||
<!-- 封禁态只读:不提供编辑/删除入口 -->
|
||||
<view
|
||||
wx:if="{{isOwner && pageState === 'normal' && !loading}}"
|
||||
wx:if="{{isOwner && pageState === 'normal' && !loading && !banned}}"
|
||||
slot="right"
|
||||
class="nav-more"
|
||||
bindtap="onMore"
|
||||
@@ -71,9 +72,11 @@ module.exports = { cls: cls }
|
||||
<view class="od-tags">
|
||||
<text wx:if="{{outfit.scene}}" class="pill pill--line">{{outfit.scene}}</text>
|
||||
<text class="pill {{outfit.source === 2 ? 'pill--ink' : ''}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
|
||||
<text class="pill {{outfit.is_public === 2 ? 'pill--line' : ''}}">{{outfit.is_public === 2 ? '已公开' : '仅自己可见'}}</text>
|
||||
<text class="pill {{statePill.cls}}">{{statePill.text}}</text>
|
||||
</view>
|
||||
<view class="t-bd od-desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
|
||||
<view class="od-note" wx:if="{{banned}}">该穿搭已被封禁,暂不支持编辑、公开与分享</view>
|
||||
<view class="od-note" wx:elif="{{pendingSave}}">AI 生成的推荐,保存后归入「AI 穿搭」的「已保存」</view>
|
||||
</view>
|
||||
|
||||
<!-- 包含衣物:横滑单品 -->
|
||||
@@ -115,7 +118,11 @@ module.exports = { cls: cls }
|
||||
<view class="icon {{isCollected ? 'icon-star-fill' : 'icon-star'}}"></view>
|
||||
</view>
|
||||
<view class="spacer"></view>
|
||||
<block wx:if="{{isOwner}}">
|
||||
<!-- 待保存的 AI 推荐:唯一操作是保存;封禁态只读,隐藏管理操作与分享 -->
|
||||
<block wx:if="{{isOwner && pendingSave}}">
|
||||
<view class="btn btn--ink btn--sm" bindtap="onSaveAsOutfit">{{saving ? '保存中…' : '保存'}}</view>
|
||||
</block>
|
||||
<block wx:elif="{{isOwner && !banned}}">
|
||||
<view class="btn btn--ghost btn--sm" bindtap="onTogglePublic">{{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}</view>
|
||||
<button class="od-share" open-type="share">
|
||||
<view class="icon icon-share"></view>
|
||||
@@ -125,7 +132,7 @@ module.exports = { cls: cls }
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 提示(分享者额度不足) -->
|
||||
<!-- 提示(分享者额度不足 / 保存失败) -->
|
||||
<v2-overlay
|
||||
visible="{{tipVisible}}"
|
||||
type="modal"
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
|
||||
/* 状态说明行:待保存 / 已封禁 */
|
||||
.od-note {
|
||||
margin-top: 22rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.od-desc {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { OutfitItem, ClothesItem, PagedResult } from '../../types/models'
|
||||
import { OutfitSource } from '../../utils/config'
|
||||
import { normalizePaged, hasMorePaged } from '../../utils/paged'
|
||||
import { navScroll } from '../../utils/nav'
|
||||
import { logger } from '../../utils/logger'
|
||||
@@ -218,6 +219,9 @@ Page({
|
||||
scene: this.data.scene,
|
||||
items: this.data.selectedUuids.map((u) => ({ clothes_uuid: u })),
|
||||
}
|
||||
// 创建态:后端 OutfitCreateRequest.Source 为 oneof=1 2 必填校验(漏传会报校验失败)
|
||||
// 编辑态:OutfitUpdateByUUIDRequest 没有 source 字段,不能传,避免把 AI 来源改掉
|
||||
if (!this.data.isEdit) body.source = OutfitSource.USER
|
||||
// 编辑态后端 OutfitUpdateByUUIDRequest.OutfitUUID 为必填(binding required),请求体需携带
|
||||
if (this.data.isEdit) body.outfit_uuid = this.data.uuid
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { request, redirectToLogin } from '../../utils/request'
|
||||
import * as auth from '../../utils/auth'
|
||||
import { PagedResult, OutfitItem, CommunityOutfitItem } from '../../types/models'
|
||||
import { OutfitSource, IsPublic, STORAGE_KEYS } from '../../utils/config'
|
||||
import { OutfitSource, STORAGE_KEYS } from '../../utils/config'
|
||||
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
|
||||
import { CoverCell, outfitCover } from '../../utils/outfit-cover'
|
||||
import { OutfitStatusPill, isPendingSave, outfitStatusPill } from '../../utils/outfit-status'
|
||||
import { setTabBarSelected } from '../../utils/tabbar'
|
||||
import { formatRelativeTime } from '../../utils/format'
|
||||
import { navScroll } from '../../utils/nav'
|
||||
@@ -11,16 +13,17 @@ import { logger } from '../../utils/logger'
|
||||
// 日志模块名
|
||||
const LOG = 'outfit'
|
||||
|
||||
// 拼图封面单元:无图时前端渲染线稿占位
|
||||
type CoverCell = { k: string; img: string }
|
||||
// AI 穿搭入口角标:只统计首页 source=2 里的待保存数量。
|
||||
// 后端 GET /outfit 只支持 source / is_public 过滤,没有 status 过滤,故不做全量翻页统计。
|
||||
const PENDING_COUNT_PAGE_SIZE = 20
|
||||
|
||||
// 列表行:我的穿搭/已收藏来自 OutfitItem;社区来自 CommunityOutfitItem(额外带作者与互动计数)。
|
||||
// 统一为交叉类型,模板按 activeTab 条件渲染差异字段。
|
||||
type OutfitRow = OutfitItem & Partial<CommunityOutfitItem> & {
|
||||
_likeCount?: number
|
||||
_collectCount?: number
|
||||
// cls 为胶囊类名(零彩色:pill--ink / pill--line / 空)
|
||||
_status?: { text: string; cls: string }
|
||||
// 状态胶囊(口径收敛在 utils/outfit-status.ts)
|
||||
_status?: OutfitStatusPill
|
||||
_time?: string
|
||||
_covers?: CoverCell[]
|
||||
_collageCls?: string
|
||||
@@ -37,6 +40,8 @@ Page({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
// AI 穿搭待保存数量(status=3):取不到时保持 0,入口只显示文案不显示数字
|
||||
pendingCount: 0,
|
||||
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
|
||||
navScrolled: false,
|
||||
},
|
||||
@@ -59,14 +64,19 @@ Page({
|
||||
this.setData({ activeTab: 1, list: [], loading: true })
|
||||
}
|
||||
}
|
||||
// 从 AI 穿搭页保存/删除后回到本页,列表与角标都要重取
|
||||
// 首帧渲染未结束就 setData 会触发「Expected updated data but get first rendering data」,
|
||||
// 整个 webview 渲染失败,用户视觉上仍看到上一个页面。首次 onShow 的刷新延迟到下一帧
|
||||
if (!(this as any).firstShown) {
|
||||
;(this as any).firstShown = true
|
||||
wx.nextTick(() => this.refresh())
|
||||
wx.nextTick(() => {
|
||||
this.refresh()
|
||||
this.fetchPendingCount()
|
||||
})
|
||||
return
|
||||
}
|
||||
this.refresh()
|
||||
this.fetchPendingCount()
|
||||
},
|
||||
|
||||
syncTabBar() {
|
||||
@@ -87,7 +97,9 @@ Page({
|
||||
|
||||
onRefresh() {
|
||||
this.setData({ refreshing: true })
|
||||
this.fetchList(1).finally(() => this.setData({ refreshing: false }))
|
||||
Promise.all([this.fetchList(1), this.fetchPendingCount()]).finally(() =>
|
||||
this.setData({ refreshing: false })
|
||||
)
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
@@ -95,15 +107,19 @@ Page({
|
||||
this.fetchList(this.data.page + 1)
|
||||
},
|
||||
|
||||
// 我的穿搭只取用户手工创建的穿搭(source=1):AI 生成的穿搭一律走「AI 穿搭」页管理,
|
||||
// 避免待保存(status=3)的 AI 结果混进我的搭配
|
||||
fetchList(page: number): Promise<void> {
|
||||
const tab = this.data.activeTab
|
||||
const url = tab === 1 ? '/collect' : tab === 2 ? '/outfit/community' : '/outfit'
|
||||
const data: Record<string, any> = { page, page_size: this.data.pageSize }
|
||||
if (tab === 0) data.source = OutfitSource.USER
|
||||
|
||||
const seq = ((this as any).reqSeq || 0) + 1
|
||||
;(this as any).reqSeq = seq
|
||||
if (page > 1) this.setData({ loadingMore: true })
|
||||
|
||||
return request<PagedResult<OutfitRow>>({ url, data: { page, page_size: this.data.pageSize } })
|
||||
return request<PagedResult<OutfitRow>>({ url, data })
|
||||
.then(async (res) => {
|
||||
const { list: raw, total } = normalizePaged<OutfitRow>(res)
|
||||
logger.debug(LOG, '列表加载成功, url=', url, 'page=', page, '条数=', raw && raw.length)
|
||||
@@ -126,6 +142,24 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 入口角标:静默取一页 AI 穿搭统计待保存数量;失败降级为不显示数字,不打扰用户
|
||||
fetchPendingCount(): Promise<void> {
|
||||
// 入口只在「我的穿搭」分段展示,其它分段不必发这次请求
|
||||
if (this.data.activeTab !== 0) return Promise.resolve()
|
||||
return request<PagedResult<OutfitRow>>({
|
||||
url: '/outfit',
|
||||
data: { page: 1, page_size: PENDING_COUNT_PAGE_SIZE, source: OutfitSource.AI },
|
||||
showError: false,
|
||||
})
|
||||
.then((res) => {
|
||||
const { list } = normalizePaged<OutfitRow>(res)
|
||||
const count = list.filter((it) => isPendingSave(it)).length
|
||||
logger.debug(LOG, 'AI 穿搭待保存数量统计, count=', count)
|
||||
this.setData({ pendingCount: count })
|
||||
})
|
||||
.catch((e: any) => logger.warn(LOG, 'AI 穿搭待保存数量统计失败(静默降级), code=', e && e.code))
|
||||
},
|
||||
|
||||
decorateList(list: OutfitRow[]): Promise<OutfitRow[]> {
|
||||
// 社区分段:作者与互动计数由后端一次性下发,本地仅补相对时间,避免逐条请求点赞接口
|
||||
if (this.data.activeTab === 2) {
|
||||
@@ -150,33 +184,11 @@ Page({
|
||||
} catch (e: any) {
|
||||
logger.warn(LOG, '点赞数拉取失败, uuid=', it.outfit_uuid, 'code=', e && e.code)
|
||||
}
|
||||
return { ...it, ...this.decorateCover(it), _likeCount: likeCount, _status: this.getStatusText(it) }
|
||||
return { ...it, ...outfitCover(it.items), _likeCount: likeCount, _status: outfitStatusPill(it) }
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
// 无缝拼图封面:取前 4 件,超出用 +N 角标;不足 1 件时补一个线稿占位格
|
||||
decorateCover(it: OutfitItem): { _covers: CoverCell[]; _collageCls: string; _moreCount: number } {
|
||||
const items = it.items || []
|
||||
const covers: CoverCell[] = items.slice(0, 4).map((c) => ({
|
||||
k: c.clothes_uuid,
|
||||
img: c.processed_image || '',
|
||||
}))
|
||||
if (!covers.length) covers.push({ k: 'empty', img: '' })
|
||||
return {
|
||||
_covers: covers,
|
||||
_collageCls: `collage--${covers.length}`,
|
||||
_moreCount: Math.max(items.length - 4, 0),
|
||||
}
|
||||
},
|
||||
|
||||
// 零彩色状态语义:AI 来源 = 黑底白字,已公开 = 描边,仅自己可见 = 浅灰
|
||||
getStatusText(it: OutfitItem): { text: string; cls: string } {
|
||||
if (it.source === OutfitSource.AI) return { text: 'AI 推荐', cls: 'pill--ink' }
|
||||
if (it.is_public === IsPublic.PUBLIC) return { text: '已公开', cls: 'pill--line' }
|
||||
return { text: '仅自己可见', cls: '' }
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/outfit-edit/outfit-edit',
|
||||
@@ -191,6 +203,14 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 进入 AI 穿搭页:AI 生成结果的统一管理入口(待保存 / 已保存)
|
||||
onAiPending() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/ai-pending/ai-pending',
|
||||
fail: (e) => logger.warn(LOG, 'navigateTo AI 穿搭失败, err=', e && e.errMsg),
|
||||
})
|
||||
},
|
||||
|
||||
onOutfitTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const uuid = e.currentTarget.dataset.uuid as string
|
||||
const owner = this.data.activeTab === 0 ? '1' : '0'
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 穿搭入口:AI 生成结果统一在这里保存与管理(待保存 / 已保存) -->
|
||||
<view wx:if="{{activeTab === 0}}" class="ai-entry" bindtap="onAiPending">
|
||||
<view class="icon icon-sparkles ae-ic"></view>
|
||||
<view class="ae-txt">
|
||||
<view class="ae-t">AI 穿搭</view>
|
||||
<view class="ae-s">AI 生成的搭配在这里保存与管理</view>
|
||||
</view>
|
||||
<text wx:if="{{pendingCount > 0}}" class="pill pill--ink">{{pendingCount}} 套待保存</text>
|
||||
<view class="icon icon-chevron-right ae-ch"></view>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view wx:if="{{loading}}" class="of-skel">
|
||||
<view class="of-skel-item" wx:for="{{[1, 2]}}" wx:key="*this">
|
||||
|
||||
@@ -30,6 +30,53 @@
|
||||
gap: 56rpx;
|
||||
}
|
||||
|
||||
/* AI 穿搭入口:仅我的穿搭分段展示,进去管理 AI 生成结果(待保存 / 已保存) */
|
||||
.ai-entry {
|
||||
margin: 32rpx var(--pad) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
padding: 30rpx 0;
|
||||
border-top: 1rpx solid var(--hairline);
|
||||
border-bottom: 1rpx solid var(--hairline);
|
||||
transition: opacity .18s var(--ease);
|
||||
}
|
||||
|
||||
.ai-entry:active {
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.ae-ic {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
color: var(--ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ae-txt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ae-t {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.005em;
|
||||
}
|
||||
|
||||
.ae-s {
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.ae-ch {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
color: var(--ink-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.of-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface OutfitItem {
|
||||
source: number // 1 用户创建 2 AI 推荐
|
||||
is_public: number // 1 私有 2 公开
|
||||
audit_status: number // 1 待审核 2 通过 3 不通过
|
||||
// 穿搭状态:1 正常 2 封禁 3 待用户保存(AI 推荐生成后未保存)
|
||||
// 列表 / 详情 / 创建响应都会返回;超过 7 天未保存的 status=3 会被后端定时清理
|
||||
status: number
|
||||
items: Array<{ clothes_uuid: string; processed_image: string }>
|
||||
}
|
||||
|
||||
@@ -206,6 +209,20 @@ export interface AiRecommendResult {
|
||||
outfit_item: Array<{ clothes_uuid: string }>
|
||||
}
|
||||
|
||||
// AI 推荐为异步任务:提交只拿 task_id,再按 2 秒轮询状态
|
||||
export interface AiRecommendSubmitResult {
|
||||
task_id: string
|
||||
status: number // 1=生成中
|
||||
}
|
||||
|
||||
export interface AiRecommendTaskStatus {
|
||||
task_id: string
|
||||
status: number // 1=生成中 2=成功 3=失败
|
||||
result?: AiRecommendResult
|
||||
error_code?: number
|
||||
error_msg?: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
|
||||
@@ -15,6 +15,8 @@ export const STORAGE_KEYS = {
|
||||
UNREAD: 'zt_closet_unread',
|
||||
OPEN_COLLECT: 'zt_open_collect',
|
||||
OPEN_PROFILE_EDIT: 'zt_open_profile_edit',
|
||||
// AI 推荐为异步任务:存 task_id,退出页面再进来可继续轮询结果
|
||||
AI_TASK: 'zt_ai_recommend_task',
|
||||
}
|
||||
|
||||
// 衣物图片处理状态
|
||||
@@ -202,6 +204,8 @@ export const ERROR_MESSAGES: Record<number, string> = {
|
||||
90300: '运势生成中,请稍后重试',
|
||||
90400: 'AI 推荐失败',
|
||||
90401: 'AI 推荐次数已达上限',
|
||||
90402: '生成任务不存在或已过期,请重新生成',
|
||||
90403: 'AI 推荐正在生成中,请稍后查询结果',
|
||||
// 服务端
|
||||
19000: '服务繁忙,请稍后重试',
|
||||
19001: '服务繁忙,请稍后重试',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 穿搭封面组装:把 outfit.items 转成无缝拼图封面所需的渲染数据。
|
||||
// 穿搭页与 AI 穿搭页共用,避免复制一份后视觉与角标口径分叉。
|
||||
|
||||
export interface CoverCell {
|
||||
k: string
|
||||
img: string
|
||||
}
|
||||
|
||||
export interface OutfitCover {
|
||||
// 前 4 件(不足补线稿占位格),无图时 img 为空串由模板渲染线稿
|
||||
_covers: CoverCell[]
|
||||
// 分格类名:collage--1 / 2 / 3 / 4
|
||||
_collageCls: string
|
||||
// 超出 4 件的剩余数量,>0 时渲染 +N 角标
|
||||
_moreCount: number
|
||||
}
|
||||
|
||||
export function outfitCover(items?: Array<{ clothes_uuid: string; processed_image?: string }>): OutfitCover {
|
||||
const list = items || []
|
||||
const covers: CoverCell[] = list.slice(0, 4).map((c) => ({
|
||||
k: c.clothes_uuid,
|
||||
img: c.processed_image || '',
|
||||
}))
|
||||
if (!covers.length) covers.push({ k: 'empty', img: '' })
|
||||
return {
|
||||
_covers: covers,
|
||||
_collageCls: `collage--${covers.length}`,
|
||||
_moreCount: Math.max(list.length - 4, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 穿搭状态语义的单一事实来源。
|
||||
//
|
||||
// 后端 OutfitCreateResponse.status:1=正常 2=封禁 3=待用户保存(AI 推荐生成后未保存),
|
||||
// 列表、详情、创建响应都会带出。这里把「状态 → 胶囊文案与样式」的判定收敛到一处,
|
||||
// 避免同一语义在穿搭页、AI 穿搭页、穿搭详情页各写一遍 if-else 而逐渐分叉。
|
||||
//
|
||||
// 零彩色纪律下的映射:需要用户操作/强调 = 实心黑胶囊;中性 = 描边胶囊;失效 = 无底色灰字。
|
||||
import { OutfitSource, IsPublic } from './config'
|
||||
|
||||
export const OUTFIT_STATUS = {
|
||||
NORMAL: 1,
|
||||
BANNED: 2,
|
||||
PENDING: 3,
|
||||
} as const
|
||||
|
||||
export interface OutfitStatusLike {
|
||||
status?: number
|
||||
source?: number
|
||||
is_public?: number
|
||||
}
|
||||
|
||||
export interface OutfitStatusPill {
|
||||
text: string
|
||||
cls: string
|
||||
}
|
||||
|
||||
// 待保存:AI 已生成但用户尚未保存;status 缺失按正常处理,兼容无该字段的老响应
|
||||
export function isPendingSave(o: OutfitStatusLike): boolean {
|
||||
return Number(o.status) === OUTFIT_STATUS.PENDING
|
||||
}
|
||||
|
||||
// 封禁:展示降级且禁止一切管理操作
|
||||
export function isBanned(o: OutfitStatusLike): boolean {
|
||||
return Number(o.status) === OUTFIT_STATUS.BANNED
|
||||
}
|
||||
|
||||
// 状态胶囊:待保存 > 封禁 > AI 来源 > 公开态
|
||||
export function outfitStatusPill(o: OutfitStatusLike): OutfitStatusPill {
|
||||
if (isPendingSave(o)) return { text: '待保存', cls: 'pill--ink' }
|
||||
if (isBanned(o)) return { text: '已封禁', cls: 'pill--plain' }
|
||||
if (Number(o.source) === OutfitSource.AI) return { text: 'AI 推荐', cls: 'pill--ink' }
|
||||
if (Number(o.is_public) === IsPublic.PUBLIC) return { text: '已公开', cls: 'pill--line' }
|
||||
return { text: '仅自己可见', cls: '' }
|
||||
}
|
||||
|
||||
// 待保存态可保存;已正常则保存幂等;封禁态不可保存
|
||||
export function canSaveOutfit(o: OutfitStatusLike): boolean {
|
||||
return isPendingSave(o)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface RequestOptions {
|
||||
auth?: boolean // 默认 true,需要鉴权
|
||||
showError?: boolean // 默认 true,业务错误自动 toast
|
||||
silentAuth?: boolean // 非关键请求(如未读红点轮询):401 只失败,不触发全局跳登录
|
||||
timeout?: number // 单次请求超时(毫秒),默认 REQUEST_TIMEOUT;AI 生成等慢接口可放宽
|
||||
}
|
||||
|
||||
// 未登录错误码:调用方可通过 e.code === NOT_LOGIN_CODE 区分
|
||||
@@ -196,7 +197,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
method,
|
||||
data,
|
||||
header: finalHeader,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
timeout: options.timeout || REQUEST_TIMEOUT,
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; msg: string; data?: T; details?: string[] }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user