From 592fa9be64f6bf8f594e1672c029c0a98b78b2df Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Tue, 15 Sep 2026 23:04:38 +0800 Subject: [PATCH] bugfix --- .../前端同步后端AI穿搭待保存改造_7e01cff1.md | 206 ++++++++++++++ miniprogram/app.json | 1 + miniprogram/pages/ai-pending/ai-pending.json | 7 + miniprogram/pages/ai-pending/ai-pending.ts | 252 +++++++++++++++++ miniprogram/pages/ai-pending/ai-pending.wxml | 111 ++++++++ miniprogram/pages/ai-pending/ai-pending.wxss | 141 ++++++++++ .../pages/ai-recommend/ai-recommend.ts | 254 ++++++++++++++++-- .../pages/ai-recommend/ai-recommend.wxml | 22 +- .../pages/ai-recommend/ai-recommend.wxss | 36 +++ .../pages/outfit-detail/outfit-detail.ts | 100 ++++++- .../pages/outfit-detail/outfit-detail.wxml | 15 +- .../pages/outfit-detail/outfit-detail.wxss | 8 + miniprogram/pages/outfit-edit/outfit-edit.ts | 4 + miniprogram/pages/outfit/outfit.ts | 82 +++--- miniprogram/pages/outfit/outfit.wxml | 11 + miniprogram/pages/outfit/outfit.wxss | 47 ++++ miniprogram/types/models.ts | 17 ++ miniprogram/utils/config.ts | 4 + miniprogram/utils/outfit-cover.ts | 30 +++ miniprogram/utils/outfit-status.ts | 49 ++++ miniprogram/utils/request.ts | 3 +- 21 files changed, 1323 insertions(+), 77 deletions(-) create mode 100644 .codebuddy/plans/前端同步后端AI穿搭待保存改造_7e01cff1.md create mode 100644 miniprogram/pages/ai-pending/ai-pending.json create mode 100644 miniprogram/pages/ai-pending/ai-pending.ts create mode 100644 miniprogram/pages/ai-pending/ai-pending.wxml create mode 100644 miniprogram/pages/ai-pending/ai-pending.wxss create mode 100644 miniprogram/utils/outfit-cover.ts create mode 100644 miniprogram/utils/outfit-status.ts diff --git a/.codebuddy/plans/前端同步后端AI穿搭待保存改造_7e01cff1.md b/.codebuddy/plans/前端同步后端AI穿搭待保存改造_7e01cff1.md new file mode 100644 index 0000000..3d71202 --- /dev/null +++ b/.codebuddy/plans/前端同步后端AI穿搭待保存改造_7e01cff1.md @@ -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 视觉零漂移 \ No newline at end of file diff --git a/miniprogram/app.json b/miniprogram/app.json index dcc880d..9fab9c7 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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", diff --git a/miniprogram/pages/ai-pending/ai-pending.json b/miniprogram/pages/ai-pending/ai-pending.json new file mode 100644 index 0000000..959f217 --- /dev/null +++ b/miniprogram/pages/ai-pending/ai-pending.json @@ -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" + } +} diff --git a/miniprogram/pages/ai-pending/ai-pending.ts b/miniprogram/pages/ai-pending/ai-pending.ts new file mode 100644 index 0000000..7960289 --- /dev/null +++ b/miniprogram/pages/ai-pending/ai-pending.ts @@ -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 { + 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>({ + 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(res) + const rows: AiRow[] = raw.map((it) => ({ + ...it, + ...outfitCover(it.items), + _status: outfitStatusPill(it), + })) + // 累积数组放实例而非 data:避免每次 setData 传输全量原始数据 + const all = mergePaged(((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 }) + }, +}) diff --git a/miniprogram/pages/ai-pending/ai-pending.wxml b/miniprogram/pages/ai-pending/ai-pending.wxml new file mode 100644 index 0000000..36873b1 --- /dev/null +++ b/miniprogram/pages/ai-pending/ai-pending.wxml @@ -0,0 +1,111 @@ + + + + + + + AI Styling + AI 穿搭 + + + + + + 待保存 + 已保存 + + + + + + 保存后归入「已保存」。超过 7 天未保存的推荐会被自动清理。 + + + + + + + + + + + + + 继续加载更多 + + + + + + + + + + + + + + +{{item._moreCount}} + {{item.items.length}} 件 + + + + {{item.outfit_name || 'AI 推荐搭配'}} + {{item._status.text}} + + {{item.outfit_desc}} + + {{item.scene}} + {{item.items.length}} 件衣物 + + + + + + {{busyUuid === item.outfit_uuid ? '保存中…' : '保存'}} + + 重新生成 + 删除 + + + + + 没有更多了 + + + + + + + + + diff --git a/miniprogram/pages/ai-pending/ai-pending.wxss b/miniprogram/pages/ai-pending/ai-pending.wxss new file mode 100644 index 0000000..9920b8d --- /dev/null +++ b/miniprogram/pages/ai-pending/ai-pending.wxss @@ -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; +} diff --git a/miniprogram/pages/ai-recommend/ai-recommend.ts b/miniprogram/pages/ai-recommend/ai-recommend.ts index a7e2d69..44fafcc 100644 --- a/miniprogram/pages/ai-recommend/ai-recommend.ts +++ b/miniprogram/pages/ai-recommend/ai-recommend.ts @@ -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,38 +136,162 @@ Page({ const body: Record = { scene_type: sceneType } if (sceneType === 2 && scene) body.scene = scene - request({ 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( - res.outfit_item.map(async (it) => { - try { - return await request({ url: `/clothes/${it.clothes_uuid}`, showError: false }) - } catch (e: any) { - logger.warn(LOG, '单品详情拉取失败, uuid=', it.clothes_uuid, 'code=', e && e.code) - return { clothes_uuid: it.clothes_uuid } as ClothesItem - } - }) - ) - this.setData({ result: res, clothes, state: 'result', generating: false }) + request({ 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) => { - this.setData({ generating: false }) const code = e && e.code - logger.warn(LOG, 'AI 推荐生成失败, code=', code) + logger.warn(LOG, 'AI 推荐任务提交失败, code=', code, 'msg=', e && e.message) if (code === 90400) { - this.setData({ state: 'empty' }) - } else if (code === 90401 || code === 80000) { - // 90401=AI 推荐次数上限;80000=额度不足兜底,统一弹窗引导购买 - this.setData({ state: 'scene', showSceneSheet: true }) + // 衣橱暂无可用衣物等:走空态引导上传 + this.stopElapsed() + this.setData({ generating: false, state: 'empty', showSceneSheet: false }) + return + } + if (code === 90401 || code === 80000) { + // 额度不足:提交时就已校验,弹窗引导购买 + this.failBackToScene() this.showLimitModal() - } else if (code === 10003) { - wx.showToast({ title: '正在生成中,请稍候', icon: 'none' }) - this.setData({ state: 'scene', showSceneSheet: true }) - } else { - this.setData({ state: 'scene', showSceneSheet: true }) + 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({ 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({ url: `/clothes/${it.clothes_uuid}`, showError: false }) + } catch (e: any) { + logger.warn(LOG, '单品详情拉取失败, uuid=', it.clothes_uuid, 'code=', e && e.code) + 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 }) + }) + }, + + // 失败:回场景抽屉;带文案时弹提示(绝不静默返回) + 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() { diff --git a/miniprogram/pages/ai-recommend/ai-recommend.wxml b/miniprogram/pages/ai-recommend/ai-recommend.wxml index f217dda..57557e4 100644 --- a/miniprogram/pages/ai-recommend/ai-recommend.wxml +++ b/miniprogram/pages/ai-recommend/ai-recommend.wxml @@ -19,7 +19,8 @@ module.exports = { cls: cls } AI 正在为你搭配… - 结合今日运势与你的衣橱 + 结合今日运势与你的衣橱,生成通常需要 10~30 秒 + 已等待 {{genElapsed}} 秒 @@ -86,9 +87,10 @@ module.exports = { cls: cls } 换一套将再消耗 1 次推荐额度 + 未保存的推荐会留在「AI 穿搭」里,可随时找回 换一套 - 保存到我的穿搭 + 保存 @@ -125,6 +127,10 @@ module.exports = { cls: cls } 开始生成(消耗 1 次推荐额度) + + 查看我的 AI 穿搭 + + @@ -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" /> + + + diff --git a/miniprogram/pages/ai-recommend/ai-recommend.wxss b/miniprogram/pages/ai-recommend/ai-recommend.wxss index 7166fbd..7a6e3b0 100644 --- a/miniprogram/pages/ai-recommend/ai-recommend.wxss +++ b/miniprogram/pages/ai-recommend/ai-recommend.wxss @@ -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; } diff --git a/miniprogram/pages/outfit-detail/outfit-detail.ts b/miniprogram/pages/outfit-detail/outfit-detail.ts index d22f45c..e900674 100644 --- a/miniprogram/pages/outfit-detail/outfit-detail.ts +++ b/miniprogram/pages/outfit-detail/outfit-detail.ts @@ -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) { diff --git a/miniprogram/pages/outfit-detail/outfit-detail.wxml b/miniprogram/pages/outfit-detail/outfit-detail.wxml index ecbf69d..87c048e 100644 --- a/miniprogram/pages/outfit-detail/outfit-detail.wxml +++ b/miniprogram/pages/outfit-detail/outfit-detail.wxml @@ -10,8 +10,9 @@ module.exports = { cls: cls } + {{outfit.scene}} {{outfit.source === 2 ? 'AI 推荐' : '自建'}} - {{outfit.is_public === 2 ? '已公开' : '仅自己可见'}} + {{statePill.text}} {{outfit.outfit_desc}} + 该穿搭已被封禁,暂不支持编辑、公开与分享 + AI 生成的推荐,保存后归入「AI 穿搭」的「已保存」 @@ -115,7 +118,11 @@ module.exports = { cls: cls } - + + + {{saving ? '保存中…' : '保存'}} + + {{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}