This commit is contained in:
2026-09-15 23:04:38 +08:00
parent 6be3f083cc
commit 592fa9be64
21 changed files with 1323 additions and 77 deletions
@@ -0,0 +1,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 视觉零漂移
+1
View File
@@ -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"
}
}
+252
View File
@@ -0,0 +1,252 @@
import { request } from '../../utils/request'
import { PagedResult, OutfitItem } from '../../types/models'
import { OutfitSource } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { CoverCell, outfitCover } from '../../utils/outfit-cover'
import { OUTFIT_STATUS, OutfitStatusPill, isPendingSave, outfitStatusPill } from '../../utils/outfit-status'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'ai-pending'
// 列表行:AI 生成结果 + 预计算的封面与状态胶囊(WXML 不能调用方法)
type AiRow = OutfitItem & {
_status: OutfitStatusPill
_covers: CoverCell[]
_collageCls: string
_moreCount: number
}
Page({
data: {
// 0 待保存(status=3) / 1 已保存(status=1)
activeTab: 0,
pendingList: [] as AiRow[],
savedList: [] as AiRow[],
// 当前分段渲染的数据(切段时同步切换,避免模板里写两套列表)
list: [] as AiRow[],
loading: true,
refreshing: false,
loadingMore: false,
page: 1,
pageSize: 20,
hasMore: true,
// 保存/删除防抖:同一时刻只允许一个写操作
saving: false,
// 正在保存的那一条:按钮显示「保存中…」,避免重复点击
busyUuid: '',
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 删除确认(v2 玻璃弹窗)
delVisible: false,
delText: '',
delUuid: '',
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
// 轻量反馈(v2 深色玻璃 toast)
toastVisible: false,
toastText: '',
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
// 操作行空白处吞掉点击,避免穿透到卡片进详情
noop() {},
onLoad() {
// 首帧渲染后再发请求,避免 setData 早于首次渲染导致 webview 渲染失败
wx.nextTick(() => this.fetchList(1))
},
onShow() {
// 从详情页返回(可能在详情页保存过)需要重取,保证分段与状态一致
if ((this as any).loaded) this.fetchList(1)
;(this as any).loaded = true
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeTab) return
logger.debug(LOG, '切换分段, index=', index)
this.setData({ activeTab: index, list: index === 0 ? this.data.pendingList : this.data.savedList })
},
onRefresh() {
this.setData({ refreshing: true })
this.fetchList(1).finally(() => this.setData({ refreshing: false }))
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading || this.data.loadingMore) return
this.fetchList(this.data.page + 1)
},
// 拉取 AI 穿搭:后端 GET /outfit 只支持 source 过滤(没有 status),
// 因此一次拉全量再本地按 status 拆成「待保存 / 已保存」两段,分页仍按原始数据推进
fetchList(page: number): Promise<void> {
const seq = ((this as any).reqSeq || 0) + 1
;(this as any).reqSeq = seq
if (page > 1) {
this.setData({ loadingMore: true })
} else {
this.setData({ loading: this.data.pendingList.length === 0 && this.data.savedList.length === 0 })
}
return request<PagedResult<OutfitItem>>({
url: '/outfit',
data: { page, page_size: this.data.pageSize, source: OutfitSource.AI },
})
.then((res) => {
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<OutfitItem>(res)
const rows: AiRow[] = raw.map((it) => ({
...it,
...outfitCover(it.items),
_status: outfitStatusPill(it),
}))
// 累积数组放实例而非 data:避免每次 setData 传输全量原始数据
const all = mergePaged<AiRow>(((this as any).allRows || []) as AiRow[], rows, page === 1)
;(this as any).allRows = all
logger.debug(LOG, 'AI 穿搭加载成功, page=', page, '条数=', rows.length, '累计=', all.length)
this.setData({
hasMore: hasMorePaged(all, total, this.data.pageSize),
loading: false,
loadingMore: false,
page,
})
this.applySplit(all)
})
.catch((e: any) => {
if ((this as any).reqSeq !== seq) return
logger.warn(LOG, 'AI 穿搭加载失败, page=', page, 'code=', e && e.code)
this.setData({ loading: false, loadingMore: false })
})
},
// 按 status 拆段并同步当前分段渲染数据
applySplit(all: AiRow[]) {
const pendingList = all.filter((it) => isPendingSave(it))
const savedList = all.filter((it) => !isPendingSave(it))
this.setData({
pendingList,
savedList,
list: this.data.activeTab === 0 ? pendingList : savedList,
})
},
// 保存为正式穿搭(status 3 → 1):成功后本地迁移段落,不重新请求以免闪屏
onSave(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
if (this.data.saving) return
this.setData({ saving: true, busyUuid: uuid })
logger.info(LOG, '保存 AI 穿搭, uuid=', uuid)
request({ url: '/ai-recommend/save', method: 'POST', data: { outfit_uuid: uuid }, showError: false })
.then(() => {
const all = (((this as any).allRows || []) as AiRow[]).map((it) =>
it.outfit_uuid === uuid
? { ...it, status: OUTFIT_STATUS.NORMAL, _status: outfitStatusPill({ ...it, status: OUTFIT_STATUS.NORMAL }) }
: it
)
;(this as any).allRows = all
this.applySplit(all)
this.setData({ saving: false, busyUuid: '' })
this.toast('已保存,可在「已保存」查看')
})
.catch((err: any) => {
this.setData({ saving: false, busyUuid: '' })
const code = err && err.code
logger.warn(LOG, '保存 AI 穿搭失败, uuid=', uuid, 'code=', code)
if (code === 40000) {
// 超过 7 天被后端定时清理:本地同步移除,避免用户反复点必然失败的按钮
this.removeLocal(uuid)
this.toast('这套搭配已被清理,请重新生成')
return
}
const detail = (err && err.details && err.details[0]) || (err && err.message) || ''
this.toast(detail || '保存失败,请稍后重试')
})
},
// 删除入口:弹 v2 玻璃确认层
onDelete(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
this.setData({
delVisible: true,
delUuid: uuid,
delText: '删除后不可恢复,这套 AI 生成的搭配会从列表中移除。',
})
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
const uuid = this.data.delUuid
if (!uuid || this.data.saving) return
this.setData({ saving: true })
request({ url: `/outfit/${uuid}`, method: 'DELETE', showError: false })
.then(() => {
logger.info(LOG, '删除 AI 穿搭成功, uuid=', uuid)
this.setData({ saving: false })
this.removeLocal(uuid)
this.toast('已删除')
})
.catch((err: any) => {
this.setData({ saving: false })
const code = err && err.code
logger.warn(LOG, '删除 AI 穿搭失败, uuid=', uuid, 'code=', code)
if (code === 40000) {
this.removeLocal(uuid)
this.toast('这套搭配已不存在')
return
}
const detail = (err && err.details && err.details[0]) || (err && err.message) || ''
this.toast(detail || '删除失败,请稍后重试')
})
},
// 本地移除(删除成功,或后端已清理)
removeLocal(uuid: string) {
const all = (((this as any).allRows || []) as AiRow[]).filter((it) => it.outfit_uuid !== uuid)
;(this as any).allRows = all
this.applySplit(all)
},
// 重新生成:回到 AI 推荐流程(会再消耗 1 次推荐额度)
onRegen() {
wx.navigateTo({
url: '/pages/ai-recommend/ai-recommend',
fail: (e) => logger.warn(LOG, 'navigateTo AI 推荐失败, err=', e && e.errMsg),
})
},
onDetail(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
logger.debug(LOG, '点击 AI 穿搭, uuid=', uuid)
wx.navigateTo({
url: `/pages/outfit-detail/outfit-detail?uuid=${uuid}&owner=1`,
fail: (err) => logger.warn(LOG, 'navigateTo 穿搭详情失败, err=', err && err.errMsg),
})
},
onGoGenerate() {
this.onRegen()
},
// 轻量反馈:先置 false 再置 true,确保连续提示也能重新触发自动关闭
toast(text: string) {
this.setData({ toastVisible: false, toastText: text })
wx.nextTick(() => this.setData({ toastVisible: true }))
},
onToastClose() {
this.setData({ toastVisible: false })
},
})
@@ -0,0 +1,111 @@
<view class="page page-flex">
<navigation-bar title="AI 穿搭" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
bindscroll="onNavScroll"
>
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">AI Styling</view>
<view class="display">AI 穿搭</view>
</view>
<!-- 下划线式文字分段 -->
<view class="segbar">
<view class="seg">
<view class="seg-item {{activeTab === 0 ? 'is-on' : ''}}" data-index="0" bindtap="onTabChange">待保存</view>
<view class="seg-item {{activeTab === 1 ? 'is-on' : ''}}" data-index="1" bindtap="onTabChange">已保存</view>
</view>
</view>
<!-- 待保存说明:把保存语义与 7 天清理规则前置说清 -->
<view wx:if="{{activeTab === 0}}" class="ap-note">
保存后归入「已保存」。超过 7 天未保存的推荐会被自动清理。
</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="ap-skel">
<view class="ap-skel-item" wx:for="{{[1, 2]}}" wx:key="*this">
<view class="sk sk-img sk-cover"></view>
<view class="sk sk-ln" style="width: 46%;"></view>
<view class="sk sk-ln" style="width: 68%;"></view>
</view>
</view>
<!-- 该分段在本页数据里为空但还有下一页:给显式加载入口,避免误以为真的没有 -->
<view wx:elif="{{!list.length && hasMore}}" class="ap-more" bindtap="onReachBottom">继续加载更多</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-sparkles"
title="{{activeTab === 0 ? '没有待保存的推荐' : '还没有保存的 AI 穿搭'}}"
desc="{{activeTab === 0 ? '让 AI 结合今日运势与你的衣橱搭一套' : '把满意的推荐保存下来,就会出现在这里'}}"
buttonText="{{activeTab === 0 ? '去 AI 推荐' : ''}}"
bind:action="onGoGenerate"
/>
<!-- 卡片流 -->
<view wx:else class="ap-list">
<view
class="ap-card"
wx:for="{{list}}"
wx:key="outfit_uuid"
data-uuid="{{item.outfit_uuid}}"
bindtap="onDetail"
>
<!-- 无缝拼图封面 -->
<view class="collage {{item._collageCls}}">
<view class="cg" wx:for="{{item._covers}}" wx:for-item="c" wx:key="k">
<image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{item._moreCount > 0}}" class="cg-more">+{{item._moreCount}}</view>
<text class="cg-badge num">{{item.items.length}} 件</text>
</view>
<view class="ap-trow">
<text class="ap-title">{{item.outfit_name || 'AI 推荐搭配'}}</text>
<text class="pill {{item._status.cls}}">{{item._status.text}}</text>
</view>
<view class="ap-desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
<view class="ap-meta">
<text class="pill pill--line" wx:if="{{item.scene}}">{{item.scene}}</text>
<text class="ap-m">{{item.items.length}} 件衣物</text>
</view>
<!-- 待保存才给内联操作;已保存保持只读,点击进详情 -->
<view class="ap-acts" wx:if="{{activeTab === 0}}" catchtap="noop">
<view class="btn btn--ink btn--sm ap-save" data-uuid="{{item.outfit_uuid}}" catchtap="onSave">
{{busyUuid === item.outfit_uuid ? '保存中…' : '保存'}}
</view>
<view class="btn btn--text" catchtap="onRegen">重新生成</view>
<view class="btn btn--text" data-uuid="{{item.outfit_uuid}}" catchtap="onDelete">删除</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 删除确认 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="删除这套搭配"
content="{{delText}}"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
<!-- 轻量反馈 -->
<v2-overlay visible="{{toastVisible}}" type="toast" content="{{toastText}}" bind:close="onToastClose" />
</view>
@@ -0,0 +1,141 @@
/** AI 穿搭:待保存 / 已保存两段,图卡与胶囊复用全局类 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
/* 分段条:吸顶玻璃 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
/* 待保存说明行:保存语义 + 7 天清理规则 */
.ap-note {
margin: 32rpx var(--pad) 0;
font-size: 23rpx;
line-height: 1.6;
color: var(--ink-3);
}
.ap-list {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 56rpx;
}
.ap-card {
display: block;
width: 100%;
transition: transform .2s var(--ease);
}
.ap-card:active {
transform: scale(.985);
}
.ap-skel {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 56rpx;
}
.ap-skel-item {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.sk-cover {
aspect-ratio: 4 / 3;
}
.ap-trow {
display: flex;
align-items: center;
gap: 18rpx;
margin-top: 24rpx;
}
.ap-title {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 500;
line-height: 1.35;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-desc {
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.62;
color: var(--ink-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ap-meta {
display: flex;
align-items: center;
gap: 22rpx;
margin-top: 24rpx;
}
.ap-m {
display: inline-flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
/* 待保存操作行:实心黑主按钮 + 下划线文字按钮 */
.ap-acts {
display: flex;
align-items: center;
gap: 30rpx;
margin-top: 32rpx;
}
.ap-save {
flex: 1;
}
/* 分段在本页数据里为空但仍有下一页时的显式加载入口 */
.ap-more {
margin: 56rpx var(--pad) 0;
text-align: center;
font-size: 25rpx;
color: var(--ink-2);
text-decoration: underline;
text-underline-offset: 7rpx;
text-decoration-thickness: 1rpx;
text-decoration-color: var(--ink-4);
transition: opacity .18s var(--ease);
}
.ap-more:active {
opacity: .55;
}
+222 -24
View File
@@ -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
+51 -31
View File
@@ -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'
+11
View File
@@ -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">
+47
View File
@@ -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%;
+17
View File
@@ -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
+4
View File
@@ -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: '服务繁忙,请稍后重试',
+30
View File
@@ -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),
}
}
+49
View File
@@ -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)
}
+2 -1
View File
@@ -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[] }