first commit
This commit is contained in:
@@ -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+」,无未读时隐藏。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"styleIsolation": "apply-shared"
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||||
|
"empty-state": "/components/empty-state/empty-state"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||||
|
"empty-state": "/components/empty-state/empty-state"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||||
|
"empty-state": "/components/empty-state/empty-state"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}` })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}` })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||||
|
"rules": [{
|
||||||
|
"action": "allow",
|
||||||
|
"page": "*"
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user