first commit

This commit is contained in:
2026-09-11 01:45:04 +08:00
commit 0a8d60d87f
145 changed files with 40978 additions and 0 deletions
@@ -0,0 +1,218 @@
---
name: zt-closet-wxmini 前端实现
overview: 基于 stitch_ai_ui 的 14 个高保真页面原型与 zt-closet-backend 的 42 个接口,在现有微信小程序骨架(TypeScript + skyline + glass-easel)上,完整实现「纸条AI衣橱」小程序:网络层、鉴权、5 个 tabBar 页与 8 个二级页,全部接通后端接口。
design:
styleKeywords:
- iOS HIG
- 清爽克制
- 毛玻璃
- 发丝分隔线
- 系统蓝
- SF Symbols 线性图标
fontSystem:
fontFamily: PingFang SC
heading:
size: 34px
weight: 700
subheading:
size: 17px
weight: 600
body:
size: 15px
weight: 400
colorSystem:
primary:
- "#007AFF"
- "#5856D6"
- "#FF2D55"
background:
- "#F2F2F7"
- "#FFFFFF"
text:
- "#000000"
- "#8E8E93"
- "#C7C7CC"
functional:
- "#34C759"
- "#FF9500"
- "#FF3B30"
- "#FFCC00"
todos:
- id: network-foundation
content: 搭建 request/token 刷新与全局配置、工具函数
status: completed
- id: tabbar-components
content: 实现自定义 tabBar 与空态、骨架屏、衣物卡片等公共组件
status: completed
dependencies:
- network-foundation
- id: clothes-flow
content: 实现登录、衣橱、上传、衣物详情页
status: completed
dependencies:
- tabbar-components
- id: outfit-flow
content: 实现穿搭、穿搭详情、创建编辑穿搭页
status: completed
dependencies:
- tabbar-components
- id: ai-horoscope
content: 实现 AI 推荐与运势页
status: completed
dependencies:
- tabbar-components
- id: notice-profile-share
content: 实现通知、我的、衣柜分享(列表/创建/详情)页
status: completed
dependencies:
- tabbar-components
- id: mall-order
content: 实现商城与订单支付退款页
status: completed
dependencies:
- tabbar-components
---
## 产品概述
「纸条AI衣橱」微信小程序:用户拍照上传衣物,AI 自动抠图并识别 15 项属性,自由组合衣物创建穿搭、生成 AI 穿搭推荐(结合星座运势)、把衣橱或精选衣物分享给好友、在社区公开穿搭并互动,还可购买会员或额度包解锁更多次数。产品气质为清爽、克制、可信赖的私人衣橱助手。
## 核心功能
- 微信授权登录:`wx.login` 换 token,首次登录自动注册并发放免费额度
- 衣橱管理:两列衣物网格、分类筛选、上传衣物(异步 AI 处理 + 状态轮询)、衣物详情查看与属性编辑、删除(含额度回退规则)
- 穿搭管理:我的穿搭/已收藏分段、创建/编辑穿搭(1~10 件衣物多选)、穿搭详情、公开到社区(扣额度)、点赞与收藏
- AI 穿搭推荐:场景选择、生成中状态、结果预览、保存为穿搭(含 180 秒并发锁与额度不足处理)
- 每日运势:星座自动推算,综合/爱情/工作/财富四项评分 + 幸运色/幸运数字
- 衣柜分享:我的分享列表、创建分享(部分/全量 + 有效期)、分享详情(属主/访客视角)
- 通知中心:列表/详情/未读红点/单条已读
- 额度与会员商城、订单支付(微信 JSAPI 支付)与退款申请
- 个人资料:头像/昵称/性别/生日编辑、我的额度/订单/分享/收藏/额度明细入口
## 视觉规范
严格遵循 iOS HIG 设计语言:系统分组灰底 #F2F2F7 + 纯白卡片 + 0.5px 发丝分隔线 #E5E5EA,强调色系统蓝 #007AFF,SF Symbols 风格线性图标,底部毛玻璃 tabBar,所有页面为 750rpx 宽、左右安全边距 32rpx。
## 技术栈
- 平台:微信小程序(TypeScript),沿用现有 `skyline` 渲染器 + `glass-easel` 组件框架
- 组件:不引入第三方组件库,纯 WXSS 手写,复用现有 `navigation-bar` 自定义导航栏
- 网络:基于 `wx.request` 自建请求层(鉴权、统一响应解析、Token 自动刷新、错误码处理)
- 后端:Go + Gin,统一前缀 `/api/v1`,42 个接口,接口契约以 `/root/zt-closet-backend/接口文档.md` 为准
## 实现方案
### 整体策略
在现有小程序骨架(`miniprogram/`)上重写:清理模板页,新增网络层、公共组件、自定义 tabBar 与 16 个页面文件(UI 原型 14 个功能模块中,「衣柜分享」含 3 个子页面,落地为 3 个路由)。所有请求统一走 `request.ts`,页面只关心数据绑定与交互,遵循「页面 → 组件/工具 → 网络层 → 后端 API」的单向数据流。
### 模块划分
- **网络层(utils)**:`config.ts`(baseURL 占位 + 错误码表)、`request.ts`(统一请求 + 拦截逻辑)、`auth.ts`(token 存取)、`format.ts`(金额分转元、秒转时间、相对时间)
- **公共组件(components)**:复用 `navigation-bar`;新增 `empty-state`(空态)、`skeleton`(骨架屏)、`clothes-card`(衣物卡片,含多选态)、`tag`(状态胶囊)
- **全局壳**:`app.ts`/`app.json`/`app.wxss`(页面注册、登录态、全局样式变量)、`custom-tab-bar/`(5 tab 自定义 tabBar,base64 SVG 线性图标)
- **页面层(16 个路由)**:5 个 tabBar 页 + 登录页 + 10 个二级页
### 系统架构
```mermaid
flowchart LR
subgraph Pages[页面层 16 路由]
P1[登录]
P2[衣橱 tab]
P3[穿搭 tab]
P4[运势 tab]
P5[通知 tab]
P6[我的 tab]
P7[上传/衣物详情]
P8[穿搭详情/创建编辑]
P9[AI 推荐]
P10[衣柜分享×3]
P11[商城/订单]
end
subgraph Components[公共组件]
C1[navigation-bar]
C2[custom-tab-bar]
C3[empty-state]
C4[skeleton]
C5[clothes-card]
C6[tag]
end
subgraph Network[网络层]
N1[request.ts 拦截/刷新]
N2[auth.ts]
N3[config.ts]
end
Pages --> Components
Pages --> Network
Network --> API[后端 /api/v1]
```
### 关键实现要点
- **请求封装**:统一解析 `body.code`(业务成败只看 code,不依赖 HTTP 状态码);401 时自动调用 `POST /user/refresh-token`(同时传 refresh/access 两个 token),成功后整体替换并重试原请求,失败则清登录态重走登录;单独处理 `code===21001`(账号禁用,HTTP 200)与 `code===10003`(AI 并发锁,HTTP 429)
- **Token 管理**:`access_token`/`refresh_token`/`expire_time` 存本地存储,所有需鉴权接口自动注入 `Authorization: Bearer`
- **数据换算**:金额统一 `分 ÷ 100` 展示为 `¥ + 两位小数`;时间 `秒 × 1000`;相对时间格式化(刚刚/x分钟前/今天/YYYY-MM-DD)
- **上传异步轮询**:上传返回 `clothes_uuid` 后轮询 `GET /clothes/:uuid/status`(1~2 秒,上限 60 次),终态 4 成功 / 5 失败(展示 `fail_reason`)
- **字段陷阱**:更新衣物主色请求字段用 `color`(响应才是 `color_main`);通知 `is_read` 1=未读 2=已读;AI 推荐响应衣物列表字段名是 `outfit_item`;衣柜分享惰性过期需同时判断 `status` 与 `expire_time`
- **性能**:列表分页 `page_size ≤ 50`,下拉刷新 + 上拉加载;图片懒加载;点赞/收藏做乐观更新 + 失败回滚(接口幂等)
- **日志与安全**:请求失败仅记录 `code`/`msg`,不打印 token 与用户敏感信息;删除/取消/退款等不可逆操作全部二次确认
### 目录结构
```
miniprogram/
├── app.json # [MODIFY] 注册 16 个页面、5 个 tabBar、全局样式/渲染配置
├── app.ts # [MODIFY] 全局登录态初始化、静默登录、全局数据
├── app.wxss # [MODIFY] 全局样式变量(色板/圆角/间距/字阶)、通用卡片/按钮/表单样式
├── utils/
│ ├── config.ts # [NEW] baseURL 占位常量(HTTPS 域名待填)、错误码映射、额度/状态枚举
│ ├── request.ts # [NEW] wx.request Promise 封装,注入鉴权头、解析 code、401 自动刷新重试、21001/10003 处理
│ ├── auth.ts # [NEW] token 的读写清除、是否已登录判断、登出
│ └── format.ts # [NEW] 金额/时间/相对时间格式化工具
├── custom-tab-bar/
│ ├── index.ts/.wxml/.wxss/.json # [NEW] 5 tab 自定义 tabBar,毛玻璃底 + 线性 SVG 图标,选中 #007AFF
├── components/
│ ├── navigation-bar/ # [REUSE] 复用现有自定义导航栏(返回/标题/右侧 slot)
│ ├── empty-state/ # [NEW] 空态组件(线性插画占位 + 主文案 + 说明 + 按钮)
│ ├── skeleton/ # [NEW] 骨架屏卡片(呼吸动画)
│ ├── clothes-card/ # [NEW] 衣物卡片(1:1 图区、处理状态徽标、多选态描边 + 对勾)
│ └── tag/ # [NEW] 状态胶囊标签(系统色浅底 + 深字)
└── pages/
├── login/ # [NEW] 微信授权登录页:插画 + 产品名 + 微信一键登录
├── wardrobe/ # [NEW] 衣橱 tab:分类筛选 + 统计 + 两列网格 + 悬浮上传按钮
├── upload/ # [NEW] 上传衣物:拍照/相册 + 处理中步骤条 + 失败态
├── clothes-detail/ # [NEW] 衣物详情:大图 + 15 项属性 + 编辑态 + 删除
├── outfit/ # [NEW] 穿搭 tab:我的穿搭/已收藏分段 + 卡片流
├── outfit-detail/ # [NEW] 穿搭详情:拼图封面 + 点赞/收藏/公开 + 更多面板
├── outfit-edit/ # [NEW] 创建/编辑穿搭:表单 + 衣物多选 + 保存/删除
├── ai-recommend/ # [NEW] AI 推荐:场景选择 + 生成中 + 结果页 + 保存
├── horoscope/ # [NEW] 运势 tab:星座大卡 + 四项评分 + 幸运色/数字
├── notification/ # [NEW] 通知 tab:筛选 + 列表 + 未读红点
├── notification-detail/ # [NEW] 通知详情:进入即标记已读
├── closet-share/ # [NEW] 衣柜分享列表:我的分享 + 新建入口
├── closet-share-create/ # [NEW] 创建分享:类型选择 + 衣物多选 + 有效期面板
├── closet-share-detail/ # [NEW] 分享详情:属主/访客视角 + 失效态
├── mall/ # [NEW] 额度与会员商城:会员卡 + 额度网格 + 商品列表 + 订单确认
├── order/ # [NEW] 我的订单:状态筛选 + 支付/取消/退款
└── profile/ # [NEW] 我的 tab:个人信息 + 功能列表 + 编辑资料
```
## 实施注意事项
- **保留向后兼容**:不删除现有 `navigation-bar` 组件;仅删除模板页 `pages/logs` 并替换 `pages/index`
- **渲染兼容**:保留 `skyline` 渲染器;自定义 tabBar 若在 skyline 下定位异常,回退方案为在 `app.json` 移除 `renderer`/`componentFramework` 切回 webview(单点配置,不影响页面代码)
- **appid 对齐风险**:`project.config.json` 的 appid(`wxfc03c59fc1bb4de5`)与后端 `configs/config.yaml` 中 `WechatMini.AppID`(`wx47cffdcb7f8c57c3`)不一致,会导致 `wx.login` 换 openid 失败,联调前需由用户统一
- **baseURL 占位**:`config.ts` 中保留 HTTPS 域名占位常量,用户填写真实域名后全局生效,无需改页面代码
## 设计风格
遵循 Apple HIG 的 iOS 原生视觉语言:清爽、克制、以内容为中心。页面背景统一 iOS 分组灰 #F2F2F7,内容承载于纯白卡片(圆角 16pt),用 0.5px 发丝分隔线区分层次,避免浓烈渐变与彩色阴影;强调色系统蓝 #007AFF,情感化点赞/收藏用系统粉 #FF2D55,辅助靛蓝 #5856D6。图标为 SF Symbols 风格线性描边,底部 tabBar 采用半透明毛玻璃 + 顶部发丝线。
## 布局与交互
- 顶部:复用自定义 navigation-bar,二级页左上角返回箭头,右上角保留微信胶囊安全区
- 底部:自定义 tabBar 五个入口(衣橱/穿搭/运势/通知/我的),选中系统蓝、未选中系统灰
- 内容:两列衣物网格(1:1 图区、12pt 间距);穿搭/AI 推荐为纵向卡片流 + 错落拼图封面
- 反馈:空态居中线性插画 + 主文案 + 引导按钮;加载态骨架屏呼吸动画;轻提示毛玻璃圆角浮层;确认操作为 iOS 居中 Alert;底部动作面板为 iOS sheet(grabber + 圆角 + 取消项独立成组)
- 列表均支持下拉刷新与上拉加载,分页每页不超过 50 条
@@ -0,0 +1,46 @@
---
name: 修复头像上传临时路径误判
overview: 修复「我的」编辑头像保存不生效:微信 chooseAvatar 返回的临时路径以 http://tmp/ 开头,导致 onSaveProfile 的 startsWith('http://') 判断误判为已上传 URL 而跳过上传。改用 avatarChanged 标志位判断是否新选头像。
todos:
- id: fix-avatar-changed-flag
content: 修改 profile.ts:新增 avatarChanged 标志位并替换 onSaveProfile 的头像判断逻辑
status: completed
---
## 产品概述
修复「我的」页面编辑资料时头像上传后保存不生效的问题,使选择的新头像能正常上传到服务端并持久保存。
## 核心功能
- 用户在编辑资料弹窗中选择新头像后,点击保存能正确触发头像上传。
- 头像上传成功后与昵称、性别、生日一并保存,刷新后头像正确展示。
- 未选择新头像时仍保持原有头像,不重复上传。
## 根因
`<button open-type="chooseAvatar">` 返回的 `avatarUrl` 是微信本地临时文件路径,格式以 `http://tmp/` 开头。原 `onSaveProfile` 用「非 `http://`/`https://` 开头」判断是否为临时头像,导致 `http://tmp/` 开头的临时路径被误判为已上传 URL,从而跳过 `/user/avatar/upload` 上传,头像既未上传也未保存。
## 技术栈
- 平台:微信小程序原生 TypeScript,沿用现有架构。
- 网络层:复用 `miniprogram/utils/request.ts` 的 `request<T>` 与 `uploadFile<T>`。
- 后端接口:复用已就绪的 `POST /user/avatar/upload`(multipart,字段名 `file`,响应 `{ avatar }`),本次无需改动。
## 实现方案
用「本次编辑是否新选了头像」的标志位 `avatarChanged` 替代 URL 前缀判断,从根本上规避微信临时路径 `http://tmp/` 开头造成的误判。
### 关键改动(仅 `miniprogram/pages/profile/profile.ts`)
1. `data` 新增 `avatarChanged: false`。
2. `onEdit` 打开编辑弹窗时重置 `avatarChanged: false`,保证每次打开都是干净状态。
3. `onChooseAvatar` 在写入 `editForm.avatar` 的同时置 `avatarChanged: true`。
4. `onSaveProfile` 删除 `startsWith('http://')`/`startsWith('https://')` 判断,改为 `if (this.data.avatarChanged && avatar)` 才先调用 `uploadFile(avatar, 'file', '/user/avatar/upload')` 上传;上传成功后再 PUT `/user/profile`(body 仅含 username/sex/birth_day,不含 avatar,避免持久化带签名的临时 URL)。
5. 保留 `avatarUploading` 防重复提交与失败复位逻辑不变。
### 实现注意事项
- 上传成功后仅依赖 `/user/avatar/upload` 后端写入 `users.avatar`,前端不把上传返回的 URL 写回 `/user/profile`,与上一轮设计保持一致。
- `avatarChanged` 只用于「是否上传」,上传完成后无需复位为 `false`(弹窗随即关闭;下次打开由 `onEdit` 重置)。
- 兼容性:未选头像(`avatarChanged=false`)时行为与原逻辑一致,直接保存昵称/性别/生日。
@@ -0,0 +1,58 @@
---
name: 修复用户头像上传
overview: 前端「编辑资料」头像选择当前只保存微信临时文件路径,未调用后端头像上传接口,导致头像不可用。修复为:选择头像后预览,保存时先调用 POST /user/avatar/upload 上传,再更新资料。
todos:
- id: extend-upload-file
content: 扩展 uploadFile 支持自定义上传 url(默认 /clothes/upload)
status: completed
- id: fix-avatar-save
content: 重构 profile 保存流程:先上传头像再保存资料
status: completed
dependencies:
- extend-upload-file
- id: update-profile-ui
content: 更新编辑弹窗上传中状态与禁用样式
status: completed
dependencies:
- fix-avatar-save
---
# 修复头像上传与获取链路
## 产品概述
修复「我的」页面头像上传不可用问题,并一并修复头像获取链路的两处隐患(分享者头像 URL 转换、前端缓存时效),使头像上传与展示均正确且持久生效。
## 核心功能
- 选择新头像后正常上传并持久保存,与昵称/性别/生日一并生效。
- 分享场景分享者头像(owner_avatar)正确转换,不再生成错误 URL。
- 前端头像展示不再因缓存过期签名 URL 而失效。
## 技术栈
- 小程序:原生 TypeScript,复用 `request` / `uploadFile`。
- 后端:Go,复用 `pkg/upload` 与既有 service 层。
## 实现方案
### 一、上传修复(前端)
- `utils/request.ts`:`uploadFile` 增加可选 `url` 参数(默认 `/clothes/upload`),保持衣物上传调用方零改动。
- `pages/profile/profile.ts`:新增 `avatarUploading` 状态;`onSaveProfile` 判断头像为临时路径(非 `http://`/`https://` 开头)时,先调 `/user/avatar/upload`(字段名 `file`,响应 `{ avatar }`),成功后再 PUT `/user/profile`(body 仅含 username/sex/birth_day,不含 avatar)。
- `pages/profile/profile.wxml` + `wxss`:保存按钮在上传中显示「上传中…」并禁用。
### 二、后端头像 URL 转换统一
- `pkg/upload/upload.go`:新增 `AvatarURL(raw string) string`——空串返回空;`http://`/`https://` 开头直接透传;否则 `GetCosUrl`。
- `internal/service/user.go`:`buildUserInfoResponse` 改用 `upload.AvatarURL`。
- `internal/service/closet.go`:详情与列表两处 owner_avatar 改用 `upload.AvatarURL`,删除无条件 `GetCosUrl`。
### 三、前端缓存时效修复
- `pages/closet-share-create/closet-share-create.ts`:`onLoad` 先读本地缓存兜底,再请求 `/user/info` 实时刷新昵称与头像并回写缓存(签名 URL 带 `t` 时间戳,缓存会过期)。
## 目录结构
- 前端:`miniprogram/utils/request.ts`、`pages/profile/{profile.ts,profile.wxml,profile.wxss}`、`pages/closet-share-create/closet-share-create.ts`
- 后端:`pkg/upload/upload.go`、`internal/service/user.go`、`internal/service/closet.go`
@@ -0,0 +1,129 @@
---
name: 前后端业务逻辑一致性检查报告
overview: 全量比对后端 Go 服务(zt-closet-backend)与微信小程序前端(zt-closet-wxmini-frontend)的业务逻辑,逐模块核对接口路径、请求/响应字段、枚举值、状态机、错误码的一致性,最终产出一份差异清单报告(不改任何代码)。
todos:
- id: explore-backend
content: 用 [subagent:code-explorer] 探索后端 dto/service/errcode/constants,提取权威契约清单
status: completed
- id: explore-frontend
content: 用 [subagent:code-explorer] 探索前端 pages/utils/types,提取实际调用契约清单
status: completed
- id: compare-core
content: 对比用户/额度/运势/衣物模块,记录差异与严重程度
status: completed
dependencies:
- explore-backend
- explore-frontend
- id: compare-social
content: 对比穿搭/AI推荐/衣柜分享/收藏点赞/通知模块,记录差异与严重程度
status: completed
dependencies:
- explore-backend
- explore-frontend
- id: compare-trade
content: 对比商品/订单/虚拟支付模块,并全表核对错误码与枚举
status: completed
dependencies:
- explore-backend
- explore-frontend
- id: gen-report
content: 汇总生成《前后端业务逻辑一致性检查报告》
status: completed
dependencies:
- compare-core
- compare-social
- compare-trade
---
## 用户需求
检查后端业务服务全量代码(`~/zt-closet-backend`),确保前端微信小程序与后端业务逻辑一致。
## 已确认的处理方式
1. **仅输出不一致报告**:以差异清单形式呈现(接口路径、字段、枚举值、状态机、错误码等),**不修改任何前后端代码**。
2. **不核对后端《接口文档.md》**:本次只关注「前端代码」与「后端源码」的一致性,以 `/root/zt-closet-backend` 的 Go 源码为权威基准。
## 核心产出
产出一份《前后端业务逻辑一致性检查报告》,按业务模块(用户 / 额度 / 运势 / 衣物 / 穿搭 / AI 推荐 / 衣柜分享 / 收藏点赞 / 通知 / 商品 / 订单支付)分类,逐项列出:
- 不一致位置(前端文件:行 + 后端文件:行)
- 差异描述
- 严重程度(阻断 / 高风险 / 低风险 / 建议)
- 建议修复方向
## 技术栈
- 后端(权威基准,只读分析):Go + Gin,位于 `/root/zt-closet-backend`
- 前端(被检查方,只读分析):微信小程序原生 + TypeScript,位于 `/root/zt-closet-wxmini-frontend/miniprogram`
- 比对方法:静态源码契约提取 + 逐模块交叉比对,不涉及运行时
## 实现方案
### 核心策略
采用「契约提取—交叉比对」方法:
1. **提取后端权威契约**:以 `internal/routers/router.go` 注册的 42 个接口为主线,通读 `internal/routers/api/v1/`、`internal/dto/`、`internal/service/`、`pkg/errcode/`,提取每个接口的路径、HTTP 方法、请求字段(名称/必填/类型/枚举)、响应字段(名称/类型/单位)、枚举值、状态机规则、错误码。
2. **提取前端实际契约**:通读 17 个页面 `.ts` 与 `utils/request.ts`、`utils/config.ts`、`utils/auth.ts`、`types/models.ts`、`typings/index.d.ts`,提取实际调用的接口路径、请求体字段名、响应字段名、枚举常量定义、错误码映射表、关键业务规则处理(轮询/幂等/额度回退/惰性过期等)。
3. **逐模块交叉比对**:后端契约 vs 前端契约,识别字段名不一致、枚举值缺失/错位、错误码遗漏、单位换算缺失、状态机处理缺失等问题。
### 比对维度(关键决策)
- 接口路径与 HTTP 方法是否一致
- 请求字段名 / 必填性 / 类型 / 枚举取值是否一致
- 响应字段名 / 类型 / 单位(金额分、时间秒)是否一致
- 枚举常量值与状态机定义是否一致
- 错误码映射表是否完整且值正确
- 关键业务规则前端是否实现(上传轮询、支付后轮询订单、点赞乐观更新、分享惰性过期、AI 推荐额度不退还提示等)
### 严重程度分级
- **阻断**:导致功能不可用或数据错误(如字段名错误、请求路径错误、枚举值错位)
- **高风险**:特定场景下功能出错或体验严重受损(如单位未换算、错误码遗漏导致误提示)
- **低风险**:边缘场景或文案差异
- **建议**:非功能性优化建议
## 实现要点
- **不修改任何代码**,唯一新增物为报告文件 `/root/zt-closet-wxmini-frontend/前后端业务逻辑一致性检查报告.md`。
- 以后端源码为唯一权威,前端为被检查对象。
- 重点核对项:
- 虚拟支付链路:`/order/virtual` + `wx.requestVirtualPayment`,字段 `sign_data`/`pay_sig`/`signature`、`platform` 枚举 `android|ios|harmony|windows`、`mode` 取值,对照后端 `dto/order.go`、`service/virtualpay.go`。
- 易错字段:主颜色请求字段 `color` vs 响应 `color_main`;通知 `is_read` 1=未读/2=已读;`/like/:targetUUID` 路径参数驼峰;AI 推荐响应字段 `outfit_item`(单数)。
- 额度与状态机:上传/删除回退规则、公开扣额度、AI 生成失败回退、衣柜分享惰性过期。
- 错误码全表:前端 `ERROR_MESSAGES` 与后端 `pkg/errcode` 逐项核对,确认缺失与值错误。
- 疑似缺口:前端未调用 `/ad-reward/grant`(广告激励额度发放)入口,需确认是否属功能缺失。
## 目录结构
本次任务不修改现有代码,仅新增一份报告文件;以下为报告产出位置与需要通读的源码范围。
```
/root/zt-closet-wxmini-frontend/
└── 前后端业务逻辑一致性检查报告.md # [NEW] 唯一产出物,按业务模块分类记录差异清单
只读分析范围(后端):
/root/zt-closet-backend/
├── internal/routers/router.go # 42 个接口注册主线
├── internal/routers/api/v1/ # 13 个 handler
├── internal/dto/ # 14 个请求/响应 DTO
├── internal/service/ # 18 个业务逻辑(含 virtualpay/constants)
└── pkg/errcode/ # 10 个错误码文件
只读分析范围(前端):
/root/zt-closet-wxmini-frontend/miniprogram/
├── pages/ # 17 个页面 .ts
├── utils/request.ts / config.ts / auth.ts # 请求封装 / 枚举与错误码 / Token
├── types/models.ts # 前端实体类型
└── typings/index.d.ts # 全局类型声明
```
## 推荐使用的 Agent 扩展
### SubAgent
- **code-explorer**
- 用途:后端(dto/service/errcode/constants 等 60+ 文件)与前端(17 页面 + utils + types)体量较大,使用该子代理跨目录、多文件并行探索,高效提取接口契约与调用点,避免主上下文被大量源码淹没。
- 预期结果:产出后端权威契约清单(接口字段/枚举/错误码/状态机)与前端实际调用契约清单(路径/字段/枚举/错误码映射/业务规则处理),作为逐模块比对的输入。
@@ -0,0 +1,101 @@
---
name: 前端与后端一致性修复
overview: 根据上一轮全量检查报告,修复前端代码使其与已更新的后端完全一致(广告激励功能暂不实现、通知筛选后端已配合无需改前端),覆盖虚拟支付轮询、收藏状态初始化、类型字段补齐、错误码补充等。
todos:
- id: fix-types-errors
content: 补齐 models.ts 类型字段与 config.ts 70007 错误码
status: completed
- id: fix-collect-status
content: outfit-detail.ts 调用收藏状态接口初始化 isCollected
status: completed
dependencies:
- fix-types-errors
- id: fix-pay-polling
content: mall.ts 支付成功后按 order_no 轮询订单确认到账
status: completed
dependencies:
- fix-types-errors
- id: fix-order-channel
content: order.ts 与 order.wxml 区分支付渠道并禁用虚拟订单取消
status: completed
dependencies:
- fix-types-errors
- id: fix-ai-quota-tip
content: ai-recommend.ts 生成前弹窗提示消耗 AI 额度
status: completed
- id: fix-login-guide
content: login.ts 新用户登录后引导完善生日
status: completed
---
## 用户需求
用户已修复后端两点:通知列表 `msg_type` 筛选、新增 `GET /collect/:targetUUID` 收藏状态查询接口。现要求前端按修复后的后端对齐,其中「广告激励额度发放」功能暂不实现,其余按上一轮一致性检查报告的结论修复前端。
## 核心修复项
1. 补齐前端类型定义与错误码:`OrderItem` 补 `pay_channel`、`GoodsItem` 补 `ai_recommend_quota`/`valid_days`、`vip_type` 注释补体验会员、错误码表补 `70007`。
2. 收藏状态正确初始化:穿搭详情页调用新增的收藏状态查询接口,不再写死为「未收藏」。
3. 虚拟支付成功后轮询订单确认到账:支付组件 `success` 不再直接刷新权益,而是轮询订单直到 `order_status=2`。
4. 订单页区分支付渠道:虚拟支付订单禁用「取消订单」入口。
5. AI 推荐生成前提示将消耗 1 次 AI 额度。
6. 新用户登录后引导完善生日。
## 边界
- 只改前端代码,不改后端。
- 广告激励(`/ad-reward/grant`)不实现。
- 通知列表前端无需改动(后端已配合支持 `msg_type`)。
- 保持现有小程序原生 + TypeScript 风格,金额单位分、时间单位秒的换算不变。
## 技术栈
- 前端:微信小程序原生 + TypeScript
- 复用现有 `miniprogram/utils/request.ts` 的 `request<T>` 封装与 `utils/config.ts` 的枚举/错误码表
## 实现方案
### 1. 类型与错误码补齐(models.ts + config.ts)
- `types/models.ts`
- `OrderItem` 增补 `pay_channel: number`(注释:1=微信支付 2=虚拟支付)。
- `GoodsItem` 增补 `ai_recommend_quota: number`(赠送 AI 推荐额度)、`valid_days: number`(加量包有效期天数)。
- `UserInfo.vip_type` 注释由「1 非会员 2 月度会员 3 年度会员」补齐为「1 非会员 2 月度会员 3 年度会员 4 体验会员」。
- `utils/config.ts` 的 `ERROR_MESSAGES` 增补 `70007: '虚拟支付订单不支持取消'`。
### 2. 收藏状态初始化(outfit-detail.ts)
- 在 `fetchDetail()` 中与点赞状态一起并行请求,新增调用 `request<{ is_collected: boolean; collect_count: number }>({ url: '/collect/' + this.data.uuid, showError: false })`。
- 用响应 `is_collected` 初始化 `this.data.isCollected`;`collect_count` 可存入 `data` 备用,失败静默保持 `false`,不影响原有点赞与详情加载。
### 3. 支付后轮询订单确认(mall.ts)
- 将 `doPayment(res)` 的 `success` 回调改为调用新增的 `pollOrder(res.order_no)`。
- `pollOrder(orderNo)` 实现:每 1.5s 调用 `request<PagedResult<OrderItem>>({ url: '/order', data: { page: 1, page_size: 50 } })`,利用后端 `Order("id DESC")` 新订单在第一页的特性,在 `list` 中按 `order_no` 匹配:
- `order_status === 2`:停止轮询,`showToast('支付成功')` 并 `fetchQuota()` + `fetchUser()`。
- `order_status` 为 3/4/5:停止轮询,提示对应结果。
- `order_status === 1`:继续轮询。
- 超过约 10 次:停止,`showToast('到账可能有延迟')` 并 `fetchQuota()` + `fetchUser()`。
- 保留现有「先关闭订单弹窗再支付」交互;`fail` 回调保持静默处理不变。
### 4. 订单支付渠道区分(order.ts + order.wxml)
- `order.ts` 的 `fetchList` map 逻辑增补:
- `_canCancel: it.order_status === 1 && it.pay_channel !== 2`
- `_payChannelText: it.pay_channel === 2 ? '虚拟支付' : '微信支付'`
- `order.wxml` 中「取消订单」按钮由直接展示改为 `wx:if="{{item._canCancel}}"` 控制,虚拟支付订单不再展示取消入口;订单信息区可选展示 `_payChannelText`。
### 5. AI 推荐额度提示(ai-recommend.ts)
- 在 `startGenerate(sceneType, scene)` 执行生成前增加 `wx.showModal` 确认,文案「本次将消耗 1 次 AI 推荐额度」,`confirm` 后才执行原有生成逻辑,`cancel` 则恢复场景选择态;`onSkipScene` 与 `onStartGenerate` 均复用该确认,避免重复弹窗。
### 6. 新用户生日引导(login.ts)
- 登录成功并保存 token/user_info 后,先按现有逻辑 `switchTab` 到衣橱页,再判断 `data.user_info.is_new_user || !data.user_info.birth_day`:满足条件时弹 `wx.showModal` 引导「完善生日可解锁今日运势」,确认后 `switchTab` 到 `pages/profile/profile`,取消则留在衣橱页。
## 实现要点(防回归)
- 所有新接口调用沿用 `request<T>` 的 `showError: false` 模式,收藏/轮询失败不打断主流程。
- 支付轮询必须按 `order_no` 精确匹配,不能只看列表是否非空,避免误判他人订单或历史订单。
- 订单取消禁用仅控制前端入口,后端 `70007` 仍作为兜底,双保险。
- 不修改金额/时间换算、枚举值、请求字段名等已确认一致的部分,避免扩大改动面。
@@ -0,0 +1,76 @@
---
name: 小程序支付切换为虚拟支付接口
overview: 将小程序商城支付从普通微信支付(wx.requestPayment)切换为微信虚拟支付(wx.requestVirtualPayment),对接后端已实现好的 /order/virtual 虚拟支付下单接口。
todos:
- id: mall-payment
content: 改造 mall.ts:调整 OrderResult 类型、confirmPay 调用 /order/virtual 并上报 platform、doPayment 改用 wx.requestVirtualPayment
status: completed
- id: models-config
content: 更新 types/models.ts 新增虚拟支付响应类型,并在 utils/config.ts 补充虚拟支付错误码文案
status: completed
- id: mall-wxml
content: 将 mall.wxml 支付按钮文案由“微信支付”改为“立即支付”
status: completed
---
## 用户需求
后端已完成虚拟支付(微信小程序虚拟支付 / XPay)接口改造,现需将小程序前端支付流程从普通微信支付切换为虚拟支付接口。
## 产品概述
将商城(额度与会员)下单支付链路从 `wx.requestPayment` 普通微信支付迁移到 `wx.requestVirtualPayment` 虚拟支付,对接后端新增的 `POST /api/v1/order/virtual` 下单接口,覆盖 iOS/Android 全终端虚拟商品支付。
## 核心功能
- 商城下单时调用虚拟支付下单接口 `/order/virtual`,上报用户平台(android/ios/harmony/windows)
- 使用后端返回的 `sign_data`、`pay_sig`、`signature` 调起 `wx.requestVirtualPayment`(道具直购模式)
- 支付成功后保留原有额度与会员信息刷新逻辑
- 补充虚拟支付相关错误码的友好提示文案
- 调整支付按钮文案为“立即支付”
## 技术栈
- 前端框架:微信小程序原生 + TypeScript
- 网络请求:复用现有 `miniprogram/utils/request.ts` 的 `request<T>` 封装
- 支付 API:`wx.requestVirtualPayment`(基础库 2.19.2+,回调风格)
## 实现方案
### 核心改造策略
将 `mall.ts` 中的下单与支付两段逻辑整体替换为虚拟支付链路:
1. 下单:`POST /order` → `POST /order/virtual`,请求体从 `{goods_code}` 扩展为 `{goods_code, platform}`
2. 调起支付:`wx.requestPayment` → `wx.requestVirtualPayment`,参数由 `pay_params` 改为 `sign_data/pay_sig/signature/mode`
3. 后端返回的 `sign_data` 已经是 JSON 字符串,必须原样透传,禁止再次 `JSON.stringify`
### 关键决策
- **mode 取值**:本项目会员/额度购买属于“道具直购”,官方文档中 mode 合法值为 `short_series_goods`(道具直购)与 `short_series_coin`(代币充值),因此使用 `short_series_goods`。
- **platform 归一化**:`wx.getDeviceInfo().platform` 可能返回 `devtools`/`mac` 等后端不接受的枚举值,需归一化为后端 `oneof=android|ios|harmony|windows` 之一;`devtools`/`mac` 等开发调试环境默认映射为 `android`(联调时按真机切换)。
- **错误提示**:后端虚拟支付错误码(70005/70006/70008/70009/79003/71004)尚未纳入前端 `ERROR_MESSAGES`,补充文案后 `request` 封装的自动 toast 可直接生效,无需额外处理。
- **向后兼容**:`types/models.ts` 中普通支付 `PayParams` 类型暂保留(避免影响其它引用),新增虚拟支付响应类型;`/order` 普通支付接口后端仍保留,但前端商城全量切换虚拟支付。
### 实现要点(防回归)
- `sign_data` 透传字符串,不做任何序列化/解析(签名基于服务端序列化结果,重序列化会导致 `-15016`/`-15005` 签名错误)。
- `wx.requestVirtualPayment` 不支持 Promise 风格,使用回调;`success` 仅表示支付页拉起成功,最终到账以后端发货推送为准,前端仍按现有逻辑刷新额度与用户信息。
- `confirmPay` 中先关闭订单弹窗再调起支付(保持现有交互),支付 `fail` 静默处理或轻提示,不重复弹订单框。
- platform 获取失败时回退 `android`,避免请求体缺失导致 400 参数校验失败。
## 目录结构
本次为既有项目局部修改,涉及文件如下:
```
miniprogram/
├── pages/
│ └── mall/
│ ├── mall.ts # [MODIFY] 核心改造:OrderResult 类型、confirmPay 下单、doPayment 调起虚拟支付、platform 归一化
│ └── mall.wxml # [MODIFY] 支付按钮文案“微信支付”→“立即支付”
├── types/
│ └── models.ts # [MODIFY] 新增虚拟支付下单响应类型 VirtualPayResult
└── utils/
└── config.ts # [MODIFY] ERROR_MESSAGES 补充虚拟支付错误码文案
```
@@ -0,0 +1,94 @@
---
name: 穿搭列表卡片显示衣物拼图封面
overview: 修复「穿搭」列表卡片封面空白(只有铃铛占位):后端 OutfitItemResponse 增加 processed_image 字段并返回 CDN 图片 URL;前端 outfit 列表卡片封面改为最多 5 张衣物拼图网格,无图时回退铃铛占位。
todos:
- id: backend-outfit-image
content: 后端 dto/outfit.go 与 service/outfit.go 补 processed_image 组装链
status: completed
- id: backend-collect-image
content: 后端 collect.go 收藏列表 items 返回带图
status: completed
dependencies:
- backend-outfit-image
- id: frontend-cover-collage
content: 前端 models.ts 类型与 outfit 列表封面拼图渲染
status: completed
dependencies:
- backend-outfit-image
- id: verify-build-lint
content: 执行后端 go build 与前端 lint 验证
status: completed
dependencies:
- backend-outfit-image
- backend-collect-image
- frontend-cover-collage
---
## 产品概述
修复「穿搭」列表页卡片封面显示空白的问题:卡片封面目前只有铃铛占位图标和件数角标,应展示该穿搭的衣物平铺拼图(与穿搭详情页的拼图封面风格一致)。
## 核心功能
- 穿搭列表卡片封面展示最多 5 件衣物的抠图平铺拼图,衣物图片缺失时仍显示铃铛占位。
- 已收藏 tab 的穿搭卡片同样展示拼图封面。
- 列表数据一次性从后端带图返回,不在前端逐件请求衣物详情,避免 N+1 网络开销。
- 详情页、AI 推荐结果页等已有页面行为保持不变。
## 技术栈
- 后端:Go(沿用现有 Gin + GORM + COS 上传体系),复用 `upload.GetCosUrl` 将 COS key 转为可访问 CDN URL。
- 前端:微信小程序原生 TypeScript + WXML/WXSS,复用 `outfit-detail` 页已有的拼图封面样式模式。
## 实现方案
### 核心策略
后端在穿搭列表/详情/收藏的 `items` 响应中为每件衣物补充 `processed_image`(可访问 URL),前端列表卡片直接渲染最多 5 张拼图。选择后端带图而非前端逐件请求的理由:列表页每张卡片含多件衣物,前端 N+1 请求代价高;而后端 `buildOutfitListResponses` 本已批量查出全部衣物模型,补图片只是内存组装,无新增 SQL。
### 后端改动(/root/zt-closet-backend,3 个文件)
1. `internal/dto/outfit.go`:`OutfitItemResponse` 增加 `ProcessedImage string \`json:"processed_image"\``(与衣物详情响应字段名保持一致)。
2. `internal/service/outfit.go`:
- 新增 helper `buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse`:组装 `ClothesUUID` + `upload.GetCosUrl(c.ProcessedImage)`(空值不转换),需新增 import `pkg/upload`。
- `resolveOwnedClothes` 返回值改为 `([]int64, []dto.OutfitItemResponse, *errcode.Error)`,利用已查出的 `clothesList` 按请求顺序组装带图 items;`Create` 直接用 items 组装响应;`UpdateByUUID` 调用点以 `_` 忽略 items。
- `itemClothesUUIDs` 改造为 `itemResponses(ctx, outfitID) ([]dto.OutfitItemResponse, error)`:按 `OutfitItem` 明细顺序、`clothesID → Clothes` 映射组装带图 items;`GetByUUID` 相应更新。
- `buildOutfitListResponses`:`uuidByID` 改为 `clothesByID map[int64]mysqlmodel.Clothes`,按 `itemsByOutfit[o.ID]` 顺序组装 items。
- `buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse)` 签名同步调整。
3. `internal/service/collect.go`:`CollectService.itemClothesUUIDs` 同样改为 `itemResponses` 返回带图 items,收藏列表 `/collect` 自动带图;新增 import `pkg/upload`。
### 前端改动(/root/zt-closet-wxmini-frontend,3 个文件)
4. `miniprogram/types/models.ts`:`OutfitItem.items` 数组元素增加 `processed_image: string`。
5. `miniprogram/pages/outfit/outfit.wxml`:`.cover` 内改为拼图网格——`wx:for="{{item.items}}" wx:key="clothes_uuid" wx:if="{{index < 5}}"`,有 `processed_image` 时渲染 `<image mode="aspectFit">`,否则铃铛占位;`items` 为空时保留居中大铃铛;`.cover-count` 件数角标保留。
6. `miniprogram/pages/outfit/outfit.wxss`:新增 `.cover-item`、`.cover-0~4`(absolute 定位 + 轻微旋转)、`.cover-img`、占位样式,尺寸适配 280rpx 高封面(约 150×190rpx 卡片),`.cover` 增加 `overflow: hidden`,风格参照 `outfit-detail.wxss` 17-67 行。
### 兼容性与性能
- `processed_image` 为新增 JSON 字段,向后兼容;详情页 `outfit-detail.ts` 仍需 `name` 字段,保持原 `/clothes/{uuid}` 请求逻辑不变。
- 列表组装维持现有批量查询路径(无 N+1),图片转换仅内存操作;`GetCosUrl` 生成签名 URL 为纯计算,无 IO。
- 衣物图片缺失(抠图失败/未处理)时 `processed_image` 为空,前端自动回退铃铛占位,无异常展示。
## 目录结构
```
/root/zt-closet-backend/
├── internal/dto/outfit.go # [MODIFY] OutfitItemResponse 增加 processed_image 字段
└── internal/service/
├── outfit.go # [MODIFY] helper 与响应组装链带图(Create/GetByUUID/List)
└── collect.go # [MODIFY] 收藏列表 items 带图
/root/zt-closet-wxmini-frontend/miniprogram/
├── types/models.ts # [MODIFY] OutfitItem.items 元素增加 processed_image
└── pages/outfit/
├── outfit.wxml # [MODIFY] 卡片封面改为最多 5 张拼图网格
└── outfit.wxss # [MODIFY] 新增拼图封面样式,适配 280rpx 高卡片
```
## 验证方式
- 后端:`cd /root/zt-closet-backend && go build ./...` 编译通过。
- 前端:对 `models.ts`、`outfit.wxml`、`outfit.wxss` 执行 lint 检查无新错误。
- 手工验证:小程序「穿搭」页两个 tab 的卡片封面应显示衣物拼图;衣物无图的旧数据回退铃铛。
@@ -0,0 +1,172 @@
---
name: 通知与分享入口互换
overview: 将底部 tabBar 的「通知」替换为「分享」,通知入口移入「我的」页面菜单;「分享」tab 页内实现「我的分享 / 分享给我的」两个分段,未读红点迁移到「我的」tab 图标上。
design:
styleKeywords:
- iOS HIG
- 清爽克制
- 胶囊分段
- 系统蓝
- 线性图标
fontSystem:
fontFamily: PingFang SC
heading:
size: 34rpx
weight: 600
subheading:
size: 28rpx
weight: 500
body:
size: 26rpx
weight: 400
colorSystem:
primary:
- "#007AFF"
- "#5856D6"
- "#FF2D55"
background:
- "#F2F2F7"
- "#FFFFFF"
text:
- "#000000"
- "#3C3C43"
- "#8E8E93"
- "#C7C7CC"
functional:
- "#FF3B30"
- "#34C759"
- "#FF9500"
todos:
- id: update-tabbar
content: 调整 tabBar 入口与红点(app.json + custom-tab-bar)
status: completed
- id: upgrade-share-page
content: 升级分享页为 tab 页并实现「我的分享/分享给我的」双分段
status: completed
dependencies:
- update-tabbar
- id: update-profile
content: 修改我的页通知入口并同步未读红点
status: completed
- id: adjust-notification
content: 调整通知页为普通页面(返回按钮、移除 tab 同步)
status: completed
---
## 产品概述
对微信小程序「纸条AI衣橱」的底部导航与入口布局进行调整:将底部导航栏第 4 个入口由「通知」改为「分享」,原「我的」页面内的「我的分享」入口改为「通知」;分享页新增「我的分享 / 分享给我的」两个分段展示;未读通知红点由通知 tab 移至底部「我的」tab 图标右上角。
## 核心功能
- 底部导航栏第 4 个入口显示「分享」(分享图标),点击进入分享页。
- 「我的」页面功能列表中新增「通知」入口(铃铛图标),点击进入通知列表页。
- 分享页顶部提供「我的分享 / 分享给我的」分段切换:
- 「我的分享」展示当前用户发起的衣柜分享,保留「新建分享」入口。
- 「分享给我的」展示他人分享给当前用户的衣柜分享,卡片展示分享者头像与昵称。
- 底部「我的」tab 图标右上角显示未读通知数红点,超过 99 显示「99+」。
- 通知页由 tab 页改为普通二级页,支持返回上一页。
## 技术栈
- 平台:微信小程序原生 + TypeScript(沿用现有 `skyline` + `glass-easel` 架构)
- 网络层:复用现有 `miniprogram/utils/request.ts` 的 `request<T>` 封装
- UI:纯 WXSS 手写,复用现有 `navigation-bar`、`empty-state`、`custom-tab-bar` 组件与 `styles/icons.wxss` 图标库
## 实现方案
### 整体策略
在现有「5 个 tabBar 页 + 二级页」架构上做入口互换与页面能力升级,不引入新页面、新组件、新依赖。分享页从二级页升级为 tabBar 页并新增分段切换;通知页从 tabBar 页降级为二级页。红点数据源由原通知页单向写缓存,扩展为 tabBar 组件主动拉取 `/notification/unread`。
### 关键决策
- **分享页分段采用「单一当前列表 + 切换重置」模式**:用 `activeTab`(0=我的分享,1=分享给我的)控制当前展示,切换分段时清空 `list` 并重置分页状态,再调用对应接口。避免维护两套分页状态,符合 KISS/YAGNI,切换后重新拉取的开销可接受。
- **两个列表接口复用同一响应结构**:`GET /closet-share` 与 `GET /closet-share/received` 均返回 `PagedResult<ClosetShareItem>`;后者额外返回 `owner_name/owner_avatar`,前端 `ClosetShareItem` 补字段即可,`decorate()` 对两接口通用。
- **红点刷新采用「缓存先行 + 异步刷新」**:tabBar 组件 `attached` 时先读 `zt_closet_unread` 立即渲染,再请求 `/notification/unread` 更新缓存与视图;通知页 `fetchUnread` 保持写缓存并调 `tabBar.refreshUnread()`,形成闭环。
- **入口切换避免 tab 页跳转错误**:`profile` 中删除原 `onTapShare` 的 `navigateTo('/pages/closet-share/closet-share')`(tab 页不能用 navigateTo),改为 `navigateTo('/pages/notification/notification')`。
### 实现注意事项
- `closet-share` 成为 tab 页后,`onShow` 需 `syncTabBar()` 设置 `selected: 3`(对应第 4 项),与现有 wardrobe/outfit/horoscope/profile 页的 selected 同步模式一致。
- `closet-share.wxml` 中 `navigation-bar` 增加 `back="{{false}}"`(tab 页不显示返回键),标题由「衣柜分享」调整为「分享」;`closet-share-create`、`closet-share-detail` 仍为二级页,用 `navigateTo` 跳转不受影响。
- `notification.wxml` 去掉 `back="{{false}}"` 以恢复默认返回按钮;`notification.ts` 删除 `syncTabBar()`(避免误将 tabBar 高亮切到已不存在的第 4 项),但保留 `fetchUnread()` 以刷新「我的」tab 红点。
- 分享给我的卡片头像使用 `image` 渲染 `owner_avatar`,为空时回退 `icon-user` 占位,与 profile 页头像处理方式一致。
## 架构设计
数据流保持「页面/组件 → request → 后端 API」的单向依赖:
- tabBar 组件 `attached` → 读本地缓存 `zt_closet_unread` 立即显示 → 请求 `GET /notification/unread` → 写缓存并更新红点。
- 分享页 `onShow` → 同步 tabBar 高亮 → 按 `activeTab` 请求 `GET /closet-share` 或 `GET /closet-share/received` → 渲染列表。
- 我的页 `onShow` → 同步 tabBar 高亮 → 拉取用户/额度信息,并调用 `tabBar.refreshUnread()` 刷新红点。
## 目录结构
本次仅修改现有文件,不新增文件:
```
miniprogram/
├── app.json # [MODIFY] tabBar.list 第4项由 notification 改为 closet-share,text 由「通知」改为「分享」
├── custom-tab-bar/
│ ├── index.ts # [MODIFY] 列表第4项改为分享/icon-share;refreshUnread 增加主动请求 /notification/unread
│ └── index.wxml # [MODIFY] 红点条件由 icon-bell 改为 icon-user(我的 tab)
├── pages/
│ ├── closet-share/
│ │ ├── closet-share.ts # [MODIFY] 增加 activeTab 双分段、双接口取数、分页重置、syncTabBar(selected=3)
│ │ ├── closet-share.wxml # [MODIFY] 增加分段控件;分享给我的卡片展示 owner 信息;新建入口仅我的分享展示;back=false
│ │ └── closet-share.wxss # [MODIFY] 增加分段样式、分享者头像昵称样式
│ ├── profile/
│ │ ├── profile.ts # [MODIFY] onTapShare 改为 onTapNotification(navigateTo notification);onShow 刷新 tabBar 红点
│ │ └── profile.wxml # [MODIFY] 「我的分享」菜单行改为「通知」(icon-bell,bindtap=onTapNotification)
│ └── notification/
│ ├── notification.ts # [MODIFY] 删除 syncTabBar;保留 fetchUnread 刷新 tabBar 红点
│ └── notification.wxml # [MODIFY] navigation-bar 去掉 back="{{false}}",恢复返回按钮
└── types/
└── models.ts # [MODIFY] ClosetShareItem 补充 owner_name、owner_avatar
```
## 关键代码结构
补充分享列表项类型,兼容「分享给我的」返回的分享者信息:
```ts
export interface ClosetShareItem {
share_uuid: string
share_type: number
clothes_count: number
expire_time: number
status: number
owner_name?: string // 分享者昵称(分享给我的列表返回)
owner_avatar?: string // 分享者头像(分享给我的列表返回)
}
```
分享页核心状态:
```ts
data: {
activeTab: 0 | 1, // 0=我的分享 1=分享给我的
list: ClosetShareItem[],
loading: boolean,
page: number,
pageSize: number,
hasMore: boolean,
}
```
## 设计风格
延续现有 iOS HIG 视觉语言:清爽克制、系统分组灰底 #F2F2F7 + 纯白卡片 + 发丝分隔线,强调色系统蓝 #007AFF,线性 SF Symbols 风格图标。
## 分享页分段设计
- 顶部在导航栏下方放置胶囊式分段控件(Segmented Control):两个等宽胶囊「我的分享」「分享给我的」,选中项为系统蓝底白字,未选中项为灰底深字,切换有轻量过渡;样式与通知页筛选胶囊保持一致,保证全站视觉统一。
- 「我的分享」列表顶部保留「+ 新建分享」描边按钮,卡片沿用现有四宫格衣物缩略图 + 件数 + 类型 + 有效期布局。
- 「分享给我的」列表卡片在左侧缩略图基础上,右侧信息区顶部新增分享者行(小圆头像 + 昵称),帮助用户快速识别来源;无头像时回退系统灰色人形占位。
- 两个分段的空态复用 empty-state 组件,文案分别区分「还没有分享」与「还没有收到分享」。
## 底部导航设计
- 第 4 个 tab 由铃铛图标替换为分享图标(三圆点连线),文字由「通知」改为「分享」。
- 未读通知红点从分享 tab 移至「我的」tab 图标右上角,沿用现有红色胶囊数字样式,超过 99 显示「99+」,无未读时隐藏。
+42
View File
@@ -0,0 +1,42 @@
{
"pages": [
"pages/login/login",
"pages/wardrobe/wardrobe",
"pages/outfit/outfit",
"pages/horoscope/horoscope",
"pages/notification/notification",
"pages/profile/profile",
"pages/upload/upload",
"pages/clothes-detail/clothes-detail",
"pages/outfit-detail/outfit-detail",
"pages/outfit-edit/outfit-edit",
"pages/ai-recommend/ai-recommend",
"pages/notification-detail/notification-detail",
"pages/closet-share/closet-share",
"pages/closet-share-create/closet-share-create",
"pages/closet-share-detail/closet-share-detail",
"pages/mall/mall",
"pages/order/order"
],
"tabBar": {
"custom": true,
"color": "#8E8E93",
"selectedColor": "#007AFF",
"backgroundColor": "#FFFFFF",
"list": [
{ "pagePath": "pages/wardrobe/wardrobe", "text": "衣橱" },
{ "pagePath": "pages/outfit/outfit", "text": "穿搭" },
{ "pagePath": "pages/horoscope/horoscope", "text": "运势" },
{ "pagePath": "pages/closet-share/closet-share", "text": "分享" },
{ "pagePath": "pages/profile/profile", "text": "我的" }
]
},
"window": {
"navigationBarTextStyle": "black",
"navigationStyle": "custom",
"backgroundColor": "#F2F2F7"
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
}
+18
View File
@@ -0,0 +1,18 @@
// app.ts
import * as auth from './utils/auth'
import { setAuthExpiredHandler } from './utils/request'
App<IAppOption>({
globalData: {
userInfo: undefined,
},
onLaunch() {
// 登录失效 / 账号禁用时统一清登录态并跳转登录页
setAuthExpiredHandler(() => {
auth.clearTokens()
auth.clearUserInfo()
wx.reLaunch({ url: '/pages/login/login' })
})
},
})
+203
View File
@@ -0,0 +1,203 @@
/**app.wxss —— 全局设计令牌与通用样式**/
@import "styles/icons.wxss";
page {
--color-primary: #007AFF;
--color-primary-aux: #5856D6;
--color-pink: #FF2D55;
--color-bg: #F2F2F7;
--color-card: #FFFFFF;
--color-hairline: #E5E5EA;
--color-text-main: #000000;
--color-text-secondary: #8E8E93;
--color-text-tertiary: #C7C7CC;
--color-green: #34C759;
--color-orange: #FF9500;
--color-red: #FF3B30;
--color-yellow: #FFCC00;
background: var(--color-bg);
color: var(--color-text-main);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
font-size: 30rpx;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ===== 布局 ===== */
.page {
min-height: 100vh;
background: var(--color-bg);
box-sizing: border-box;
}
/* ===== 卡片 ===== */
.card {
background: var(--color-card);
border-radius: 32rpx;
padding: 32rpx;
box-sizing: border-box;
}
/* ===== 发丝分隔线 ===== */
.hairline {
border-bottom: 1rpx solid var(--color-hairline);
}
.hairline-t {
border-top: 1rpx solid var(--color-hairline);
}
/* ===== 文本 ===== */
.t-main { color: var(--color-text-main); }
.t-sub { color: var(--color-text-secondary); }
.t-weak { color: var(--color-text-tertiary); }
.t-primary { color: var(--color-primary); }
.t-danger { color: var(--color-red); }
.t-green { color: var(--color-green); }
.t-orange { color: var(--color-orange); }
.fs-title { font-size: 68rpx; font-weight: 700; }
.fs-h1 { font-size: 40rpx; font-weight: 700; }
.fs-h2 { font-size: 34rpx; font-weight: 600; }
.fs-body { font-size: 30rpx; }
.fs-caption { font-size: 26rpx; }
.fs-note { font-size: 22rpx; }
.num { font-variant-numeric: tabular-nums; }
/* ===== 按钮 ===== */
.btn {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 24rpx;
font-size: 30rpx;
font-weight: 600;
}
.btn-primary {
height: 96rpx;
background: var(--color-primary);
color: #fff;
}
.btn-primary.disabled {
opacity: 0.4;
}
.btn-primary:active { opacity: 0.7; }
.btn-secondary {
height: 88rpx;
background: var(--color-bg);
color: var(--color-text-main);
}
.btn-secondary:active { opacity: 0.7; }
.btn-outline {
height: 88rpx;
border: 1rpx solid var(--color-primary);
color: var(--color-primary);
background: #fff;
font-weight: 500;
}
.btn-outline:active { opacity: 0.7; }
.btn-danger-text {
color: var(--color-red);
font-size: 30rpx;
font-weight: 500;
}
/* ===== 输入框 ===== */
.input {
height: 88rpx;
background: var(--color-bg);
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 28rpx;
box-sizing: border-box;
width: 100%;
}
.textarea {
background: var(--color-bg);
border-radius: 20rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
width: 100%;
min-height: 160rpx;
box-sizing: border-box;
}
/* ===== 胶囊标签 ===== */
.tag {
display: inline-flex;
align-items: center;
height: 44rpx;
padding: 0 20rpx;
border-radius: 22rpx;
font-size: 22rpx;
font-weight: 500;
box-sizing: border-box;
}
.tag-blue { background: rgba(0, 122, 255, 0.1); color: #007AFF; }
.tag-gray { background: #F2F2F7; color: #8E8E93; }
.tag-green { background: rgba(52, 199, 89, 0.12); color: #34C759; }
.tag-orange { background: rgba(255, 149, 0, 0.12); color: #FF9500; }
.tag-red { background: rgba(255, 59, 48, 0.12); color: #FF3B30; }
.tag-indigo { background: rgba(88, 86, 214, 0.12); color: #5856D6; }
/* ===== 骨架屏 ===== */
.skeleton {
background: linear-gradient(90deg, #F2F2F7 25%, #E5E5EA 37%, #F2F2F7 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
/* ===== 悬浮按钮 ===== */
.fab {
position: fixed;
right: 32rpx;
bottom: 180rpx;
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 122, 255, 0.35);
z-index: 20;
}
.fab:active { opacity: 0.8; }
/* ===== 底部安全区 ===== */
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* ===== 通用列表行 ===== */
.list-row {
display: flex;
align-items: center;
height: 88rpx;
box-sizing: border-box;
}
.list-row:active { background: #F2F2F7; }
/* ===== 1:1 方图占位 ===== */
.square {
position: relative;
width: 100%;
background: #F2F2F7;
border-radius: 24rpx;
overflow: hidden;
}
.square::after {
content: '';
display: block;
padding-bottom: 100%;
}
@@ -0,0 +1,4 @@
{
"component": true,
"styleIsolation": "apply-shared"
}
@@ -0,0 +1,14 @@
Component({
properties: {
item: { type: Object, value: {} },
selectable: { type: Boolean, value: false },
selected: { type: Boolean, value: false },
statusText: { type: String, value: '' },
statusType: { type: String, value: 'gray' },
},
methods: {
onTap() {
this.triggerEvent('select', { item: this.data.item })
},
},
})
@@ -0,0 +1,20 @@
<view class="clothes-card {{selected ? 'selected' : ''}}" bindtap="onTap">
<view class="clothes-img">
<image
wx:if="{{item.processed_image}}"
class="clothes-img-img"
src="{{item.processed_image}}"
mode="aspectFill"
lazy-load
/>
<view wx:else class="clothes-placeholder">
<view class="icon icon-hanger"></view>
</view>
<view wx:if="{{statusText}}" class="clothes-status tag tag-{{statusType}}">{{statusText}}</view>
</view>
<view class="clothes-name">{{item.name || '未命名衣物'}}</view>
<view class="clothes-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}{{item.color_main ? ' · ' + item.color_main : ''}}</view>
<view wx:if="{{selectable}}" class="clothes-check {{selected ? 'on' : ''}}">
<view wx:if="{{selected}}" class="icon icon-check"></view>
</view>
</view>
@@ -0,0 +1,91 @@
.clothes-card {
position: relative;
background: #fff;
border-radius: 32rpx;
padding: 20rpx;
box-sizing: border-box;
border: 2rpx solid transparent;
}
.clothes-card.selected {
border-color: #007AFF;
}
.clothes-img {
position: relative;
width: 100%;
aspect-ratio: 1;
background: #F2F2F7;
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 16rpx;
}
.clothes-img-img {
width: 100%;
height: 100%;
}
.clothes-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.clothes-placeholder .icon {
width: 72rpx;
height: 72rpx;
}
.clothes-status {
position: absolute;
top: 12rpx;
right: 12rpx;
}
.clothes-name {
font-size: 26rpx;
font-weight: 500;
color: #1C1C1E;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clothes-sub {
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clothes-check {
position: absolute;
top: 32rpx;
right: 32rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #C7C7CC;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.clothes-check.on {
background: #007AFF;
border-color: #007AFF;
color: #fff;
}
.clothes-check .icon {
width: 28rpx;
height: 28rpx;
}
@@ -0,0 +1,4 @@
{
"component": true,
"styleIsolation": "apply-shared"
}
@@ -0,0 +1,14 @@
Component({
properties: {
icon: { type: String, value: 'icon-hanger' },
title: { type: String, value: '' },
desc: { type: String, value: '' },
buttonText: { type: String, value: '' },
buttonType: { type: String, value: 'primary' },
},
methods: {
onTap() {
this.triggerEvent('action')
},
},
})
@@ -0,0 +1,12 @@
<view class="empty">
<view class="empty-icon">
<view class="icon icon-lg {{icon}}"></view>
</view>
<view class="empty-title">{{title}}</view>
<view wx:if="{{desc}}" class="empty-desc">{{desc}}</view>
<view
wx:if="{{buttonText}}"
class="empty-btn btn {{buttonType === 'outline' ? 'btn-outline' : 'btn-primary'}}"
bindtap="onTap"
>{{buttonText}}</view>
</view>
@@ -0,0 +1,37 @@
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 48rpx;
text-align: center;
}
.empty-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #E8F2FF;
display: flex;
align-items: center;
justify-content: center;
color: #007AFF;
margin-bottom: 32rpx;
}
.empty-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
}
.empty-desc {
font-size: 26rpx;
color: #8E8E93;
margin-top: 12rpx;
line-height: 1.6;
}
.empty-btn {
margin-top: 40rpx;
width: 360rpx;
}
@@ -0,0 +1,5 @@
{
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {}
}
@@ -0,0 +1,105 @@
Component({
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
/**
* 组件的属性列表
*/
properties: {
extClass: {
type: String,
value: ''
},
title: {
type: String,
value: ''
},
background: {
type: String,
value: ''
},
color: {
type: String,
value: ''
},
back: {
type: Boolean,
value: true
},
loading: {
type: Boolean,
value: false
},
homeButton: {
type: Boolean,
value: false,
},
animated: {
// 显示隐藏的时候opacity动画效果
type: Boolean,
value: true
},
show: {
// 显示隐藏导航,隐藏的时候navigation-bar的高度占位还在
type: Boolean,
value: true,
observer: '_showChange'
},
// back为true的时候,返回的页面深度
delta: {
type: Number,
value: 1
},
},
/**
* 组件的初始数据
*/
data: {
displayStyle: ''
},
lifetimes: {
attached() {
const rect = wx.getMenuButtonBoundingClientRect()
wx.getSystemInfo({
success: (res) => {
const isAndroid = res.platform === 'android'
const isDevtools = res.platform === 'devtools'
this.setData({
ios: !isAndroid,
innerPaddingRight: `padding-right: ${res.windowWidth - rect.left}px`,
leftWidth: `width: ${res.windowWidth - rect.left }px`,
safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${res.safeArea.top}px); padding-top: ${res.safeArea.top}px` : ``
})
}
})
},
},
/**
* 组件的方法列表
*/
methods: {
_showChange(show: boolean) {
const animated = this.data.animated
let displayStyle = ''
if (animated) {
displayStyle = `opacity: ${
show ? '1' : '0'
};transition:opacity 0.5s;`
} else {
displayStyle = `display: ${show ? '' : 'none'}`
}
this.setData({
displayStyle
})
},
back() {
const data = this.data
if (data.delta) {
wx.navigateBack({
delta: data.delta
})
}
this.triggerEvent('back', { delta: data.delta }, {})
}
},
})
@@ -0,0 +1,64 @@
<view class="weui-navigation-bar {{extClass}}">
<view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="color: {{color}}; background: {{background}}; {{displayStyle}}; {{innerPaddingRight}}; {{safeAreaTop}};">
<!-- 左侧按钮 -->
<view class='weui-navigation-bar__left' style="{{leftWidth}};">
<block wx:if="{{back || homeButton}}">
<!-- 返回上一页 -->
<block wx:if="{{back}}">
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_goback">
<view
bindtap="back"
class="weui-navigation-bar__btn_goback_wrapper"
hover-class="weui-active"
hover-stay-time="100"
aria-role="button"
aria-label="返回"
>
<view class="weui-navigation-bar__button weui-navigation-bar__btn_goback"></view>
</view>
</view>
</block>
<!-- 返回首页 -->
<block wx:if="{{homeButton}}">
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_home">
<view
bindtap="home"
class="weui-navigation-bar__btn_home_wrapper"
hover-class="weui-active"
aria-role="button"
aria-label="首页"
>
<view class="weui-navigation-bar__button weui-navigation-bar__btn_home"></view>
</view>
</view>
</block>
</block>
<block wx:else>
<slot name="left"></slot>
</block>
</view>
<!-- 标题 -->
<view class='weui-navigation-bar__center'>
<view wx:if="{{loading}}" class="weui-navigation-bar__loading" aria-role="alert">
<view
class="weui-loading"
aria-role="img"
aria-label="加载中"
></view>
</view>
<block wx:if="{{title}}">
<text>{{title}}</text>
</block>
<block wx:else>
<slot name="center"></slot>
</block>
</view>
<!-- 右侧留空 -->
<view class='weui-navigation-bar__right'>
<slot name="right"></slot>
</view>
</view>
</view>
@@ -0,0 +1,96 @@
.weui-navigation-bar {
--weui-FG-0:rgba(0,0,0,.9);
--height: 44px;
--left: 16px;
}
.weui-navigation-bar .android {
--height: 48px;
}
.weui-navigation-bar {
overflow: hidden;
color: var(--weui-FG-0);
flex: none;
}
.weui-navigation-bar__inner {
position: relative;
top: 0;
left: 0;
height: calc(var(--height) + env(safe-area-inset-top));
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: env(safe-area-inset-top);
width: 100%;
box-sizing: border-box;
}
.weui-navigation-bar__left {
position: relative;
padding-left: var(--left);
display: flex;
flex-direction: row;
align-items: flex-start;
height: 100%;
box-sizing: border-box;
}
.weui-navigation-bar__btn_goback_wrapper {
padding: 11px 18px 11px 16px;
margin: -11px -18px -11px -16px;
}
.weui-navigation-bar__btn_goback_wrapper.weui-active {
opacity: 0.5;
}
.weui-navigation-bar__btn_goback {
font-size: 12px;
width: 12px;
height: 24px;
-webkit-mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
-webkit-mask-size: cover;
mask-size: cover;
background-color: var(--weui-FG-0);
}
.weui-navigation-bar__center {
font-size: 17px;
text-align: center;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
font-weight: bold;
flex: 1;
height: 100%;
}
.weui-navigation-bar__loading {
margin-right: 4px;
align-items: center;
}
.weui-loading {
font-size: 16px;
width: 16px;
height: 16px;
display: block;
background: transparent url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='80px' height='80px' viewBox='0 0 80 80' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Eloading%3C/title%3E%3Cdefs%3E%3ClinearGradient x1='94.0869141%25' y1='0%25' x2='94.0869141%25' y2='90.559082%25' id='linearGradient-1'%3E%3Cstop stop-color='%23606060' stop-opacity='0' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3ClinearGradient x1='100%25' y1='8.67370605%25' x2='100%25' y2='90.6286621%25' id='linearGradient-2'%3E%3Cstop stop-color='%23606060' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3C/defs%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' opacity='0.9'%3E%3Cg%3E%3Cpath d='M40,0 C62.09139,0 80,17.90861 80,40 C80,62.09139 62.09139,80 40,80 L40,73 C58.2253967,73 73,58.2253967 73,40 C73,21.7746033 58.2253967,7 40,7 L40,0 Z' fill='url(%23linearGradient-1)'%3E%3C/path%3E%3Cpath d='M40,0 L40,7 C21.7746033,7 7,21.7746033 7,40 C7,58.2253967 21.7746033,73 40,73 L40,80 C17.90861,80 0,62.09139 0,40 C0,17.90861 17.90861,0 40,0 Z' fill='url(%23linearGradient-2)'%3E%3C/path%3E%3Ccircle id='Oval' fill='%23606060' cx='40.5' cy='3.5' r='3.5'%3E%3C/circle%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A") no-repeat;
background-size: 100%;
margin-left: 0;
animation: loading linear infinite 1s;
}
@keyframes loading {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"styleIsolation": "apply-shared"
}
+43
View File
@@ -0,0 +1,43 @@
import { request } from '../utils/request'
Component({
data: {
selected: 0,
unread: 0,
list: [
{ pagePath: '/pages/wardrobe/wardrobe', text: '衣橱', icon: 'icon-hanger' },
{ pagePath: '/pages/outfit/outfit', text: '穿搭', icon: 'icon-shirt' },
{ pagePath: '/pages/horoscope/horoscope', text: '运势', icon: 'icon-sparkles' },
{ pagePath: '/pages/closet-share/closet-share', text: '分享', icon: 'icon-share' },
{ pagePath: '/pages/profile/profile', text: '我的', icon: 'icon-user' },
],
},
lifetimes: {
attached() {
this.refreshUnread()
},
},
methods: {
refreshUnread() {
const unread = (wx.getStorageSync('zt_closet_unread') as number) || 0
this.setData({ unread })
this.fetchUnread()
},
fetchUnread() {
request<{ unread_count: number }>({ url: '/notification/unread', showError: false })
.then((res) => {
const unread = res.unread_count || 0
wx.setStorageSync('zt_closet_unread', unread)
this.setData({ unread })
})
.catch(() => {})
},
switchTab(e: WechatMiniprogram.TouchEvent) {
const { path, index } = e.currentTarget.dataset as { path: string; index: number }
wx.switchTab({ url: path })
this.setData({ selected: index })
},
},
})
+16
View File
@@ -0,0 +1,16 @@
<view class="tab-bar">
<view
wx:for="{{list}}"
wx:key="pagePath"
class="tab-item {{selected === index ? 'active' : ''}}"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="tab-icon-wrap">
<view class="icon tab-icon {{item.icon}}"></view>
<view wx:if="{{item.icon === 'icon-user' && unread > 0}}" class="tab-badge">{{unread > 99 ? '99+' : unread}}</view>
</view>
<text class="tab-text">{{item.text}}</text>
</view>
</view>
+58
View File
@@ -0,0 +1,58 @@
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1rpx solid #E5E5EA;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12rpx 0 8rpx;
color: #8E8E93;
}
.tab-item.active {
color: #007AFF;
}
.tab-icon-wrap {
position: relative;
margin-bottom: 6rpx;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
}
.tab-badge {
position: absolute;
top: -8rpx;
right: -20rpx;
min-width: 28rpx;
height: 28rpx;
padding: 0 8rpx;
border-radius: 14rpx;
background: #FF3B30;
color: #fff;
font-size: 20rpx;
line-height: 28rpx;
text-align: center;
box-sizing: border-box;
}
.tab-text {
font-size: 20rpx;
line-height: 1;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,126 @@
import { request } from '../../utils/request'
import { AiRecommendResult, ClothesItem } from '../../types/models'
Page({
data: {
state: 'scene' as 'scene' | 'generating' | 'result' | 'empty',
showSceneSheet: true,
sceneType: 1,
scene: '',
result: null as AiRecommendResult | null,
clothes: [] as ClothesItem[],
generating: false,
},
noop() {},
selectDaily() {
this.setData({ sceneType: 1 })
},
selectCustom() {
this.setData({ sceneType: 2 })
},
onSceneInput(e: WechatMiniprogram.Input) {
this.setData({ scene: e.detail.value })
},
onSkipScene() {
this.setData({ showSceneSheet: false })
this.startGenerate(1, '')
},
onStartGenerate() {
this.setData({ showSceneSheet: false })
this.startGenerate(this.data.sceneType, this.data.scene)
},
startGenerate(sceneType: number, scene: string) {
if (this.data.generating) return
wx.showModal({
title: 'AI 穿搭推荐',
content: '本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭',
confirmText: '开始生成',
cancelText: '取消',
success: (res) => {
if (res.confirm) this.doGenerate(sceneType, scene)
},
})
},
doGenerate(sceneType: number, scene: string) {
if (this.data.generating) return
this.setData({ state: 'generating', generating: true })
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) => {
const clothes = await Promise.all(
res.outfit_item.map(async (it) => {
try {
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
} catch (e) {
return { clothes_uuid: it.clothes_uuid } as ClothesItem
}
})
)
this.setData({ result: res, clothes, state: 'result', generating: false })
})
.catch((e: any) => {
this.setData({ generating: false })
const code = e && e.code
if (code === 90400) {
this.setData({ state: 'empty' })
} else if (code === 90401) {
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 })
}
})
},
showLimitModal() {
wx.showModal({
title: 'AI 推荐额度不足',
content: '购买额度包后可继续生成 AI 穿搭推荐',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/mall/mall' })
},
})
},
onSave() {
if (!this.data.result) return
request({
url: '/ai-recommend/save',
method: 'POST',
data: { outfit_uuid: this.data.result.outfit_uuid },
})
.then(() => {
wx.showToast({ title: '已保存到我的穿搭', icon: 'success' })
setTimeout(() => {
wx.redirectTo({
url: `/pages/outfit-detail/outfit-detail?uuid=${this.data.result!.outfit_uuid}&owner=1`,
})
}, 500)
})
.catch(() => {})
},
onReroll() {
this.startGenerate(this.data.sceneType, this.data.scene)
},
onGoUpload() {
wx.navigateTo({ url: '/pages/upload/upload' })
},
})
@@ -0,0 +1,90 @@
<view class="page rec-page">
<navigation-bar title="AI 穿搭推荐" />
<!-- 生成中 -->
<view wx:if="{{state === 'generating'}}" class="generating">
<view class="gen-ani">
<view class="icon icon-shirt gen-icon"></view>
<view class="gen-ring"></view>
</view>
<view class="gen-title">AI 正在为你搭配…</view>
<view class="gen-desc">结合今日运势与你的衣橱</view>
</view>
<!-- 衣橱为空 -->
<empty-state
wx:elif="{{state === 'empty'}}"
icon="icon-hanger"
title="还没有可用衣物"
desc="先上传几件处理成功的衣物吧"
buttonText="去上传"
bind:action="onGoUpload"
/>
<!-- 结果页 -->
<scroll-view wx:elif="{{state === 'result'}}" scroll-y class="scroll-body">
<view class="card result-cover">
<view class="rc-item rc-{{index}}" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 5}}">
<image wx:if="{{item.processed_image}}" class="rc-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger rc-placeholder"></view>
</view>
</view>
<view class="card result-info">
<view class="ri-title">{{result.outfit_name}}</view>
<view class="ri-desc">{{result.outfit_desc}}</view>
<view class="tag-row">
<text wx:if="{{result.scene}}" class="tag tag-gray">{{result.scene}}</text>
<text class="tag tag-indigo">AI 推荐</text>
</view>
<view class="items-title">推荐单品</view>
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
<view class="items-row">
<view class="item-chip" wx:for="{{clothes}}" wx:key="clothes_uuid">
<image wx:if="{{item.processed_image}}" class="item-img" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="item-img item-img-empty"><view class="icon icon-hanger"></view></view>
<text class="item-name">{{item.name || '未命名'}}</text>
</view>
</view>
</scroll-view>
</view>
<view class="footer">
<view class="btn btn-primary" bindtap="onSave">保存到我的穿搭</view>
<view class="btn btn-outline reroll" bindtap="onReroll">换一套</view>
<view class="reroll-tip">换一套将再消耗 1 次推荐额度</view>
</view>
</scroll-view>
<!-- 场景选择弹窗 -->
<view wx:if="{{showSceneSheet}}" class="mask" bindtap="onSkipScene">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="sheet-title">选择推荐场景</view>
<view class="scene-card {{sceneType === 1 ? 'active' : ''}}" bindtap="selectDaily">
<view class="scene-name">每日推荐</view>
<view class="scene-desc">按今日运势与衣橱自动搭配</view>
<view wx:if="{{sceneType === 1}}" class="icon icon-check scene-check"></view>
</view>
<view class="scene-card {{sceneType === 2 ? 'active' : ''}}" bindtap="selectCustom">
<view class="scene-name">自定义场景</view>
<view class="scene-desc">描述你想去的场合</view>
<view wx:if="{{sceneType === 2}}" class="icon icon-check scene-check"></view>
</view>
<input
wx:if="{{sceneType === 2}}"
class="input scene-input"
value="{{scene}}"
bindinput="onSceneInput"
placeholder="例如:海边度假、周一例会、初次约会"
/>
<view class="btn btn-primary sheet-btn" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
<view class="sheet-skip" bindtap="onSkipScene">跳过,使用每日推荐</view>
</view>
</view>
</view>
@@ -0,0 +1,281 @@
.rec-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
/* 生成中 */
.generating {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
}
.gen-ani {
position: relative;
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
}
.gen-icon {
width: 72rpx;
height: 72rpx;
color: #007AFF;
}
.gen-ring {
position: absolute;
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 4rpx solid #E5E5EA;
border-top-color: #007AFF;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.gen-title {
margin-top: 48rpx;
font-size: 32rpx;
font-weight: 600;
color: #000;
}
.gen-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
}
/* 结果封面 */
.result-cover {
margin: 24rpx;
height: 420rpx;
position: relative;
overflow: hidden;
}
.rc-item {
position: absolute;
width: 180rpx;
height: 240rpx;
background: #F8F8FA;
border-radius: 20rpx;
border: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.rc-0 { left: 30rpx; top: 70rpx; transform: rotate(-5deg); }
.rc-1 { left: 130rpx; top: 130rpx; transform: rotate(3deg); }
.rc-2 { left: 240rpx; top: 60rpx; transform: rotate(6deg); }
.rc-3 { left: 350rpx; top: 140rpx; transform: rotate(-3deg); }
.rc-4 { left: 460rpx; top: 80rpx; transform: rotate(8deg); }
.rc-img {
width: 100%;
height: 100%;
}
.rc-placeholder {
width: 64rpx;
height: 64rpx;
color: #C7C7CC;
}
/* 结果信息 */
.result-info {
margin: 0 24rpx 24rpx;
}
.ri-title {
font-size: 36rpx;
font-weight: 700;
color: #000;
}
.ri-desc {
font-size: 28rpx;
color: #3C3C43;
margin-top: 16rpx;
line-height: 1.6;
}
.tag-row {
display: flex;
gap: 12rpx;
margin-top: 20rpx;
}
.items-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
margin-top: 32rpx;
}
.items-scroll {
margin-top: 20rpx;
white-space: nowrap;
}
.items-row {
display: inline-flex;
}
.item-chip {
display: flex;
flex-direction: column;
align-items: center;
width: 140rpx;
margin-right: 20rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #F2F2F7;
}
.item-img-empty {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.item-img-empty .icon {
width: 56rpx;
height: 56rpx;
}
.item-name {
font-size: 20rpx;
color: #8E8E93;
margin-top: 8rpx;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer {
padding: 16rpx 24rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.reroll {
margin-top: 20rpx;
}
.reroll-tip {
text-align: center;
font-size: 22rpx;
color: #8E8E93;
margin-top: 16rpx;
}
/* 场景弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 20rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-grabber {
width: 72rpx;
height: 10rpx;
border-radius: 5rpx;
background: #E5E5EA;
margin: 0 auto 24rpx;
}
.sheet-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
margin-bottom: 24rpx;
}
.scene-card {
position: relative;
border: 2rpx solid #E5E5EA;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 20rpx;
}
.scene-card.active {
border-color: #007AFF;
background: rgba(0, 122, 255, 0.04);
}
.scene-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.scene-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.scene-check {
position: absolute;
top: 28rpx;
right: 28rpx;
width: 36rpx;
height: 36rpx;
color: #007AFF;
}
.scene-input {
margin-bottom: 20rpx;
}
.sheet-btn {
width: 100%;
}
.sheet-skip {
text-align: center;
font-size: 26rpx;
color: #8E8E93;
margin-top: 20rpx;
padding: 12rpx;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
}
}
@@ -0,0 +1,148 @@
import { request } from '../../utils/request'
import { ClothesItem, PagedResult, UserInfo } from '../../types/models'
import { STORAGE_KEYS } from '../../utils/config'
const CATEGORIES = [
{ name: '全部', value: '' },
{ name: '上装', value: '上装' },
{ name: '下装', value: '下装' },
{ name: '连衣裙', value: '连衣裙' },
{ name: '外套', value: '外套' },
{ name: '鞋', value: '鞋' },
{ name: '配饰', value: '配饰' },
]
const EXPIRE_OPTIONS = [
{ label: '1 天', days: 1 },
{ label: '7 天', days: 7 },
{ label: '30 天', days: 30 },
{ label: '长期有效', days: 0 },
]
Page({
data: {
view: 'config' as 'config' | 'select' | 'success',
shareType: 1,
expireDays: 7,
expireOptions: EXPIRE_OPTIONS,
selectedUuids: [] as string[],
categories: CATEGORIES,
activeCategory: 0,
allClothes: [] as ClothesItem[],
selectLoading: true,
shareUuid: '',
nickname: '微信用户',
avatar: '',
selectedCount: 0,
},
onLoad() {
this.loadUserInfo()
},
loadUserInfo() {
const cached = wx.getStorageSync(STORAGE_KEYS.USER_INFO) as Record<string, any>
if (cached) {
this.setData({ nickname: cached.username || '微信用户', avatar: cached.avatar || '' })
}
// 头像为带时间戳的签名 URL,缓存可能过期,实时刷新并回写缓存。
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
this.setData({ nickname: user.username || '微信用户', avatar: user.avatar || '' })
})
.catch(() => {})
},
selectPartial() {
this.setData({ shareType: 1 })
},
selectFull() {
this.setData({ shareType: 2 })
},
selectExpire(e: WechatMiniprogram.TouchEvent) {
const days = Number(e.currentTarget.dataset.days)
this.setData({ expireDays: days })
},
openSelect() {
this.setData({ view: 'select', selectLoading: true })
this.fetchAllClothes()
},
fetchAllClothes() {
const cat = CATEGORIES[this.data.activeCategory].value
const params: Record<string, any> = { page: 1, page_size: 50 }
if (cat) params.category_main = cat
request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0,
}))
this.setData({ allClothes: list, selectLoading: false })
})
.catch(() => this.setData({ selectLoading: false }))
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
this.setData({ activeCategory: index, selectLoading: true })
this.fetchAllClothes()
},
onToggleSelect(e: WechatMiniprogram.CustomEvent) {
const item = e.detail.item as ClothesItem
const uuids = this.data.selectedUuids
const idx = uuids.indexOf(item.clothes_uuid)
if (idx >= 0) {
this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid))
} else {
this.syncSelect([...uuids, item.clothes_uuid])
}
},
syncSelect(uuids: string[]) {
this.setData({
selectedUuids: uuids,
allClothes: this.data.allClothes.map((it) => ({
...it,
_selected: uuids.indexOf(it.clothes_uuid) >= 0,
})),
})
},
confirmSelect() {
this.setData({ view: 'config', selectedCount: this.data.selectedUuids.length })
},
backToConfig() {
this.setData({ view: 'config' })
},
onConfirm() {
const body: Record<string, any> = { share_type: this.data.shareType }
if (this.data.shareType === 1) {
if (!this.data.selectedUuids.length) {
wx.showToast({ title: '请选择要分享的衣物', icon: 'none' })
return
}
body.clothes_uuids = this.data.selectedUuids
}
const days = this.data.expireDays
body.expire_time = days > 0 ? Math.floor(Date.now() / 1000) + days * 86400 : 0
request<{ share_uuid: string }>({ url: '/closet-share', method: 'POST', data: body })
.then((res) => this.setData({ shareUuid: res.share_uuid, view: 'success' }))
.catch(() => {})
},
onViewShare() {
wx.redirectTo({ url: `/pages/closet-share-detail/closet-share-detail?uuid=${this.data.shareUuid}` })
},
onDone() {
wx.navigateBack()
},
})
@@ -0,0 +1,102 @@
<view class="page create-page">
<navigation-bar title="创建分享" />
<!-- 配置视图 -->
<scroll-view wx:if="{{view === 'config'}}" scroll-y class="scroll-body">
<view class="section-title">分享方式</view>
<view class="type-card card {{shareType === 1 ? 'active' : ''}}" bindtap="selectPartial">
<view class="type-name">部分衣物</view>
<view class="type-desc">挑选几件衣物分享给好友</view>
<view wx:if="{{shareType === 1}}" class="icon icon-check type-check"></view>
</view>
<view class="type-card card {{shareType === 2 ? 'active' : ''}}" bindtap="selectFull">
<view class="type-name">全量快照</view>
<view class="type-desc">分享当前衣橱的全部衣物</view>
<view wx:if="{{shareType === 2}}" class="icon icon-check type-check"></view>
</view>
<!-- 选择衣物入口(部分衣物) -->
<view wx:if="{{shareType === 1}}" class="card select-entry" bindtap="openSelect">
<view class="entry-left">
<view class="entry-icon"><view class="icon icon-shirt"></view></view>
<view class="entry-title">选择衣物</view>
</view>
<view class="entry-right">
<text class="entry-count {{selectedCount ? 'on' : ''}}">{{selectedCount ? '已选 ' + selectedCount + ' 件' : '未选择'}}</text>
<view class="icon icon-chevron-right"></view>
</view>
</view>
<view class="section-title">有效期</view>
<view class="card expire-card">
<view
class="expire-item hairline"
wx:for="{{expireOptions}}"
wx:key="days"
data-days="{{item.days}}"
bindtap="selectExpire"
>
<text>{{item.label}}</text>
<view wx:if="{{expireDays === item.days}}" class="icon icon-check expire-check"></view>
</view>
</view>
<view class="footer">
<view class="btn btn-primary" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
</view>
</scroll-view>
<!-- 选择衣物视图 -->
<view wx:elif="{{view === 'select'}}" class="select-view">
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<scroll-view scroll-y class="select-body">
<view wx:if="{{selectLoading}}" class="grid">
<view class="grid-item" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="skeleton sk-card"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
</scroll-view>
<view class="select-footer">
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
</view>
</view>
<!-- 成功预览 -->
<view wx:else class="success-view">
<view class="success-icon"><view class="icon icon-check"></view></view>
<view class="success-title">分享已创建</view>
<view class="preview-card">
<view class="preview-top">
<view class="avatar">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
</view>
<text class="preview-name">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
</view>
<view class="preview-body">
<view class="icon icon-hanger"></view>
</view>
<view class="preview-btn">查看衣橱</view>
</view>
<view class="success-btns">
<view class="btn btn-outline" bindtap="onDone">完成</view>
<view class="btn btn-primary" bindtap="onViewShare">查看分享</view>
</view>
</view>
</view>
@@ -0,0 +1,322 @@
.create-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.section-title {
font-size: 26rpx;
color: #8E8E93;
padding: 24rpx 24rpx 16rpx;
}
.type-card {
position: relative;
margin: 0 24rpx 20rpx;
border: 2rpx solid transparent;
}
.type-card.active {
border-color: #007AFF;
}
.type-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.type-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.type-check {
position: absolute;
top: 32rpx;
right: 32rpx;
width: 36rpx;
height: 36rpx;
color: #007AFF;
}
.select-entry {
margin: 0 24rpx 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry-left {
display: flex;
align-items: center;
}
.entry-icon {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(0, 122, 255, 0.1);
color: #007AFF;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.entry-icon .icon {
width: 40rpx;
height: 40rpx;
}
.entry-title {
font-size: 28rpx;
font-weight: 600;
color: #1C1C1E;
}
.entry-right {
display: flex;
align-items: center;
color: #8E8E93;
}
.entry-count {
font-size: 26rpx;
margin-right: 8rpx;
}
.entry-count.on {
color: #007AFF;
}
.entry-right .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
.expire-card {
margin: 0 24rpx;
padding: 0 32rpx;
}
.expire-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
font-size: 28rpx;
color: #000;
}
.expire-check {
width: 36rpx;
height: 36rpx;
color: #007AFF;
}
.footer {
padding: 24rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
/* 选择视图 */
.select-view {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.cat-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.select-body {
flex: 1;
height: 0;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
}
.select-footer {
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.sf-count {
font-size: 30rpx;
font-weight: 600;
color: #1C1C1E;
}
.sf-confirm {
width: 200rpx;
height: 80rpx;
font-size: 28rpx;
}
/* 成功视图 */
.success-view {
flex: 1;
padding: 48rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.success-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #34C759;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.success-icon .icon {
width: 64rpx;
height: 64rpx;
}
.success-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
margin-top: 24rpx;
}
.preview-card {
width: 100%;
background: #fff;
border-radius: 32rpx;
margin-top: 40rpx;
padding: 32rpx;
box-sizing: border-box;
}
.preview-top {
display: flex;
align-items: center;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #F2F2F7;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
margin-right: 16rpx;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 40rpx;
height: 40rpx;
}
.preview-name {
font-size: 26rpx;
color: #000;
}
.preview-body {
height: 320rpx;
background: #F8F8FA;
border-radius: 24rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.preview-body .icon {
width: 96rpx;
height: 96rpx;
}
.preview-btn {
margin-top: 24rpx;
height: 88rpx;
border-radius: 24rpx;
background: #007AFF;
color: #fff;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
}
.success-btns {
width: 100%;
display: flex;
gap: 20rpx;
margin-top: 40rpx;
}
.success-btns .btn {
flex: 1;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,57 @@
import { request } from '../../utils/request'
import { ClosetShareDetail } from '../../types/models'
import { formatDateTime } from '../../utils/format'
Page({
data: {
uuid: '',
detail: null as ClosetShareDetail | null,
expireText: '',
loading: true,
invalid: false,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) this.fetchDetail()
},
fetchDetail() {
request<ClosetShareDetail>({ url: `/closet-share/${this.data.uuid}`, showError: false })
.then((detail) => {
const expireText =
detail.expire_time === 0 ? '长期有效' : `有效期至 ${formatDateTime(detail.expire_time)}`
this.setData({ detail, expireText, loading: false })
})
.catch((e: any) => {
this.setData({ loading: false })
const code = e && e.code
if (code === 90100 || code === 90101 || code === 90102) {
this.setData({ invalid: true })
}
})
},
onCancel() {
wx.showModal({
title: '取消分享',
content: '取消后该分享链接将失效,且不返还衣柜分享额度',
confirmText: '取消分享',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/closet-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
wx.showToast({ title: '已取消', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
onBackHome() {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
},
})
@@ -0,0 +1,42 @@
<view class="page detail-page">
<navigation-bar title="分享详情" />
<empty-state
wx:if="{{invalid}}"
icon="icon-share"
title="该分享已过期或已被取消"
desc="分享链接已失效"
buttonText="返回首页"
bind:action="onBackHome"
/>
<scroll-view wx:else scroll-y class="scroll-body">
<!-- 分享者信息 -->
<view class="card owner-card">
<view class="avatar">
<image wx:if="{{detail.owner_avatar}}" class="avatar-img" src="{{detail.owner_avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
</view>
<view class="owner-info">
<view class="owner-name">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
<view class="owner-expire">{{expireText}}</view>
</view>
</view>
<!-- 衣物网格 -->
<view class="grid">
<view class="grid-item" wx:for="{{detail.clothes}}" wx:key="clothes_uuid">
<view class="clothes-card">
<image wx:if="{{item.processed_image}}" class="clothes-img" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="clothes-placeholder"><view class="icon icon-hanger"></view></view>
<view class="clothes-name">{{item.name || '未命名'}}</view>
<view class="clothes-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}</view>
</view>
</view>
</view>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
</scroll-view>
</view>
@@ -0,0 +1,122 @@
.detail-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.owner-card {
margin: 24rpx;
display: flex;
align-items: center;
}
.avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: #F2F2F7;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 56rpx;
height: 56rpx;
}
.owner-info {
margin-left: 24rpx;
}
.owner-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.owner-expire {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.clothes-card {
background: #fff;
border-radius: 32rpx;
padding: 20rpx;
box-sizing: border-box;
}
.clothes-img {
width: 100%;
aspect-ratio: 1;
border-radius: 24rpx;
background: #F2F2F7;
}
.clothes-placeholder {
width: 100%;
aspect-ratio: 1;
border-radius: 24rpx;
background: #F2F2F7;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.clothes-placeholder .icon {
width: 72rpx;
height: 72rpx;
}
.clothes-name {
font-size: 26rpx;
font-weight: 500;
color: #1C1C1E;
margin-top: 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clothes-sub {
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
}
.cancel-btn {
text-align: center;
padding: 32rpx 0;
}
.footer-space {
height: 40rpx;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,92 @@
import { request } from '../../utils/request'
import { PagedResult, ClosetShareItem } from '../../types/models'
Page({
data: {
tabs: [
{ name: '我的分享' },
{ name: '分享给我的' },
],
activeTab: 0, // 0 我的分享 1 分享给我的
list: [] as ClosetShareItem[],
loading: true,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.syncTabBar()
this.refresh()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 3 })
},
onTabTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeTab) return
this.setData({ activeTab: index, list: [], loading: true })
this.refresh()
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number) {
const url = this.data.activeTab === 0 ? '/closet-share' : '/closet-share/received'
request<PagedResult<ClosetShareItem>>({ url, data: { page, page_size: this.data.pageSize } })
.then((res) => {
const list = res.list.map((it) => this.decorate(it))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
decorate(it: ClosetShareItem) {
const now = Math.floor(Date.now() / 1000)
let status = it.status
if (status === 1 && it.expire_time > 0 && it.expire_time < now) status = 3
let expireText = '长期有效'
if (status === 2) expireText = '已取消'
else if (status === 3) expireText = '已过期'
else if (status === 4) expireText = '已封禁'
else if (it.expire_time > 0) {
const days = Math.ceil((it.expire_time - now) / 86400)
expireText = days > 0 ? `${days} 天后过期` : '已过期'
}
return {
...it,
_status: status,
_expireText: expireText,
_typeText: it.share_type === 1 ? '部分衣物' : '全量衣橱',
_invalid: status === 2 || status === 3 || status === 4,
}
},
onCreate() {
wx.navigateTo({ url: '/pages/closet-share-create/closet-share-create' })
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
wx.navigateTo({ url: `/pages/closet-share-detail/closet-share-detail?uuid=${uuid}` })
},
})
@@ -0,0 +1,59 @@
<view class="page page-flex">
<navigation-bar title="分享" back="{{false}}" />
<!-- 分段切换 -->
<view class="seg-wrap">
<view class="seg-control">
<view
wx:for="{{tabs}}"
wx:key="name"
class="seg-item {{activeTab === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onTabTap"
>{{item.name}}</view>
</view>
</view>
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
<view wx:if="{{activeTab === 0}}" class="new-btn btn btn-outline" bindtap="onCreate">+ 新建分享</view>
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-share"
title="{{activeTab === 0 ? '还没有分享' : '还没有收到分享'}}"
desc="{{activeTab === 0 ? '把衣橱或精选衣物分享给好友' : '好友分享给你的衣橱会出现在这里'}}"
buttonText="{{activeTab === 0 ? '新建分享' : ''}}"
bind:action="onCreate"
/>
<view class="list">
<view
class="share-card card {{item._invalid ? 'invalid' : ''}}"
wx:for="{{list}}"
wx:key="share_uuid"
data-uuid="{{item.share_uuid}}"
bindtap="onItemTap"
>
<view class="preview-grid">
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
</view>
<view class="share-info">
<view wx:if="{{activeTab === 1}}" class="owner-row">
<image wx:if="{{item.owner_avatar}}" class="owner-avatar" src="{{item.owner_avatar}}" mode="aspectFill" />
<view wx:else class="owner-avatar owner-avatar-placeholder">
<view class="icon icon-user owner-avatar-icon"></view>
</view>
<text class="owner-name">{{item.owner_name || '好友'}}</text>
</view>
<view class="share-title">衣柜分享 · {{item.clothes_count}} 件</view>
<view class="tag tag-gray share-type">{{item._typeText}}</view>
<view class="share-expire">{{item._expireText}}</view>
</view>
<view wx:if="{{item._invalid}}" class="share-badge">{{item._status === 2 ? '已取消' : '已过期'}}</view>
</view>
</view>
</scroll-view>
</view>
@@ -0,0 +1,151 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.seg-wrap {
background: #fff;
padding: 20rpx 24rpx;
}
.seg-control {
display: flex;
background: #F2F2F7;
border-radius: 16rpx;
padding: 4rpx;
}
.seg-item {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
border-radius: 12rpx;
font-size: 28rpx;
color: #3C3C43;
transition: all 0.2s;
}
.seg-item.active {
background: #007AFF;
color: #fff;
font-weight: 600;
}
.new-btn {
margin: 24rpx;
width: calc(100% - 48rpx);
}
.list {
padding: 0 24rpx 24rpx;
}
.share-card {
position: relative;
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 24rpx;
}
.share-card.invalid {
opacity: 0.5;
}
.preview-grid {
display: flex;
flex-wrap: wrap;
width: 160rpx;
height: 160rpx;
gap: 8rpx;
flex-shrink: 0;
}
.pg-cell {
width: 76rpx;
height: 76rpx;
background: #F2F2F7;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.pg-cell .icon {
width: 40rpx;
height: 40rpx;
}
.owner-row {
display: flex;
align-items: center;
margin-bottom: 10rpx;
}
.owner-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
background: #F2F2F7;
overflow: hidden;
}
.owner-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.owner-avatar-icon {
width: 24rpx;
height: 24rpx;
}
.owner-name {
font-size: 24rpx;
color: #8E8E93;
}
.share-info {
flex: 1;
margin-left: 24rpx;
}
.share-title {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.share-type {
margin-top: 12rpx;
}
.share-expire {
font-size: 22rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.share-badge {
position: absolute;
top: 16rpx;
right: 16rpx;
font-size: 20rpx;
color: #8E8E93;
background: #F2F2F7;
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
@@ -0,0 +1,199 @@
import { request } from '../../utils/request'
import { ClothesItem } from '../../types/models'
import { ProcessStatus } from '../../utils/config'
const ATTRS = [
{ key: 'category_main', label: '大类', type: 'text' },
{ key: 'category_sub', label: '小类', type: 'text' },
{ key: 'style', label: '风格', type: 'text' },
{ key: 'color_main', label: '主色', type: 'color' },
{ key: 'color_sub', label: '副色', type: 'text' },
{ key: 'thickness', label: '厚度', type: 'text' },
{ key: 'version', label: '版型', type: 'text' },
{ key: 'material', label: '材质', type: 'text' },
{ key: 'pattern', label: '花纹', type: 'text' },
{ key: 'sleeve_type', label: '袖型', type: 'text' },
{ key: 'clothes_type', label: '衣长', type: 'text' },
{ key: 'neckline_type', label: '领型', type: 'text' },
{ key: 'formality', label: '正式程度', type: 'score' },
{ key: 'occasions', label: '适用场合', type: 'text' },
{ key: 'visual_impact', label: '视觉冲击力', type: 'score' },
]
// 将后端 image_process_status 映射为前端展示步骤:0=排队中 1=AI 抠图 2=智能分类
function statusToStep(status: number): number {
if (status === ProcessStatus.QUEUED) return 0
if (status === ProcessStatus.MATTING) return 1
if (status === ProcessStatus.CLASSIFYING) return 2
return 2
}
Page({
data: {
uuid: '',
attrs: ATTRS,
detail: {} as ClothesItem,
formData: {} as ClothesItem,
imageStatus: 0,
step: 0,
failReason: '',
loading: true,
editMode: false,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) {
this.fetchDetail()
this.pollStatus()
}
},
fetchDetail(): Promise<void> {
return request<ClothesItem>({ url: `/clothes/${this.data.uuid}` })
.then((detail) => this.setData({ detail, loading: false }))
.catch(() => this.setData({ loading: false }))
},
pollStatus() {
let count = 0
;(this as any).pollTimer = setInterval(() => {
count++
request<{ image_process_status: number; fail_reason?: string }>({
url: `/clothes/${this.data.uuid}/status`,
showError: false,
})
.then((res) => {
const status = res.image_process_status
if (status === ProcessStatus.SUCCESS) {
// 成功:先拉最新详情,再切换为详情视图,避免空属性闪烁
this.clearTimer()
this.fetchDetail().then(() => this.setData({ imageStatus: status, step: 2 }))
} else if (status === ProcessStatus.FAILED) {
this.clearTimer()
this.setData({ imageStatus: status, failReason: res.fail_reason || '识别失败' })
} else {
this.setData({ imageStatus: status, step: statusToStep(status) })
}
})
.catch(() => {})
if (count >= 120) {
this.clearTimer()
}
}, 2000)
},
clearTimer() {
const t = (this as any).pollTimer
if (t) {
clearInterval(t)
;(this as any).pollTimer = null
}
},
onUnload() {
this.clearTimer()
},
onToggleEdit() {
if (this.data.editMode) {
this.setData({ editMode: false })
} else {
this.setData({ editMode: true, formData: { ...this.data.detail } })
}
},
onNameInput(e: WechatMiniprogram.Input) {
this.setData({ 'formData.name': e.detail.value })
},
onRemarkInput(e: WechatMiniprogram.Input) {
this.setData({ 'formData.remark': e.detail.value })
},
onAttrTap(e: WechatMiniprogram.TouchEvent) {
if (!this.data.editMode) return
const key = e.currentTarget.dataset.key as string
const attr = ATTRS.find((a) => a.key === key)
if (!attr) return
const current = (this.data.formData as any)[key] || ''
if (attr.type === 'score') {
wx.showActionSheet({
itemList: ['1', '2', '3', '4', '5'],
success: (res) => {
this.setData({ [`formData.${key}`]: String(res.tapIndex + 1) })
},
})
return
}
wx.showModal({
title: `编辑${attr.label}`,
editable: true,
placeholderText: `请输入${attr.label}`,
content: current,
success: (res) => {
if (res.confirm) {
this.setData({ [`formData.${key}`]: res.content })
}
},
})
},
onSave() {
const fd = this.data.formData as any
const body: Record<string, any> = {}
const fields: Array<[string, string]> = [
['name', 'name'],
['remark', 'remark'],
['category_main', 'category_main'],
['category_sub', 'category_sub'],
['style', 'style'],
['color_main', 'color'], // 主色请求字段名是 color
['color_sub', 'color_sub'],
['thickness', 'thickness'],
['version', 'version'],
['material', 'material'],
['pattern', 'pattern'],
['sleeve_type', 'sleeve_type'],
['clothes_type', 'clothes_type'],
['neckline_type', 'neckline_type'],
['formality', 'formality'],
['occasions', 'occasions'],
['visual_impact', 'visual_impact'],
]
fields.forEach(([src, dest]) => {
if (fd[src]) body[dest] = fd[src]
})
request({ url: `/clothes/${this.data.uuid}`, method: 'PUT', data: body })
.then(() => {
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ editMode: false })
this.fetchDetail()
})
.catch(() => {})
},
onDelete() {
const canRefund = this.data.imageStatus !== ProcessStatus.FAILED
const content = canRefund ? '删除后将退回 1 次上传额度,确认删除?' : '确认删除?'
wx.showModal({
title: '删除这件衣物',
content,
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/clothes/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
})
@@ -0,0 +1,132 @@
<view class="page detail-page">
<navigation-bar title="衣物详情">
<view
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
slot="right"
class="nav-action"
bindtap="onToggleEdit"
>{{editMode ? '取消' : '编辑'}}</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 处理中:显示步骤进度 -->
<block wx:if="{{imageStatus === 1 || imageStatus === 2 || imageStatus === 3}}">
<view class="card processing-card">
<view class="proc-icon"><view class="icon icon-hanger"></view></view>
<view class="steps">
<view class="step">
<view class="step-dot {{step >= 0 ? 'active' : ''}}"></view>
<text class="step-text {{step >= 0 ? 'active-text' : ''}}">排队中</text>
</view>
<view class="step-line {{step >= 1 ? 'active' : ''}}"></view>
<view class="step">
<view class="step-dot {{step >= 1 ? 'active' : ''}}"></view>
<text class="step-text {{step >= 1 ? 'active-text' : ''}}">AI 抠图</text>
</view>
<view class="step-line {{step >= 2 ? 'active' : ''}}"></view>
<view class="step">
<view class="step-dot {{step >= 2 ? 'active' : ''}}"></view>
<text class="step-text {{step >= 2 ? 'active-text' : ''}}">智能分类</text>
</view>
</view>
<view class="proc-text">AI 正在识别你的衣物…</view>
<view class="proc-desc">通常需要 10 秒左右,请稍候</view>
</view>
</block>
<!-- 识别失败 -->
<block wx:elif="{{imageStatus === 5}}">
<view class="img-box failed-box">
<view class="fail-big-icon">!</view>
</view>
<view class="card name-card">
<view class="name-text">{{detail.name || '未命名衣物'}}</view>
</view>
<view class="card fail-card">
<view class="fail-title">识别失败:{{failReason || '未知原因'}}</view>
<view class="fail-desc">本次上传额度已自动退回,你可以删除或重新上传</view>
</view>
</block>
<!-- 正常详情 -->
<block wx:else>
<!-- 大图区 -->
<view class="img-box">
<image
wx:if="{{detail.processed_image}}"
class="img"
src="{{detail.processed_image}}"
mode="aspectFit"
/>
<view wx:else class="img-placeholder"><view class="icon icon-hanger"></view></view>
</view>
<!-- 名称 -->
<view class="card name-card">
<input
wx:if="{{editMode}}"
class="input"
value="{{formData.name}}"
bindinput="onNameInput"
placeholder="衣物名称"
/>
<view wx:else class="name-text">{{detail.name || '未命名衣物'}}</view>
</view>
<!-- 属性列表 -->
<view class="card attr-card">
<view
class="attr-row hairline"
wx:for="{{attrs}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onAttrTap"
>
<view class="attr-label">{{item.label}}</view>
<view class="attr-value">
<block wx:if="{{item.type === 'score'}}">
<view class="score-bar">
<view class="score-fill" style="width: {{(detail[item.key] || 0) * 20}}%"></view>
</view>
<text class="score-num">{{detail[item.key] || 0}}/5</text>
</block>
<block wx:else>
<text class="attr-text">{{detail[item.key] || '—'}}</text>
<view
wx:if="{{item.key === 'color_main' && detail.color_main}}"
class="color-dot"
style="background: {{detail.color_main}}"
></view>
</block>
</view>
<view wx:if="{{editMode}}" class="icon icon-chevron-right edit-arrow"></view>
</view>
<!-- 备注 -->
<view class="remark-row">
<textarea
wx:if="{{editMode}}"
class="textarea"
value="{{formData.remark}}"
bindinput="onRemarkInput"
placeholder="备注"
/>
<view wx:elif="{{detail.remark}}" class="remark-text">备注:{{detail.remark}}</view>
</view>
</view>
</block>
<!-- 底部操作:处理中隐藏,其他显示 -->
<view
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
class="footer"
>
<block wx:if="{{editMode}}">
<view class="btn btn-primary" bindtap="onSave">保存</view>
</block>
<block wx:else>
<view class="btn-danger-text delete-btn" bindtap="onDelete">删除这件衣物</view>
</block>
</view>
</scroll-view>
</view>
@@ -0,0 +1,257 @@
.detail-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-action {
font-size: 30rpx;
color: #007AFF;
padding: 0 8rpx;
}
.img-box {
margin: 24rpx;
height: 640rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.img {
width: 100%;
height: 100%;
}
.img-placeholder {
color: #C7C7CC;
}
.img-placeholder .icon {
width: 120rpx;
height: 120rpx;
}
.name-card {
margin: 0 24rpx 24rpx;
}
.name-text {
font-size: 40rpx;
font-weight: 700;
color: #000;
}
.attr-card {
margin: 0 24rpx 24rpx;
padding: 0 32rpx;
}
.attr-row {
display: flex;
align-items: center;
min-height: 88rpx;
}
.attr-label {
width: 200rpx;
font-size: 28rpx;
color: #000;
flex-shrink: 0;
}
.attr-value {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
}
.attr-text {
font-size: 28rpx;
color: #3C3C43;
}
.color-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
margin-left: 16rpx;
border: 1rpx solid #E5E5EA;
}
.score-bar {
width: 160rpx;
height: 8rpx;
border-radius: 4rpx;
background: #F2F2F7;
overflow: hidden;
}
.score-fill {
height: 100%;
background: #007AFF;
border-radius: 4rpx;
}
.score-num {
font-size: 26rpx;
color: #8E8E93;
margin-left: 16rpx;
min-width: 60rpx;
text-align: right;
}
.edit-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
margin-left: 12rpx;
}
.remark-row {
padding: 24rpx 0;
}
.remark-text {
font-size: 26rpx;
color: #8E8E93;
line-height: 1.6;
}
.footer {
padding: 16rpx 24rpx 48rpx;
}
.delete-btn {
text-align: center;
padding: 24rpx 0;
}
/* 处理中 */
.processing-card {
margin: 24rpx;
padding: 64rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.proc-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #F2F2F7;
color: #C7C7CC;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
}
.proc-icon .icon {
width: 96rpx;
height: 96rpx;
}
.steps {
display: flex;
align-items: center;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
font-size: 22rpx;
color: #C7C7CC;
}
.step-text {
font-size: 22rpx;
color: #C7C7CC;
}
.step-text.active-text {
color: #007AFF;
}
.step-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #E5E5EA;
margin-bottom: 12rpx;
}
.step-dot.active {
background: #007AFF;
}
.step-line {
width: 100rpx;
height: 2rpx;
background: #E5E5EA;
margin: 0 16rpx 34rpx;
}
.step-line.active {
background: #007AFF;
}
.proc-text {
margin-top: 48rpx;
font-size: 30rpx;
font-weight: 500;
color: #000;
}
.proc-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
}
/* 识别失败 */
.failed-box {
background: #FFF3F3;
}
.fail-big-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #FF3B30;
color: #fff;
font-size: 72rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.fail-card {
margin: 0 24rpx 24rpx;
padding: 24rpx 32rpx;
}
.fail-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
}
.fail-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
line-height: 1.5;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { request } from '../../utils/request'
import { HoroscopeData } from '../../types/models'
const COLOR_MAP: Record<string, string> = {
红色: '#FF3B30',
橙色: '#FF9500',
黄色: '#FFCC00',
绿色: '#34C759',
蓝色: '#007AFF',
紫色: '#5856D6',
粉色: '#FF2D55',
白色: '#FFFFFF',
黑色: '#000000',
灰色: '#8E8E93',
}
Page({
data: {
horoscope: null as HoroscopeData | null,
statDate: '',
luckyColorHex: '#007AFF',
loading: true,
noBirth: false,
},
onShow() {
this.syncTabBar()
if (!this.data.horoscope) this.fetchHoroscope(0)
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 2 })
},
fetchHoroscope(retry: number) {
this.setData({ loading: true })
request<HoroscopeData>({ url: '/horoscope/today', showError: false })
.then((res) => {
this.setData({
horoscope: res,
statDate: this.formatStatDate(res.stat_date),
luckyColorHex: COLOR_MAP[res.lucky_color] || '#007AFF',
loading: false,
noBirth: false,
})
})
.catch((e: any) => {
const code = e && e.code
const details = (e && e.details) || []
const noBirth = details.some((d: string) => d.indexOf('生日') >= 0)
if (code === 10000 && noBirth) {
this.setData({ noBirth: true, loading: false })
} else if (code === 90300 && retry < 3) {
wx.showToast({ title: '运势生成中,请稍后重试', icon: 'none' })
setTimeout(() => this.fetchHoroscope(retry + 1), 4000)
} else {
this.setData({ loading: false })
}
})
},
formatStatDate(d: number): string {
const s = String(d)
return `${s.slice(0, 4)}.${s.slice(4, 6)}.${s.slice(6, 8)}`
},
onGoProfile() {
wx.switchTab({ url: '/pages/profile/profile' })
},
onGenerate() {
wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' })
},
})
@@ -0,0 +1,92 @@
<view class="page page-flex">
<navigation-bar title="每日运势" back="{{false}}">
<view slot="right" class="nav-action" bindtap="onGoProfile">去设置生日</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="sk-wrap">
<view class="skeleton sk-big"></view>
<view class="skeleton sk-mid"></view>
<view class="skeleton sk-mid"></view>
</view>
<!-- 未设置生日 -->
<empty-state
wx:elif="{{noBirth}}"
icon="icon-calendar"
title="还没有设置生日"
desc="设置生日后即可查看你的星座运势"
buttonText="去完善资料"
bind:action="onGoProfile"
/>
<!-- 运势内容 -->
<view wx:else class="content">
<!-- 星座大卡 -->
<view class="card horo-card">
<view class="horo-top">
<view class="zodiac">
<view class="icon icon-sparkles zodiac-icon"></view>
<text class="zodiac-name">{{horoscope.zodiac}}</text>
</view>
<view class="horo-date">
<text>{{statDate}}</text>
<text class="nav-action" bindtap="onGoProfile">去设置生日</text>
</view>
</view>
<view class="overall">
<text class="overall-label">综合运势</text>
<view class="overall-score">
<text class="score-num">{{horoscope.overall_score}}.0</text>
<view class="stars">
<view
class="icon star {{index < horoscope.overall_score ? 'icon-star-fill on' : 'icon-star'}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:key="*this"
></view>
</view>
</view>
</view>
<!-- 三项评分 -->
<view class="scores">
<view class="score-row">
<text class="score-label">爱情运势</text>
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.love_score * 20}}%"></view></view>
<text class="score-value">{{horoscope.love_score}}/5</text>
</view>
<view class="score-row">
<text class="score-label">工作运势</text>
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.career_score * 20}}%"></view></view>
<text class="score-value">{{horoscope.career_score}}/5</text>
</view>
<view class="score-row">
<text class="score-label">财富运势</text>
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.wealth_score * 20}}%"></view></view>
<text class="score-value">{{horoscope.wealth_score}}/5</text>
</view>
</view>
<view class="daily-text">
<text class="daily-label">今日运势</text>
<text class="daily-content">{{horoscope.daily_text}}</text>
</view>
<view class="lucky-row">
<view class="lucky-card">
<view class="lucky-dot" style="background: {{luckyColorHex}}"></view>
<text class="lucky-label">幸运色 · {{horoscope.lucky_color}}</text>
</view>
<view class="lucky-card">
<text class="lucky-number">{{horoscope.lucky_number}}</text>
<text class="lucky-label">幸运数字</text>
</view>
</view>
</view>
<view class="gen-btn btn btn-outline" bindtap="onGenerate">生成今日推荐穿搭</view>
</view>
</scroll-view>
</view>
+212
View File
@@ -0,0 +1,212 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-action {
font-size: 26rpx;
color: #007AFF;
padding: 0 8rpx;
}
.sk-wrap {
padding: 24rpx;
}
.sk-big {
height: 560rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.sk-mid {
height: 200rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.content {
padding: 24rpx;
}
.horo-card {
background: #E8F2FF;
}
.horo-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.zodiac {
display: flex;
align-items: center;
}
.zodiac-icon {
width: 56rpx;
height: 56rpx;
color: #007AFF;
margin-right: 16rpx;
}
.zodiac-name {
font-size: 40rpx;
font-weight: 700;
color: #000;
}
.horo-date {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 24rpx;
color: #8E8E93;
}
.overall {
margin-top: 40rpx;
}
.overall-label {
font-size: 26rpx;
color: #8E8E93;
}
.overall-score {
display: flex;
align-items: center;
margin-top: 12rpx;
}
.score-num {
font-size: 120rpx;
font-weight: 700;
color: #000;
line-height: 1;
margin-right: 32rpx;
}
.stars {
display: flex;
gap: 8rpx;
}
.star {
width: 40rpx;
height: 40rpx;
color: #C7C7CC;
}
.star.on {
color: #007AFF;
}
.scores {
margin-top: 32rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
}
.score-row {
display: flex;
align-items: center;
padding: 16rpx 0;
}
.score-label {
width: 160rpx;
font-size: 26rpx;
color: #000;
}
.score-bar {
flex: 1;
height: 12rpx;
background: #F2F2F7;
border-radius: 6rpx;
overflow: hidden;
}
.score-fill {
height: 100%;
background: #007AFF;
border-radius: 6rpx;
}
.score-value {
width: 80rpx;
text-align: right;
font-size: 26rpx;
color: #8E8E93;
}
.daily-text {
margin-top: 24rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
}
.daily-label {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #000;
}
.daily-content {
display: block;
font-size: 26rpx;
color: #3C3C43;
margin-top: 12rpx;
line-height: 1.6;
}
.lucky-row {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.lucky-card {
flex: 1;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
align-items: center;
}
.lucky-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
margin-right: 16rpx;
}
.lucky-number {
font-size: 44rpx;
font-weight: 700;
color: #000;
margin-right: 16rpx;
}
.lucky-label {
font-size: 24rpx;
color: #8E8E93;
}
.gen-btn {
margin-top: 24rpx;
width: 100%;
}
+3
View File
@@ -0,0 +1,3 @@
{
"usingComponents": {}
}
+118
View File
@@ -0,0 +1,118 @@
import { request } from '../../utils/request'
import * as auth from '../../utils/auth'
import { STORAGE_KEYS, QuotaType } from '../../utils/config'
import { LoginResult, UserInfo, QuotaItem } from '../../types/models'
import { formatDate } from '../../utils/format'
interface GiftItem {
icon: string
name: string
value: string
}
Page({
data: {
loading: false,
userInfo: {} as UserInfo,
giftVisible: false,
giftItems: [] as GiftItem[],
},
onLoad() {
if (auth.isLoggedIn()) {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
}
},
onLogin() {
if (this.data.loading) return
this.setData({ loading: true })
wx.login({
success: (res) => {
if (!res.code) {
this.setData({ loading: false })
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
return
}
request<LoginResult>({
url: '/user/wxlogin',
method: 'POST',
data: { code: res.code },
auth: false,
})
.then((data) => {
auth.setTokens({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expireTime: data.expire_time,
})
wx.setStorageSync(STORAGE_KEYS.USER_INFO, data.user_info)
this.setData({ userInfo: data.user_info })
if (data.user_info.is_new_user) {
this.showWelcomeGift(data.user_info)
} else if (!data.user_info.birth_day) {
this.showBirthGuide()
} else {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
}
})
.catch(() => {
this.setData({ loading: false })
})
},
fail: () => {
this.setData({ loading: false })
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
},
})
},
showWelcomeGift(userInfo: UserInfo) {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => this.setData({ giftItems: this.buildGift(userInfo, res.quotas), giftVisible: true }))
.catch(() => this.setData({ giftItems: this.buildGift(userInfo, []), giftVisible: true }))
},
buildGift(userInfo: UserInfo, quotas: QuotaItem[]): GiftItem[] {
const total = (t: number) => {
const q = quotas.find((it) => it.quota_type === t)
return q ? q.total : 0
}
const items: GiftItem[] = []
if (userInfo.vip_type === 4 && userInfo.vip_expire) {
items.push({ icon: 'icon-crown', name: '体验会员', value: `有效期至 ${formatDate(userInfo.vip_expire)}` })
}
items.push({ icon: 'icon-camera', name: '上传额度', value: `${total(QuotaType.UPLOAD)} 次 · 永久` })
items.push({ icon: 'icon-sparkles', name: 'AI 推荐额度', value: `${total(QuotaType.AI)} 次/天` })
items.push({ icon: 'icon-shirt', name: '搭配分享额度', value: `${total(QuotaType.OUTFIT_SHARE)} 次/天` })
items.push({ icon: 'icon-hanger', name: '衣柜分享额度', value: `${total(QuotaType.CLOSET_SHARE)} 次/天` })
return items
},
onGiftConfirm() {
this.setData({ giftVisible: false })
if (!this.data.userInfo.birth_day) {
this.showBirthGuide()
} else {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
}
},
showBirthGuide() {
wx.showModal({
title: '完善个人信息',
content: '设置生日后即可查看今日星座运势',
confirmText: '去完善',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/profile/profile' })
else wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
},
})
},
openAgreement() {
wx.showToast({ title: '协议内容敬请期待', icon: 'none' })
},
})
+50
View File
@@ -0,0 +1,50 @@
<view class="login-page">
<!-- 顶部插画区 -->
<view class="hero">
<view class="icon hero-icon hero-1 icon-hanger"></view>
<view class="icon hero-icon hero-2 icon-shirt"></view>
<view class="icon hero-icon hero-3 icon-sparkles"></view>
<view class="icon hero-icon hero-4 icon-camera"></view>
<view class="icon hero-icon hero-5 icon-star"></view>
</view>
<!-- Logo 与产品名 -->
<view class="brand">
<view class="brand-logo">
<view class="icon icon-hanger"></view>
</view>
<view class="brand-name">纸条AI衣橱</view>
<view class="brand-slogan">把你的衣橱,交给 AI 打理</view>
</view>
<!-- 底部登录区 -->
<view class="login-footer">
<view class="btn btn-primary login-btn {{loading ? 'disabled' : ''}}" bindtap="onLogin">
<view class="wechat-icon"></view>
<text>{{loading ? '登录中…' : '微信一键登录'}}</text>
</view>
<view class="agreement">
<text>登录即代表同意</text>
<text class="link" bindtap="openAgreement">《用户协议》</text>
<text>和</text>
<text class="link" bindtap="openAgreement">《隐私政策》</text>
</view>
</view>
<!-- 新手礼包弹窗 -->
<view wx:if="{{giftVisible}}" class="gift-mask">
<view class="gift-card">
<view class="gift-badge"><view class="icon icon-sparkles"></view></view>
<view class="gift-title">欢迎加入纸条AI衣橱</view>
<view class="gift-sub">新人礼包已到账,请查收</view>
<view class="gift-list">
<view class="gift-item" wx:for="{{giftItems}}" wx:key="name">
<view class="gift-item-icon icon {{item.icon}}"></view>
<text class="gift-item-name">{{item.name}}</text>
<text class="gift-item-val">{{item.value}}</text>
</view>
</view>
<view class="btn btn-primary gift-btn" bindtap="onGiftConfirm">开心收下</view>
</view>
</view>
</view>
+194
View File
@@ -0,0 +1,194 @@
.login-page {
min-height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 顶部插画区 */
.hero {
position: relative;
height: 480rpx;
background: #E8F2FF;
border-radius: 0 0 48rpx 48rpx;
overflow: hidden;
}
.hero-icon {
position: absolute;
color: #007AFF;
opacity: 0.85;
}
.hero-1 { width: 140rpx; height: 140rpx; left: 60rpx; top: 120rpx; transform: rotate(-12deg); }
.hero-2 { width: 120rpx; height: 120rpx; right: 80rpx; top: 80rpx; transform: rotate(10deg); color: #5856D6; }
.hero-3 { width: 100rpx; height: 100rpx; left: 160rpx; top: 280rpx; transform: rotate(8deg); }
.hero-4 { width: 110rpx; height: 110rpx; right: 170rpx; top: 260rpx; transform: rotate(-6deg); color: #5856D6; }
.hero-5 { width: 70rpx; height: 70rpx; right: 60rpx; top: 340rpx; transform: rotate(15deg); }
/* 品牌区 */
.brand {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
}
.brand-logo {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: #007AFF;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 122, 255, 0.25);
}
.brand-logo .icon {
width: 72rpx;
height: 72rpx;
}
.brand-name {
font-size: 48rpx;
font-weight: 700;
color: #000;
}
.brand-slogan {
font-size: 28rpx;
color: #8E8E93;
margin-top: 16rpx;
}
/* 底部 */
.login-footer {
padding: 0 64rpx;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
}
.login-btn {
width: 100%;
}
.wechat-icon {
width: 44rpx;
height: 44rpx;
margin-right: 12rpx;
background-color: #fff;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M9.5 3C5.36 3 2 5.69 2 9c0 1.87 1.03 3.54 2.62 4.65L4 16.5l3.27-1.72c.73.2 1.47.3 2.23.3h.5A6.2 6.2 0 0 1 9 12c0-3.3 3.36-6 7.5-6h.5A5.7 5.7 0 0 0 9.5 3z'/%3E%3Cpath d='M22 12c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.72 0 1.42-.1 2.06-.28L20.5 18l-.42-2.6c1.17-.88 1.92-2.06 1.92-3.4z'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M9.5 3C5.36 3 2 5.69 2 9c0 1.87 1.03 3.54 2.62 4.65L4 16.5l3.27-1.72c.73.2 1.47.3 2.23.3h.5A6.2 6.2 0 0 1 9 12c0-3.3 3.36-6 7.5-6h.5A5.7 5.7 0 0 0 9.5 3z'/%3E%3Cpath d='M22 12c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.72 0 1.42-.1 2.06-.28L20.5 18l-.42-2.6c1.17-.88 1.92-2.06 1.92-3.4z'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
}
.agreement {
margin-top: 24rpx;
font-size: 22rpx;
color: #8E8E93;
text-align: center;
}
.link {
color: #007AFF;
}
/* 新手礼包弹窗 */
.gift-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.gift-card {
width: 560rpx;
max-height: 80vh;
overflow-y: auto;
background: #fff;
border-radius: 32rpx;
padding: 48rpx 40rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.gift-badge {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FF9500, #FF2D55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.gift-badge .icon {
width: 52rpx;
height: 52rpx;
}
.gift-title {
font-size: 36rpx;
font-weight: 700;
color: #000;
}
.gift-sub {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
margin-bottom: 32rpx;
}
.gift-list {
width: 100%;
margin-bottom: 40rpx;
}
.gift-item {
display: flex;
align-items: center;
padding: 22rpx 0;
border-bottom: 1rpx solid #F2F2F7;
}
.gift-item:last-child {
border-bottom: none;
}
.gift-item-icon {
width: 40rpx;
height: 40rpx;
color: #007AFF;
margin-right: 20rpx;
flex-shrink: 0;
}
.gift-item-name {
flex: 1;
font-size: 28rpx;
color: #000;
}
.gift-item-val {
font-size: 26rpx;
color: #8E8E93;
}
.gift-btn {
width: 100%;
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+216
View File
@@ -0,0 +1,216 @@
import { request } from '../../utils/request'
import { QuotaItem, UserInfo, GoodsItem, QuotaFlowItem, PagedResult, VirtualPayResult, OrderItem } from '../../types/models'
import { QUOTA_NAMES, GoodsType, GOODS_TYPE_NAMES } from '../../utils/config'
import { fenToYuan, formatDate, formatDateTime } from '../../utils/format'
const GOODS_ICONS: Record<number, string> = {
[GoodsType.MEMBER]: 'icon-crown',
[GoodsType.UPLOAD]: 'icon-camera',
[GoodsType.AI]: 'icon-sparkles',
[GoodsType.CLOSET_SHARE]: 'icon-hanger',
[GoodsType.OUTFIT_SHARE]: 'icon-shirt',
}
const GOODS_FILTERS = [
{ name: '全部', value: 0 },
{ name: '会员包', value: 1 },
{ name: '上传包', value: 2 },
{ name: 'AI 推荐包', value: 3 },
{ name: '衣柜分享包', value: 4 },
{ name: '搭配分享包', value: 5 },
]
type PayPlatform = 'android' | 'ios' | 'harmony' | 'windows'
// 归一化平台:后端仅接受 android/ios/harmony/windows,devtools/mac 等调试环境默认按 android 处理
function normalizePlatform(): PayPlatform {
let p = 'android'
try {
p = wx.getSystemInfoSync().platform
} catch (e) {
// 获取失败时回退 android,避免请求体缺失导致参数校验失败
}
if (p === 'ios' || p === 'harmony' || p === 'windows') return p
return 'android'
}
Page({
data: {
view: 'mall' as 'mall' | 'flow',
userInfo: {} as UserInfo,
isVip: false,
vipExpireText: '',
quotas: [] as QuotaItem[],
goodsFilters: GOODS_FILTERS,
activeFilter: 0,
goods: [] as GoodsItem[],
flowList: [] as QuotaFlowItem[],
goodsIcons: GOODS_ICONS,
orderVisible: false,
orderGoods: null as GoodsItem | null,
loading: true,
},
onLoad(query: Record<string, string>) {
if (query.tab === 'flow') this.setData({ view: 'flow' })
},
onShow() {
this.fetchUser()
this.fetchQuota()
this.fetchGoods()
if (this.data.view === 'flow') this.fetchFlow()
},
fetchUser() {
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
const isVip = user.vip_type !== 1
this.setData({
userInfo: user,
isVip,
vipExpireText: isVip && user.vip_expire ? `有效期至 ${formatDate(user.vip_expire)}` : '',
})
})
.catch(() => {})
},
fetchQuota() {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => {
const quotas = res.quotas.map((q) => ({
...q,
_name: QUOTA_NAMES[q.quota_type] || '额度',
_percent: q.total > 0 ? Math.round((q.used / q.total) * 100) : 0,
}))
this.setData({ quotas })
})
.catch(() => {})
},
fetchGoods() {
const params: Record<string, any> = { page: 1, page_size: 50 }
const filter = this.data.activeFilter
if (filter > 0) params.goods_type = filter
request<PagedResult<GoodsItem>>({ url: '/goods', data: params, auth: false })
.then((res) => {
const goods = res.list.map((g) => ({
...g,
_icon: GOODS_ICONS[g.goods_type] || 'icon-crown',
_typeName: GOODS_TYPE_NAMES[g.goods_type] || '',
_priceText: fenToYuan(g.discount_price),
_originText: fenToYuan(g.price),
}))
this.setData({ goods, loading: false })
})
.catch(() => this.setData({ loading: false }))
},
fetchFlow() {
request<PagedResult<QuotaFlowItem>>({ url: '/quota/flow', data: { page: 1, page_size: 50 } })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_time: formatDateTime(it.created_at),
_sign: it.change_num > 0 ? `+${it.change_num}` : `${it.change_num}`,
_name: QUOTA_NAMES[it.quota_type] || '额度',
}))
this.setData({ flowList: list })
})
.catch(() => {})
},
onGoodsFilter(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeFilter) return
this.setData({ activeFilter: index, loading: true })
this.fetchGoods()
},
onTapMember() {
this.setData({ activeFilter: 1, loading: true })
this.fetchGoods()
},
onSwitchMall() {
this.setData({ view: 'mall' })
},
onSwitchFlow() {
this.setData({ view: 'flow' })
this.fetchFlow()
},
onBuy(e: WechatMiniprogram.TouchEvent) {
const goods = e.currentTarget.dataset.goods as GoodsItem
this.setData({ orderGoods: goods, orderVisible: true })
},
onCloseOrder() {
this.setData({ orderVisible: false })
},
noop() {},
confirmPay() {
if (!this.data.orderGoods) return
request<VirtualPayResult>({
url: '/order/virtual',
method: 'POST',
data: {
goods_code: this.data.orderGoods.goods_code,
platform: normalizePlatform(),
},
})
.then((res) => {
this.setData({ orderVisible: false })
this.doPayment(res)
})
.catch(() => {})
},
doPayment(res: VirtualPayResult) {
wx.requestVirtualPayment({
signData: res.sign_data,
paySig: res.pay_sig,
signature: res.signature,
mode: 'short_series_goods',
success: () => {
// 拉起成功仅代表进入支付页,最终到账以后端发货推送为准,轮询订单确认
this.pollOrder(res.order_no)
},
fail: () => {},
})
},
pollOrder(orderNo: string) {
let count = 0
const timer = setInterval(() => {
count++
request<PagedResult<OrderItem>>({ url: '/order', data: { page: 1, page_size: 50 }, showError: false })
.then((res) => {
const order = res.list.find((it) => it.order_no === orderNo)
if (order && order.order_status === 2) {
clearInterval(timer)
wx.showToast({ title: '支付成功', icon: 'success' })
this.fetchQuota()
this.fetchUser()
} else if (order && (order.order_status === 3 || order.order_status === 4 || order.order_status === 5)) {
clearInterval(timer)
const msg = order.order_status === 5 ? '订单已退款' : '订单未支付成功'
wx.showToast({ title: msg, icon: 'none' })
} else if (count >= 10) {
this.finishPayTimeout(timer)
}
})
.catch(() => {
if (count >= 10) this.finishPayTimeout(timer)
})
}, 1500)
},
finishPayTimeout(timer: ReturnType<typeof setInterval>) {
clearInterval(timer)
wx.showToast({ title: '到账可能有延迟,请稍后查看', icon: 'none' })
this.fetchQuota()
this.fetchUser()
},
})
+95
View File
@@ -0,0 +1,95 @@
<view class="page mall-page">
<navigation-bar title="额度与会员" />
<!-- 商城视图 -->
<scroll-view wx:if="{{view === 'mall'}}" scroll-y class="scroll-body">
<!-- 会员卡 -->
<view class="card member-card">
<view class="member-left">
<view class="icon icon-crown member-icon"></view>
<view>
<view class="member-name">{{isVip ? '月度会员' : '开通会员'}}</view>
<view class="member-desc">{{isVip ? vipExpireText : '开通会员,解锁更多 AI 与上传权益'}}</view>
</view>
</view>
<view class="member-btn" bindtap="onTapMember">{{isVip ? '立即续费' : '立即开通'}}</view>
</view>
<!-- 额度网格 -->
<view class="quota-grid">
<view class="quota-cell" wx:for="{{quotas}}" wx:key="quota_type">
<view class="quota-name">{{item._name}}</view>
<view class="quota-num">{{item.remain}}<text class="quota-total">/ {{item.total}}</text></view>
<view class="quota-bar">
<view class="quota-fill {{item._percent >= 80 ? 'warn' : ''}}" style="width: {{item._percent}}%"></view>
</view>
</view>
</view>
<!-- 额度明细入口 -->
<view class="card flow-entry" bindtap="onSwitchFlow">
<text>额度明细</text>
<view class="icon icon-chevron-right"></view>
</view>
<!-- 商品筛选 -->
<scroll-view scroll-x class="goods-filter" show-scrollbar="{{false}}">
<view class="filter-row">
<view
wx:for="{{goodsFilters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onGoodsFilter"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 商品列表 -->
<view class="goods-list">
<view class="goods-card card" wx:for="{{goods}}" wx:key="goods_code">
<view class="icon {{item._icon}} goods-icon"></view>
<view class="goods-info">
<view class="goods-name">{{item.goods_name}}</view>
<view class="goods-desc">{{item.description}}</view>
</view>
<view class="goods-right">
<view class="goods-price num">{{item._priceText}}</view>
<view class="goods-origin num" wx:if="{{item.price > item.discount_price}}">{{item._originText}}</view>
<view class="buy-btn" data-goods="{{item}}" bindtap="onBuy">购买</view>
</view>
</view>
</view>
<view class="footer-space"></view>
</scroll-view>
<!-- 额度明细视图 -->
<scroll-view wx:else scroll-y class="scroll-body">
<view class="flow-head" bindtap="onSwitchMall">← 返回商城</view>
<view class="flow-list">
<view class="flow-row card" wx:for="{{flowList}}" wx:key="quota_flow_uuid">
<view class="flow-left">
<view class="flow-reason">{{item.reason || item._name}}</view>
<view class="flow-time">{{item._time}}</view>
</view>
<view class="flow-right">
<view class="flow-num {{item.change_num > 0 ? 'plus' : ''}}">{{item._sign}}</view>
<view class="flow-after">剩余 {{item.new_value}}</view>
</view>
</view>
</view>
<empty-state wx:if="{{!flowList.length}}" icon="icon-doc" title="暂无额度明细" />
</scroll-view>
<!-- 订单确认弹窗 -->
<view wx:if="{{orderVisible}}" class="mask" bindtap="onCloseOrder">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="order-goods">{{orderGoods.goods_name}}</view>
<view class="order-desc">{{orderGoods.description}}</view>
<view class="order-amount">实付 <text class="order-price num">{{orderGoods._priceText}}</text></view>
<view class="btn btn-primary" bindtap="confirmPay">立即支付</view>
</view>
</view>
</view>
+330
View File
@@ -0,0 +1,330 @@
.mall-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
/* 会员卡 */
.member-card {
margin: 24rpx;
background: #E8F2FF;
display: flex;
align-items: center;
justify-content: space-between;
}
.member-left {
display: flex;
align-items: center;
}
.member-icon {
width: 72rpx;
height: 72rpx;
color: #007AFF;
margin-right: 24rpx;
}
.member-name {
font-size: 32rpx;
font-weight: 600;
color: #007AFF;
}
.member-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.member-btn {
height: 64rpx;
padding: 0 32rpx;
border: 1rpx solid #007AFF;
border-radius: 32rpx;
color: #007AFF;
font-size: 24rpx;
display: flex;
align-items: center;
flex-shrink: 0;
}
/* 额度网格 */
.quota-grid {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 20rpx;
}
.quota-cell {
width: calc(50% - 10rpx);
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
box-sizing: border-box;
}
.quota-name {
font-size: 24rpx;
color: #8E8E93;
}
.quota-num {
font-size: 48rpx;
font-weight: 700;
color: #000;
margin-top: 8rpx;
}
.quota-total {
font-size: 24rpx;
color: #C7C7CC;
font-weight: 400;
}
.quota-bar {
height: 10rpx;
background: #F2F2F7;
border-radius: 5rpx;
margin-top: 16rpx;
overflow: hidden;
}
.quota-fill {
height: 100%;
background: #007AFF;
border-radius: 5rpx;
}
.quota-fill.warn {
background: #FF9500;
}
.flow-entry {
margin: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
}
.flow-entry .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
/* 商品筛选 */
.goods-filter {
background: #fff;
white-space: nowrap;
margin-bottom: 20rpx;
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.filter-item {
flex-shrink: 0;
padding: 0 28rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 28rpx;
font-size: 24rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
/* 商品列表 */
.goods-list {
padding: 0 24rpx;
}
.goods-card {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 24rpx;
}
.goods-icon {
width: 72rpx;
height: 72rpx;
color: #007AFF;
margin-right: 20rpx;
flex-shrink: 0;
}
.goods-info {
flex: 1;
min-width: 0;
}
.goods-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.goods-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 6rpx;
}
.goods-right {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.goods-price {
font-size: 30rpx;
font-weight: 700;
color: #007AFF;
}
.goods-origin {
font-size: 22rpx;
color: #C7C7CC;
text-decoration: line-through;
}
.buy-btn {
margin-top: 12rpx;
height: 56rpx;
padding: 0 32rpx;
border-radius: 28rpx;
background: #007AFF;
color: #fff;
font-size: 24rpx;
display: flex;
align-items: center;
}
.footer-space {
height: 40rpx;
}
/* 额度明细视图 */
.flow-head {
padding: 24rpx;
font-size: 26rpx;
color: #007AFF;
}
.flow-list {
padding: 0 24rpx;
}
.flow-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
padding: 24rpx;
}
.flow-reason {
font-size: 28rpx;
color: #000;
}
.flow-time {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
}
.flow-right {
text-align: right;
}
.flow-num {
font-size: 32rpx;
font-weight: 600;
color: #8E8E93;
}
.flow-num.plus {
color: #34C759;
}
.flow-after {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 4rpx;
}
/* 订单弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 20rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-grabber {
width: 72rpx;
height: 10rpx;
border-radius: 5rpx;
background: #E5E5EA;
margin: 0 auto 24rpx;
}
.order-goods {
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
}
.order-desc {
font-size: 24rpx;
color: #8E8E93;
text-align: center;
margin-top: 12rpx;
}
.order-amount {
font-size: 28rpx;
color: #000;
text-align: center;
margin: 32rpx 0;
}
.order-price {
font-size: 44rpx;
font-weight: 700;
color: #FF9500;
}
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
@@ -0,0 +1,26 @@
import { request } from '../../utils/request'
import { NotificationItem } from '../../types/models'
import { formatDateTime } from '../../utils/format'
Page({
data: {
uuid: '',
item: {} as NotificationItem,
time: '',
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) {
this.fetchDetail()
request({ url: `/notification/${uuid}/read`, method: 'POST', showError: false }).catch(() => {})
}
},
fetchDetail() {
request<NotificationItem>({ url: `/notification/${this.data.uuid}` })
.then((item) => this.setData({ item, time: formatDateTime(item.created_at) }))
.catch(() => {})
},
})
@@ -0,0 +1,14 @@
<view class="page detail-page">
<navigation-bar title="通知详情" />
<view class="card detail-card">
<view class="icon-row">
<view class="n-icon {{item.msg_type === 1 ? 'like' : item.msg_type === 2 ? 'order' : 'sys'}}">
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
</view>
</view>
<view class="detail-title">{{item.title}}</view>
<view class="detail-time">{{time}}</view>
<view class="detail-content">{{item.content}}</view>
</view>
</view>
@@ -0,0 +1,65 @@
.detail-page {
padding: 24rpx;
box-sizing: border-box;
}
.detail-card {
margin-top: 24rpx;
padding: 48rpx 40rpx;
}
.icon-row {
display: flex;
justify-content: center;
margin-bottom: 32rpx;
}
.n-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
}
.n-icon.order {
background: #E8F2FF;
color: #007AFF;
}
.n-icon.sys {
background: #F2F2F7;
color: #8E8E93;
}
.n-icon .icon {
width: 64rpx;
height: 64rpx;
}
.detail-title {
font-size: 36rpx;
font-weight: 700;
color: #000;
text-align: center;
}
.detail-time {
font-size: 24rpx;
color: #C7C7CC;
text-align: center;
margin-top: 16rpx;
}
.detail-content {
font-size: 28rpx;
color: #3C3C43;
margin-top: 32rpx;
line-height: 1.7;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,78 @@
import { request } from '../../utils/request'
import { PagedResult, NotificationItem } from '../../types/models'
import { formatRelativeTime } from '../../utils/format'
const FILTERS = [
{ name: '全部', value: 0 },
{ name: '点赞', value: 1 },
{ name: '订单', value: 2 },
{ name: '系统', value: 3 },
]
Page({
data: {
filters: FILTERS,
activeFilter: 0,
list: [] as NotificationItem[],
loading: true,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.refresh()
this.fetchUnread()
},
fetchUnread() {
request<{ unread_count: number }>({ url: '/notification/unread', showError: false })
.then((res) => {
wx.setStorageSync('zt_closet_unread', res.unread_count)
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar && tabBar.refreshUnread) tabBar.refreshUnread()
})
.catch(() => {})
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number) {
const params: Record<string, any> = { page, page_size: this.data.pageSize }
const filter = this.data.activeFilter
if (filter > 0) params.msg_type = filter
request<PagedResult<NotificationItem>>({ url: '/notification', data: params })
.then((res) => {
const list = res.list.map((it) => ({ ...it, _time: formatRelativeTime(it.created_at) }))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
onFilterTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeFilter) return
this.setData({ activeFilter: index, list: [], loading: true })
this.refresh()
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
wx.navigateTo({ url: `/pages/notification-detail/notification-detail?uuid=${uuid}` })
},
})
@@ -0,0 +1,53 @@
<view class="page page-flex">
<navigation-bar title="通知" />
<scroll-view
scroll-y
class="scroll-body"
bindscrolltolower="onReachBottom"
>
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 空态 -->
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-bell"
title="还没有消息"
desc="穿搭被点赞、订单动态都会出现在这里"
/>
<!-- 列表 -->
<view class="list">
<view
class="n-card card {{item.is_read === 1 ? 'unread' : ''}}"
wx:for="{{list}}"
wx:key="notification_uuid"
data-uuid="{{item.notification_uuid}}"
bindtap="onItemTap"
>
<view class="n-icon {{item.msg_type === 1 ? 'like' : item.msg_type === 2 ? 'order' : 'sys'}}">
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
</view>
<view class="n-body">
<view class="n-title-row">
<view wx:if="{{item.is_read === 1}}" class="n-dot"></view>
<text class="n-title {{item.is_read === 2 ? 'read' : ''}}">{{item.title}}</text>
</view>
<view class="n-content">{{item.content}}</view>
<view class="n-time">{{item._time}}</view>
</view>
</view>
</view>
</scroll-view>
</view>
@@ -0,0 +1,126 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.filter-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
.list {
padding: 24rpx;
}
.n-card {
display: flex;
align-items: flex-start;
padding: 28rpx;
margin-bottom: 20rpx;
}
.n-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
}
.n-icon.order {
background: #E8F2FF;
color: #007AFF;
}
.n-icon.sys {
background: #F2F2F7;
color: #8E8E93;
}
.n-icon .icon {
width: 44rpx;
height: 44rpx;
}
.n-body {
flex: 1;
min-width: 0;
}
.n-title-row {
display: flex;
align-items: center;
}
.n-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #007AFF;
margin-right: 12rpx;
flex-shrink: 0;
}
.n-title {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.n-title.read {
color: #8E8E93;
font-weight: 500;
}
.n-content {
font-size: 26rpx;
color: #3C3C43;
margin-top: 8rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.n-time {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+127
View File
@@ -0,0 +1,127 @@
import { request } from '../../utils/request'
import { PagedResult, OrderItem } from '../../types/models'
import { fenToYuan, formatDateTime, formatCountdown } from '../../utils/format'
const STATUS_FILTERS = [
{ name: '全部', value: 0 },
{ name: '待支付', value: 1 },
{ name: '已支付', value: 2 },
{ name: '已取消', value: 3 },
{ name: '已过期', value: 4 },
{ name: '已退款', value: 5 },
]
const STATUS_TEXT: Record<number, string> = { 1: '待支付', 2: '已支付', 3: '已取消', 4: '已过期', 5: '已退款' }
const STATUS_TYPE: Record<number, string> = { 1: 'orange', 2: 'green', 3: 'gray', 4: 'gray', 5: 'blue' }
Page({
data: {
filters: STATUS_FILTERS,
activeFilter: 0,
list: [] as OrderItem[],
loading: true,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.refresh()
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number) {
const params: Record<string, any> = { page, page_size: this.data.pageSize }
const filter = this.data.activeFilter
if (filter > 0) params.status = filter
request<PagedResult<OrderItem>>({ url: '/order', data: params })
.then((res) => {
const now = Math.floor(Date.now() / 1000)
const list = res.list.map((it) => ({
...it,
_statusText: STATUS_TEXT[it.order_status] || '',
_statusType: STATUS_TYPE[it.order_status] || 'gray',
_amount: fenToYuan(it.pay_amount),
_time: formatDateTime(it.created_at),
_orderTail: `…${it.order_no.slice(-4)}`,
_countdown: it.order_status === 1 && it.expire_time > now ? formatCountdown(it.expire_time - now) : '',
_benefit: it.member_days > 0 ? `${it.member_days} 天会员` : '',
_canCancel: it.order_status === 1 && it.pay_channel !== 2,
_payChannelText: it.pay_channel === 2 ? '虚拟支付' : '',
}))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
onFilterTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeFilter) return
this.setData({ activeFilter: index, list: [], loading: true })
this.refresh()
},
onCancel(e: WechatMiniprogram.TouchEvent) {
const orderNo = e.currentTarget.dataset.no as string
wx.showModal({
title: '取消订单',
content: '确认取消该订单?',
success: (res) => {
if (!res.confirm) return
request({ url: `/order/${orderNo}/cancel`, method: 'POST' })
.then(() => {
wx.showToast({ title: '已取消', icon: 'success' })
this.refresh()
})
.catch(() => {})
},
})
},
onPay() {
wx.showToast({ title: '请返回商城重新下单支付', icon: 'none' })
},
onRefund(e: WechatMiniprogram.TouchEvent) {
const orderNo = e.currentTarget.dataset.no as string
wx.showModal({
title: '申请退款',
editable: true,
placeholderText: '请填写退款原因',
success: (res) => {
if (!res.confirm) return
const reason = (res.content || '').trim()
if (!reason) {
wx.showToast({ title: '请填写退款原因', icon: 'none' })
return
}
request({ url: '/order/refund', method: 'POST', data: { order_no: orderNo, reason } })
.then(() => {
wx.showToast({ title: '退款申请已提交,等待审核', icon: 'none' })
this.refresh()
})
.catch(() => {})
},
})
},
onGoMall() {
wx.navigateTo({ url: '/pages/mall/mall' })
},
})
+55
View File
@@ -0,0 +1,55 @@
<view class="page page-flex">
<navigation-bar title="我的订单" />
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-doc"
title="还没有订单"
desc=""
buttonText="去看看额度包"
bind:action="onGoMall"
/>
<!-- 订单列表 -->
<view class="list">
<view class="order-card card" wx:for="{{list}}" wx:key="order_no">
<view class="order-top">
<text class="order-no">订单 {{item._orderTail}}</text>
<text wx:if="{{item._payChannelText}}" class="tag tag-gray">{{item._payChannelText}}</text>
<text class="tag tag-{{item._statusType}}">{{item._statusText}}</text>
</view>
<view class="order-body">
<view class="order-goods">{{item.goods_name}}</view>
<view class="order-benefit" wx:if="{{item._benefit}}">{{item._benefit}}</view>
<view class="order-bottom">
<text class="order-amount">实付 <text class="num">{{item._amount}}</text></text>
<view class="order-actions">
<block wx:if="{{item.order_status === 1}}">
<text wx:if="{{item._countdown}}" class="countdown">剩余 {{item._countdown}}</text>
<view wx:if="{{item._canCancel}}" class="action-btn outline" data-no="{{item.order_no}}" bindtap="onCancel">取消订单</view>
<view class="action-btn primary" data-no="{{item.order_no}}" bindtap="onPay">去支付</view>
</block>
<block wx:elif="{{item.order_status === 2}}">
<view class="action-btn outline" data-no="{{item.order_no}}" bindtap="onRefund">申请退款</view>
</block>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
+122
View File
@@ -0,0 +1,122 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.filter-item {
flex-shrink: 0;
padding: 0 28rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 28rpx;
font-size: 24rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
.list {
padding: 24rpx;
}
.order-card {
margin-bottom: 20rpx;
padding: 28rpx;
}
.order-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.order-no {
font-size: 24rpx;
color: #8E8E93;
}
.order-body {
margin-top: 20rpx;
}
.order-goods {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.order-benefit {
font-size: 24rpx;
color: #8E8E93;
margin-top: 6rpx;
}
.order-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
}
.order-amount {
font-size: 26rpx;
color: #3C3C43;
}
.order-amount .num {
font-size: 32rpx;
font-weight: 600;
color: #000;
}
.order-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.countdown {
font-size: 24rpx;
color: #8E8E93;
}
.action-btn {
height: 56rpx;
padding: 0 28rpx;
border-radius: 28rpx;
font-size: 24rpx;
display: flex;
align-items: center;
}
.action-btn.outline {
border: 1rpx solid #C7C7CC;
color: #3C3C43;
}
.action-btn.primary {
background: #007AFF;
color: #fff;
}
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
@@ -0,0 +1,145 @@
import { request } from '../../utils/request'
import { OutfitItem, ClothesItem } from '../../types/models'
import { OutfitSource, IsPublic } from '../../utils/config'
Page({
data: {
uuid: '',
isOwner: true,
outfit: {} as OutfitItem,
clothes: [] as ClothesItem[],
isLiked: false,
likeCount: 0,
isCollected: false,
loading: true,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const isOwner = query.owner !== '0'
this.setData({ uuid, isOwner })
if (uuid) this.fetchDetail()
},
async fetchDetail() {
try {
const outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}` })
this.setData({ outfit })
const clothes = await Promise.all(
outfit.items.map(async (it) => {
try {
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
} catch (e) {
return { clothes_uuid: it.clothes_uuid } as ClothesItem
}
})
)
this.setData({ clothes })
const like = await request<{ is_liked: boolean; like_count: number }>({
url: `/like/${this.data.uuid}`,
showError: false,
})
this.setData({ isLiked: like.is_liked, likeCount: like.like_count, loading: false })
// 收藏状态:调用后端 /collect/:targetUUID 初始化,失败静默保持未收藏
request<{ is_collected: boolean; collect_count: number }>({
url: `/collect/${this.data.uuid}`,
showError: false,
})
.then((collect) => this.setData({ isCollected: collect.is_collected }))
.catch(() => {})
} catch (e) {
this.setData({ loading: false })
}
},
onLike() {
const action = this.data.isLiked ? 2 : 1
request({ url: '/like', method: 'POST', data: { target_uuid: this.data.uuid, action } })
.then(() => {
this.setData({
isLiked: action === 1,
likeCount: this.data.likeCount + (action === 1 ? 1 : -1),
})
})
.catch(() => {})
},
onCollect() {
const action = this.data.isCollected ? 2 : 1
request({ url: '/collect', method: 'POST', data: { target_uuid: this.data.uuid, action } })
.then(() => this.setData({ isCollected: action === 1 }))
.catch(() => {})
},
onTogglePublic() {
const isPublic = this.data.outfit.is_public === IsPublic.PUBLIC
if (isPublic) {
this.doPublic(IsPublic.PRIVATE)
} else {
wx.showModal({
title: '公开到社区',
content: '公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度',
confirmText: '确认公开',
success: (res) => {
if (res.confirm) this.doPublic(IsPublic.PUBLIC)
},
})
}
},
doPublic(isPublic: number) {
request({ url: `/outfit/${this.data.uuid}/public`, method: 'PUT', data: { is_public: isPublic } })
.then(() => {
this.setData({ 'outfit.is_public': isPublic })
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
})
.catch(() => {})
},
onMore() {
wx.showActionSheet({
itemList: ['编辑穿搭', '删除穿搭'],
success: (res) => {
if (res.tapIndex === 0) this.onEdit()
else this.onDelete()
},
})
},
onEdit() {
wx.navigateTo({ url: `/pages/outfit-edit/outfit-edit?uuid=${this.data.uuid}` })
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
wx.navigateTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${uuid}` })
},
onShareAppMessage() {
return {
title: this.data.outfit.outfit_name || '我的穿搭',
path: `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`,
}
},
})
@@ -0,0 +1,79 @@
<view class="page detail-page">
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}">
<view wx:if="{{isOwner}}" slot="right" class="nav-more" bindtap="onMore">
<view class="icon icon-ellipsis"></view>
</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 拼图封面 -->
<view class="cover">
<view
class="cover-item cover-{{index}}"
wx:for="{{clothes}}"
wx:key="clothes_uuid"
wx:if="{{index < 5}}"
>
<image wx:if="{{item.processed_image}}" class="cover-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger cover-placeholder"></view>
</view>
<view wx:if="{{!clothes.length}}" class="cover-empty">
<view class="icon icon-hanger"></view>
</view>
</view>
<!-- 信息卡片 -->
<view class="card info-card">
<view class="title-row">
<text class="title">{{outfit.outfit_name}}</text>
</view>
<view class="tag-row">
<text wx:if="{{outfit.scene}}" class="tag tag-gray">{{outfit.scene}}</text>
<text class="tag {{outfit.source === 2 ? 'tag-indigo' : 'tag-gray'}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
<text class="tag {{outfit.is_public === 2 ? 'tag-blue' : 'tag-gray'}}">{{outfit.is_public === 2 ? '已公开' : '仅自己可见'}}</text>
</view>
<view class="desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
<!-- 包含衣物 -->
<view class="items-title">包含衣物</view>
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
<view class="items-row">
<view
class="item-chip"
wx:for="{{clothes}}"
wx:key="clothes_uuid"
data-uuid="{{item.clothes_uuid}}"
bindtap="onItemTap"
>
<image wx:if="{{item.processed_image}}" class="item-img" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="item-img item-img-empty"><view class="icon icon-hanger"></view></view>
<text class="item-name">{{item.name || '未命名'}}</text>
</view>
</view>
</scroll-view>
</view>
<view class="footer-placeholder"></view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="footer">
<view class="footer-left">
<view class="fb-btn" bindtap="onLike">
<view class="icon icon-heart {{isLiked ? 'liked' : ''}}"></view>
<text class="{{isLiked ? 'liked' : ''}}">{{likeCount}}</text>
</view>
<view class="fb-btn" wx:if="{{!isOwner}}" bindtap="onCollect">
<view class="icon {{isCollected ? 'icon-star-fill collected' : 'icon-star'}}"></view>
</view>
</view>
<view class="footer-right" wx:if="{{isOwner}}">
<view class="fb-public {{outfit.is_public === 2 ? 'public' : ''}}" bindtap="onTogglePublic">
{{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}
</view>
<button class="fb-share" open-type="share">
<view class="icon icon-share"></view>
</button>
</view>
</view>
</view>
@@ -0,0 +1,241 @@
.detail-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-more {
padding: 0 8rpx;
color: #000;
}
/* 封面 */
.cover {
margin: 24rpx;
height: 480rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.cover-item {
position: absolute;
width: 200rpx;
height: 260rpx;
background: #F8F8FA;
border-radius: 20rpx;
border: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cover-0 { left: 30rpx; top: 80rpx; transform: rotate(-6deg); }
.cover-1 { left: 120rpx; top: 140rpx; transform: rotate(2deg); }
.cover-2 { left: 220rpx; top: 70rpx; transform: rotate(5deg); }
.cover-3 { left: 320rpx; top: 150rpx; transform: rotate(-2deg); }
.cover-4 { left: 420rpx; top: 90rpx; transform: rotate(7deg); }
.cover-img {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 80rpx;
height: 80rpx;
color: #C7C7CC;
}
.cover-empty {
color: #C7C7CC;
}
.cover-empty .icon {
width: 120rpx;
height: 120rpx;
}
/* 信息卡 */
.info-card {
margin: 0 24rpx 24rpx;
}
.title {
font-size: 40rpx;
font-weight: 700;
color: #000;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.desc {
font-size: 28rpx;
color: #3C3C43;
margin-top: 20rpx;
line-height: 1.6;
}
.items-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
margin-top: 32rpx;
}
.items-scroll {
margin-top: 20rpx;
white-space: nowrap;
}
.items-row {
display: inline-flex;
}
.item-chip {
display: flex;
flex-direction: column;
align-items: center;
width: 140rpx;
margin-right: 20rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #F2F2F7;
}
.item-img-empty {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.item-img-empty .icon {
width: 56rpx;
height: 56rpx;
}
.item-name {
font-size: 20rpx;
color: #8E8E93;
margin-top: 8rpx;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-placeholder {
height: 40rpx;
}
/* 底部操作栏 */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 32rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
z-index: 50;
}
.footer-left {
display: flex;
align-items: center;
gap: 40rpx;
}
.fb-btn {
display: flex;
align-items: center;
font-size: 26rpx;
color: #8E8E93;
}
.fb-btn .icon {
width: 48rpx;
height: 48rpx;
margin-right: 8rpx;
}
.liked {
color: #FF2D55;
}
.collected {
color: #007AFF;
}
.footer-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.fb-public {
height: 76rpx;
padding: 0 40rpx;
border-radius: 38rpx;
background: #007AFF;
color: #fff;
font-size: 28rpx;
font-weight: 500;
display: flex;
align-items: center;
}
.fb-public.public {
background: #fff;
border: 1rpx solid #007AFF;
color: #007AFF;
}
.fb-share {
width: 76rpx;
height: 76rpx;
padding: 0;
margin: 0;
background: #fff;
border: 1rpx solid #E5E5EA;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #007AFF;
line-height: 1;
}
.fb-share::after {
border: none;
}
.fb-share .icon {
width: 40rpx;
height: 40rpx;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
}
}
@@ -0,0 +1,179 @@
import { request } from '../../utils/request'
import { OutfitItem, ClothesItem, PagedResult } from '../../types/models'
const CATEGORIES = [
{ name: '全部', value: '' },
{ name: '上装', value: '上装' },
{ name: '下装', value: '下装' },
{ name: '连衣裙', value: '连衣裙' },
{ name: '外套', value: '外套' },
{ name: '鞋', value: '鞋' },
{ name: '配饰', value: '配饰' },
]
Page({
data: {
uuid: '',
isEdit: false,
view: 'form' as 'form' | 'select',
outfitName: '',
outfitDesc: '',
scene: '',
selectedUuids: [] as string[],
selectedItems: [] as ClothesItem[],
categories: CATEGORIES,
activeCategory: 0,
allClothes: [] as ClothesItem[],
selectLoading: true,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
if (uuid) {
this.setData({ uuid, isEdit: true })
this.fetchOutfit(uuid)
}
},
async fetchOutfit(uuid: string) {
try {
const outfit = await request<OutfitItem>({ url: `/outfit/${uuid}` })
const uuids = outfit.items.map((i) => i.clothes_uuid)
const clothes = await Promise.all(
uuids.map(async (id) => {
try {
return await request<ClothesItem>({ url: `/clothes/${id}`, showError: false })
} catch (e) {
return { clothes_uuid: id } as ClothesItem
}
})
)
this.setData({
outfitName: outfit.outfit_name,
outfitDesc: outfit.outfit_desc,
scene: outfit.scene,
selectedUuids: uuids,
selectedItems: clothes,
})
} catch (e) {}
},
onNameInput(e: WechatMiniprogram.Input) {
this.setData({ outfitName: e.detail.value })
},
onDescInput(e: WechatMiniprogram.Input) {
this.setData({ outfitDesc: e.detail.value })
},
onSceneInput(e: WechatMiniprogram.Input) {
this.setData({ scene: e.detail.value })
},
openSelect() {
this.setData({ view: 'select', selectLoading: true })
this.fetchAllClothes()
},
backToForm() {
this.setData({ view: 'form' })
},
fetchAllClothes() {
const cat = CATEGORIES[this.data.activeCategory].value
const params: Record<string, any> = { page: 1, page_size: 50 }
if (cat) params.category_main = cat
request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0,
}))
this.setData({ allClothes: list, selectLoading: false })
})
.catch(() => this.setData({ selectLoading: false }))
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
this.setData({ activeCategory: index, selectLoading: true })
this.fetchAllClothes()
},
onToggleSelect(e: WechatMiniprogram.CustomEvent) {
const item = e.detail.item as ClothesItem
const uuids = this.data.selectedUuids
const idx = uuids.indexOf(item.clothes_uuid)
if (idx >= 0) {
this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid))
} else {
if (uuids.length >= 10) {
wx.showToast({ title: '最多选择 10 件衣物', icon: 'none' })
return
}
this.syncSelect([...uuids, item.clothes_uuid])
}
},
syncSelect(uuids: string[]) {
this.setData({
selectedUuids: uuids,
allClothes: this.data.allClothes.map((it) => ({
...it,
_selected: uuids.indexOf(it.clothes_uuid) >= 0,
})),
})
},
confirmSelect() {
const selectedItems = this.data.allClothes.filter(
(it) => this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0
)
this.setData({ view: 'form', selectedItems })
},
onSave() {
const name = this.data.outfitName.trim()
if (!name) {
wx.showToast({ title: '请填写穿搭名称', icon: 'none' })
return
}
if (!this.data.selectedUuids.length) {
wx.showToast({ title: '至少选择 1 件衣物', icon: 'none' })
return
}
const body = {
outfit_name: name,
outfit_desc: this.data.outfitDesc,
scene: this.data.scene,
items: this.data.selectedUuids.map((u) => ({ clothes_uuid: u })),
}
const req = this.data.isEdit
? request({ url: `/outfit/${this.data.uuid}`, method: 'PUT', data: body })
: request({ url: '/outfit', method: 'POST', data: body })
req
.then(() => {
wx.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
})
@@ -0,0 +1,114 @@
<view class="page edit-page">
<navigation-bar title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}">
<view wx:if="{{view === 'select'}}" slot="right" class="nav-cancel" bindtap="backToForm">取消</view>
</navigation-bar>
<!-- 表单视图 -->
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body">
<!-- 拼图预览 -->
<view class="card preview">
<view wx:if="{{selectedItems.length}}" class="preview-items">
<view
class="preview-item preview-{{index}}"
wx:for="{{selectedItems}}"
wx:key="clothes_uuid"
wx:if="{{index < 5}}"
>
<image wx:if="{{item.processed_image}}" class="preview-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger preview-placeholder"></view>
</view>
<view wx:if="{{selectedItems.length > 5}}" class="preview-more">+{{selectedItems.length - 5}}</view>
</view>
<view wx:else class="preview-empty">
<view class="icon icon-hanger"></view>
<text class="pe-title">还没有选择衣物</text>
<text class="pe-desc">点击下方「选择衣物」添加你的单品</text>
</view>
</view>
<!-- 表单 -->
<view class="card form-card">
<view class="field">
<view class="field-label">
<text>穿搭名称 <text class="req">*</text></text>
<text class="count">{{outfitName.length}}/32</text>
</view>
<input class="input" value="{{outfitName}}" bindinput="onNameInput" placeholder="给这套穿搭起个名字" maxlength="32" />
</view>
<view class="field">
<view class="field-label">
<text>穿搭描述</text>
<text class="count">{{outfitDesc.length}}/500</text>
</view>
<textarea class="textarea" value="{{outfitDesc}}" bindinput="onDescInput" placeholder="简单描述这套穿搭的灵感或亮点" maxlength="500" />
</view>
<view class="field">
<view class="field-label">
<text>适合场景</text>
<text class="count">{{scene.length}}/100</text>
</view>
<input class="input" value="{{scene}}" bindinput="onSceneInput" placeholder="例如:通勤、约会、旅行" maxlength="100" />
</view>
</view>
<!-- 选择衣物入口 -->
<view class="card select-entry" bindtap="openSelect">
<view class="entry-left">
<view class="entry-icon"><view class="icon icon-shirt"></view></view>
<view>
<view class="entry-title">选择衣橱单品</view>
<view class="entry-desc">{{selectedUuids.length ? '点击调整当前穿搭包含的衣物' : '选择 1~10 件衣物合成画报'}}</view>
</view>
</view>
<view class="entry-right">
<text class="entry-count {{selectedUuids.length ? 'on' : ''}}">{{selectedUuids.length ? '已选 ' + selectedUuids.length + ' 件' : '未选择'}}</text>
<view class="icon icon-chevron-right"></view>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer">
<view wx:if="{{isEdit}}" class="btn-danger-text delete-link" bindtap="onDelete">删除穿搭</view>
<view
class="btn btn-primary {{(!outfitName || !selectedUuids.length) ? 'disabled' : ''}}"
bindtap="onSave"
>{{isEdit ? '保存修改' : '保存'}}</view>
</view>
</scroll-view>
<!-- 多选视图 -->
<view wx:else class="select-view">
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<scroll-view scroll-y class="select-body">
<view wx:if="{{selectLoading}}" class="grid">
<view class="grid-item" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="skeleton sk-card"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
</scroll-view>
<view class="select-footer">
<view>
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
<view class="sf-desc">最多可选择 10 件单品</view>
</view>
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
</view>
</view>
</view>
@@ -0,0 +1,296 @@
.edit-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-cancel {
font-size: 30rpx;
color: #007AFF;
padding: 0 8rpx;
}
/* 预览区 */
.preview {
margin: 24rpx;
height: 320rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
color: #C7C7CC;
}
.preview-empty .icon {
width: 96rpx;
height: 96rpx;
}
.pe-title {
font-size: 26rpx;
color: #8E8E93;
margin-top: 16rpx;
}
.pe-desc {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
}
.preview-items {
width: 100%;
height: 100%;
position: relative;
}
.preview-item {
position: absolute;
width: 140rpx;
height: 180rpx;
background: #F8F8FA;
border-radius: 16rpx;
border: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-0 { left: 30rpx; top: 60rpx; transform: rotate(-5deg); }
.preview-1 { left: 120rpx; top: 100rpx; transform: rotate(3deg); }
.preview-2 { left: 210rpx; top: 50rpx; transform: rotate(6deg); }
.preview-3 { left: 300rpx; top: 110rpx; transform: rotate(-3deg); }
.preview-4 { left: 390rpx; top: 70rpx; transform: rotate(8deg); }
.preview-img {
width: 100%;
height: 100%;
}
.preview-placeholder {
width: 56rpx;
height: 56rpx;
color: #C7C7CC;
}
.preview-more {
position: absolute;
right: 24rpx;
top: 24rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #007AFF;
color: #fff;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 表单 */
.form-card {
margin: 0 24rpx 24rpx;
}
.field {
margin-bottom: 32rpx;
}
.field:last-child {
margin-bottom: 0;
}
.field-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
font-weight: 600;
color: #1C1C1E;
margin-bottom: 16rpx;
}
.req {
color: #FF3B30;
}
.count {
font-size: 22rpx;
color: #8E8E93;
font-weight: 400;
}
/* 选择衣物入口 */
.select-entry {
margin: 0 24rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry-left {
display: flex;
align-items: center;
}
.entry-icon {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(0, 122, 255, 0.1);
color: #007AFF;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.entry-icon .icon {
width: 40rpx;
height: 40rpx;
}
.entry-title {
font-size: 28rpx;
font-weight: 600;
color: #1C1C1E;
}
.entry-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 4rpx;
}
.entry-right {
display: flex;
align-items: center;
color: #8E8E93;
}
.entry-count {
font-size: 26rpx;
margin-right: 8rpx;
}
.entry-count.on {
color: #007AFF;
}
.entry-right .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
/* 底部按钮 */
.footer {
padding: 16rpx 24rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.delete-link {
text-align: center;
padding: 16rpx 0 24rpx;
}
/* 多选视图 */
.select-view {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.cat-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.select-body {
flex: 1;
height: 0;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
}
.select-footer {
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.sf-count {
font-size: 30rpx;
font-weight: 600;
color: #1C1C1E;
}
.sf-desc {
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
}
.sf-confirm {
width: 200rpx;
height: 80rpx;
font-size: 28rpx;
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+107
View File
@@ -0,0 +1,107 @@
import { request } from '../../utils/request'
import { PagedResult, OutfitItem } from '../../types/models'
import { OutfitSource, IsPublic } from '../../utils/config'
Page({
data: {
activeTab: 0,
list: [] as OutfitItem[],
loading: true,
refreshing: false,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.syncTabBar()
const openCollect = wx.getStorageSync('zt_open_collect')
if (openCollect) {
wx.removeStorageSync('zt_open_collect')
if (this.data.activeTab !== 1) {
this.setData({ activeTab: 1, list: [], loading: true })
}
}
this.refresh()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 1 })
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeTab) return
this.setData({ activeTab: index, list: [], loading: true })
this.refresh()
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onRefresh() {
this.setData({ refreshing: true })
this.fetchList(1).finally(() => this.setData({ refreshing: false }))
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number): Promise<void> {
const isCollect = this.data.activeTab === 1
const url = isCollect ? '/collect' : '/outfit'
return request<PagedResult<OutfitItem>>({ url, data: { page, page_size: this.data.pageSize } })
.then(async (res) => {
const items = await this.decorateList(res.list)
const merged = page === 1 ? items : this.data.list.concat(items)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
decorateList(list: OutfitItem[]): Promise<OutfitItem[]> {
return Promise.all(
list.map(async (it) => {
let likeCount = 0
try {
const like = await request<{ is_liked: boolean; like_count: number }>({
url: `/like/${it.outfit_uuid}`,
showError: false,
})
likeCount = like.like_count
} catch (e) {}
return { ...it, _likeCount: likeCount, _status: this.getStatusText(it) }
})
)
},
getStatusText(it: OutfitItem): { text: string; type: string } {
if (it.source === OutfitSource.AI) return { text: 'AI 推荐', type: 'indigo' }
if (it.is_public === IsPublic.PUBLIC) return { text: '已公开', type: 'blue' }
return { text: '仅自己可见', type: 'gray' }
},
onCreate() {
wx.navigateTo({ url: '/pages/outfit-edit/outfit-edit' })
},
onAiRecommend() {
wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' })
},
onOutfitTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
const owner = this.data.activeTab === 0 ? '1' : '0'
wx.navigateTo({ url: `/pages/outfit-detail/outfit-detail?uuid=${uuid}&owner=${owner}` })
},
})
+84
View File
@@ -0,0 +1,84 @@
<view class="page page-flex">
<navigation-bar title="穿搭" back="{{false}}" />
<scroll-view
scroll-y
class="scroll-body"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
>
<!-- 分段控件 -->
<view class="topbar">
<view class="segment">
<view class="segment-item {{activeTab === 0 ? 'active' : ''}}" data-index="0" bindtap="onTabChange">我的穿搭</view>
<view class="segment-item {{activeTab === 1 ? 'active' : ''}}" data-index="1" bindtap="onTabChange">已收藏</view>
</view>
</view>
<!-- 入口按钮 -->
<view class="entry-row">
<view class="entry-btn entry-outline" bindtap="onCreate">+ 创建穿搭</view>
<view class="entry-btn entry-primary" bindtap="onAiRecommend">AI 推荐</view>
</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="list">
<view class="skeleton sk-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-shirt"
title="{{activeTab === 0 ? '还没有穿搭' : '还没有收藏的穿搭'}}"
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : ''}}"
buttonText="{{activeTab === 0 ? '创建穿搭' : ''}}"
bind:action="onCreate"
/>
<!-- 卡片流 -->
<view wx:else class="list">
<view
class="outfit-card card"
wx:for="{{list}}"
wx:key="outfit_uuid"
data-uuid="{{item.outfit_uuid}}"
bindtap="onOutfitTap"
>
<view class="cover">
<view
class="cover-item cover-{{index}}"
wx:for="{{item.items}}"
wx:key="clothes_uuid"
wx:if="{{index < 5}}"
>
<image wx:if="{{item.processed_image}}" class="cover-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger cover-ph"></view>
</view>
<view wx:if="{{!item.items.length}}" class="icon icon-hanger"></view>
<text class="cover-count">{{item.items.length}} 件</text>
</view>
<view class="card-body">
<view class="title-row">
<text class="title">{{item.outfit_name}}</text>
<text class="tag tag-{{item._status.type}}">{{item._status.text}}</text>
</view>
<view class="desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
<view class="meta-row">
<text class="tag tag-gray" wx:if="{{item.scene}}">{{item.scene}}</text>
<text class="meta">{{item.items.length}} 件衣物</text>
<view class="spacer"></view>
<view class="meta-icon">
<view class="icon icon-heart heart"></view>
<text>{{item._likeCount || 0}}</text>
</view>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
</scroll-view>
</view>
+208
View File
@@ -0,0 +1,208 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.topbar {
background: #fff;
padding: 20rpx 32rpx;
}
.segment {
display: flex;
background: #F2F2F7;
border-radius: 20rpx;
padding: 6rpx;
}
.segment-item {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
font-size: 28rpx;
color: #3C3C43;
border-radius: 16rpx;
}
.segment-item.active {
background: #fff;
color: #007AFF;
font-weight: 600;
}
.entry-row {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx;
}
.entry-btn {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 500;
}
.entry-outline {
border: 1rpx solid #007AFF;
color: #007AFF;
background: #fff;
}
.entry-primary {
background: #007AFF;
color: #fff;
}
.list {
padding: 0 32rpx;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.outfit-card {
margin-bottom: 24rpx;
padding: 24rpx;
}
.cover {
height: 280rpx;
background: #F8F8FA;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
position: relative;
overflow: hidden;
}
.cover .icon {
width: 96rpx;
height: 96rpx;
}
.cover-item {
position: absolute;
width: 150rpx;
height: 190rpx;
background: #fff;
border: 1rpx solid #E5E5EA;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cover-0 { left: 16rpx; top: 40rpx; transform: rotate(-6deg); }
.cover-1 { left: 140rpx; top: 82rpx; transform: rotate(3deg); }
.cover-2 { left: 268rpx; top: 32rpx; transform: rotate(5deg); }
.cover-3 { left: 392rpx; top: 84rpx; transform: rotate(-3deg); }
.cover-4 { left: 480rpx; top: 40rpx; transform: rotate(6deg); }
.cover-img {
width: 100%;
height: 100%;
}
.cover-ph {
width: 72rpx;
height: 72rpx;
color: #C7C7CC;
}
.cover-count {
position: absolute;
top: 20rpx;
right: 20rpx;
font-size: 22rpx;
color: #007AFF;
background: rgba(0, 122, 255, 0.1);
padding: 4rpx 20rpx;
border-radius: 20rpx;
}
.card-body {
margin-top: 20rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 32rpx;
font-weight: 600;
color: #000;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.desc {
font-size: 26rpx;
color: #8E8E93;
margin-top: 12rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.meta-row {
display: flex;
align-items: center;
margin-top: 16rpx;
}
.meta {
font-size: 22rpx;
color: #8E8E93;
margin-left: 12rpx;
}
.spacer {
flex: 1;
}
.meta-icon {
display: flex;
align-items: center;
font-size: 22rpx;
color: #8E8E93;
}
.heart {
width: 36rpx;
height: 36rpx;
color: #C7C7CC;
margin-right: 8rpx;
}
.no-more {
text-align: center;
font-size: 24rpx;
color: #C7C7CC;
padding: 24rpx 0 40rpx;
}
+5
View File
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
+172
View File
@@ -0,0 +1,172 @@
import { request, uploadFile } from '../../utils/request'
import { UserInfo, QuotaItem } from '../../types/models'
import { QuotaType, STORAGE_KEYS } from '../../utils/config'
import { formatDate } from '../../utils/format'
function getZodiac(birthDay: number): string {
if (!birthDay) return ''
const d = new Date(birthDay * 1000)
const m = d.getMonth() + 1
const day = d.getDate()
const arr = [
[120, '摩羯座'], [218, '水瓶座'], [320, '双鱼座'], [419, '白羊座'],
[520, '金牛座'], [621, '双子座'], [722, '巨蟹座'], [822, '狮子座'],
[922, '处女座'], [1023, '天秤座'], [1122, '天蝎座'], [1221, '射手座'],
]
const val = m * 100 + day
for (let i = 0; i < arr.length; i++) {
if (val <= (arr[i][0] as number)) return arr[i][1] as string
}
return '摩羯座'
}
Page({
data: {
userInfo: {} as UserInfo,
uploadRemain: 0,
zodiac: '',
editVisible: false,
avatarUploading: false,
avatarChanged: false,
editForm: {
username: '',
sex: 0,
birth_day: 0,
avatar: '',
},
birthDateStr: '',
today: '',
},
onLoad() {
const d = new Date()
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`)
this.setData({ today: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` })
},
onShow() {
this.syncTabBar()
this.fetchUser()
this.fetchQuota()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) {
tabBar.setData({ selected: 4 })
if (tabBar.refreshUnread) tabBar.refreshUnread()
}
},
fetchUser() {
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
this.setData({ userInfo: user, zodiac: getZodiac(user.birth_day) })
})
.catch(() => {})
},
fetchQuota() {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => {
const upload = res.quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
if (upload) this.setData({ uploadRemain: upload.remain })
})
.catch(() => {})
},
onEdit() {
const u = this.data.userInfo
this.setData({
editVisible: true,
avatarChanged: false,
editForm: {
username: u.username || '',
sex: u.sex || 0,
birth_day: u.birth_day || 0,
avatar: u.avatar || '',
},
birthDateStr: u.birth_day ? formatDate(u.birth_day) : '',
})
},
onCloseEdit() {
this.setData({ editVisible: false })
},
noop() {},
onChooseAvatar(e: WechatMiniprogram.CustomEvent) {
this.setData({ 'editForm.avatar': e.detail.avatarUrl, avatarChanged: true })
},
onNameInput(e: WechatMiniprogram.Input) {
this.setData({ 'editForm.username': e.detail.value })
},
onSexSelect() {
wx.showActionSheet({
itemList: ['男', '女'],
success: (res) => this.setData({ 'editForm.sex': res.tapIndex + 1 }),
})
},
onBirthChange(e: WechatMiniprogram.CustomEvent) {
const dateStr = e.detail.value as string
const ts = Math.floor(new Date(dateStr.replace(/-/g, '/')).getTime() / 1000)
this.setData({ 'editForm.birth_day': ts, birthDateStr: dateStr })
},
onSaveProfile() {
if (this.data.avatarUploading) return
const f = this.data.editForm
const avatar = f.avatar || ''
const saveProfile = () => {
const body: Record<string, any> = {}
if (f.username) body.username = f.username
if (f.sex) body.sex = f.sex
if (f.birth_day) body.birth_day = f.birth_day
request({ url: '/user/profile', method: 'PUT', data: body })
.then(() => {
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ editVisible: false, avatarUploading: false })
this.fetchUser()
})
.catch(() => this.setData({ avatarUploading: false }))
}
if (this.data.avatarChanged && avatar) {
this.setData({ avatarUploading: true })
uploadFile<{ avatar: string }>(avatar, 'file', '/user/avatar/upload')
.then(() => saveProfile())
.catch(() => this.setData({ avatarUploading: false }))
} else {
saveProfile()
}
},
onTapMall() {
wx.navigateTo({ url: '/pages/mall/mall' })
},
onTapOrder() {
wx.navigateTo({ url: '/pages/order/order' })
},
onTapNotification() {
wx.navigateTo({ url: '/pages/notification/notification' })
},
onTapCollect() {
wx.setStorageSync('zt_open_collect', 1)
wx.switchTab({ url: '/pages/outfit/outfit' })
},
onTapFlow() {
wx.navigateTo({ url: '/pages/mall/mall?tab=flow' })
},
onTapService() {
wx.showToast({ title: '客服与反馈敬请期待', icon: 'none' })
},
onTapAbout() {
wx.showToast({ title: '纸条AI衣橱 v1.0.0', icon: 'none' })
},
})
+125
View File
@@ -0,0 +1,125 @@
<view class="page page-flex">
<navigation-bar title="我的" back="{{false}}" />
<scroll-view scroll-y class="scroll-body">
<!-- 额度提示条 -->
<view wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="quota-banner" bindtap="onTapMall">
你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包 >
</view>
<!-- 个人信息 -->
<view class="card user-card">
<view class="avatar">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
</view>
<view class="user-info">
<view class="user-name">
<text>{{userInfo.username || '微信用户'}}</text>
<text wx:if="{{userInfo.sex === 1}}" class="sex-tag">男</text>
<text wx:elif="{{userInfo.sex === 2}}" class="sex-tag">女</text>
</view>
<view class="user-tags">
<text wx:if="{{userInfo.vip_type !== 1}}" class="tag tag-blue">月度会员</text>
<text wx:if="{{zodiac}}" class="tag tag-gray">{{zodiac}}</text>
<text wx:else class="tag tag-gray" bindtap="onEdit">设置生日</text>
</view>
</view>
<view class="edit-link" bindtap="onEdit">编辑资料<view class="icon icon-chevron-right"></view></view>
</view>
<!-- 功能列表 1 -->
<view class="card menu-card">
<view class="menu-row hairline" bindtap="onTapMall">
<view class="icon icon-gauge menu-icon"></view>
<text class="menu-label">我的额度</text>
<text class="menu-value">上传额度剩 {{uploadRemain}}</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapOrder">
<view class="icon icon-doc menu-icon"></view>
<text class="menu-label">我的订单</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapNotification">
<view class="icon icon-bell menu-icon"></view>
<text class="menu-label">通知</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapCollect">
<view class="icon icon-star menu-icon"></view>
<text class="menu-label">我的收藏</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row" bindtap="onTapFlow">
<view class="icon icon-doc menu-icon"></view>
<text class="menu-label">额度明细</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</view>
<!-- 功能列表 2 -->
<view class="card menu-card">
<view class="menu-row hairline" bindtap="onEdit">
<view class="icon icon-pencil menu-icon"></view>
<text class="menu-label">编辑资料</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onEdit">
<view class="icon icon-calendar menu-icon"></view>
<text class="menu-label">设置生日</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapService">
<view class="icon icon-headphones menu-icon"></view>
<text class="menu-label">客服与反馈</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row" bindtap="onTapAbout">
<view class="icon icon-info menu-icon"></view>
<text class="menu-label">关于纸条AI衣橱</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</view>
<view class="version">v1.0.0</view>
</scroll-view>
<!-- 编辑资料弹窗 -->
<view wx:if="{{editVisible}}" class="mask" bindtap="onCloseEdit">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="sheet-title">编辑资料</view>
<view class="avatar-wrap">
<image wx:if="{{editForm.avatar}}" class="avatar-img" src="{{editForm.avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
<view class="avatar-tip">点击更换头像</view>
<view class="form-field">
<text class="form-label">昵称</text>
<input class="input" type="nickname" value="{{editForm.username}}" bindinput="onNameInput" maxlength="32" placeholder="请输入昵称" />
</view>
<view class="form-field">
<text class="form-label">性别</text>
<view class="form-value" bindtap="onSexSelect">
<text class="{{editForm.sex ? '' : 'placeholder'}}">{{editForm.sex === 1 ? '男' : editForm.sex === 2 ? '女' : '请选择'}}</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</view>
<view class="form-field">
<text class="form-label">生日</text>
<picker mode="date" value="{{birthDateStr}}" end="{{today}}" bindchange="onBirthChange">
<view class="form-value">
<text class="{{birthDateStr ? '' : 'placeholder'}}">{{birthDateStr || '请选择生日'}}</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</picker>
</view>
<view class="btn btn-primary sheet-btn {{avatarUploading ? 'btn-disabled' : ''}}" bindtap="onSaveProfile">{{avatarUploading ? '上传中…' : '保存'}}</view>
</view>
</view>
</view>
+266
View File
@@ -0,0 +1,266 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.quota-banner {
margin: 24rpx 24rpx 0;
background: #FFF3E6;
color: #FF9500;
font-size: 24rpx;
padding: 20rpx 24rpx;
border-radius: 16rpx;
}
.user-card {
margin: 24rpx;
display: flex;
align-items: center;
}
.avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: #F2F2F7;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 72rpx;
height: 72rpx;
}
.user-info {
flex: 1;
margin-left: 24rpx;
min-width: 0;
}
.user-name {
display: flex;
align-items: center;
font-size: 36rpx;
font-weight: 700;
color: #000;
}
.sex-tag {
font-size: 20rpx;
color: #007AFF;
background: rgba(0, 122, 255, 0.1);
border-radius: 8rpx;
padding: 2rpx 12rpx;
margin-left: 12rpx;
font-weight: 400;
}
.user-tags {
display: flex;
gap: 12rpx;
margin-top: 12rpx;
}
.edit-link {
display: flex;
align-items: center;
font-size: 26rpx;
color: #8E8E93;
}
.edit-link .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
.menu-card {
margin: 0 24rpx 24rpx;
padding: 0 32rpx;
}
.menu-row {
display: flex;
align-items: center;
height: 100rpx;
}
.menu-icon {
width: 44rpx;
height: 44rpx;
color: #007AFF;
margin-right: 24rpx;
}
.menu-label {
flex: 1;
font-size: 28rpx;
color: #000;
}
.menu-value {
font-size: 26rpx;
color: #8E8E93;
}
.menu-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
margin-left: 12rpx;
}
.version {
text-align: center;
font-size: 22rpx;
color: #C7C7CC;
padding: 24rpx 0 48rpx;
}
/* 编辑弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 20rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom) + 120rpx);
max-height: 85vh;
overflow-y: auto;
}
.sheet-grabber {
width: 72rpx;
height: 10rpx;
border-radius: 5rpx;
background: #E5E5EA;
margin: 0 auto 24rpx;
}
.sheet-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
margin-bottom: 24rpx;
}
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
margin: 0 auto;
border-radius: 50%;
background: #F2F2F7;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-sizing: border-box;
color: #C7C7CC;
}
.avatar-wrap .avatar-img {
width: 100%;
height: 100%;
}
.avatar-wrap .avatar-placeholder {
width: 88rpx;
height: 88rpx;
}
.avatar-btn {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
opacity: 0;
}
.avatar-btn::after {
border: none;
}
.avatar-tip {
text-align: center;
font-size: 22rpx;
color: #8E8E93;
margin: 16rpx 0 24rpx;
}
.form-field {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #F2F2F7;
}
.form-label {
width: 140rpx;
font-size: 28rpx;
color: #000;
flex-shrink: 0;
}
.form-field .input {
flex: 1;
background: #fff;
padding: 0;
}
.form-value {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
}
.placeholder {
color: #C7C7CC;
}
.sheet-btn {
margin-top: 32rpx;
width: 100%;
}
.btn-disabled {
opacity: 0.5;
pointer-events: none;
}
+5
View File
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
+135
View File
@@ -0,0 +1,135 @@
import { request, uploadFile } from '../../utils/request'
import { ProcessStatus } from '../../utils/config'
Page({
data: {
state: 'idle' as 'idle' | 'processing' | 'failed',
imagePath: '',
clothesUuid: '',
failReason: '',
step: 0,
},
onChoose() {
wx.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
const sourceType: Array<'camera' | 'album'> = res.tapIndex === 0 ? ['camera'] : ['album']
this.chooseImage(sourceType)
},
})
},
chooseImage(sourceType: Array<'camera' | 'album'>) {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType,
success: (res) => {
const file = res.tempFiles[0]
this.startUpload(file.tempFilePath)
},
})
},
startUpload(filePath: string) {
this.setData({ state: 'processing', imagePath: filePath, step: 0, failReason: '' })
uploadFile<{ clothes_uuid: string; image_process_status: number }>(filePath)
.then((res) => {
this.setData({ clothesUuid: res.clothes_uuid })
this.askNextAction()
})
.catch((e: any) => {
this.setData({ state: 'idle' })
if (e && e.code === 80000) this.showQuotaModal()
})
},
askNextAction() {
// 后台继续轮询,仅用于更新步骤进度(不再自动跳详情)
this.pollStatus(this.data.clothesUuid)
// 立即弹窗让用户选择下一步,避免干等
wx.showModal({
title: '衣物已上传',
content: 'AI 正在识别中,可在衣橱查看进度',
confirmText: '继续上传',
cancelText: '回到衣橱',
success: (res) => {
this.clearTimer()
if (res.confirm) {
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
} else {
wx.navigateBack({
delta: 1,
fail: () => wx.switchTab({ url: '/pages/wardrobe/wardrobe' }),
})
}
},
})
},
pollStatus(uuid: string) {
let count = 0
;(this as any).pollTimer = setInterval(() => {
count++
request<{ image_process_status: number; fail_reason: string }>({
url: `/clothes/${uuid}/status`,
showError: false,
})
.then((res) => {
const status = res.image_process_status
if (status === ProcessStatus.MATTING) {
this.setData({ step: 1 })
} else if (status === ProcessStatus.CLASSIFYING) {
this.setData({ step: 2 })
} else if (status === ProcessStatus.SUCCESS) {
// 处理成功不再自动跳转,由用户自行从衣橱进入详情
this.clearTimer()
} else if (status === ProcessStatus.FAILED) {
this.clearTimer()
this.setData({ state: 'failed', failReason: res.fail_reason || '识别失败' })
}
})
.catch(() => {})
if (count >= 60) {
this.clearTimer()
this.setData({ state: 'failed', failReason: '处理超时,请重试' })
}
}, 1500)
},
clearTimer() {
const t = (this as any).pollTimer
if (t) {
clearInterval(t)
;(this as any).pollTimer = null
}
},
onUnload() {
this.clearTimer()
},
showQuotaModal() {
wx.showModal({
title: '上传额度不足',
content: '当前剩余 0 次,购买额度包后可继续上传',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/mall/mall' })
},
})
},
onRetry() {
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', failReason: '' })
},
onManualEdit() {
if (!this.data.clothesUuid) return
wx.redirectTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${this.data.clothesUuid}` })
},
})
+48
View File
@@ -0,0 +1,48 @@
<view class="page upload-page">
<navigation-bar title="上传衣物" />
<!-- 初始态 -->
<view wx:if="{{state === 'idle'}}" class="idle">
<view class="upload-zone" bindtap="onChoose">
<view class="icon icon-camera upload-camera"></view>
<view class="upload-title">拍照或从相册选择</view>
<view class="upload-desc">支持 JPG / PNG,单张不超过 20MB</view>
<view class="upload-desc">上传消耗 1 次上传额度</view>
</view>
<view class="upload-tip">AI 将自动抠图并识别 15 项属性</view>
</view>
<!-- 处理中 -->
<view wx:elif="{{state === 'processing'}}" class="processing">
<image class="proc-img" src="{{imagePath}}" mode="aspectFit" />
<view class="steps">
<view class="step">
<view class="step-dot {{step >= 0 ? 'active' : ''}}"></view>
<text class="{{step >= 0 ? 'active-text' : ''}}">排队中</text>
</view>
<view class="step-line {{step >= 1 ? 'active' : ''}}"></view>
<view class="step">
<view class="step-dot {{step >= 1 ? 'active' : ''}}"></view>
<text class="{{step >= 1 ? 'active-text' : ''}}">AI 抠图</text>
</view>
<view class="step-line {{step >= 2 ? 'active' : ''}}"></view>
<view class="step">
<view class="step-dot {{step >= 2 ? 'active' : ''}}"></view>
<text class="{{step >= 2 ? 'active-text' : ''}}">智能分类</text>
</view>
</view>
<view class="proc-text">AI 正在识别你的衣物…</view>
<view class="proc-desc">通常需要 10 秒左右,请稍候</view>
</view>
<!-- 失败态 -->
<view wx:else class="failed">
<view class="fail-icon">!</view>
<view class="fail-title">识别失败:{{failReason}}</view>
<view class="fail-desc">本次上传额度已自动退回</view>
<view class="fail-btns">
<view class="btn btn-primary" bindtap="onRetry">重新上传</view>
<view class="btn btn-outline" bindtap="onManualEdit">手动填写信息</view>
</view>
</view>
</view>
+156
View File
@@ -0,0 +1,156 @@
.upload-page {
padding: 32rpx;
box-sizing: border-box;
}
/* 初始态 */
.upload-zone {
margin-top: 40rpx;
height: 520rpx;
border: 3rpx dashed #C7C7CC;
border-radius: 32rpx;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-camera {
width: 100rpx;
height: 100rpx;
color: #007AFF;
margin-bottom: 24rpx;
}
.upload-title {
font-size: 30rpx;
color: #000;
font-weight: 500;
}
.upload-desc {
font-size: 22rpx;
color: #8E8E93;
margin-top: 12rpx;
}
.upload-tip {
text-align: center;
font-size: 24rpx;
color: #8E8E93;
margin-top: 24rpx;
}
/* 处理中 */
.processing {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.proc-img {
width: 480rpx;
height: 480rpx;
border-radius: 32rpx;
background: #F2F2F7;
}
.steps {
display: flex;
align-items: center;
margin-top: 56rpx;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
font-size: 22rpx;
color: #C7C7CC;
}
.step-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #E5E5EA;
margin-bottom: 12rpx;
}
.step-dot.active {
background: #007AFF;
}
.active-text {
color: #007AFF;
}
.step-line {
width: 100rpx;
height: 2rpx;
background: #E5E5EA;
margin: 0 16rpx 34rpx;
}
.step-line.active {
background: #007AFF;
}
.proc-text {
margin-top: 56rpx;
font-size: 30rpx;
font-weight: 500;
color: #000;
}
.proc-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
}
/* 失败态 */
.failed {
margin-top: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 40rpx;
}
.fail-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #FF3B30;
color: #fff;
font-size: 72rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.fail-title {
margin-top: 40rpx;
font-size: 32rpx;
font-weight: 600;
color: #000;
text-align: center;
}
.fail-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 16rpx;
}
.fail-btns {
width: 100%;
margin-top: 56rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card",
"empty-state": "/components/empty-state/empty-state"
}
}
+107
View File
@@ -0,0 +1,107 @@
import { request } from '../../utils/request'
import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
import { QuotaType } from '../../utils/config'
const CATEGORIES = [
{ name: '全部', value: '' },
{ name: '上装', value: '上装' },
{ name: '下装', value: '下装' },
{ name: '连衣裙', value: '连衣裙' },
{ name: '外套', value: '外套' },
{ name: '鞋', value: '鞋' },
{ name: '配饰', value: '配饰' },
]
Page({
data: {
categories: CATEGORIES,
activeCategory: 0,
list: [] as ClothesItem[],
total: 0,
uploadRemain: 0,
loading: true,
refreshing: false,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.syncTabBar()
this.refresh()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 0 })
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
this.fetchQuota()
},
onRefresh() {
this.setData({ refreshing: true })
Promise.all([this.fetchList(1), this.fetchQuota()]).finally(() => {
this.setData({ refreshing: false })
})
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number): Promise<void> {
const cat = CATEGORIES[this.data.activeCategory].value
const params: Record<string, any> = { page, page_size: this.data.pageSize }
if (cat) params.category_main = cat
return request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '',
_statusType: 'blue',
}))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
total: res.total,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => {
this.setData({ loading: false })
})
},
fetchQuota(): Promise<void> {
return request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => {
const upload = res.quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
if (upload) this.setData({ uploadRemain: upload.remain })
})
.catch(() => {})
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
this.setData({ activeCategory: index, list: [], loading: true })
this.refresh()
},
onClothesTap(e: WechatMiniprogram.CustomEvent) {
const item = e.detail.item as ClothesItem
wx.navigateTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${item.clothes_uuid}` })
},
onUpload() {
wx.navigateTo({ url: '/pages/upload/upload' })
},
})
+64
View File
@@ -0,0 +1,64 @@
<view class="page page-flex">
<navigation-bar title="我的衣橱" back="{{false}}" />
<scroll-view
scroll-y
class="scroll-body"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
>
<!-- 分类筛选 -->
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 统计 -->
<view class="stat">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="grid">
<view class="grid-item" wx:for="{{[1, 2, 3, 4, 5, 6]}}" wx:key="*this">
<view class="skeleton sk-card"></view>
<view class="skeleton sk-line"></view>
</view>
</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-hanger"
title="衣橱还空着"
desc="拍照上传第一件衣物,AI 自动抠图并识别属性"
buttonText="上传衣物"
bind:action="onUpload"
/>
<!-- 衣物网格 -->
<view wx:else class="grid">
<view class="grid-item" wx:for="{{list}}" wx:key="clothes_uuid">
<clothes-card
item="{{item}}"
statusText="{{item._statusText}}"
statusType="{{item._statusType}}"
bind:select="onClothesTap"
/>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
</scroll-view>
<view class="fab" bindtap="onUpload">
<view class="icon icon-plus"></view>
</view>
</view>
+74
View File
@@ -0,0 +1,74 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.cat-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.stat {
padding: 8rpx 32rpx 0;
font-size: 22rpx;
color: #8E8E93;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.sk-card {
height: 320rpx;
border-radius: 32rpx;
}
.sk-line {
height: 40rpx;
margin-top: 16rpx;
border-radius: 12rpx;
}
.no-more {
text-align: center;
font-size: 24rpx;
color: #C7C7CC;
padding: 24rpx 0 40rpx;
}
+7
View File
@@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{
"action": "allow",
"page": "*"
}]
}
+163
View File
@@ -0,0 +1,163 @@
/** SF Symbols 风格线性图标库(CSS mask + currentColor 单色可换色)**/
.icon {
display: inline-block;
width: 44rpx;
height: 44rpx;
background-color: currentColor;
flex-shrink: 0;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.icon-sm { width: 40rpx; height: 40rpx; }
.icon-md { width: 56rpx; height: 56rpx; }
.icon-lg { width: 72rpx; height: 72rpx; }
/* 衣橱 / 衣架 */
.icon-hanger {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v3'/%3E%3Cpath d='M12 6c-2.5 0-4.5 2-4.5 4.5L4 20h16l-3.5-9.5C16.5 8 14.5 6 12 6z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v3'/%3E%3Cpath d='M12 6c-2.5 0-4.5 2-4.5 4.5L4 20h16l-3.5-9.5C16.5 8 14.5 6 12 6z'/%3E%3C/svg%3E");
}
/* 穿搭 / T恤 */
.icon-shirt {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3l4 4-2 2-1-1v3l2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6l2-2V8L6 9 4 7l4-4h2a2 2 0 0 0 4 0h2z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3l4 4-2 2-1-1v3l2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6l2-2V8L6 9 4 7l4-4h2a2 2 0 0 0 4 0h2z'/%3E%3C/svg%3E");
}
/* 运势 / 四角星 */
.icon-sparkles {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z'/%3E%3C/svg%3E");
}
/* 通知 / 铃铛 */
.icon-bell {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9'/%3E%3Cpath d='M13.73 21a2 2 0 0 1-3.46 0'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9'/%3E%3Cpath d='M13.73 21a2 2 0 0 1-3.46 0'/%3E%3C/svg%3E");
}
/* 我的 / 人形 */
.icon-user {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
}
/* 加号 */
.icon-plus {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
}
/* 右箭头 */
.icon-chevron-right {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
}
/* 相机 */
.icon-camera {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='12' cy='13' r='4'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='12' cy='13' r='4'/%3E%3C/svg%3E");
}
/* 点赞 / 爱心 */
.icon-heart {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E");
}
/* 收藏 / 星(线框) */
.icon-star {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
}
/* 收藏 / 星(实心) */
.icon-star-fill {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
}
/* 分享 */
.icon-share {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cpath d='M8.59 13.51l6.83 3.98'/%3E%3Cpath d='M15.41 6.51l-6.82 3.98'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cpath d='M8.59 13.51l6.83 3.98'/%3E%3Cpath d='M15.41 6.51l-6.82 3.98'/%3E%3C/svg%3E");
}
/* 删除 / 垃圾桶 */
.icon-trash {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6'/%3E%3Cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6'/%3E%3Cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E");
}
/* 编辑 / 铅笔 */
.icon-pencil {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'/%3E%3C/svg%3E");
}
/* 会员 / 皇冠 */
.icon-crown {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8l4 4 5-6 5 6 4-4-2 9H5L3 8z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8l4 4 5-6 5 6 4-4-2 9H5L3 8z'/%3E%3C/svg%3E");
}
/* 订单 / 文档 */
.icon-doc {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
}
/* 额度 / 仪表 */
.icon-gauge {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0-18 0'/%3E%3Cpath d='M12 12l5-3'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0-18 0'/%3E%3Cpath d='M12 12l5-3'/%3E%3C/svg%3E");
}
/* 对勾 */
.icon-check {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
}
/* 叉 */
.icon-close {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6L6 18'/%3E%3Cpath d='M6 6l12 12'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6L6 18'/%3E%3Cpath d='M6 6l12 12'/%3E%3C/svg%3E");
}
/* 更多 / 三点 */
.icon-ellipsis {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E");
}
/* 生日 / 日历 */
.icon-calendar {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4'/%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4'/%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E");
}
/* 客服 / 耳机 */
.icon-headphones {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 18v-6a9 9 0 0 1 18 0v6'/%3E%3Cpath d='M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 18v-6a9 9 0 0 1 18 0v6'/%3E%3Cpath d='M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z'/%3E%3C/svg%3E");
}
/* 关于 / 信息 */
.icon-info {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
}
/* 设置 / 齿轮 */
.icon-setting {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");
}
+182
View File
@@ -0,0 +1,182 @@
// 后端实体类型定义(对照 接口文档.md)
export interface UserInfo {
username: string
avatar: string
sex: number // 0 未知 1 男 2 女
birth_day: number // 秒级时间戳,0 未设置
phone: string
is_new_user: boolean
status: number // 1 正常 2 临时禁用 3 永久禁用 4 已注销
ban_reason: string
ban_start: number
ban_expire: number
vip_type: number // 1 非会员 2 月度会员 3 年度会员 4 体验会员
vip_expire: number
}
export interface ClothesItem {
clothes_uuid: string
category_main: string
category_sub: string
style: string
color_main: string
color_sub: string
thickness: string
version: string
material: string
pattern: string
sleeve_type: string
clothes_type: string
neckline_type: string
formality: string
occasions: string
visual_impact: string
name: string
remark: string
processed_image: string
}
export interface OutfitItem {
outfit_uuid: string
outfit_name: string
outfit_desc: string
scene: string
source: number // 1 用户创建 2 AI 推荐
is_public: number // 1 私有 2 公开
audit_status: number // 1 待审核 2 通过 3 不通过
items: Array<{ clothes_uuid: string; processed_image: string }>
}
export interface GoodsItem {
id: number
goods_code: string
goods_name: string
goods_type: number // 1 会员 2 上传 3 AI 4 衣柜分享 5 搭配分享
price: number // 分
discount_price: number // 分
member_days: number
upload_quota: number
ai_recommend_quota: number
closet_share_quota: number
outfit_share_quota: number
valid_days: number
description: string
}
export interface OrderItem {
order_no: string
goods_name: string
unit_price: number // 分
pay_amount: number // 分
pay_channel: number // 1 微信支付 2 虚拟支付
order_status: number // 1 待支付 2 已支付 3 已取消 4 已过期 5 已退款
member_days: number
expire_time: number // 秒
pay_time: number
refund_time: number
refund_amount: number
created_at: number
}
export interface NotificationItem {
notification_uuid: string
msg_type: number // 1 点赞 2 订单 3 系统
title: string
content: string
is_read: number // 1 未读 2 已读
created_at: number
}
export interface QuotaItem {
quota_type: number
total: number
used: number
remain: number
}
export interface QuotaFlowItem {
quota_flow_uuid: string
quota_type: number
change_target: number
change_type: number // 1 购买 2 活动 3 上传消耗 4 删除回退 5 人工 6 退款回收
change_num: number // 正增负减
new_value: number
reason: string
order_no: string
created_at: number
}
export interface PayParams {
timeStamp: string
nonceStr: string
package: string
signType: string
paySign: string
}
// 虚拟支付下单响应(小程序虚拟支付 /order/virtual 返回)
export interface VirtualPayResult {
order_no: string
pay_amount: number // 分
expire_time: number // 秒
sign_data: string // 参与签名的 JSON 字符串(原样透传给 wx.requestVirtualPayment)
pay_sig: string // 支付签名
signature: string // 用户态签名
}
export interface HoroscopeData {
stat_date: number
zodiac: string
overall_score: number
love_score: number
career_score: number
wealth_score: number
daily_text: string
lucky_color: string
lucky_number: string
}
export interface ClosetShareItem {
share_uuid: string
share_type: number
clothes_count: number
expire_time: number
status: number
owner_name?: string
owner_avatar?: string
}
export interface ClosetShareDetail {
share_uuid: string
owner_name: string
owner_avatar: string
clothes: ClothesItem[]
expire_time: number
status: number
is_owner: boolean
}
export interface AiRecommendResult {
outfit_uuid: string
outfit_name: string
outfit_desc: string
scene: string
source: number
status: number
outfit_item: Array<{ clothes_uuid: string }>
}
export interface LoginResult {
access_token: string
refresh_token: string
expire_time: number
user_info: UserInfo
}
export interface PagedResult<T> {
total: number
page: number
page_size: number
list: T[]
}
+39
View File
@@ -0,0 +1,39 @@
import { STORAGE_KEYS } from './config'
export interface TokenData {
accessToken: string
refreshToken: string
expireTime: number
}
export function getAccessToken(): string {
return wx.getStorageSync(STORAGE_KEYS.ACCESS_TOKEN) || ''
}
export function getRefreshToken(): string {
return wx.getStorageSync(STORAGE_KEYS.REFRESH_TOKEN) || ''
}
export function getExpireTime(): number {
return wx.getStorageSync(STORAGE_KEYS.EXPIRE_TIME) || 0
}
export function setTokens(data: TokenData): void {
wx.setStorageSync(STORAGE_KEYS.ACCESS_TOKEN, data.accessToken)
wx.setStorageSync(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken)
wx.setStorageSync(STORAGE_KEYS.EXPIRE_TIME, data.expireTime)
}
export function clearTokens(): void {
wx.removeStorageSync(STORAGE_KEYS.ACCESS_TOKEN)
wx.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
wx.removeStorageSync(STORAGE_KEYS.EXPIRE_TIME)
}
export function isLoggedIn(): boolean {
return !!getAccessToken()
}
export function clearUserInfo(): void {
wx.removeStorageSync(STORAGE_KEYS.USER_INFO)
}
+154
View File
@@ -0,0 +1,154 @@
// 全局配置与常量
// 后端服务域名(不含 /api/v1 前缀、不含结尾斜杠)
export const BASE_URL = 'https://zt-closet-backend.lzcwy.cn'
// API 统一前缀
export const API_PREFIX = '/api/v1'
// 本地存储 key
export const STORAGE_KEYS = {
ACCESS_TOKEN: 'zt_closet_access_token',
REFRESH_TOKEN: 'zt_closet_refresh_token',
EXPIRE_TIME: 'zt_closet_expire_time',
USER_INFO: 'zt_closet_user_info',
}
// 衣物图片处理状态
export enum ProcessStatus {
MATTING = 1, // 抠图中
CLASSIFYING = 2, // 分类中
QUEUED = 3, // 排队中
SUCCESS = 4, // 成功
FAILED = 5, // 失败
}
// 订单状态
export enum OrderStatus {
PENDING = 1, // 待支付
PAID = 2, // 已支付
CANCELLED = 3, // 已取消
EXPIRED = 4, // 已过期
REFUNDED = 5, // 已退款
}
// 额度类型
export enum QuotaType {
UPLOAD = 1, // 上传
AI = 2, // AI 推荐
OUTFIT_SHARE = 3, // 社区分享(搭配分享)
CLOSET_SHARE = 4, // 衣柜分享
}
// 通知类型
export enum MsgType {
LIKE = 1, // 点赞通知
ORDER = 2, // 订单通知
SYSTEM = 3, // 系统通知
}
// 穿搭来源
export enum OutfitSource {
USER = 1, // 用户创建
AI = 2, // AI 推荐
}
// 公开状态
export enum IsPublic {
PRIVATE = 1, // 不公开
PUBLIC = 2, // 公开
}
// 穿搭审核状态
export enum AuditStatus {
PENDING = 1, // 待审核
PASS = 2, // 审核通过
REJECT = 3, // 审核不通过
}
// 衣柜分享状态
export enum ShareStatus {
NORMAL = 1, // 正常
CANCELLED = 2, // 已取消
EXPIRED = 3, // 已过期
BANNED = 4, // 已封禁
}
// 分享类型
export enum ShareType {
PARTIAL = 1, // 部分衣物
FULL = 2, // 全量快照
}
// 商品类型
export enum GoodsType {
MEMBER = 1, // 会员包
UPLOAD = 2, // 上传包
AI = 3, // AI 推荐包
CLOSET_SHARE = 4, // 衣柜分享包
OUTFIT_SHARE = 5, // 搭配分享包
}
// 额度名称映射
export const QUOTA_NAMES: Record<number, string> = {
[QuotaType.UPLOAD]: '上传额度',
[QuotaType.AI]: 'AI 推荐额度',
[QuotaType.OUTFIT_SHARE]: '社区分享额度',
[QuotaType.CLOSET_SHARE]: '衣柜分享额度',
}
// 商品类型名称映射
export const GOODS_TYPE_NAMES: Record<number, string> = {
[GoodsType.MEMBER]: '会员',
[GoodsType.UPLOAD]: '上传',
[GoodsType.AI]: 'AI 推荐',
[GoodsType.CLOSET_SHARE]: '衣柜分享',
[GoodsType.OUTFIT_SHARE]: '搭配分享',
}
// 错误码统一文案(兜底,优先展示服务端 details[0])
export const ERROR_MESSAGES: Record<number, string> = {
80000: '额度不足',
80001: '额度记录不存在',
90401: 'AI 推荐次数已达上限',
90400: 'AI 推荐失败',
90300: '运势生成中,请稍后重试',
10003: '操作过于频繁,请稍后重试',
11000: '请先登录',
11001: '登录状态已失效',
11002: '登录状态已过期',
11004: '请先登录',
12000: '无权限操作',
21001: '账号已被禁用',
22000: '用户不存在',
30000: '上传文件为空',
30001: '不支持的文件类型',
30002: '文件大小超过限制',
50000: '衣物不存在',
50001: '该衣物已被搭配使用,无法删除',
50005: '无权操作该衣物',
40000: '穿搭不存在',
40004: '无权操作该穿搭',
60000: '商品不存在',
60001: '商品已下架',
70000: '订单不存在',
70001: '订单状态不正确',
70002: '订单已支付',
70005: '虚拟支付未开通',
70006: '商品尚未发布为虚拟支付道具',
70007: '虚拟支付订单不支持取消',
70008: 'iOS 虚拟支付最低 1 元',
70009: '订单真实性校验失败',
71001: '当前状态不允许退款',
71004: 'iOS 订单请通过 App Store 申请退款',
79003: '创建虚拟支付订单失败',
90100: '分享不存在',
90101: '分享已过期',
90102: '分享已取消',
90200: '目标内容不存在',
19000: '服务繁忙,请稍后重试',
19001: '服务繁忙,请稍后重试',
19002: '服务繁忙,请稍后重试',
19003: '服务繁忙,请稍后重试',
19999: '服务繁忙,请稍后重试',
}

Some files were not shown because too many files have changed in this diff Show More