feat: ui update

This commit is contained in:
2026-09-15 16:01:59 +08:00
parent 1a11816839
commit 26c392d612
89 changed files with 10377 additions and 4861 deletions
@@ -0,0 +1,297 @@
---
name: 小程序UI全量改版V2灰阶杂志风
overview: 把 miniprogram/ 现有 18 个页面的 UI 从旧 iOS 蓝白风格全量重写为 stitch_ai_ui_v2 的「零彩色灰阶杂志风 + 毛玻璃悬浮层」,保持全部业务逻辑与状态矩阵不变,并补齐上传页处理中三步进度等 v2 稿未覆盖的细节,交付真机可运行的小程序代码。
design:
styleKeywords:
- 零彩色灰阶
- 毛玻璃浮层
- 杂志大留白
- 衬线大标题
- 发丝线分隔
- 悬浮胶囊标签栏
- 买手店线稿质感
- 苹果级缓动
fontSystem:
fontFamily: Songti SC / PingFang SC
heading:
size: 68rpx
weight: 500
subheading:
size: 34rpx
weight: 600
body:
size: 30rpx
weight: 400
colorSystem:
primary:
- "#0A0A0A"
- "#1C1C1E"
- "#2C2C2E"
background:
- "#FFFFFF"
- "#F7F7F5"
- "#EFEFED"
text:
- "#0A0A0A"
- "#6E6E73"
- "#AEAEB2"
functional:
- "#C9C9CE"
- "#B9B9BE"
- "#E5E5E7"
- "#D2D2D7"
todos:
- id: baseline-tokens
content: 用 [subagent:code-explorer] 盘点旧彩色类与原生弹窗引用,重写 app.wxss 令牌与 icons.wxss 图标
status: completed
- id: shell
content: 重写悬浮玻璃胶囊 tabBar 与「大标题+滚动收缩」导航,新增 utils/nav.ts 阈值 handler
status: completed
dependencies:
- baseline-tokens
- id: overlay
content: 新增 v2-overlay 玻璃弹层组件(Modal/ActionSheet/Toast)并替换原生确认与选择弹窗
status: completed
dependencies:
- baseline-tokens
- id: tab-pages
content: 重写 5 个 tab 页(衣橱/穿搭/运势/分享/我的)的视觉与信息层级
status: completed
dependencies:
- shell
- overlay
- id: detail-pages
content: 重写详情与表单页(衣物详情/穿搭详情/穿搭编辑/AI 推荐/通知两页)
status: completed
dependencies:
- shell
- overlay
- id: upload-flow
content: 补全上传处理过程三态,重写登录/分享三页/商城/订单
status: completed
dependencies:
- shell
- overlay
- id: qa
content: 用 [skill:playwright-cli] 逐屏对照 v2 基准走查,清理零彩色与类名残留并输出真机验收清单
status: completed
dependencies:
- tab-pages
- detail-pages
- upload-flow
---
## 产品概述
把正在运行的「纸条 AI 衣橱」微信小程序(`miniprogram/`,当前仍是 iOS 蓝白彩色风)整体升级为 v2 设计语言:零彩色灰阶、毛玻璃浮层、杂志式大留白、衬线大标题。18 个页面全部覆盖,真机可运行,业务逻辑与在途改动保持不变。同时补全 v2 相对现有前端需要强化的部分,重点是衣物上传后的处理过程界面。
## 核心功能
- 全局设计系统替换:旧 iOS 彩色令牌(#007AFF 蓝等)→ v2 灰阶令牌、衬线/无衬线双字阶、1rpx 发丝线、玻璃层与统一缓动
- 导航体系重构:进入页面为左对齐衬线大标题 + 大写英文 eyebrow,向下滚动收缩为居中玻璃小标题
- 底部标签栏改为悬浮玻璃胶囊,未读徽标零彩色化(黑底白字数字胶囊)
- 18 个页面按 v2 重排信息层级与视觉:登录、衣橱、穿搭、运势、分享、我的、上传、衣物详情、穿搭详情、穿搭编辑、AI 推荐、通知列表与详情、分享创建与两个分享详情、额度与会员、订单
- 衣物处理过程界面补全:选图后显示用户实际图片 + 三段发丝线进度轨(排队中 / AI 抠图 / 智能分类,由 ProcessStatus 轮询驱动 step 0/1/2)+ 失败态与重新上传 / 手动填写 / 额度不足弹窗,并保持衣橱「AI 处理中」角标与衣物详情处理中态一致
- 弹层视觉统一:居中玻璃卡、底部动作表、深色玻璃胶囊 Toast
- 全状态覆盖:骨架屏 / 空态 / 列表 / 加载更多 / 没有更多了 / 失败 / 失效 / 只读
## 视觉要求
- 零彩色纪律:所有颜色取自灰阶令牌,唯一彩色例外是运势页幸运色的 8px 色点;旧状态色语义在零彩色下重建(进行中=实心黑胶囊,中性=浅灰胶囊,失效=降透明度或删除线,AI 来源=黑底白字描边胶囊,删除/取消=黑字下划线文字按钮)
- 内容区像杂志内页:纯白纸面、超大留白、发丝线分组、大块内容保持直角,圆角只出现在胶囊/抽屉/小控件
- 衣物图片是唯一主角;导航、底部标签、弹窗与抽屉毛玻璃悬浮在内容之上
- 交互保持顺滑:统一缓动、按压缩放反馈、列表递进入场,滚动收缩不做逐帧重绘
## 边界与约束
- 只改 UI 层(WXML/WXSS + 必要的 TS 触点),不重构请求、轮询、支付、分享、日志等业务逻辑
- 工作区存在未提交改动(穿搭详情、穿搭页、分享页、衣物详情、AI 推荐、config.ts、models.ts),必须以当前工作区版本为基线保留
- 不做设计稿 HTML 的新增或修改,`stitch_ai_ui_v2/index.html` 仅作只读视觉基准
## 技术栈
- 微信小程序原生开发:WXML + WXSS + TypeScript(`strict` 严格模式),`renderer: webview`,无框架、无构建链、无组件库
- 视觉事实来源:`stitch_ai_ui_v2/index.html`(单文件 375×812 交互原型,只读)
- 单位换算规则:v2 的 px × 2 = rpx(`--pad:20px`→`40rpx`,`.display:34px`→`68rpx`,`.eyebrow:10px`→`20rpx`),0.5px 发丝线 → `1rpx`
- 图标:沿用现有 `styles/icons.wxss` 的 CSS mask + `currentColor` 方案(天然单色,符合零彩色),把内联 SVG 的 `stroke-width` 由 2 调整为 1.5,并补齐 v2 用到的图标(返回 chevron-left、时钟、图片、锁、向上箭头等)
- 字体:系统字体栈(衬线 `"Songti SC","STSong","Noto Serif SC",Georgia,serif`;无衬线 `-apple-system,BlinkMacSystemFont,"PingFang SC"`),禁用 `wx.loadFontFace`(需 https 外链)
## 实现方案
核心策略:**令牌层 → 组件层 → 页面层** 三段式。先把设计系统落到 `app.wxss` 与共享组件,再逐页重排 WXML/WXSS,TS 只做最小触点修改,避免 18 页各写一套视觉。
1. **令牌层先行**:重写 `app.wxss`,用 `page` 选择器固化灰阶、双字阶、发丝线、玻璃、圆角、缓动与排版工具类;全量删除旧彩色类(`.tag-blue/.tag-green/.tag-red/.tag-orange/.tag-indigo/.btn-primary` 蓝底/`.fab` 蓝底蓝阴影等,页面侧仅约 35 处引用),**不留兼容别名**,防止零彩色纪律被绕过。
2. **组件层收敛重复**:重写 `custom-tab-bar`(通栏 → 悬浮玻璃胶囊,选中态实心黑 + 顶部短横线)、`components/navigation-bar`(同标签名原地重写,页面 JSON 不用改,保留胶囊安全区计算与页面栈不足 switchTab 兜底)、`components/clothes-card`、`components/empty-state`;新增 `components/v2-overlay` 承载 Modal / ActionSheet / Toast 三类玻璃弹层。
3. **页面层只改结构与样式**:18 页 WXML/WXSS 按 v2 稿逐屏重排(下划线式分类条与分段、规格表、数字条、无缝拼图、悬浮玻璃操作条等),TS 仅在必要处改动:状态角标语义字段(如 `_statusType: 'blue'` → 灰阶语义)、弹层调用替换、滚动收缩 handler 接入。
4. **滚动收缩驱动方式**:15 个页面用 `<scroll-view>`(wardrobe 还带 refresher 与上拉分页),3 个页面(login / notification-detail / upload)用页面级滚动。统一新增 `utils/nav.ts` 导出共享 handler,页面侧仅一行接入;scroll-view 页面加 `bindscroll="onNavScroll"`,页面级滚动页面用 `onPageScroll`。
5. **上传处理过程补全**:processing 态改为 `Processing` eyebrow + 衬线大标题「AI 正在识别你的衣物…」+ 灰字「通常需要 10 秒左右,请稍候」+ 用户实际选中图片(`imagePath`,极小圆角、居中留白)+ 三段发丝线进度轨(实心/空心圆点 + 1px 连线,`step` 由既有 `pollStatus` 驱动);failed 态为「!」+ 衬线标题 + 双按钮;保留「衣物已上传」弹窗、额度不足弹窗与手动填写跳转。
关键取舍:
- **不引入组件库(tdesign 等)**:v2 需要精确控制字距、行高、发丝线与玻璃透明度,组件库反而增加覆盖成本与体积;原生 WXSS 即可满足。
- **弹层不做全量替换**:`utils/request` 层统一的 `wx.showToast` 错误提示保留原生(非阻塞、改动风险高),仅把用户可见的确认/选择类弹窗(上传来源选择、上传完成、额度不足、删除确认、公开确认、取消分享、AI 生成确认、支付确认)迁移到 `v2-overlay`,回调分支与原逻辑一一对应。
- **不做逐页独立样式**:所有页面只消费令牌与通用类,避免出现 18 份近似样式,降低后续维护成本。
性能与可靠性:
- 滚动监听只在跨越阈值(约 56px)时翻转布尔值 `setData`,绝不逐帧 setData;避免 `scroll-view` 高频事件造成卡顿
- `backdrop-filter` 每屏限制 2 层(导航 + tabBar 或浮层),并给出 `rgba` 兜底底色,规避 Android 低版本不支持导致的透明穿帮
- 保留 `lazy-load`、`wx:key`、`mergePaged` 分页合并、未读轮询 30s 节流与 tabBar 首帧安全 setData 机制
- 页面切换动画用 CSS `animation` 实现,不重建 DOM,不增加运行时开销
## 执行注意
- **未提交改动保护**:`outfit-detail.wxml/.ts`、`outfit.wxml/.ts/.wxss`、`closet-share.wxml/.ts`、`closet-share-create.ts`、`clothes-detail.ts`、`ai-recommend.ts`、`outfit-share-detail.ts`、`types/models.ts`、`utils/config.ts` 的在途逻辑必须原样保留,以工作区当前版本为基线改写 WXML,禁止回退或覆盖。
- **零彩色自查**:改完后用正则扫描 `miniprogram/**/*.wxss`,除幸运色色点与衣物线稿灰阶外,不允许出现任何有彩色 hex / rgba 彩色通道。
- **类名一致性**:WXML 引用的每个类都要在 WXSS(页面或全局)中有定义,避免残留 `.card/.tag-*` 等已删除类造成样式丢失。
- **悬浮层避让**:悬浮 tabBar 与悬浮操作条要求内容区 `padding-bottom` 预留(≥200rpx),避免最后一行内容被遮挡;滚动收缩的导航下内容顶部要预留状态栏 + 导航高度。
- **组件样式隔离**:`clothes-card`/`empty-state` 已是 `apply-shared`,可继续复用全局令牌与通用类;新增组件若需复用全局类,同样在 `.json` 声明 `styleIsolation: apply-shared`。
- **日志规范**:沿用 `utils/logger`,本次不新增噪声日志;弹层与滚动逻辑异常只走 `logger.warn`。
- **验收方式**:Linux 环境无法启动微信开发者工具,视觉验收依赖「v2 原型基准截图 + 静态自查 + 用户真机/开发者工具预览」,需在收尾时输出逐页验收清单。
## 架构设计
```mermaid
graph TD
A["令牌层 app.wxss:灰阶 / 双字阶 / 发丝线 / 玻璃 / 圆角 / 缓动"] --> B["图标层 styles/icons.wxss:1.5px 描边 mask 图标"]
A --> C["壳层:custom-tab-bar 悬浮胶囊 + navigation-bar 大标题收缩 + utils/nav.ts 滚动阈值"]
A --> D["通用组件层:clothes-card / empty-state / v2-overlay(Modal·ActionSheet·Toast)"]
C --> E["页面层:5 个 tab 页 / 详情页 / 表单流程页 / 分享页 / 交易页 / 登录页"]
D --> E
E --> F["业务逻辑层:pages/*/*.ts 保持不变(请求 / 轮询 / 支付 / 分享 / 日志)"]
```
## 目录结构
```
/root/zt-closet-wxmini-frontend/miniprogram/
├── app.wxss # [MODIFY] 全量替换为 v2 令牌与通用类:灰阶墨阶/纸面/发丝线/玻璃/深色块、
│ # 衬线+无衬线双字阶、.eyebrow/.display/.t-*/.num/.hair/.pad 工具类、
│ # .btn(ink/ghost/text)/.tag(零彩色变体)/.chip/.skeleton/.seg/.spec/.statbar/.tile/.empty、
│ # 悬浮层与安全区;删除全部旧彩色类与旧卡片投影
├── app.json # [MODIFY] window.backgroundColor → #FFFFFF;tabBar color #AEAEB2 /
│ # selectedColor #0A0A0A / backgroundColor #FFFFFF(custom tabBar 的兜底色)
├── styles/icons.wxss # [MODIFY] 26 个 mask 图标描边 2 → 1.5;补齐 chevron-left / clock / image /
│ # lock / arrow-up-right / star-half 等 v2 用到的图标;尺寸档位对齐 v2(21px→42rpx 等)
├── utils/nav.ts # [NEW] 导出 onNavScroll / onPageScrollNav 共享 handler:按阈值(56px)翻转
│ # navScrolled 布尔值,跨阈值才 setData;供 18 页一行接入
├── custom-tab-bar/index.wxml # [MODIFY] 通栏 → 悬浮玻璃胶囊结构;选中态实心黑 + 顶部短横线;徽标零彩色
├── custom-tab-bar/index.wxss # [MODIFY] 左右各 32rpx、圆角 56rpx、距底 24rpx + env(safe-area-inset-bottom)、
│ # rgba(255,255,255,.72) + backdrop-filter + 1rpx 发丝边;.tab-badge 黑底白字数字胶囊
├── custom-tab-bar/index.ts # [KEEP] 未读轮询节流、首帧安全 setData、matchRoute 选中态逻辑不动
├── components/navigation-bar/ # [MODIFY] 原地重写(标签名不变,页面 JSON 无需改)
│ ├── navigation-bar.wxml # 结构:状态栏占位 + 返回按钮 + 右侧操作槽;大标题态在页面内容顶部渲染
│ ├── navigation-bar.wxss # 非滚动:透明无边框;滚动后:玻璃底 + 居中 30rpx 小标题 + 1rpx 发丝下边
│ ├── navigation-bar.ts # 保留 getMenuButtonBoundingClientRect 安全区计算与页面栈不足 switchTab 兜底;
│ │ # 新增 large / eyebrow / scrolled / actionText 属性
│ └── navigation-bar.json # [MODIFY] 如需复用全局 .icon/.eyebrow 则声明 styleIsolation: apply-shared
├── components/clothes-card/ # [MODIFY] 去卡片容器与描边,图下两行(衬线品名 + 灰色属性行);
│ └── (wxml/wxss) # 状态角标改零彩色(进行中=实心黑胶囊,其他=浅灰胶囊);勾选圈改细描边黑圈
├── components/empty-state/ # [MODIFY] 细描边线稿图标 + 衬线标题 + 灰字描述 + 黑胶囊/描边双按钮
│ └── (wxml/wxss)
├── components/v2-overlay/ # [NEW] 统一玻璃弹层组件
│ ├── index.wxml / index.wxss # modal 居中玻璃卡 / actionsheet 底部动作表(含抓手)/ toast 深色玻璃胶囊
│ ├── index.ts # properties: type/title/content/buttons/visible;methods: open/close;
│ │ # triggerEvent('confirm'|'cancel'|'select'),与原生回调分支一一对应
│ └── index.json # styleIsolation: apply-shared
└── pages/ # 18 页:WXML/WXSS 重排 + 必要 TS 触点
├── login/ # [MODIFY] 全屏纯白 + 居中线稿 Logo + 衬线品牌名 + 灰字 slogan + 底部实心黑胶囊;
│ # 新手礼包改居中玻璃卡 + 发丝线权益列表
├── wardrobe/ # [MODIFY] 衬线大标题 + 右侧统计行;分类由横滑胶囊改下划线式分类条(保留 categories
│ # 数据与选中绑定);网格去卡片容器;FAB 改黑底白玻璃圆钮;
│ # ts 仅改 _statusType 语义('blue' → 零彩色变体)
├── outfit/ # [MODIFY] 分段改下划线式文字分段;「创建穿搭」「AI 推荐」合并为 tabBar 上方
│ # 悬浮玻璃双按钮条;社区卡改瀑布流编辑排版
├── horoscope/ # [MODIFY] 巨大衬线星座名 + 超大衬线数字 + 5 个细描边星;三项评分改横向发丝线刻度尺;
│ # 长文本改衬线引文排版;幸运色/数字两栏发丝线小格(幸运色色点保留)
├── closet-share/ # [MODIFY] 下划线分段;分享卡改横向条目(左 72×72 灰度拼图 + 右衬线标题/灰字有效期);
│ # 失效项降至 40% 透明度 + 描边角标
├── profile/ # [MODIFY] 72px 头像 + 衬线昵称 + 灰字副信息;额度概览改四栏发丝线数字条;
│ # 功能列表改纯发丝线分组列表(行高 104rpx + 灰色值 + 极细箭头)
├── upload/ # [MODIFY] 三态重写:idle 巨大虚线发丝线方框 + 细描边相机 + 衬线引导语;
│ # processing 显示实际图片 + 三段发丝线进度轨(排队中/AI 抠图/智能分类);
│ # failed 极简「!」+ 衬线标题 + 双按钮;ts 接入 v2-overlay 替换弹窗
├── clothes-detail/ # [MODIFY] 属性改规格表(左 13px 灰标签 / 右 15px 黑值 + 发丝线行,分组用中英文 eyebrow);
│ # 量化评分改细描边方点;适用场合改描边胶囊;保留处理中/失败/失效/只读四态
├── outfit-detail/ # [MODIFY] 无缝白缝拼图封面 + 衬线大标题 + eyebrow 来源标签 + 发丝线标签行 +
│ # 横滑单品缩略图;底部改悬浮玻璃胶囊操作条;保留属主/访客双角色与在途逻辑
├── outfit-edit/ # [MODIFY] 表单视图与选择衣物视图双视图重排;选择态改细描边勾选 + 黑胶囊计数条
├── ai-recommend/ # [MODIFY] 生成态改呼吸感细描边圆环 + 衬线文案;结果态改拼图卡 + 横滑单品;ts 替换确认弹窗
├── notification/ # [MODIFY] 未读用左侧 2.5px 黑竖条 + 黑字标题,已读降为 ink-3;3 态覆盖
├── notification-detail/ # [MODIFY] 衬线标题 + eyebrow 时间 + 发丝线正文排版
├── closet-share-create/ # [MODIFY] 配置 / 选择衣物 / 成功预览三视图重排;底部实心黑胶囊按钮
├── closet-share-detail/ # [MODIFY] 分享者行 + 无缝拼图 + 只读网格 + 悬浮玻璃「分享给好友」黑胶囊;保留需登录/失效态
├── outfit-share-detail/ # [MODIFY] 同上结构,保留双角色与失效态
├── mall/ # [MODIFY] 会员卡用全稿唯一深色块(--block);额度改四栏数字条;商品行左线稿图标 /
│ # 中名称描述 / 右 tabular 价格 + 黑胶囊「购买」;ts 替换支付确认弹窗
└── order/ # [MODIFY] 订单状态改描边胶囊(文案不依赖颜色);待支付倒计时改 tabular 衬线数字;3 态覆盖
```
## 关键代码结构
`app.wxss` 的令牌块是全部 18 页与所有组件的共享契约(rpx 已按 px×2 换算,`page` 选择器替代 `:root`):
```css
page {
/* 墨阶 */
--ink: #0A0A0A; --ink-2: #6E6E73; --ink-3: #AEAEB2; --ink-4: #C9C9CE; --art: #B9B9BE;
/* 纸面 */
--paper: #FFFFFF; --paper-2: #F7F7F5; --paper-3: #EFEFED;
/* 发丝线与填充 */
--hairline: rgba(0,0,0,.08); --hairline-2: rgba(0,0,0,.14);
--fill: rgba(120,120,128,.07); --fill-2: rgba(120,120,128,.13);
/* 深色块(全稿唯一视觉锚点:商城会员卡) */
--block: #1C1C1E; --block-2: #2C2C2E;
/* 玻璃 */
--glass: rgba(255,255,255,.72); --glass-2: rgba(255,255,255,.86); --glass-dark: rgba(28,28,30,.82);
/* 字族与缓动 */
--serif: "Songti SC","STSong","Noto Serif SC",Georgia,serif;
--sans: -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif;
--ease: cubic-bezier(.32,.72,0,1);
/* 圆角与度量 */
--r-img: 8rpx; --r-ctrl: 20rpx; --r-sheet: 40rpx; --r-pill: 999rpx;
--pad: 40rpx; --nav-h: 88rpx; --tab-h: 120rpx;
}
```
## 设计风格
沿用 v2 稿的「安静的展厅」:把衣橱当成一间高级买手店,内容区是一张纯白纸张,衣物图片是唯一主角,所有 UI 元素退到发丝线级别;导航、底部标签、弹窗做成悬浮在纸面上的毛玻璃层。零彩色,靠字重对比、留白尺度、发丝线与玻璃透明度建立秩序。
## 布局与信息架构
- 页面左右边距 40rpx;区块垂直间距 64rpx,块内 32rpx;正文行高 1.6~1.75;图文之间永远留白
- 分组用 1rpx 发丝线而非卡片描边/投影;大块内容保持直角,圆角只出现在胶囊、抽屉与小控件
- 导航:进入页面为左对齐衬线大标题 + 大写英文 eyebrow,向下滚动收缩为居中玻璃小标题
- 底部标签:悬浮玻璃胶囊(左右各 32rpx、圆角 56rpx、距底 24rpx + 安全区),选中态实心黑 + 顶部短横线
- 分类与分段:由横滑胶囊改为下划线式极简条,选中项黑字加粗 + 2rpx 黑色下划线
## 页面要点
- 五个 tab 页:衣橱为衬线大标题 + 统计行 + 下划线分类条 + 无容器网格;穿搭为下划线分段 + tabBar 上方悬浮玻璃双按钮条;运势为杂志内页(巨大衬线星座名、超大数字、发丝线刻度尺、衬线引文、两栏小格);分享为横向条目卡 + 失效降透明;我的为四栏数字条 + 纯发丝线分组列表
- 详情页:规格表(左灰标签 / 右黑值)+ 细描边方点评分 + 描边胶囊标签 + 无缝拼图封面 + 悬浮玻璃底部操作条
- 表单流程页:上传 idle 为巨大虚线方框 + 细描边相机;processing 为实际图片 + 三段发丝线进度轨(实心/空心圆点 + 1px 连线);failed 为极简「!」+ 衬线标题 + 双按钮;穿搭编辑为表单与选择双视图
- 分享与交易:分享创建三视图 + 两个分享详情只读网格;额度与会员用全稿唯一深色块 + 四栏数字条 + 黑胶囊购买;订单用描边状态胶囊 + tabular 衬线倒计时
- 登录:全屏纯白 + 居中线稿 Logo + 衬线品牌名 + 灰字 slogan + 底部实心黑胶囊按钮,新手礼包为居中玻璃卡
## 交互与动效
统一缓动 cubic-bezier(.32,.72,0,1),时长 320~420ms;按压 scale(.97) + 透明度 0.6;列表入场 40ms 递增;页面切换右滑入 + 轻微缩放淡入;弹层自底部滑入带轻微回弹;四类弹层(居中玻璃卡 / 底部抽屉 / 动作表 / 深色玻璃 Toast)统一由 v2-overlay 管理,保证可开关且不遮挡导航与标签栏。
## 响应式与适配
以 375pt 为基准,全量使用 rpx 自适应;顶部状态栏与微信胶囊由 navigation-bar 组件按 `wx.getMenuButtonBoundingClientRect()` 动态计算;底部安全区用 `env(safe-area-inset-bottom)`;悬浮层下内容预留 padding,避免最后一行被遮挡。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 改造前一次性盘点旧彩色类与原生弹窗的全部引用点、18 个页面的区块与状态矩阵基线,形成逐页改造清单,避免漏改导致样式丢失或弹窗语义错位
- Expected outcome: 一份可逐条勾选的清单:旧彩色类引用位置(约 35 处)、`wx.showModal/showActionSheet` 调用点、每页 WXML 区块与状态分支、各页滚动容器类型
### Skill
- **playwright-cli**
- Purpose: 用无头浏览器打开 `stitch_ai_ui_v2/index.html`,逐屏截图存档为视觉基准,并在收尾阶段对关键屏做尺寸、颜色、字阶的像素级对照走查
- Expected outcome: 18 屏基准截图 + 与实现值的对照结论(间距、字号、发丝线、玻璃透明度、悬浮层避让),据此修正偏差
@@ -0,0 +1,206 @@
---
name: 穿搭页补全社区分段
overview: 在穿搭 tab 新增「社区」分段,浏览所有用户公开的穿搭(作者信息、拼图封面、点赞/收藏数、场景与发布时间),并新增后端社区列表接口支撑。
design:
styleKeywords:
- iOS HIG
- 清爽克制
- 发丝分隔线
- 系统蓝
- 线性图标
fontSystem:
fontFamily: PingFang SC
heading:
size: 32rpx
weight: 600
subheading:
size: 28rpx
weight: 500
body:
size: 26rpx
weight: 400
colorSystem:
primary:
- "#007AFF"
- "#5856D6"
background:
- "#F2F2F7"
- "#FFFFFF"
text:
- "#000000"
- "#3C3C43"
- "#8E8E93"
- "#C7C7CC"
functional:
- "#FF2D55"
- "#34C759"
- "#FF9500"
- "#FF3B30"
todos:
- id: backend-community-api
content: 用 [subagent:code-explorer] 确认后端批量查询与计数模式;实现 GET /outfit/community 接口(DTO/DAO/Service/路由)并更新接口文档
status: completed
- id: frontend-community-data
content: models.ts 新增社区穿搭类型;outfit.ts 实现三态分段、社区取数、相对时间与详情跳转
status: completed
dependencies:
- backend-community-api
- id: frontend-community-ui
content: outfit.wxml/wxss 实现社区分段、作者行卡片、互动数据与空态样式
status: completed
dependencies:
- frontend-community-data
- id: verify-community
content: 运行 go build 与 tsc 类型检查,核对前后端字段契约与路由注册
status: completed
dependencies:
- backend-community-api
- frontend-community-ui
---
## 产品概述
为「纸条AI衣橱」小程序补全社区功能:在「穿搭」页现有「我的穿搭 / 已收藏」两个分段基础上,新增「社区」分段,让所有用户都能浏览其他用户公开的穿搭,并进入详情点赞、收藏,形成「公开到社区 → 他人浏览互动」的完整闭环。
## 核心功能
- 穿搭页分段控件扩展为三态:「我的穿搭 / 已收藏 / 社区」,切换即加载对应列表并重置分页
- 社区分段以卡片流展示其他用户公开的穿搭(仅展示已公开且审核通过、状态正常的穿搭)
- 社区卡片内容:作者头像与昵称(卡片顶部)、拼图式衣物封面与件数、穿搭名称、场景标签、点赞数与收藏数、相对发布时间
- 点击社区卡片进入他人视角的穿搭详情页,可点赞、收藏(复用现有详情页,不新增页面)
- 社区列表支持下拉刷新、上拉加载更多、骨架屏加载态与空态提示
- 社区分段隐藏「+ 创建穿搭 / AI 推荐」入口行(浏览场景不展示管理入口)
## 视觉与交互效果
延续现有 iOS HIG 风格:分组灰底、纯白卡片、发丝分隔线、系统蓝强调色。社区卡片在现有穿搭卡片结构上顶部新增作者行(圆形头像 + 昵称 + 右侧相对时间),底部 meta 行新增点赞、收藏两组互动数据,整体与「我的穿搭」卡片流视觉统一、切换分段有平滑过渡。
## 技术栈
- 前端:微信小程序原生 + TypeScript(沿用现有 webview 渲染与纯 WXSS 手写方案,不引入组件库)
- 后端:Go + Gin + GORM/MySQL(zt-closet-backend,新增接口沿用现有分层:router → handler → service → dao)
- 复用:前端 `request` 封装、`paged` 分页工具、`format.formatRelativeTime`、`logger`、`empty-state` 组件与图标库;后端 `PageRequest/PageResponse`、`normalizePage`、批量查询与批量组装模式
## 实现方案
### 总体策略
后端新增鉴权接口 `GET /outfit/community`:分页返回「其他用户已公开(is_public=2 且 audit_status=2 且 status=1)」的穿搭列表,聚合作者信息与点赞/收藏计数后一次性下发;前端将 `pages/outfit` 页扩展为三态分段,第三段对接新接口渲染社区卡片流,点击进入现有他人视角详情页(owner=0)。全程复用既有模式,不引入新架构、不新增页面、不改动 tabBar。
### 关键决策与理由
1. **接口路径 `GET /outfit/community`**:与既有 `GET /outfit/:uuid` 在同一 Gin 路由树共存是安全的——项目内已有完全相同形态的先例(`/notification/unread` 与 `/notification/:uuid` 共存且线上运行正常),Gin 1.12 静态路由优先匹配。
2. **排除本人的公开穿搭**(SQL 加 `user_id != 当前用户`):贴合用户「查看其他用户公开的穿搭」的表述,且避免与「我的穿搭」分段内容重复;后续如需包含自己,仅需去掉一个 WHERE 条件。
3. **互动数据由后端批量聚合下发**(like_count / collect_count):现有穿搭列表在前端逐条请求 `/like/:uuid`(一页 20 条即 20 次请求),社区列表直接使用后端聚合值,杜绝该 N+1 问题。
4. **后端批量组装杜绝 N+1**:复用 `buildOutfitListResponses` 的批量模式(一次性批量查明细 + 衣物),作者信息用已有 `UserGetByIDs`,点赞/收藏计数新增 `GROUP BY target_id` 批量方法;单页固定约 7 条 SQL,与页大小无关。
5. **社区卡片复用现有拼图封面结构**(cover-item 循环)与卡片骨架,仅新增作者行与互动数据块,模板通过 `activeTab === 2` 条件渲染差异部分,避免大段重复。
6. **仅社区分段隐藏入口按钮行**,「我的穿搭 / 已收藏」保持现状,控制改动面。
### 性能与可靠性
- 后端:分页 `page/page_size`(上限 50,`normalizePage` 规整),`ORDER BY id DESC` 走主键;批量计数对空列表短路,避免 `IN ()` 查询;错误统一返回现有错误码(19001 等),沿用 slog 日志风格。
- 前端:`page_size=20`、切换分段重置并丢弃过期响应(沿用现有 reqSeq 序号防乱序机制);辅助字段(相对时间)本地计算零请求;失败不打断主流程,仅记录 code 不打印敏感信息。
- 契约一致性:`created_at` 秒级时间戳(前端 `formatRelativeTime` 直接消费);`owner_avatar` 由后端 `upload.AvatarURL` 转为可访问 URL;`processed_image` 为完整 URL。
### 实施注意事项
- 后端仓库位于 `/root/zt-closet-backend`(不在前端工作区),当前存在未提交改动(收藏/通知修复),新代码只做追加、不动无关文件。
- 后端改动需用户自行部署后,前端指向线上域名(BASE_URL)才能联调。
- 前端不改 `app.json`、不改 `outfit-detail`、不改 tabBar;`outfit.json` 已引入所需组件,无需调整。
- 复用 `isOutfitPublicVisible` 的可见性口径(公开 2 + 审核通过 2 + 状态正常 1),保证社区列表与详情页可见性判定一致。
## 目录结构
后端 `/root/zt-closet-backend/`:
```
├── internal/dto/outfit.go # [MODIFY] 新增社区列表 DTO:OutfitCommunityListRequest(内嵌 PageRequest)/ OutfitCommunityListResponse(内嵌 PageResponse)/ OutfitCommunityItem(含 outfit_uuid、outfit_name、outfit_desc、scene、source、created_at、items、owner_name、owner_avatar、like_count、collect_count)
├── internal/mysqldao/outfit.go # [MODIFY] 新增 OutfitListCommunity(ctx, excludeUserID, page, pageSize):查询 is_public=2 AND audit_status=2 AND status=1 AND user_id != 本人,Count + Order("id DESC") 分页,参照 OutfitListByUserID 写法
├── internal/mysqldao/like.go # [MODIFY] 新增 LikeCountByTargets(ctx, likeType, targetIDs):SELECT target_id, COUNT(*) ... GROUP BY target_id(仅 status=1),空入参短路
├── internal/mysqldao/collect.go # [MODIFY] 新增 CollectCountByTargets 批量计数(同上模式)
├── internal/service/outfit.go # [MODIFY] 新增 CommunityList:normalizePage → 查列表 → 批量组装(复用 OutfitItemListByOutfitIDs + ClothesGetByIDs + UserGetByIDs + upload.AvatarURL + 批量计数),空列表直接返回空数组
├── internal/routers/api/v1/outfit.go # [MODIFY] 新增 Community handler(GetUserID → ShouldBindQuery → svc → ToResponse,与现有 List 写法一致)
├── internal/routers/router.go # [MODIFY] 鉴权组注册 auth.GET("/outfit/community", outfitHandler.Community)
└── 接口文档.md # [MODIFY] 新增社区列表接口章节;修正 FAQ「没有社区广场接口」的过时说明
```
前端 `/root/zt-closet-wxmini-frontend/`:
```
└── miniprogram/
├── types/models.ts # [MODIFY] 新增 CommunityOutfitItem 类型(与后端响应字段一一对应)
└── pages/outfit/
├── outfit.ts # [MODIFY] activeTab 扩展 0/1/2;fetchList 按分段选 /outfit、/collect、/outfit/community;社区项装饰(直接取 like_count/collect_count,计算相对时间 _time);onOutfitTap 社区项 owner=0;空态文案分支;日志沿用 logger
├── outfit.wxml # [MODIFY] 分段控件增加「社区」;entry-row 在社区分段隐藏;卡片新增作者行(社区分段)与收藏数、相对时间;状态标签仅非社区分段展示;空态文案分支
└── outfit.wxss # [MODIFY] 新增作者行、圆形头像(含 icon-user 占位)、昵称、时间、互动数据样式(延续现有 rpx 与色板)
```
## 关键代码结构
后端响应契约(新增到 `internal/dto/outfit.go`):
```
type OutfitCommunityListResponse struct {
PageResponse
List []OutfitCommunityItem `json:"list"`
}
type OutfitCommunityItem struct {
OutfitUUID string `json:"outfit_uuid"`
OutfitName string `json:"outfit_name"`
OutfitDesc string `json:"outfit_desc"`
Scene string `json:"scene"`
Source int8 `json:"source"`
CreatedAt int64 `json:"created_at"`
Items []OutfitItemResponse `json:"items"`
OwnerName string `json:"owner_name"`
OwnerAvatar string `json:"owner_avatar"`
LikeCount int64 `json:"like_count"`
CollectCount int64 `json:"collect_count"`
}
```
前端类型(新增到 `types/models.ts`):
```ts
export interface CommunityOutfitItem {
outfit_uuid: string
outfit_name: string
outfit_desc: string
scene: string
source: number
created_at: number
items: { clothes_uuid: string; processed_image: string }[]
owner_name: string
owner_avatar: string
like_count: number
collect_count: number
}
```
## 设计风格
延续项目既有 iOS HIG 视觉语言:分组灰底 #F2F2F7、纯白卡片(圆角 32rpx)、0.5px 发丝分隔线、系统蓝 #007AFF 强调色、SF Symbols 风格线性图标,清爽克制、以内容为中心,社区分段与现有分段无缝衔接。
## 分段控件
胶囊分段由两项扩展为三项「我的穿搭 / 已收藏 / 社区」,三项等宽;选中项为白色滑块 + 系统蓝加粗文字,未选中为 #3C3C43;切换带轻量过渡,样式沿用现有 .segment 规格(高 64rpx、圆角 16rpx)。
## 社区卡片
- 作者行(新增):左侧 56rpx 圆形头像(无头像回退浅灰底 icon-user 占位),昵称 26rpx 深色单行省略,右侧相对时间 22rpx 灰字
- 封面区:复用现有拼图 cover-item 错落摆放(最多 5 件)+ 右上角件数胶囊
- 信息区:穿搭名 32rpx 加粗单行省略,描述 26rpx 灰字最多两行
- 互动区(meta 行):左侧场景标签(tag-gray)与件数,右侧点赞(icon-heart)与收藏(icon-star)计数,图标 36rpx 灰字 22rpx,间距与现有卡片一致
## 状态设计
加载态复用骨架屏(sk-card 呼吸动画);社区空态复用 empty-state(title「还没有公开的穿搭」,desc「把喜欢的穿搭公开到社区,让大家看到你的搭配」,无按钮);列表底部「没有更多了」提示沿用现有样式;卡片按压态用现有 active 透明度反馈。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 后端仓库(117 个 Go 文件)实现社区接口前,跨文件梳理 like/collect 数据模型、批量计数与批量组装模式、DTO/Handler 既有写法,确保新代码完全复用现有模式
- Expected outcome: 产出可复用的批量计数 SQL 模式与接口实现要点清单(表名、字段、既有工具函数位置),降低返工与不一致风险
@@ -0,0 +1,270 @@
---
name: 纸条AI衣橱小程序UI全量重构设计稿
overview: 为「纸条AI衣橱」微信小程序(18 个页面)重新设计一套「杂志风纯白留白 + 毛玻璃悬浮层、纯黑白灰零彩色」的苹果式简约高级 UI,并以单个自包含 HTML 文件交付可点击跳转、tab 可切换、弹窗可开关的高保真交互原型,完整覆盖现有全部功能与全部状态。
design:
architecture:
framework: html
styleKeywords:
- 杂志编辑部风
- 毛玻璃浮层
- 零彩色灰阶
- 大留白
- 发丝线分隔
- 衬线大标题
- 悬浮胶囊标签栏
- 苹果级缓动
- 买手店线稿质感
fontSystem:
fontFamily: Songti SC / PingFang SC
heading:
size: 34px
weight: 500
subheading:
size: 17px
weight: 500
body:
size: 15px
weight: 400
colorSystem:
primary:
- "#0A0A0A"
- "#1C1C1E"
- "#2C2C2E"
background:
- "#FFFFFF"
- "#F7F7F5"
- "#EFEFED"
text:
- "#0A0A0A"
- "#6E6E73"
- "#AEAEB2"
functional:
- "#E5E5E7"
- "#D2D2D7"
- "#0A0A0A"
- "#AEAEB2"
todos:
- id: baseline
content: 用 [subagent:code-explorer] 核对 18 页区块、状态矩阵与微文案,产出页面清单基线
status: completed
- id: design-system
content: 建立零彩色设计令牌、双字阶、设备壳、毛玻璃浮层与 SVG 图标衣物线稿组件库
status: completed
dependencies:
- baseline
- id: interaction-core
content: 实现栈式路由、标签与分段切换、状态切换器、四类弹层控制器与左侧索引
status: completed
dependencies:
- design-system
- id: flow-closet
content: 完成登录、衣橱网格、上传三态与衣物详情四态页面
status: completed
dependencies:
- interaction-core
- id: flow-outfit
content: 完成穿搭三段式、穿搭详情、穿搭编辑双视图与 AI 推荐页
status: completed
dependencies:
- interaction-core
- id: flow-share
content: 完成运势内页、分享列表、衣柜分享创建三视图与两个分享详情
status: completed
dependencies:
- interaction-core
- id: flow-profile
content: 完成我的页、通知列表与通知详情、编辑资料底部抽屉
status: completed
dependencies:
- interaction-core
- id: flow-commerce
content: 完成商城双视图与订单页,含支付确认弹窗与待支付倒计时
status: completed
dependencies:
- interaction-core
- id: spec-and-qa
content: 补设计规范页,用 [skill:playwright-cli] 逐屏截图走查全链路与状态矩阵
status: completed
dependencies:
- flow-closet
- flow-outfit
- flow-share
- flow-profile
- flow-commerce
- id: deploy-preview
content: 用 [integration:cloudStudio] 部署原型,产出手机可预览链接
status: completed
dependencies:
- spec-and-qa
---
## 产品概述
「纸条 AI 衣橱」是一款智能衣橱与穿搭小程序,覆盖衣物管理、AI 穿搭推荐、每日运势、好友分享、会员额度与订单等完整业务。本次对全部 18 个页面做整体 UI 重构,产出可点击走通全流程的单文件交互原型,整体基调为「苹果简约高级」。
## 核心功能
- 登录与新手礼包引导
- 衣橱:分类浏览、拍照上传衣物(含 AI 识别进度)、衣物详情与 15 项属性编辑
- 穿搭:我的穿搭 / 已收藏 / 社区三段浏览,创建与编辑穿搭,AI 推荐生成与保存
- 运势:星座今日运势、四项评分、幸运色与幸运数字
- 分享:衣柜分享与穿搭分享的创建、查看、只读浏览与失效态
- 我的:资料与额度概览、通知、商城会员与额度包、订单管理
- 全局:5 个底部标签、骨架屏 / 空态 / 异常态、弹窗与底部抽屉、未读红点
## 视觉要求
- 采用「浅色为底 + 玻璃浮层」:内容区像杂志内页,纯白纸面、衬线大标题、超大留白、发丝分隔线,衣物图片是唯一主角;导航栏、底部标签、弹窗与抽屉用毛玻璃悬浮在内容之上
- 零彩色:完全用黑白灰层次表达信息与状态,唯一色彩来自衣物本身与幸运色
- 页面间可真实点击跳转、标签可切换、弹窗可开关,能像真机一样走完整流程
- 重构不只是换皮,需按产品设计逻辑重排信息架构、层级与交互反馈
## 技术栈
- 交付物:单个自包含 HTML 文件,路径 `/root/zt-closet-wxmini-frontend/stitch_ai_ui_v2/index.html`(新建目录,不覆盖旧 `stitch_ai_ui/`)
- 纯原生 HTML5 + CSS3(CSS 自定义属性设计令牌、Grid/Flex、backdrop-filter、clip-path)+ 原生 ES6 JavaScript,无框架、无构建、无包管理
- 零外部依赖:不使用 Tailwind CDN / FontAwesome / Google Fonts(旧稿的致命问题:离线打开即掉样式),图标全部用内联 `<svg><symbol>` sprite,字体走系统字体栈
- 图片占位:内联 SVG 灰度线稿(衬衫 / 裤装 / 连衣裙 / 外套 / 鞋 / 包 / 配饰剪影),既离线可用又天然契合零彩色调性
- 验证:用 playwright-cli 以 `file://` 打开原型,逐屏截图并点击走查
## 实现方案
核心策略:**一套设计令牌 + 一个页面容器 + 一个路由栈 + 一个状态机**,让 18 屏共享同一套视觉与交互内核,避免逐页重造。
1. **设计系统先行**:用 CSS 变量固化零彩色灰阶、衬线/无衬线双字阶、间距刻度、发丝线、玻璃材质、软阴影与统一缓动曲线。所有页面只消费令牌,禁止硬编码颜色。
2. **页面容器**:18 屏写成 `<section class="screen" data-screen="wardrobe">`,默认 `display:none`,由路由控制显隐;5 个 tab 屏共享同一个底部玻璃 tabBar(提到 screen 之外,随 tab 路由显隐),与真实小程序 `custom-tab-bar` 的共享方式一致。
3. **栈式路由**:`navigate(screen, params)` / `back()`,维护页面栈以支持真实返回;`params` 驱动同一屏的变体(`owner=1|0` 决定穿搭详情是属主态还是访客态、`readonly=true` 决定衣物详情是否隐藏编辑删除、`tab=flow` 决定商城进入额度明细视图)。
4. **状态机**:每屏内用 `data-state`(`skeleton | empty | list | loading | error`)切换,右侧状态切换器直接改这个属性,一套标记覆盖完整状态矩阵,无需为每个状态写重复结构。
5. **统一弹层**:四类 —— Modal(居中玻璃卡)、Sheet(底部抽屉带抓手)、ActionSheet(iOS 底部动作表)、Toast(深色玻璃胶囊),全部由同一 overlay 控制器管理,保证「可开关」。
6. **真实跳转**:所有可点元素声明 `data-nav="clothes-detail"` + `data-params='{"uuid":"c1"}'`,在 `#app` 上做事件委托统一处理,保证能像真机一样点着走。
关键取舍:
- **不引 Tailwind CDN**:离线可用 + 杂志风排版需要精确控制字距、行高、发丝线、玻璃透明度,原子类反而增加噪音。代价是 CSS 体积更大 → 用「令牌 + 语义类」收敛,禁止逐页堆裸样式。
- **不引字体 CDN**:中文衬线用 `"Songti SC", "STSong", "Noto Serif SC", serif` 回退栈,拉丁回退 `Georgia`,保证任何环境打开都不掉字。
- **占位图用 SVG 线稿而非图床外链**:后端 `processed_image` 在原型中取不到,外链图会离线失效且引入杂色;灰度线稿的代价是不如实拍图真实,通过大量留白与统一构图补足质感。
- **单文件而非多文件**:用户明确要「单文件可点击交互原型」。代价是文件较大 → 用分节注释 + 页面名前缀类名组织。
性能与可靠性:
- 单文件无网络请求,首屏即渲染,无加载抖动。`backdrop-filter` 是唯一较重属性 → 限制每屏玻璃层数量(导航栏 + 底部栏/浮层,最多 2 层),避免大面积模糊导致滚动掉帧。
- 事件委托统一在 `#app` 上,避免 18 屏 × N 元素逐个绑定监听。
- 页面切换只切 `display`,不重建 DOM,保证跳转瞬时响应。
## 架构设计
```mermaid
graph TD
A["原型外壳:左索引 + iPhone 设备框 + 右状态切换器"] --> B["路由层:navigate / back / params 页面栈"]
B --> C["页面层:18 个 section.screen + data-state 状态机"]
B --> D["浮层层:tabBar / Modal / Sheet / ActionSheet / Toast"]
C --> E["组件层:按钮 / 分段 / 胶囊 / 列表行 / 规格行 / 卡片 / 骨架 / 空态"]
D --> E
E --> F["令牌层:零彩色灰阶 / 双字阶 / 间距 / 发丝线 / 玻璃 / 阴影 / 缓动"]
C --> G["素材层:内联 SVG symbol 图标 + 衣物线稿占位插画"]
```
## 目录结构
```
/root/zt-closet-wxmini-frontend/
├── stitch_ai_ui_v2/ # [NEW] 新版设计稿目录(旧 stitch_ai_ui/ 原样保留)
│ └── index.html # [NEW] 单文件可点击交互原型,内部按以下分节组织:
│ # <style>
│ # §1 设计令牌:零彩色灰阶 / 双字阶 / 间距刻度 / 圆角 / 发丝线 / 玻璃 / 阴影 / 缓动
│ # §2 基础层:reset、排版类(.display-serif / .eyebrow / .num)、发丝线工具类
│ # §3 外壳层:工作台背景、左索引面板、iPhone 375×812 设备框、状态栏、微信胶囊、安全区
│ # §4 玻璃层:.glass-nav / .glass-tabbar / .glass-sheet / .glass-modal / .toast
│ # §5 组件层:按钮(实心黑/描边/文字)、分段控件、胶囊标签、列表行、规格行、统计数字条、评分点阵、进度轨、衣物卡、穿搭拼图卡、分享卡、订单卡、骨架屏、空态、徽标
│ # §6 页面层:18 屏专属样式,类名带页面前缀(.wd- / .of- / .cd- / .hs- / .cs- / .pf- / .up- / .od- / .ai- / .nt- / .ml- / .lg-)
│ # <body>
│ # 左索引面板:分组(登录 / 核心 Tab / 详情 / 表单流程 / 分享 / 交易 / 规范)
│ # 设备框 → <main id="app"> 内含 18 个 <section class="screen">
│ # 共享浮层容器:tabBar + overlay(Modal / Sheet / ActionSheet / Toast)
│ # 右状态切换器:skeleton / empty / list / error / loading
│ # 底部:色板与字阶规范展示
│ # <script>
│ # §A 数据:18 页 mock 数据(衣物、穿搭、运势、分享、商品、订单、通知)
│ # §B 路由:navigate / back / 页面栈 / params 分发
│ # §C 状态机:setState(screen, state)
│ # §D 弹层控制器:openModal / openSheet / openActionSheet / toast
│ # §E 交互:事件委托、tab 切换、分段控件、多选计数、点赞收藏乐观更新、倒计时
│ # §F 引导:默认进入衣橱屏
└── miniprogram/ # 只读参考,本次不修改任何文件
├── app.json # 参考:18 页注册 + 5 tab 配置
├── app.wxss # 参考:旧设计令牌(本次全量替换的依据)
├── styles/icons.wxss # 参考:24 个图标的语义与命名
├── types/models.ts # 参考:全部实体字段名
├── custom-tab-bar/ # 参考:tab 结构 + 未读红点规则
├── components/ # 参考:navigation-bar / clothes-card / empty-state
└── pages/*/*.wxml # 参考:每页区块结构与微文案(唯一事实来源)
```
## 执行注意
- **字段与文案以 `.wxml` / `.ts` 为唯一事实来源**(如「共 N 件 · 上传额度剩余 N 次」「AI 正在识别你的衣物…通常需要 10 秒左右」),不要凭印象造文案。
- **零彩色纪律**:所有颜色只能取自令牌(`--paper` / `--paper-2` / `--ink` / `--ink-2` / `--ink-3` / `--hairline` / `--fill` / `--glass`),禁止出现任何有彩色 hex。唯一例外是幸运色的 8px 色点(业务数据)与衣物线稿本身的灰阶。
- **旧状态色语义需在零彩色下重建**:进行中/强调 = 实心黑胶囊;中性 = 浅灰胶囊;失效/已读 = 降透明度或删除线;AI 来源 = 黑底白字描边胶囊;删除/取消 = 黑字下划线文字按钮,不依赖红色。
- **小程序真实感三件套**:微信右上角胶囊(87×32pt)、状态栏 44px、底部安全区 `env(safe-area-inset-bottom)`,每一屏都必须体现。
- **不修改 `miniprogram/` 下任何文件,不覆盖 `stitch_ai_ui/`**;本次只新增 `stitch_ai_ui_v2/index.html`。
- 玻璃层下方滚动内容需预留 padding,避免内容被悬浮导航/底栏遮挡。
## 设计主题
「安静的展厅」—— 把衣橱当成一间高级买手店:内容区是一张纯白纸张,衣物图片是唯一主角,所有 UI 元素退到发丝线级别;导航、底部标签、弹窗做成一层悬浮在纸面上的毛玻璃。零彩色,靠字重对比、留白尺度、发丝线、灰阶层次与玻璃透明度建立秩序与高级感。
## 视觉语言
- **布局**:页面左右边距 20px;区块垂直间距 32px,块内 16px;正文行高 1.6~1.75;图片与文字之间永远留白,绝不贴边。卡片取消描边与投影,改用留白 + 发丝线分组,仅极少数关键卡保留极软阴影。
- **双字阶**:衬线(Songti SC / STSong / Noto Serif SC / Georgia)用于页面主标题、数字大数与穿搭名,字重 400~500、负字距 -0.01em;无衬线(-apple-system / PingFang SC)用于正文、按钮、标签与导航。10~11px 大写英文 eyebrow 标签(`WARDROBE` / `AI STYLIST`),字距 0.16em、`--ink-3` 灰,是最关键的「高级感」装置。
- **发丝线替代边框**:所有分隔用 0.5px `rgba(0,0,0,.08)`,圆角只在小尺寸控件上出现(胶囊、抽屉),大块内容保持直角,强化杂志感。
- **玻璃材质**:`rgba(255,255,255,.72)` + `backdrop-filter: saturate(180%) blur(20px)` + 0.5px 发丝边。底部标签用悬浮胶囊(左右各留 16px、圆角 28px、距底 12px + 安全区),而非通栏。
- **动效**:统一缓动 `cubic-bezier(.32,.72,0,1)`,时长 320~420ms。页面切换从右滑入 + 轻微缩放淡入;列表入场 40ms 递增 stagger;按压 `scale(.97)` + 透明度 0.6;弹层自底部滑入并带轻微回弹。
- **图片处理**:衣物图统一用内联 SVG 灰度线稿,1:1 或 3:4 画幅,居中留白 12%,无边框、仅 4px 极小圆角,像买手店目录里的商品线稿;穿搭拼图封面用 1px 白缝的无缝网格。
## 色彩系统(零彩色灰阶语义)
`--ink` #0A0A0A(主文字 / 主按钮 / 选中态)→ `--ink-2` #6E6E73(次要文字 / 描述)→ `--ink-3` #AEAEB2(弱化 / 占位 / 已读)→ `--hairline` rgba(0,0,0,.08)(分隔)→ `--paper-2` #F7F7F5(页面底)→ `--paper` #FFFFFF(内容纸面)。唯一深色块用于商城会员卡(#1C1C1E),作为全稿视觉锚点。
## 导航与信息架构重构
全局自定义导航改为 Apple 式行为:进入页面时是 34px 左对齐衬线大标题 + eyebrow 小标签,向下滚动时收缩为居中玻璃小标题 —— 这是最大的高级感来源,也让每个 tab 页有杂志封面感。tabBar 从通栏改为悬浮玻璃胶囊,图标改 1.5px 细线性描边,选中态用实心黑 + 顶部 3px 短横线,不再使用彩色。
## 逐页重构要点
- **登录**:全屏纯白 + 居中线稿 Logo + 衬线品牌名「纸条 AI 衣橱」+ 灰字 slogan + 底部实心黑胶囊按钮;新手礼包改为居中玻璃卡 + 发丝线权益列表。
- **衣橱**:大标题 + 右侧统计;分类由横滑胶囊改为下划线式极简分类条;网格去卡片容器,图下两行(衬线品名 + 灰色属性行);FAB 改黑底白玻璃圆钮。
- **穿搭**:分段改下划线式文字分段;取消独立入口按钮行,把「创建穿搭」「AI 推荐」合并为 tabBar 上方的悬浮玻璃双按钮条;社区卡改瀑布流编辑排版,meta 用细描边图标 + tabular 数字。
- **运势**:做成杂志内页 —— 巨大衬线星座名;综合运势用超大衬线数字 + 5 个细描边星;三项评分改横向发丝线刻度尺;长文本用衬线引文式排版;幸运色/数字改两栏发丝线小格。
- **分享**:下划线分段;分享卡改横向条目(左 72×72 灰度拼图 + 右衬线标题/灰字有效期);失效项降至 40% 透明度 + 描边小角标;新建分享移到导航栏右侧「+」。
- **我的**:大号头像 72px + 衬线昵称 + 一行灰字副信息;额度概览改四栏发丝线分隔数字条(大号 tabular 数字 + 10px eyebrow);功能列表改纯发丝线分组列表(行高 52px、右侧灰色值 + 极细箭头);会员权益用描边胶囊。
- **上传**:idle 为巨大虚线发丝线方框 + 中央细描边相机 + 衬线引导语;processing 用三段发丝线进度轨(实心/空心圆点 + 1px 连线);failed 用极简「!」+ 衬线标题 + 双按钮。
- **衣物详情**:属性展示改为规格表 —— 左标签(13px 灰)/ 右值(15px 黑)的发丝线行,分组标题用「中文 / 大写英文」eyebrow;量化评分改 5 个细描边方点;适用场合用无底色描边胶囊。
- **穿搭详情 / AI 推荐 / 穿搭编辑**:封面用无缝白缝网格;信息区为衬线大标题 + eyebrow 来源标签 + 发丝线标签行 + 描述 + 横滑单品(80×80 无边框缩略图);底部操作改悬浮玻璃胶囊条。AI 生成态用呼吸感细描边圆环 + 衬线文案替代 spinner。
- **商城 / 订单 / 通知 / 分享详情**:商城会员卡用唯一深色块,额度改四栏数字条,商品行左线稿图标 / 中名称描述 / 右 tabular 价格 + 黑胶囊「购买」;订单状态用描边胶囊 + 文案不依赖颜色,倒计时用 tabular 衬线数字;通知未读用左侧 4px 黑竖条 + 黑字标题;分享详情为分享者行 + 无缝拼图 + 只读网格 + 悬浮玻璃「分享给好友」黑胶囊。
## 交互与适配
- 所有列表覆盖「骨架屏 → 空态 → 正常列表 → 加载更多 → 没有更多了」;表单覆盖「默认 / 校验置灰 / 提交中 / 成功」;详情覆盖「正常 / 处理中 / 失败 / 失效 / 只读」。
- 右侧状态切换器可即时切换任一屏的状态,左侧索引按「登录 / 核心 Tab / 详情 / 表单流程 / 分享 / 交易 / 规范」分组,点击直达。
- 适配:以 375×812pt 为基准,设备框固定宽高、内容区独立滚动;底部安全区用 `env(safe-area-inset-bottom)` 模拟;工作台外层在窄屏下自动纵向堆叠,保证在桌面浏览器与手机浏览器都能正常预览。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 在执行前核对 18 个页面的 UI 区块、状态矩阵、微文案与跳转关系,并盘点旧稿 `stitch_ai_ui/` 已覆盖的状态,形成页面清单基线。
- Expected outcome: 一份可驱动左侧索引面板与右侧状态切换器的页面/状态清单,确保重构不漏功能、不漏状态。
### Skill
- **playwright-cli**
- Purpose: 用无头浏览器以 `file://` 打开本地原型,逐屏截图、点击走查跳转链路、开关弹层与状态切换器。
- Expected outcome: 18 屏截图 + 跳转链路验证结果,据此发现并修复渲染错位、玻璃层遮挡、路由断链等缺陷。
### Integration
- **cloudStudio**
- Purpose: 将原型目录部署为可访问链接,便于在手机上真机预览毛玻璃与滚动效果。
- Expected outcome: 一个可在手机浏览器/微信中打开预览的线上地址;若部署失败则以本地文件交付为准,不阻塞主交付物。
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"records": []
}
+3 -3
View File
@@ -21,8 +21,8 @@
],
"tabBar": {
"custom": true,
"color": "#8E8E93",
"selectedColor": "#007AFF",
"color": "#AEAEB2",
"selectedColor": "#0A0A0A",
"backgroundColor": "#FFFFFF",
"list": [
{ "pagePath": "pages/wardrobe/wardrobe", "text": "衣橱" },
@@ -35,7 +35,7 @@
"window": {
"navigationBarTextStyle": "black",
"navigationStyle": "custom",
"backgroundColor": "#F2F2F7"
"backgroundColor": "#FFFFFF"
},
"renderer": "webview",
"style": "v2",
+792 -151
View File
@@ -1,203 +1,844 @@
/**app.wxss —— 全局设计令牌与通用样式**/
/** app.wxss —— v2 设计令牌与通用样式:零彩色灰阶 / 毛玻璃浮层 / 杂志大留白 **/
@import "styles/icons.wxss";
/* ============================================================================
§1 设计令牌(唯一颜色与度量来源,页面禁止硬编码彩色)
========================================================================== */
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;
/* 墨阶 */
--ink: #0A0A0A;
--ink-2: #6E6E73;
--ink-3: #AEAEB2;
--ink-4: #C9C9CE;
--art: #B9B9BE; /* 衣物线稿色 */
/* 纸面 */
--paper: #FFFFFF;
--paper-2: #F7F7F5;
--paper-3: #EFEFED;
/* 发丝线与填充 */
--hairline: rgba(0, 0, 0, .08);
--hairline-2: rgba(0, 0, 0, .14);
--fill: rgba(120, 120, 128, .07);
--fill-2: rgba(120, 120, 128, .13);
/* 深色块(全稿唯一视觉锚点:商城会员卡) */
--block: #1C1C1E;
--block-2: #2C2C2E;
/* 玻璃 */
--glass: rgba(255, 255, 255, .72);
--glass-2: rgba(255, 255, 255, .86);
--glass-dark: rgba(28, 28, 30, .82);
/* 字族 */
--serif: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", Georgia, "Times New Roman", serif;
--sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
/* 缓动 */
--ease: cubic-bezier(.32, .72, 0, 1);
--ease-out: cubic-bezier(.22, 1, .36, 1);
/* 圆角:仅小控件与抽屉使用圆角,大块内容保持直角 */
--r-img: 8rpx;
--r-ctrl: 20rpx;
--r-sheet: 40rpx;
--r-pill: 999rpx;
/* 度量 */
--pad: 40rpx;
--nav-h: 88rpx;
--tab-h: 120rpx;
--safe-b: 28rpx;
background: var(--color-bg);
color: var(--color-text-main);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 30rpx;
line-height: 1.5;
line-height: 1.62;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ===== 布局 ===== */
view,
text,
image,
input,
textarea,
scroll-view,
swiper,
swiper-item {
box-sizing: border-box;
}
/* ============================================================================
§2 排版 · 双字阶(衬线大标题 / 无衬线正文)
========================================================================== */
.eyebrow {
font-size: 20rpx;
font-weight: 600;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--ink-3);
line-height: 1;
}
.display {
font-family: var(--serif);
font-size: 68rpx;
font-weight: 500;
letter-spacing: -.012em;
line-height: 1.14;
}
.t-xl { font-family: var(--serif); font-size: 52rpx; font-weight: 500; letter-spacing: -.01em; line-height: 1.22; }
.t-lg { font-family: var(--serif); font-size: 40rpx; font-weight: 500; letter-spacing: -.005em; line-height: 1.32; }
.t-md { font-size: 34rpx; font-weight: 600; line-height: 1.38; }
.t-bd { font-size: 30rpx; line-height: 1.62; }
.t-sm { font-size: 26rpx; line-height: 1.55; color: var(--ink-2); }
.t-xs { font-size: 22rpx; line-height: 1.5; color: var(--ink-3); }
.t-quote { font-family: var(--serif); font-size: 32rpx; line-height: 1.85; letter-spacing: .005em; color: var(--ink); }
.num { font-variant-numeric: tabular-nums; }
.c-ink { color: var(--ink); }
.c-2 { color: var(--ink-2); }
.c-3 { color: var(--ink-3); }
.c-4 { color: var(--ink-4); }
.ell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ============================================================================
§3 布局 · 页面容器 / 页头 / 间距 / 发丝线
========================================================================== */
.page {
min-height: 100vh;
background: var(--color-bg);
box-sizing: border-box;
background: var(--paper);
}
/* 底部预留:悬浮标签栏 / 悬浮双按钮条 / 悬浮操作条遮挡避让 */
.pb-tab { padding-bottom: calc(var(--tab-h) + 60rpx + env(safe-area-inset-bottom)); }
.pb-float { padding-bottom: calc(var(--tab-h) + 220rpx + env(safe-area-inset-bottom)); }
.pb-dock { padding-bottom: calc(190rpx + env(safe-area-inset-bottom)); }
.pb-safe { padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
/* ===== 卡片 ===== */
.card {
background: var(--color-card);
border-radius: 32rpx;
padding: 32rpx;
box-sizing: border-box;
.head { padding: 24rpx var(--pad) 0; }
.head .eyebrow { margin-bottom: 20rpx; }
.head-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 28rpx; }
.head-meta {
font-size: 22rpx;
color: var(--ink-3);
letter-spacing: .02em;
padding-bottom: 12rpx;
text-align: right;
line-height: 1.6;
font-variant-numeric: tabular-nums;
}
.pad { padding-left: var(--pad); padding-right: var(--pad); }
.spacer { flex: 1; }
.center { display: flex; align-items: center; justify-content: center; }
.stack { display: flex; flex-direction: column; }
.sec { margin-top: 64rpx; }
.hair { border-bottom: 1rpx solid var(--hairline); }
.hair-t { border-top: 1rpx solid var(--hairline); }
.gap-8 { height: 8rpx; }
.gap-16 { height: 16rpx; }
.gap-24 { height: 24rpx; }
.gap-32 { height: 32rpx; }
.gap-48 { height: 48rpx; }
.gap-64 { height: 64rpx; }
.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
/* ===== 发丝分隔线 ===== */
.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; }
/* ===== 按钮 ===== */
/* ============================================================================
§4 按钮 · 实心黑 / 描边 / 浅填充 / 玻璃 / 下划线文字
========================================================================== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14rpx;
height: 100rpx;
padding: 0 44rpx;
border-radius: var(--r-pill);
font-size: 30rpx;
font-weight: 500;
letter-spacing: .01em;
white-space: nowrap;
transition: transform .18s var(--ease), opacity .18s var(--ease), background .2s var(--ease);
}
.btn:active { transform: scale(.972); opacity: .74; }
.btn--block { display: flex; width: 100%; }
.btn--sm { height: 72rpx; padding: 0 30rpx; font-size: 27rpx; }
.btn--ink { background: var(--ink); color: #fff; }
.btn--ink.is-off { background: var(--fill-2); color: var(--ink-3); pointer-events: none; }
.btn--ghost { box-shadow: inset 0 0 0 1rpx var(--hairline-2); color: var(--ink); }
.btn--plain { background: var(--fill); color: var(--ink); }
.btn--glass {
background: var(--glass-2);
-webkit-backdrop-filter: saturate(180%) blur(22px);
backdrop-filter: saturate(180%) blur(22px);
border: 1rpx solid rgba(255, 255, 255, .7);
box-shadow: 0 16rpx 48rpx -20rpx rgba(0, 0, 0, .18);
color: var(--ink);
}
.btn--text {
height: auto;
padding: 0;
border-radius: 0;
font-size: 28rpx;
font-weight: 400;
color: var(--ink);
text-decoration: underline;
text-underline-offset: 7rpx;
text-decoration-thickness: 1rpx;
text-decoration-color: var(--ink-4);
}
.btn--text:active { opacity: .5; transform: none; }
.btn .icon { width: 34rpx; height: 34rpx; }
/* ============================================================================
§5 胶囊标签 · 零彩色状态语义
进行中/强调 = pill--ink;中性 = pill;失效/只读 = pill--line + 降透明
========================================================================== */
.pill {
display: inline-flex;
align-items: center;
gap: 8rpx;
height: 46rpx;
padding: 0 18rpx;
border-radius: var(--r-pill);
font-size: 22rpx;
font-weight: 500;
letter-spacing: .015em;
background: var(--fill);
color: var(--ink-2);
white-space: nowrap;
}
.pill--ink { background: var(--ink); color: #fff; }
.pill--line { background: transparent; box-shadow: inset 0 0 0 1rpx var(--hairline-2); color: var(--ink-2); }
.pill--plain { background: transparent; color: var(--ink-3); padding: 0 4rpx; }
.pill--dim { opacity: .45; }
.pill--dot::before {
content: '';
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: currentColor;
opacity: .55;
}
.pill .icon { width: 24rpx; height: 24rpx; }
/* ============================================================================
§6 列表 · 分组标题 / 行 / 规格表 / 数字条
========================================================================== */
.rows { background: var(--paper); }
.row {
display: flex;
align-items: center;
gap: 24rpx;
min-height: 104rpx;
width: 100%;
padding: 0 var(--pad);
border-bottom: 1rpx solid var(--hairline);
transition: background .16s var(--ease);
}
.row:active { background: var(--fill); }
.row-lb { flex: 1; font-size: 30rpx; line-height: 1.4; }
.row-vl { font-size: 27rpx; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.row-ch { width: 26rpx; height: 26rpx; color: var(--ink-4); flex-shrink: 0; }
.row-ic { width: 38rpx; height: 38rpx; color: var(--ink-2); flex-shrink: 0; }
.row-badge {
min-width: 34rpx;
height: 34rpx;
padding: 0 10rpx;
border-radius: 18rpx;
background: var(--ink);
color: #fff;
font-size: 20rpx;
font-weight: 600;
line-height: 34rpx;
text-align: center;
font-variant-numeric: tabular-nums;
}
.grp-hd { display: flex; align-items: baseline; gap: 18rpx; padding: 0 var(--pad) 22rpx; }
.grp-hd .gh-cn { font-size: 26rpx; font-weight: 600; letter-spacing: .01em; color: var(--ink); }
.grp-hd .gh-en { font-size: 19rpx; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-3); }
.grp-line { height: 1rpx; background: var(--hairline); margin: 0 var(--pad) 8rpx; }
.spec {
display: flex;
align-items: flex-start;
gap: 28rpx;
padding: 22rpx var(--pad);
border-bottom: 1rpx solid var(--hairline);
}
.spec-k { width: 160rpx; flex-shrink: 0; font-size: 26rpx; color: var(--ink-2); line-height: 1.5; }
.spec-v { flex: 1; font-size: 29rpx; line-height: 1.5; text-align: right; color: var(--ink); }
.spec-v .dot {
display: inline-block;
width: 18rpx;
height: 18rpx;
border-radius: 50%;
margin-right: 12rpx;
vertical-align: -1rpx;
box-shadow: inset 0 0 0 1rpx rgba(0, 0, 0, .14);
}
.stats { display: flex; border-top: 1rpx solid var(--hairline); border-bottom: 1rpx solid var(--hairline); }
.stat { flex: 1; padding: 28rpx 0 30rpx; text-align: center; position: relative; }
.stat + .stat::before {
content: '';
position: absolute;
left: 0;
top: 28rpx;
bottom: 30rpx;
width: 1rpx;
background: var(--hairline);
}
.stat-n {
font-family: var(--serif);
font-size: 48rpx;
font-weight: 500;
line-height: 1;
letter-spacing: -.01em;
font-variant-numeric: tabular-nums;
}
.stat-n .unit { font-size: 24rpx; color: var(--ink-3); margin-left: 4rpx; }
.stat-l {
margin-top: 12rpx;
font-size: 19rpx;
font-weight: 600;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--ink-3);
}
/* ============================================================================
§7 进度轨 / 评分点阵 / 刻度尺
========================================================================== */
.track { height: 3rpx; background: var(--hairline-2); position: relative; overflow: hidden; }
.track .bar { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ink); }
.track.is-warn .bar { background: var(--ink-2); }
.dots { display: inline-flex; gap: 10rpx; vertical-align: middle; }
.dots .d { width: 16rpx; height: 16rpx; border-radius: 50%; box-shadow: inset 0 0 0 2rpx var(--ink-4); }
.dots .d.on { background: var(--ink); box-shadow: none; }
.scale { position: relative; height: 52rpx; }
.scale-line { position: absolute; left: 0; right: 0; top: 24rpx; height: 2rpx; background: var(--hairline-2); }
.scale-fill { position: absolute; left: 0; top: 24rpx; height: 2rpx; background: var(--ink); }
.scale-pip {
position: absolute;
top: 17rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: var(--ink);
transform: translateX(-50%);
}
.scale-ticks {
position: absolute;
left: 0;
right: 0;
top: 20rpx;
height: 10rpx;
display: flex;
justify-content: space-between;
}
.scale-ticks .tk { width: 1rpx; height: 10rpx; background: var(--hairline-2); }
/* ============================================================================
§8 图片瓦片 · 拼图封面 · 勾选
========================================================================== */
.tile-img {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: var(--paper-2);
border-radius: var(--r-img);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 24rpx;
font-size: 30rpx;
font-weight: 600;
overflow: hidden;
}
.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;
.tile-img .ph { width: 56%; height: 56%; color: var(--art); }
.tile-img.is-empty .ph { width: 34%; height: 34%; color: var(--ink-4); }
.tile-img .pic { width: 100%; height: 100%; }
.tile-name {
margin-top: 18rpx;
font-family: var(--serif);
font-size: 28rpx;
box-sizing: border-box;
width: 100%;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.textarea {
background: var(--color-bg);
.tile-sub {
margin-top: 4rpx;
font-size: 21rpx;
color: var(--ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-flag {
position: absolute;
top: 14rpx;
left: 14rpx;
height: 38rpx;
padding: 0 14rpx;
border-radius: 20rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
width: 100%;
min-height: 160rpx;
box-sizing: border-box;
background: var(--glass-2);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
font-size: 19rpx;
font-weight: 500;
line-height: 38rpx;
color: var(--ink-2);
}
/* ===== 胶囊标签 ===== */
.tag {
display: inline-flex;
.tile-flag.is-ink { background: var(--ink); color: #fff; }
.tile-check {
position: absolute;
right: 14rpx;
bottom: 14rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, .9), 0 0 0 1rpx rgba(0, 0, 0, .12);
background: rgba(255, 255, 255, .55);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
height: 44rpx;
padding: 0 20rpx;
border-radius: 22rpx;
font-size: 22rpx;
font-weight: 500;
box-sizing: border-box;
justify-content: center;
color: #fff;
transition: background .18s var(--ease), color .18s var(--ease);
}
.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; }
.tile-check .icon { width: 24rpx; height: 24rpx; opacity: 0; }
.is-picked .tile-check { background: var(--ink); }
.is-picked .tile-check .icon { opacity: 1; }
.is-picked .tile-img { box-shadow: inset 0 0 0 3rpx var(--ink); }
/* ===== 骨架屏 ===== */
.skeleton {
background: linear-gradient(90deg, #F2F2F7 25%, #E5E5EA 37%, #F2F2F7 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
.collage {
position: relative;
display: grid;
gap: 2rpx;
background: var(--paper);
border-radius: var(--r-img);
overflow: hidden;
aspect-ratio: 1 / 1;
grid-template-columns: 1fr 1fr;
}
@keyframes skeleton-loading {
.collage--1 { grid-template-columns: 1fr; aspect-ratio: 1 / 1; }
.collage--2 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; aspect-ratio: 2 / 1; }
.collage--3 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.collage--3 .cg:first-child { grid-row: span 2; }
.collage .cg {
position: relative;
background: var(--paper-2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-width: 0;
min-height: 0;
}
.collage .cg .ph { width: 44%; height: 44%; color: var(--art); }
.collage .cg .pic { width: 100%; height: 100%; }
.cg-more {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .7);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
font-size: 34rpx;
color: var(--ink);
}
.cg-badge {
position: absolute;
right: 16rpx;
bottom: 16rpx;
height: 42rpx;
padding: 0 16rpx;
border-radius: 22rpx;
background: var(--glass-2);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
font-size: 21rpx;
line-height: 42rpx;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
/* ============================================================================
§9 骨架屏 · 空态 · 页脚提示
========================================================================== */
.sk {
background: linear-gradient(90deg, #F2F2F0 25%, #E9E9E6 37%, #F2F2F0 63%);
background-size: 400% 100%;
animation: skLoad 1.5s ease infinite;
}
@keyframes skLoad {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
.sk-img { width: 100%; aspect-ratio: 1 / 1; border-radius: var(--r-img); }
.sk-ln { height: 20rpx; border-radius: 10rpx; }
.empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 128rpx 88rpx 0;
}
.empty-ic { width: 104rpx; height: 104rpx; color: var(--ink-4); margin-bottom: 40rpx; }
.empty-t { font-family: var(--serif); font-size: 38rpx; font-weight: 500; letter-spacing: -.005em; }
.empty-d { margin-top: 16rpx; font-size: 26rpx; line-height: 1.7; color: var(--ink-2); max-width: 480rpx; }
.empty-b { margin-top: 48rpx; }
.foot-note { padding: 52rpx 0 16rpx; text-align: center; font-size: 22rpx; color: var(--ink-4); letter-spacing: .06em; }
/* ============================================================================
§10 分段条 / 分类条 · 下划线式极简
========================================================================== */
.seg { display: flex; gap: 36rpx; padding: 0 var(--pad); white-space: nowrap; }
.seg-item {
position: relative;
flex-shrink: 0;
padding: 22rpx 0 24rpx;
font-size: 29rpx;
color: var(--ink-3);
letter-spacing: .005em;
transition: color .2s var(--ease);
}
.seg-item.is-on { color: var(--ink); font-weight: 500; }
.seg-item.is-on::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3rpx;
background: var(--ink);
border-radius: 2rpx;
}
.seg-scroll { white-space: nowrap; }
/* ============================================================================
§11 悬浮层 · 底部操作条 / 双按钮条 / FAB
========================================================================== */
.dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 45;
padding: 24rpx var(--pad) calc(28rpx + env(safe-area-inset-bottom));
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(22px);
backdrop-filter: saturate(180%) blur(22px);
border-top: 1rpx solid var(--hairline);
}
.dock-row { display: flex; align-items: center; gap: 24rpx; }
.dock-ic {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 25rpx;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
transition: color .18s var(--ease), transform .18s var(--ease);
}
.dock-ic .icon { width: 42rpx; height: 42rpx; }
.dock-ic.is-on { color: var(--ink); }
.dock-ic:active { transform: scale(.92); }
.float-duo {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(var(--tab-h) + 48rpx + env(safe-area-inset-bottom));
z-index: 45;
display: flex;
gap: 18rpx;
}
.float-duo .btn { flex: 1; height: 92rpx; font-size: 28rpx; }
/* ===== 悬浮按钮 ===== */
.fab {
position: fixed;
right: 32rpx;
bottom: 180rpx;
right: 40rpx;
bottom: calc(var(--tab-h) + 56rpx + env(safe-area-inset-bottom));
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: var(--color-primary);
background: var(--ink);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 122, 255, 0.35);
z-index: 20;
box-shadow: 0 20rpx 44rpx -16rpx rgba(0, 0, 0, .45);
z-index: 46;
transition: transform .18s var(--ease), opacity .18s var(--ease);
}
.fab:active { opacity: 0.8; }
.fab .icon { width: 44rpx; height: 44rpx; }
.fab:active { transform: scale(.94); opacity: .8; }
/* ===== 底部安全区 ===== */
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
/* ============================================================================
§12 表单 · 输入框 / 文本域 / 选择行
========================================================================== */
.input {
height: 96rpx;
background: var(--fill);
border-radius: var(--r-ctrl);
padding: 0 28rpx;
font-size: 29rpx;
width: 100%;
}
.textarea {
background: var(--fill);
border-radius: var(--r-ctrl);
padding: 22rpx 28rpx;
font-size: 29rpx;
width: 100%;
min-height: 200rpx;
}
.input-ph { color: var(--ink-3); }
/* ===== 通用列表行 ===== */
.list-row {
/* ============================================================================
§13 通用动效
========================================================================== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16rpx); }
to { opacity: 1; transform: none; }
}
.fade-up { animation: fadeUp .42s var(--ease) both; }
@keyframes pulse {
0%, 100% { opacity: .35; transform: scale(.96); }
50% { opacity: 1; transform: scale(1); }
}
.pulse { animation: pulse 1.6s var(--ease) infinite; }
/* ============================================================================
§14 导航右侧操作(供 navigation-bar 的 right 插槽内容使用)
========================================================================== */
.nav-action {
font-size: 28rpx;
color: var(--ink-2);
letter-spacing: .01em;
white-space: nowrap;
padding: 12rpx 4rpx;
transition: color .16s var(--ease), opacity .16s var(--ease);
}
.nav-action:active { opacity: .5; }
.nav-action--ink { color: var(--ink); font-weight: 500; }
.nav-more {
width: 68rpx;
height: 68rpx;
display: flex;
align-items: center;
height: 88rpx;
box-sizing: border-box;
justify-content: center;
color: var(--ink);
}
.list-row:active { background: #F2F2F7; }
.nav-more .icon { width: 40rpx; height: 40rpx; }
/* ===== 1:1 方图占位 ===== */
.square {
position: relative;
/* ============================================================================
§15 底部抽屉(页面级 Sheet,含抓手 / 标题行 / 滚动体 / 底部操作区)
========================================================================== */
.scrim {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(0, 0, 0, .28);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
animation: fadeIn .28s var(--ease) both;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.sheet-wrap {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 71;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.sheet {
width: 100%;
background: #F2F2F7;
border-radius: 24rpx;
max-height: 86vh;
display: flex;
flex-direction: column;
border-radius: var(--r-sheet) var(--r-sheet) 0 0;
overflow: hidden;
background: var(--glass-2);
-webkit-backdrop-filter: saturate(180%) blur(30px);
backdrop-filter: saturate(180%) blur(30px);
border-top: 1rpx solid rgba(255, 255, 255, .7);
box-shadow: 0 -36rpx 100rpx -40rpx rgba(0, 0, 0, .28);
animation: sheetIn .46s var(--ease) both;
}
@keyframes sheetIn {
from { transform: translateY(101%); }
to { transform: none; }
}
.sheet-grab {
width: 72rpx;
height: 8rpx;
border-radius: 4rpx;
background: var(--ink-4);
margin: 16rpx auto 0;
flex-shrink: 0;
}
.sheet-hd {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 28rpx var(--pad) 24rpx;
flex-shrink: 0;
}
.sheet-hd .sheet-t { font-size: 32rpx; font-weight: 600; letter-spacing: -.005em; }
.sheet-hd .sheet-x { font-size: 28rpx; color: var(--ink-2); }
.sheet-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-foot {
padding: 24rpx var(--pad) calc(32rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid var(--hairline);
flex-shrink: 0;
}
/* ============================================================================
§16 衣物处理过程(上传页处理中态 / 衣物详情处理中态共用)
========================================================================== */
.up-preview {
margin: 52rpx var(--pad) 0;
aspect-ratio: 4 / 5;
background: var(--paper-2);
border-radius: var(--r-img);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.square::after {
content: '';
display: block;
padding-bottom: 100%;
.up-preview .ph { width: 44%; height: 44%; color: var(--art); }
.up-preview .pic { width: 100%; height: 100%; }
.up-steps {
padding: 64rpx var(--pad) 0;
display: flex;
flex-direction: column;
}
.up-step {
display: flex;
align-items: center;
gap: 28rpx;
position: relative;
padding-bottom: 48rpx;
}
.up-step:last-child { padding-bottom: 0; }
/* 竖向发丝线连接 */
.up-step::before {
content: '';
position: absolute;
left: 9rpx;
top: 26rpx;
bottom: 0;
width: 1rpx;
background: var(--hairline-2);
}
.up-step:last-child::before { display: none; }
.up-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
flex-shrink: 0;
background: var(--paper);
box-shadow: inset 0 0 0 2rpx var(--ink-4);
position: relative;
z-index: 1;
}
.up-step.is-done .up-dot { background: var(--ink); box-shadow: none; }
.up-step.is-on .up-dot {
box-shadow: inset 0 0 0 2rpx var(--ink);
animation: dotPulse 1.8s var(--ease) infinite;
}
@keyframes dotPulse {
0%, 100% { box-shadow: inset 0 0 0 2rpx var(--ink), 0 0 0 0 rgba(10, 10, 10, .18); }
55% { box-shadow: inset 0 0 0 2rpx var(--ink), 0 0 0 12rpx rgba(10, 10, 10, 0); }
}
.up-lb { font-size: 27rpx; color: var(--ink-3); letter-spacing: .01em; }
.up-step.is-done .up-lb,
.up-step.is-on .up-lb { color: var(--ink); }
/* 失败态「!」 */
.up-bang {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
margin-bottom: 44rpx;
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
font-size: 46rpx;
line-height: 1;
}
.up-duo { display: flex; gap: 20rpx; padding: 56rpx var(--pad) 0; }
.up-duo .btn { flex: 1; }
/* ============================================================================
§17 居中玻璃弹窗卡(页面级自定义弹窗,如登录新手礼包)
========================================================================== */
.modal-wrap {
position: fixed;
inset: 0;
z-index: 72;
display: flex;
align-items: center;
justify-content: center;
padding: 0 68rpx;
}
.modal {
width: 100%;
max-width: 576rpx;
border-radius: var(--r-sheet);
overflow: hidden;
background: var(--glass-2);
-webkit-backdrop-filter: saturate(180%) blur(30px);
backdrop-filter: saturate(180%) blur(30px);
border: 1rpx solid rgba(255, 255, 255, .7);
box-shadow: 0 48rpx 120rpx -36rpx rgba(0, 0, 0, .32);
animation: modalIn .42s var(--ease) both;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(.94); }
to { opacity: 1; transform: none; }
}
.modal-body { padding: 44rpx 40rpx 36rpx; text-align: center; }
.modal-title {
font-family: var(--serif);
font-size: 36rpx;
font-weight: 500;
letter-spacing: -.005em;
}
.modal-text {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.6;
color: var(--ink-2);
}
.modal-acts { display: flex; border-top: 1rpx solid var(--hairline); }
.modal-act {
flex: 1;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: var(--ink-2);
transition: background .16s var(--ease);
}
.modal-act:active { background: var(--fill); }
.modal-act + .modal-act { border-left: 1rpx solid var(--hairline); }
.modal-act--main { color: var(--ink); font-weight: 600; }
@@ -1,20 +1,19 @@
<view class="clothes-card {{selected ? 'selected' : ''}}" bindtap="onTap">
<view class="clothes-img">
<!-- v2 衣物瓦片:无卡片容器,图下两行(衬线品名 + 灰色属性行) -->
<view class="tile {{selected ? 'is-picked' : ''}}" bindtap="onTap">
<view class="tile-img">
<image
wx:if="{{item.processed_image}}"
class="clothes-img-img"
class="pic"
src="{{item.processed_image}}"
mode="aspectFill"
lazy-load
/>
<view wx:else class="clothes-placeholder">
<view class="icon icon-hanger"></view>
<view wx:else class="ph icon icon-hanger"></view>
<view wx:if="{{statusText}}" class="tile-flag {{statusType === 'ink' ? 'is-ink' : ''}}">{{statusText}}</view>
<view wx:if="{{selectable}}" class="tile-check">
<view class="icon icon-check"></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 class="tile-name">{{item.name || '未命名衣物'}}</view>
<view class="tile-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}{{item.color_main ? ' · ' + item.color_main : ''}}</view>
</view>
@@ -1,91 +1,5 @@
.clothes-card {
/** 衣物瓦片:视觉全部来自全局令牌与 .tile-* 通用类,此处只做定位兜底 **/
.tile {
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;
display: block;
}
@@ -1,12 +1,12 @@
<!-- v2 空态:细描边线稿图标 + 衬线标题 + 灰字描述 + 黑胶囊/描边按钮 -->
<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 class="icon icon-xl {{icon}} empty-ic"></view>
<view class="empty-t">{{title}}</view>
<view wx:if="{{desc}}" class="empty-d">{{desc}}</view>
<view
wx:if="{{buttonText}}"
class="empty-btn btn {{buttonType === 'outline' ? 'btn-outline' : 'btn-primary'}}"
class="empty-btn btn btn--sm {{buttonType === 'outline' ? 'btn--ghost' : 'btn--ink'}}"
hover-class="none"
bindtap="onTap"
>{{buttonText}}</view>
</view>
@@ -1,37 +1,4 @@
.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* 通用类 **/
.empty-btn {
margin-top: 40rpx;
width: 360rpx;
min-width: 280rpx;
}
@@ -5,78 +5,43 @@ const LOG = 'navigation-bar'
Component({
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
multipleSlots: true, // 右侧操作槽(如「编辑」「取消」「更多」)
},
/**
* 组件的属性列表
*/
properties: {
extClass: {
type: String,
value: ''
},
// 滚动收缩后显示的居中标题
title: {
type: String,
value: ''
},
background: {
type: String,
value: ''
},
color: {
type: String,
value: ''
value: '',
},
// 是否显示返回按钮
back: {
type: Boolean,
value: true
value: true,
},
loading: {
type: Boolean,
value: false
// 返回的页面深度
delta: {
type: Number,
value: 1,
},
homeButton: {
// 是否已滚动(由页面 utils/nav.ts 的 handler 驱动)
scrolled: {
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: '',
ios: false,
innerPaddingRight: '',
leftWidth: '',
safeAreaTop: ''
// 状态栏高度(px)与微信胶囊占位(右侧 padding)
safeTop: 0,
capsulePad: '',
},
lifetimes: {
attached() {
const rect = wx.getMenuButtonBoundingClientRect()
const apply = (windowWidth: number, platform: string, safeTop: number) => {
const isAndroid = platform === 'android'
const isDevtools = platform === 'devtools'
const apply = (windowWidth: number, safeTop: number) => {
this.setData({
ios: !isAndroid,
innerPaddingRight: `padding-right: ${windowWidth - rect.left}px`,
leftWidth: `width: ${windowWidth - rect.left}px`,
safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${safeTop}px); padding-top: ${safeTop}px` : ``
safeTop: safeTop > 0 ? safeTop : 0,
// 预留右上角胶囊宽度,保证右侧操作不压到胶囊
capsulePad: `padding-right: ${Math.max(windowWidth - rect.left, 0)}px;`,
})
}
@@ -85,35 +50,18 @@ Component({
if (typeof wxAny.getWindowInfo === 'function') {
const win = wxAny.getWindowInfo()
const dev = typeof wxAny.getDeviceInfo === 'function' ? wxAny.getDeviceInfo() : {}
apply(win.windowWidth, dev.platform || win.platform || '', win.safeArea ? win.safeArea.top : 0)
apply(win.windowWidth, win.safeArea ? win.safeArea.top : 0)
logger.debug(LOG, '布局计算完成, 窗口宽=', win.windowWidth, '平台=', dev.platform || win.platform)
return
}
// 低版本基础库走废弃接口兜底
logger.warn(LOG, 'getWindowInfo 不可用,降级到已废弃的 wx.getSystemInfo')
wx.getSystemInfo({
success: (res) => apply(res.windowWidth, res.platform, res.safeArea ? res.safeArea.top : 0)
success: (res) => apply(res.windowWidth, res.safeArea ? res.safeArea.top : 0),
})
},
},
/**
* 组件的方法列表
*/
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) {
@@ -124,17 +72,17 @@ Component({
logger.debug(LOG, '返回上一页, delta=', data.delta)
wx.navigateBack({
delta: data.delta,
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg)
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
})
} else {
logger.warn(LOG, '页面栈不足,switchTab 回衣橱兜底, 栈深=', pages.length)
wx.switchTab({
url: '/pages/wardrobe/wardrobe',
fail: (e) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e && e.errMsg)
fail: (e) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e && e.errMsg),
})
}
}
this.triggerEvent('back', { delta: data.delta }, {})
}
},
},
})
@@ -1,64 +1,27 @@
<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 class="nav-root">
<!-- 悬浮玻璃导航:滚动收缩后显示居中标题 -->
<view class="nav {{scrolled ? 'is-glass' : ''}}">
<!-- 高度含状态栏:全局 box-sizing 为 border-box,需显式把状态栏高度算进总高 -->
<view class="nav-bar" style="{{capsulePad}} height: calc(88rpx + {{safeTop}}px); padding-top: {{safeTop}}px;">
<view class="nav-left">
<view
bindtap="back"
class="weui-navigation-bar__btn_goback_wrapper"
hover-class="weui-active"
hover-stay-time="100"
wx:if="{{back}}"
class="nav-back"
hover-class="nav-press"
hover-stay-time="80"
aria-role="button"
aria-label="返回"
bindtap="back"
>
<view class="weui-navigation-bar__button weui-navigation-bar__btn_goback"></view>
<view class="icon icon-chevron-left"></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'>
<view class="nav-title {{scrolled ? 'is-on' : ''}}">{{title}}</view>
<view class="nav-right">
<slot name="right"></slot>
</view>
</view>
</view>
<!-- 占位:悬浮导航不参与文档流,用等高占位撑开内容 -->
<view class="nav-holder" style="height: calc(88rpx + {{safeTop}}px);"></view>
</view>
@@ -1,96 +1,94 @@
.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);
/** v2 导航:透明 → 滚动后毛玻璃,大标题在页面内容区 **/
.nav-root {
position: relative;
flex: none;
}
.weui-navigation-bar__inner {
position: relative;
.nav {
position: fixed;
top: 0;
left: 0;
height: calc(var(--height) + env(safe-area-inset-top));
right: 0;
z-index: 40;
border-bottom: 1rpx solid transparent;
transition: background .3s cubic-bezier(.32, .72, 0, 1), border-color .3s cubic-bezier(.32, .72, 0, 1);
}
.nav.is-glass {
background: var(--glass, rgba(255, 255, 255, .72));
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom-color: var(--hairline, rgba(0, 0, 0, .08));
}
.nav-bar {
position: relative;
height: 88rpx;
display: flex;
align-items: center;
padding-left: 40rpx;
}
.nav-left {
display: flex;
align-items: center;
}
.nav-back {
width: 68rpx;
height: 68rpx;
margin-left: -14rpx;
border-radius: 50%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: env(safe-area-inset-top);
color: var(--ink, #0A0A0A);
transition: background .16s cubic-bezier(.32, .72, 0, 1), transform .16s cubic-bezier(.32, .72, 0, 1);
}
.nav-back .icon {
width: 38rpx;
height: 38rpx;
}
.nav-press {
background: var(--fill, rgba(120, 120, 128, .07));
transform: scale(.94);
}
.nav-title {
position: absolute;
left: 50%;
bottom: 0;
height: 88rpx;
transform: translateX(-50%);
display: flex;
align-items: center;
max-width: 320rpx;
font-size: 32rpx;
font-weight: 600;
letter-spacing: -.005em;
opacity: 0;
transition: opacity .26s cubic-bezier(.32, .72, 0, 1);
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-title.is-on {
opacity: 1;
}
.nav-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 16rpx;
}
/* 悬浮导航不参与文档流,用等高占位撑开内容(高度由内联样式给出) */
.nav-holder {
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);
}
flex: none;
}
@@ -0,0 +1,5 @@
{
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {}
}
@@ -0,0 +1,88 @@
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'v2-overlay'
/**
* v2 统一玻璃弹层。
*
* 三类形态:
* modal 居中玻璃卡(确认/提示),buttons 决定按钮,事件 confirm/cancel
* actionsheet 底部动作表(选择),items 决定条目,事件 select/cancel
* toast 深色玻璃胶囊,duration 后自动关闭并抛出 close
*
* 页面侧只维护 visible 与文案数据,回调分支与原生 wx.showModal 一一对应。
*/
Component({
properties: {
visible: { type: Boolean, value: false },
type: { type: String, value: 'modal' },
title: { type: String, value: '' },
content: { type: String, value: '' },
// modal 按钮:[{ text, value, type: 'main' | 'normal' }]
buttons: { type: Array, value: [] },
// actionsheet 条目:['拍照', '从相册选择'] 或 [{ text, value }]
items: { type: Array, value: [] },
cancelText: { type: String, value: '取消' },
// toast 自动关闭时长(ms)
duration: { type: Number, value: 1800 },
},
observers: {
visible(v: boolean) {
if (!v) {
this.clearTimer()
return
}
if (this.data.type === 'toast') this.startTimer()
},
},
lifetimes: {
detached() {
this.clearTimer()
},
},
methods: {
startTimer() {
this.clearTimer()
const t = setTimeout(() => {
logger.debug(LOG, 'toast 自动关闭')
this.triggerEvent('close')
}, this.data.duration)
;(this as any).timer = t
},
clearTimer() {
const t = (this as any).timer
if (t) {
clearTimeout(t)
;(this as any).timer = null
}
},
onBtn(e: WechatMiniprogram.TouchEvent) {
const index = Number((e.currentTarget.dataset as any).index) || 0
const btn = (this.data.buttons[index] || {}) as any
logger.debug(LOG, 'modal 按钮点击, index=', index, 'value=', btn.value)
this.triggerEvent('confirm', { index, value: btn.value || '' })
},
onItem(e: WechatMiniprogram.TouchEvent) {
const index = Number((e.currentTarget.dataset as any).index) || 0
const raw = this.data.items[index] as any
const value = raw && typeof raw === 'object' ? raw.value || '' : raw
logger.debug(LOG, 'actionsheet 条目点击, index=', index, 'value=', value)
this.triggerEvent('select', { index, value })
},
onCancel() {
logger.debug(LOG, '弹层取消')
this.triggerEvent('cancel')
},
// 阻断蒙层下的页面滚动
noop() {},
},
})
@@ -0,0 +1,53 @@
<!-- v2 玻璃弹层:居中玻璃卡 Modal / 底部动作表 ActionSheet / 深色玻璃胶囊 Toast -->
<view wx:if="{{visible}}" class="ov-root" catchtouchmove="noop">
<!-- 居中玻璃卡 -->
<block wx:if="{{type === 'modal'}}">
<view class="scrim"></view>
<view class="modal-wrap">
<view class="modal">
<view class="modal-body">
<view wx:if="{{title}}" class="modal-title">{{title}}</view>
<view wx:if="{{content}}" class="modal-text">{{content}}</view>
</view>
<view class="modal-acts">
<view
wx:for="{{buttons}}"
wx:key="index"
class="modal-act {{item.type === 'main' ? 'modal-act--main' : ''}}"
data-index="{{index}}"
hover-class="ov-press"
hover-stay-time="80"
bindtap="onBtn"
>{{item.text}}</view>
</view>
</view>
</view>
</block>
<!-- 底部动作表 -->
<block wx:elif="{{type === 'actionsheet'}}">
<view class="scrim" bindtap="onCancel"></view>
<view class="as-wrap">
<view class="as-card">
<view wx:if="{{title}}" class="as-hd">{{title}}</view>
<view
wx:for="{{items}}"
wx:key="index"
class="as-item"
data-index="{{index}}"
hover-class="ov-press"
hover-stay-time="80"
bindtap="onItem"
>{{item.text || item}}</view>
</view>
<view class="as-cancel" bindtap="onCancel">{{cancelText}}</view>
</view>
</block>
<!-- 深色玻璃胶囊 -->
<block wx:else>
<view class="toast-wrap">
<view class="toast">{{content}}</view>
</view>
</block>
</view>
@@ -0,0 +1,159 @@
/** v2 弹层:毛玻璃 + 发丝线 + 苹果级缓动 **/
.ov-root {
position: fixed;
inset: 0;
z-index: 80;
}
.scrim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .28);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
animation: ovFade .28s cubic-bezier(.32, .72, 0, 1) both;
}
@keyframes ovFade {
from { opacity: 0; }
to { opacity: 1; }
}
.ov-press { opacity: .6; }
/* Modal */
.modal-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 68rpx;
pointer-events: none;
}
.modal {
width: 100%;
max-width: 576rpx;
border-radius: var(--r-sheet, 40rpx);
overflow: hidden;
pointer-events: auto;
background: rgba(255, 255, 255, .86);
-webkit-backdrop-filter: saturate(180%) blur(30px);
backdrop-filter: saturate(180%) blur(30px);
border: 1rpx solid rgba(255, 255, 255, .7);
box-shadow: 0 48rpx 120rpx -36rpx rgba(0, 0, 0, .32);
animation: ovModalIn .42s cubic-bezier(.32, .72, 0, 1) both;
}
@keyframes ovModalIn {
from { opacity: 0; transform: scale(.94); }
to { opacity: 1; transform: none; }
}
.modal-body { padding: 44rpx 40rpx 36rpx; text-align: center; }
.modal-title {
font-family: var(--serif, serif);
font-size: 36rpx;
font-weight: 500;
letter-spacing: -.005em;
color: var(--ink, #0A0A0A);
}
.modal-text {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.6;
color: var(--ink-2, #6E6E73);
}
.modal-acts { display: flex; border-top: 1rpx solid var(--hairline, rgba(0, 0, 0, .08)); }
.modal-act {
flex: 1;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: var(--ink-2, #6E6E73);
transition: background .16s cubic-bezier(.32, .72, 0, 1);
}
.modal-act + .modal-act { border-left: 1rpx solid var(--hairline, rgba(0, 0, 0, .08)); }
.modal-act--main { color: var(--ink, #0A0A0A); font-weight: 600; }
/* ActionSheet */
.as-wrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
padding: 0 16rpx calc(16rpx + env(safe-area-inset-bottom));
}
.as-card {
border-radius: 28rpx;
overflow: hidden;
background: rgba(255, 255, 255, .86);
-webkit-backdrop-filter: saturate(180%) blur(30px);
backdrop-filter: saturate(180%) blur(30px);
border: 1rpx solid rgba(255, 255, 255, .7);
box-shadow: 0 -20rpx 80rpx -36rpx rgba(0, 0, 0, .3);
animation: ovAsIn .4s cubic-bezier(.32, .72, 0, 1) both;
}
@keyframes ovAsIn {
from { opacity: 0; transform: translateY(24rpx); }
to { opacity: 1; transform: none; }
}
.as-hd {
padding: 26rpx 32rpx;
text-align: center;
font-size: 25rpx;
color: var(--ink-3, #AEAEB2);
border-bottom: 1rpx solid var(--hairline, rgba(0, 0, 0, .08));
}
.as-item {
height: 104rpx;
line-height: 104rpx;
text-align: center;
font-size: 34rpx;
color: var(--ink, #0A0A0A);
border-bottom: 1rpx solid var(--hairline, rgba(0, 0, 0, .08));
transition: background .16s cubic-bezier(.32, .72, 0, 1);
}
.as-item:last-child { border-bottom: 0; }
.as-cancel {
margin-top: 16rpx;
height: 104rpx;
line-height: 104rpx;
text-align: center;
font-size: 34rpx;
font-weight: 600;
color: var(--ink, #0A0A0A);
border-radius: 28rpx;
background: rgba(255, 255, 255, .86);
-webkit-backdrop-filter: saturate(180%) blur(30px);
backdrop-filter: saturate(180%) blur(30px);
border: 1rpx solid rgba(255, 255, 255, .7);
}
/* Toast */
.toast-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.toast {
max-width: 460rpx;
padding: 26rpx 40rpx;
border-radius: 28rpx;
background: rgba(28, 28, 30, .82);
color: #fff;
font-size: 27rpx;
line-height: 1.5;
text-align: center;
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
box-shadow: 0 36rpx 88rpx -32rpx rgba(0, 0, 0, .5);
animation: ovToastIn .34s cubic-bezier(.32, .72, 0, 1) both;
}
@keyframes ovToastIn {
from { opacity: 0; transform: scale(.92) translateY(12rpx); }
to { opacity: 1; transform: none; }
}
+6 -5
View File
@@ -1,16 +1,17 @@
<view class="tab-bar">
<!-- v2 悬浮玻璃胶囊标签栏 -->
<view class="tabbar">
<view
wx:for="{{list}}"
wx:key="pagePath"
class="tab-item {{selected === index ? 'active' : ''}}"
class="tab-item {{selected === index ? 'is-on' : ''}}"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="tab-icon-wrap">
<view class="icon tab-icon {{item.icon}}"></view>
<view class="tab-ic-wrap">
<view class="icon tab-ic {{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>
<text class="tab-tx">{{item.text}}</text>
</view>
</view>
+70 -41
View File
@@ -1,58 +1,87 @@
.tab-bar {
/** v2 悬浮玻璃胶囊标签栏:左右留边、距底 + 安全区,选中态实心黑 + 顶部短横线 **/
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
left: 32rpx;
right: 32rpx;
bottom: calc(24rpx + env(safe-area-inset-bottom));
height: 120rpx;
z-index: 50;
border-radius: 60rpx;
background: rgba(255, 255, 255, .72);
-webkit-backdrop-filter: saturate(180%) blur(22px);
backdrop-filter: saturate(180%) blur(22px);
border: 1rpx solid rgba(255, 255, 255, .7);
box-shadow: 0 0 0 1rpx rgba(0, 0, 0, .05), 0 20rpx 60rpx -20rpx rgba(0, 0, 0, .16);
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;
align-items: center;
}
.tab-item {
position: relative;
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12rpx 0 8rpx;
color: #8E8E93;
gap: 6rpx;
color: var(--ink-3, #AEAEB2);
transition: color .2s cubic-bezier(.32, .72, 0, 1);
}
.tab-item.active {
color: #007AFF;
.tab-ic {
width: 42rpx;
height: 42rpx;
}
.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 {
.tab-tx {
font-size: 20rpx;
letter-spacing: .02em;
line-height: 1;
}
/* 选中态短横线 */
.tab-item::after {
content: '';
position: absolute;
bottom: 12rpx;
left: 50%;
width: 28rpx;
height: 4rpx;
border-radius: 4rpx;
background: var(--ink, #0A0A0A);
transform: translateX(-50%) scaleX(.3);
opacity: 0;
transition: opacity .24s cubic-bezier(.32, .72, 0, 1), transform .24s cubic-bezier(.32, .72, 0, 1);
}
.tab-item.is-on {
color: var(--ink, #0A0A0A);
}
.tab-item.is-on::after {
opacity: 1;
transform: translateX(-50%) scaleX(1);
}
.tab-ic-wrap {
position: relative;
}
/* 未读徽标:零彩色黑底白字数字胶囊 */
.tab-badge {
position: absolute;
top: -6rpx;
left: calc(50% + 6rpx);
min-width: 30rpx;
height: 30rpx;
padding: 0 8rpx;
border-radius: 16rpx;
background: var(--ink, #0A0A0A);
color: #fff;
font-size: 18rpx;
font-weight: 600;
line-height: 30rpx;
text-align: center;
font-variant-numeric: tabular-nums;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+43 -22
View File
@@ -1,5 +1,6 @@
import { request } from '../../utils/request'
import { AiRecommendResult, ClothesItem } from '../../types/models'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -14,8 +15,24 @@ Page({
result: null as AiRecommendResult | null,
clothes: [] as ClothesItem[],
generating: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
confirmVisible: false,
confirmButtons: [
{ text: '取消', value: '' },
{ text: '开始生成', value: 'ok', type: 'main' },
],
limitVisible: false,
limitButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
noop() {},
selectDaily() {
@@ -42,16 +59,20 @@ Page({
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)
// 待生成的场景参数暂存到实例,确认后再发起
;(this as any).pendingScene = { sceneType, scene }
this.setData({ confirmVisible: true })
},
fail: (e) => logger.debug(LOG, '取消生成确认, err=', e && e.errMsg),
})
onConfirmCancel() {
this.setData({ confirmVisible: false })
},
onConfirmGenerate(e: WechatMiniprogram.CustomEvent) {
this.setData({ confirmVisible: false })
if (e.detail.value !== 'ok') return
const pending = (this as any).pendingScene || { sceneType: 1, scene: '' }
this.doGenerate(pending.sceneType, pending.scene)
},
doGenerate(sceneType: number, scene: string) {
@@ -82,7 +103,8 @@ Page({
logger.warn(LOG, 'AI 推荐生成失败, code=', code)
if (code === 90400) {
this.setData({ state: 'empty' })
} else if (code === 90401) {
} else if (code === 90401 || code === 80000) {
// 90401=AI 推荐次数上限;80000=额度不足兜底,统一弹窗引导购买
this.setData({ state: 'scene', showSceneSheet: true })
this.showLimitModal()
} else if (code === 10003) {
@@ -95,20 +117,19 @@ Page({
},
showLimitModal() {
wx.showModal({
title: 'AI 推荐额度不足',
content: '购买额度包后可继续生成 AI 穿搭推荐',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.setData({ limitVisible: true })
},
onLimitCancel() {
this.setData({ limitVisible: false })
},
onLimitConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ limitVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (e) => logger.warn(LOG, 'navigateTo 商城失败, err=', e && e.errMsg),
})
}
},
fail: (e) => logger.warn(LOG, '额度弹窗失败, err=', e && e.errMsg),
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
+106 -43
View File
@@ -1,14 +1,25 @@
<view class="page rec-page">
<navigation-bar title="AI 穿搭推荐" />
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<!-- 生成中 -->
<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 class="page page-flex">
<navigation-bar title="AI 穿搭推荐" scrolled="{{navScrolled}}" />
<!-- 生成中:呼吸感细描边圆环 + 衬线文案 -->
<view wx:if="{{state === 'generating'}}" class="ai-gen">
<view class="ai-ring">
<view class="ai-ring-core">
<view class="icon icon-sparkles"></view>
</view>
<view class="gen-title">AI 正在为你搭配…</view>
<view class="gen-desc">结合今日运势与你的衣橱</view>
</view>
<view class="ai-gen-t">AI 正在为你搭配…</view>
<view class="ai-gen-d">结合今日运势与你的衣橱</view>
</view>
<!-- 衣橱为空 -->
@@ -22,54 +33,82 @@
/>
<!-- 结果页 -->
<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>
<scroll-view wx:elif="{{state === 'result'}}" scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">AI Stylist</view>
<view class="display">今日推荐</view>
</view>
<!-- 无缝拼图封面 -->
<view class="ai-cover">
<view class="collage {{cl.cls(clothes.length)}}">
<view class="cg" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{clothes.length > 4}}" class="cg-more">+{{clothes.length - 4}}</view>
<text class="cg-badge num">{{clothes.length}} 件</text>
</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 class="ai-info">
<view class="t-xl">{{result.outfit_name}}</view>
<view class="ai-tags">
<text wx:if="{{result.scene}}" class="pill pill--line">{{result.scene}}</text>
<text class="pill pill--ink">AI 推荐</text>
</view>
<view class="t-bd ai-desc">{{result.outfit_desc}}</view>
</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 class="ai-items">
<view class="grp-hd">
<text class="gh-cn">推荐单品</text>
<text class="gh-en">Items</text>
</view>
<scroll-view scroll-x class="ai-items-scroll" show-scrollbar="{{false}}">
<view class="ai-items-row">
<view class="ai-chip" wx:for="{{clothes}}" wx:key="clothes_uuid">
<view class="ai-thumb">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<text class="ai-chip-n">{{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>
<view class="gap-64"></view>
</scroll-view>
<!-- 场景选择弹窗 -->
<view wx:if="{{showSceneSheet}}" class="mask" bindtap="onSkipScene">
<!-- 悬浮底部操作条 -->
<view class="dock" wx:if="{{state === 'result'}}">
<view class="ai-reroll-tip">换一套将再消耗 1 次推荐额度</view>
<view class="dock-row">
<view class="btn btn--ghost" bindtap="onReroll">换一套</view>
<view class="btn btn--ink" bindtap="onSave">保存到我的穿搭</view>
</view>
</view>
<!-- 场景选择抽屉 -->
<view wx:if="{{showSceneSheet}}" class="scrim" bindtap="onSkipScene"></view>
<view wx:if="{{showSceneSheet}}" class="sheet-wrap">
<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="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">选择推荐场景</text>
<text class="sheet-x" bindtap="onSkipScene">跳过</text>
</view>
<view class="sheet-body">
<view class="scene-card {{sceneType === 1 ? 'is-on' : ''}}" 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-card {{sceneType === 2 ? 'is-on' : ''}}" bindtap="selectCustom">
<view class="scene-name">自定义场景</view>
<view class="scene-desc">描述你想去的场合</view>
<view wx:if="{{sceneType === 2}}" class="icon icon-check scene-check"></view>
@@ -81,10 +120,34 @@
value="{{scene}}"
bindinput="onSceneInput"
placeholder="例如:海边度假、周一例会、初次约会"
placeholder-class="input-ph"
/>
</view>
<view class="sheet-foot">
<view class="btn btn--ink btn--block" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
</view>
</view>
</view>
<!-- 生成确认 -->
<v2-overlay
visible="{{confirmVisible}}"
type="modal"
title="AI 穿搭推荐"
content="本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭"
buttons="{{confirmButtons}}"
bind:confirm="onConfirmGenerate"
bind:cancel="onConfirmCancel"
/>
<view class="btn btn-primary sheet-btn" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
<view class="sheet-skip" bindtap="onSkipScene">跳过,使用每日推荐</view>
</view>
</view>
<!-- 额度不足 -->
<v2-overlay
visible="{{limitVisible}}"
type="modal"
title="AI 推荐额度不足"
content="购买额度包后可继续生成 AI 穿搭推荐"
buttons="{{limitButtons}}"
bind:confirm="onLimitConfirm"
bind:cancel="onLimitCancel"
/>
</view>
+130 -219
View File
@@ -1,7 +1,9 @@
.rec-page {
/** AI 推荐:呼吸感圆环生成态 + 拼图结果卡 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -10,272 +12,181 @@
}
/* 生成中 */
.generating {
.ai-gen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
padding: 0 var(--pad) 120rpx;
}
.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;
.ai-ring {
width: 216rpx;
height: 216rpx;
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;
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
animation: aiBreath 2.4s var(--ease) infinite;
}
.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 {
.ai-ring-core {
width: 152rpx;
height: 152rpx;
border-radius: 50%;
background: var(--paper-2);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
color: var(--ink-2);
}
.item-img-empty .icon {
.ai-ring-core .icon {
width: 56rpx;
height: 56rpx;
}
.item-name {
font-size: 20rpx;
color: #8E8E93;
margin-top: 8rpx;
max-width: 140rpx;
@keyframes aiBreath {
0%, 100% { transform: scale(.96); opacity: .6; }
50% { transform: scale(1); opacity: 1; }
}
.ai-gen-t {
margin-top: 64rpx;
font-family: var(--serif);
font-size: 42rpx;
font-weight: 500;
letter-spacing: -.005em;
}
.ai-gen-d {
margin-top: 18rpx;
font-size: 25rpx;
color: var(--ink-2);
}
/* 结果 */
.ai-cover {
padding: 40rpx var(--pad) 0;
}
.ai-info {
padding: 48rpx var(--pad) 0;
}
.ai-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 26rpx;
}
.ai-desc {
margin-top: 28rpx;
}
.ai-items {
margin-top: 64rpx;
}
.ai-items-scroll {
white-space: nowrap;
}
.ai-items-row {
display: inline-flex;
gap: 28rpx;
padding: 0 var(--pad);
}
.ai-chip {
display: flex;
flex-direction: column;
width: 160rpx;
}
.ai-thumb {
width: 160rpx;
height: 160rpx;
background: var(--paper-2);
border-radius: var(--r-img);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.ai-thumb .ph {
width: 52%;
height: 52%;
color: var(--art);
}
.ai-thumb .pic {
width: 100%;
height: 100%;
}
.ai-chip-n {
margin-top: 14rpx;
font-size: 22rpx;
color: var(--ink-3);
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 {
.ai-reroll-tip {
padding-bottom: 20rpx;
text-align: center;
font-size: 22rpx;
color: #8E8E93;
margin-top: 16rpx;
color: var(--ink-3);
}
/* 场景弹窗 */
.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;
.dock-row .btn {
flex: 1;
}
/* 场景抽屉 */
.scene-card {
position: relative;
border: 2rpx solid #E5E5EA;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 20rpx;
margin: 0 var(--pad) 20rpx;
padding: 32rpx;
border-radius: var(--r-ctrl);
background: var(--fill);
transition: background .18s var(--ease);
}
.scene-card.active {
border-color: #007AFF;
background: rgba(0, 122, 255, 0.04);
.scene-card.is-on {
background: var(--paper);
box-shadow: inset 0 0 0 1rpx var(--ink);
}
.scene-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
font-family: var(--serif);
font-size: 32rpx;
font-weight: 500;
}
.scene-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-2);
}
.scene-check {
position: absolute;
top: 28rpx;
right: 28rpx;
width: 36rpx;
height: 36rpx;
color: #007AFF;
top: 32rpx;
right: 32rpx;
width: 34rpx;
height: 34rpx;
color: var(--ink);
}
.scene-input {
margin-bottom: 20rpx;
}
.sheet-btn {
width: 100%;
}
.sheet-skip {
text-align: center;
font-size: 26rpx;
color: #8E8E93;
margin-top: 20rpx;
padding: 12rpx;
margin: 0 var(--pad) 20rpx;
width: auto;
display: block;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
"clothes-card": "/components/clothes-card/clothes-card",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -2,6 +2,7 @@ import { request } from '../../utils/request'
import { ClothesItem, PagedResult, UserInfo } from '../../types/models'
import { STORAGE_KEYS } from '../../utils/config'
import { normalizePaged, hasMorePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -45,8 +46,21 @@ Page({
nickname: '微信用户',
avatar: '',
selectedCount: 0,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 额度不足引导:v2 玻璃弹窗
quotaVisible: false,
quotaTitle: '衣柜分享额度不足',
quotaText: '购买额度包后可创建衣柜分享',
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad() {
this.loadUserInfo()
},
@@ -182,6 +196,10 @@ Page({
.catch((e: any) => {
logger.warn(LOG, '分享创建失败, code=', e && e.code)
this.setData({ submitting: false })
// 衣柜分享额度不足:弹窗引导购买
if (e && e.code === 80000) {
this.setData({ quotaVisible: true })
}
})
},
@@ -197,4 +215,17 @@ Page({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
})
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
})
@@ -1,102 +1,143 @@
<view class="page create-page">
<navigation-bar title="创建分享" />
<view class="page page-flex">
<navigation-bar title="创建分享" scrolled="{{navScrolled}}" />
<!-- 配置视图 -->
<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>
<scroll-view wx:if="{{view === 'config'}}" scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">New Share</view>
<view class="display">创建分享</view>
</view>
<view class="grp-hd csc-grp"><text class="gh-cn">分享方式</text><text class="gh-en">Type</text></view>
<view class="csc-types">
<view class="csc-type {{shareType === 1 ? 'is-on' : ''}}" bindtap="selectPartial">
<view class="csc-type-n">部分衣物</view>
<view class="csc-type-d">挑选几件衣物分享给好友</view>
<view wx:if="{{shareType === 1}}" class="icon icon-check csc-check"></view>
</view>
<view class="csc-type {{shareType === 2 ? 'is-on' : ''}}" bindtap="selectFull">
<view class="csc-type-n">全量快照</view>
<view class="csc-type-d">分享当前衣橱的全部衣物</view>
<view wx:if="{{shareType === 2}}" class="icon icon-check csc-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 wx:if="{{shareType === 1}}" class="rows csc-entry">
<view class="row" bindtap="openSelect">
<view class="icon row-ic icon-shirt"></view>
<text class="row-lb">选择衣物</text>
<text class="row-vl">{{selectedCount ? '已选 ' + selectedCount + ' 件' : '未选择'}}</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<view class="section-title">有效期</view>
<view class="card expire-card">
<view class="grp-hd csc-grp"><text class="gh-cn">有效期</text><text class="gh-en">Expire</text></view>
<view class="rows">
<view
class="expire-item hairline"
class="row"
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>
<text class="row-lb">{{item.label}}</text>
<view wx:if="{{expireDays === item.days}}" class="icon csc-tick icon-check"></view>
</view>
</view>
<view class="footer">
<view class="btn btn-primary" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
</view>
<view class="gap-64"></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">
<block wx:elif="{{view === 'select'}}">
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
class="seg-item {{activeCategory === index ? 'is-on' : ''}}"
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>
<scroll-view scroll-y class="select-body pb-dock" bindscroll="onNavScroll">
<view wx:if="{{selectLoading}}" class="csc-grid">
<view class="csc-skel" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="sk sk-img"></view>
<view class="sk sk-ln" style="width: 62%;"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<view wx:else class="csc-grid">
<view class="csc-tile" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
<view class="gap-32"></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>
</block>
<!-- 成功预览 -->
<scroll-view wx:else scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Ready</view>
<view class="display">分享已创建</view>
</view>
<view class="csc-done-ic">
<view class="icon icon-check"></view>
</view>
<view class="csc-preview">
<view class="csc-preview-hd">
<view class="csc-av">
<image wx:if="{{avatar}}" class="pic" src="{{avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></view>
</view>
<text class="csc-preview-n">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
</view>
<view class="csc-preview-body">
<view class="icon icon-hanger"></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 class="gap-64"></view>
</scroll-view>
<!-- 悬浮底部操作条 -->
<view class="dock">
<block wx:if="{{view === 'config'}}">
<view class="btn btn--ink btn--block" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
</block>
<block wx:elif="{{view === 'select'}}">
<view class="dock-row">
<view class="csc-count-wrap">
<view class="csc-count-t num">已选 {{selectedUuids.length}} 件</view>
<view class="csc-count-d">最多可选择 10 件单品</view>
</view>
<text class="preview-name">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
<view class="btn btn--ink btn--sm csc-confirm" bindtap="confirmSelect">确定</view>
</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>
</block>
<block wx:else>
<view class="dock-row">
<view class="btn btn--ghost" bindtap="onDone">完成</view>
<view class="btn btn--ink" bindtap="onViewShare">查看分享</view>
</view>
</block>
</view>
<!-- 额度不足 -->
<v2-overlay
visible="{{quotaVisible}}"
type="modal"
title="{{quotaTitle}}"
content="{{quotaText}}"
buttons="{{quotaButtons}}"
bind:confirm="onQuotaConfirm"
bind:cancel="onQuotaCancel"
/>
</view>
@@ -1,7 +1,9 @@
.create-page {
/** 创建分享:三视图(配置 / 选择衣物 / 成功预览)**/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,314 +11,196 @@
height: 0;
}
.section-title {
font-size: 26rpx;
color: #8E8E93;
padding: 24rpx 24rpx 16rpx;
.csc-grp {
margin-top: 64rpx;
}
.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;
/* 分享方式:两张可点卡片 */
.csc-types {
padding: 0 var(--pad);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
.csc-type {
position: relative;
padding: 34rpx 36rpx;
border-radius: var(--r-ctrl);
background: var(--fill);
transition: background .18s var(--ease), box-shadow .18s var(--ease);
}
.csc-type.is-on {
background: var(--paper);
box-shadow: inset 0 0 0 1rpx var(--ink);
}
.csc-type-n {
font-family: var(--serif);
font-size: 32rpx;
font-weight: 500;
}
.csc-type-d {
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-2);
}
.csc-check {
position: absolute;
top: 34rpx;
right: 36rpx;
width: 34rpx;
height: 34rpx;
color: var(--ink);
}
.csc-entry {
margin-top: 32rpx;
}
.csc-tick {
width: 30rpx;
height: 30rpx;
color: var(--ink);
}
/* 选择衣物视图 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
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;
.csc-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 40rpx var(--pad) 0;
}
.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;
.csc-skel {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
}
.success-icon {
width: 120rpx;
height: 120rpx;
.csc-tile {
display: block;
width: 100%;
}
/* 成功预览 */
.csc-done-ic {
margin: 56rpx auto 0;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
background: #34C759;
color: #fff;
color: var(--ink);
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
display: flex;
align-items: center;
justify-content: center;
}
.success-icon .icon {
width: 64rpx;
height: 64rpx;
.csc-done-ic .icon {
width: 44rpx;
height: 44rpx;
}
.success-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
margin-top: 24rpx;
.csc-preview {
margin: 64rpx var(--pad) 0;
border-top: 1rpx solid var(--hairline);
border-bottom: 1rpx solid var(--hairline);
padding: 40rpx 0;
}
.preview-card {
width: 100%;
background: #fff;
border-radius: 32rpx;
margin-top: 40rpx;
padding: 32rpx;
box-sizing: border-box;
}
.preview-top {
.csc-preview-hd {
display: flex;
align-items: center;
gap: 20rpx;
}
.avatar {
width: 64rpx;
height: 64rpx;
.csc-av {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
color: var(--ink-3);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
margin-right: 16rpx;
flex-shrink: 0;
}
.avatar-img {
.csc-av .pic {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 40rpx;
height: 40rpx;
.csc-av .icon {
width: 30rpx;
height: 30rpx;
}
.preview-name {
font-size: 26rpx;
color: #000;
.csc-preview-n {
font-size: 27rpx;
color: var(--ink-2);
}
.preview-body {
height: 320rpx;
background: #F8F8FA;
border-radius: 24rpx;
margin-top: 24rpx;
.csc-preview-body {
margin-top: 32rpx;
aspect-ratio: 16 / 9;
background: var(--paper-2);
border-radius: var(--r-img);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
color: var(--art);
}
.preview-body .icon {
.csc-preview-body .icon {
width: 96rpx;
height: 96rpx;
}
.preview-btn {
margin-top: 24rpx;
/* 底部操作条 */
.csc-count-wrap {
flex: 1;
min-width: 0;
}
.csc-count-t {
font-size: 30rpx;
font-weight: 600;
color: var(--ink);
}
.csc-count-d {
margin-top: 4rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.csc-confirm {
width: 240rpx;
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 {
.dock-row .btn {
flex: 1;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -2,6 +2,7 @@ import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { ClosetShareDetail } from '../../types/models'
import { ShareStatus } from '../../utils/config'
import { formatDateTime } from '../../utils/format'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -17,8 +18,19 @@ Page({
invalidTitle: '该分享已过期或已被取消',
invalidDesc: '分享链接已失效',
needLogin: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 取消分享确认:v2 玻璃弹窗
cancelVisible: false,
cancelButtons: [
{ text: '再想想', value: '' },
{ text: '取消分享', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid)
@@ -99,13 +111,16 @@ Page({
},
onCancel() {
wx.showModal({
title: '取消分享',
content: '取消后该分享链接将失效,且不返还衣柜分享额度',
confirmText: '取消分享',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
this.setData({ cancelVisible: true })
},
onCancelCancel() {
this.setData({ cancelVisible: false })
},
onCancelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ cancelVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/closet-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消分享成功')
@@ -113,15 +128,12 @@ Page({
;(this as any).backTimer = setTimeout(
() =>
wx.navigateBack({
fail: (e: any) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
fail: (err: any) => logger.warn(LOG, 'navigateBack 失败, err=', err && err.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '取消分享失败, code=', e && e.code))
},
fail: (e: any) => logger.warn(LOG, '取消分享弹窗失败, err=', e && e.errMsg),
})
.catch((err: any) => logger.warn(LOG, '取消分享失败, code=', err && err.code))
},
onBackHome() {
@@ -1,5 +1,5 @@
<view class="page detail-page">
<navigation-bar title="分享详情" />
<view class="page page-flex">
<navigation-bar title="分享详情" scrolled="{{navScrolled}}" />
<empty-state
wx:if="{{invalid && needLogin}}"
@@ -19,36 +19,53 @@
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>
<scroll-view wx:else scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<!-- 分享者行 -->
<view class="head">
<view class="eyebrow">Shared Closet</view>
<view class="display">{{detail.owner_name}} 的衣橱</view>
</view>
<view class="owner-info">
<view class="owner-name">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
<view class="owner-expire">{{expireText}}</view>
<view class="csd-owner">
<view class="csd-av">
<image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></view>
</view>
<view class="csd-owner-body">
<view class="csd-owner-n">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
<view class="csd-owner-e num">{{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 class="csd-grid">
<view class="csd-tile" wx:for="{{detail.clothes}}" wx:key="clothes_uuid">
<view class="tile-img">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" lazy-load />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view class="tile-name">{{item.name || '未命名'}}</view>
<view class="tile-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}</view>
</view>
</view>
<!-- 转发出口 -->
<button class="share-btn" open-type="share">分享给好友</button>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃操作条 -->
<view class="dock" wx:if="{{!invalid}}">
<view wx:if="{{detail.is_owner}}" class="btn--text csd-cancel" bindtap="onCancel">取消分享</view>
<button class="btn btn--ink btn--block" open-type="share">分享给好友</button>
</view>
<!-- 取消分享确认 -->
<v2-overlay
visible="{{cancelVisible}}"
type="modal"
title="取消分享"
content="取消后该分享链接将失效,且不返还衣柜分享额度"
buttons="{{cancelButtons}}"
bind:confirm="onCancelConfirm"
bind:cancel="onCancelCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 衣柜分享详情:分享者行 + 只读网格 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,129 +11,65 @@
height: 0;
}
.owner-card {
margin: 24rpx;
.csd-owner {
display: flex;
align-items: center;
gap: 24rpx;
padding: 40rpx var(--pad) 0;
}
.avatar {
width: 96rpx;
height: 96rpx;
.csd-av {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
color: var(--ink-3);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
.csd-av .pic {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 56rpx;
height: 56rpx;
.csd-av .icon {
width: 36rpx;
height: 36rpx;
}
.owner-info {
margin-left: 24rpx;
.csd-owner-body {
min-width: 0;
}
.owner-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
.csd-owner-n {
font-size: 27rpx;
color: var(--ink);
}
.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 {
.csd-owner-e {
margin-top: 6rpx;
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
color: var(--ink-3);
}
.share-btn {
margin: 32rpx 24rpx 0;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
font-weight: 500;
color: var(--color-primary, #000);
background: #fff;
border-radius: 24rpx;
.csd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 56rpx var(--pad) 0;
}
.share-btn::after {
border: none;
.csd-tile {
display: block;
width: 100%;
}
.cancel-btn {
.csd-cancel {
display: block;
text-align: center;
padding: 32rpx 0;
}
.footer-space {
height: 40rpx;
padding: 8rpx 0 28rpx;
}
+27 -15
View File
@@ -2,8 +2,9 @@ import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, ClosetShareItem, OutfitShareItem } from '../../types/models'
import { ShareStatus, ShareType } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { normalizePaged } from '../../utils/paged'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -37,8 +38,13 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -82,30 +88,36 @@ Page({
return this.fetchMine(page)
},
// 我的分享:仅衣柜分享
// 我的分享:衣柜分享 + 穿搭分享并行拉取后按时间倒序合并
fetchMine(page: number) {
// 请求序号:切 tab 时旧响应可能后到,序号不匹配直接丢弃
const seq = ((this as any).reqSeq || 0) + 1
;(this as any).reqSeq = seq
if (page > 1) this.setData({ loadingMore: true })
Promise.all([
request<PagedResult<ClosetShareItem>>({
url: '/closet-share',
data: { page, page_size: this.data.pageSize },
})
.then((res) => {
}),
request<PagedResult<OutfitShareItem>>({
url: '/outfit-share',
data: { page, page_size: this.data.pageSize },
}),
])
.then(([closetRes, outfitRes]) => {
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<ClosetShareItem>(res)
logger.debug(LOG, '我的分享加载成功, page=', page, '条数=', raw && raw.length)
const list = raw.map((it) => this.decorateCloset(it))
const merged = mergePaged(this.data.list, list, page === 1)
this.setData({
list: merged,
hasMore: hasMorePaged(merged, total, this.data.pageSize),
loading: false,
loadingMore: false,
page,
})
const closetList = normalizePaged<ClosetShareItem>(closetRes).list.map((it) => this.decorateCloset(it))
const outfitList = normalizePaged<OutfitShareItem>(outfitRes).list.map((it) => this.decorateOutfit(it))
logger.debug(LOG, '我的分享加载成功, page=', page, '衣柜=', closetList.length, '穿搭=', outfitList.length)
const prev = page === 1 ? [] : this.data.list
const merged = prev
.concat(closetList, outfitList)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
// 任一侧仍返回满页则认为还有更多
const hasMore =
closetList.length >= this.data.pageSize || outfitList.length >= this.data.pageSize
this.setData({ list: merged, hasMore, loading: false, loadingMore: false, page })
})
.catch((e: any) => {
if ((this as any).reqSeq !== seq) return
@@ -1,67 +1,79 @@
<view class="page page-flex">
<navigation-bar title="分享" back="{{false}}" />
<navigation-bar title="分享" back="{{false}}" scrolled="{{navScrolled}}">
<view slot="right" class="nav-action nav-action--ink" bindtap="onCreate">新建</view>
</navigation-bar>
<!-- 分段切换 -->
<view class="seg-wrap">
<view class="seg-control">
<scroll-view scroll-y class="scroll-body pb-tab" bindscrolltolower="onReachBottom" bindscroll="onNavScroll">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Sharing</view>
<view class="display">分享</view>
</view>
<!-- 下划线式文字分段 -->
<view class="segbar">
<view class="seg">
<view
wx:for="{{tabs}}"
wx:key="name"
class="seg-item {{activeTab === index ? 'active' : ''}}"
class="seg-item {{activeTab === index ? 'is-on' : ''}}"
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 ? '把衣橱或精选衣物分享给好友' : '好友分享给你的衣橱或穿搭会出现在这里'}}"
desc="{{activeTab === 0 ? '把衣橱、精选衣物或穿搭分享给好友' : '好友分享给你的衣橱或穿搭会出现在这里'}}"
buttonText="{{activeTab === 0 ? '新建分享' : ''}}"
bind:action="onCreate"
/>
<view class="list">
<!-- 横向条目卡:左缩略图 + 右标题/有效期 -->
<view class="cs-list">
<view
class="share-card card {{item._invalid ? 'invalid' : ''}}"
class="cs-card {{item._invalid ? 'is-dead' : ''}}"
wx:for="{{list}}"
wx:key="share_uuid"
data-uuid="{{item.share_uuid}}"
data-kind="{{item.kind}}"
bindtap="onItemTap"
>
<!-- 衣柜分享:四宫格占位 -->
<view class="preview-grid" wx:if="{{item.kind === 'closet'}}">
<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 class="cs-thumb" wx:if="{{item.kind === 'closet'}}">
<view class="c"><view class="icon icon-hanger"></view></view>
<view class="c"><view class="icon icon-hanger"></view></view>
<view class="c"><view class="icon icon-hanger"></view></view>
<view class="c"><view class="icon icon-hanger"></view></view>
</view>
<!-- 穿搭分享:封面 -->
<view class="preview-cover" wx:else>
<image wx:if="{{item.cover}}" class="preview-cover-img" src="{{item.cover}}" mode="aspectFill" />
<view wx:else class="icon icon-hanger preview-cover-ph"></view>
<view class="cs-thumb is-photo" wx:else>
<image wx:if="{{item.cover}}" class="pic" src="{{item.cover}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></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 class="cs-body">
<view class="cs-owner" wx:if="{{activeTab === 1}}">
<image wx:if="{{item.owner_avatar}}" class="oav" src="{{item.owner_avatar}}" mode="aspectFill" />
<view wx:else class="oav">
<view class="icon icon-user"></view>
</view>
<text class="owner-name">{{item.owner_name || '好友'}}</text>
<text class="onm">{{item.owner_name || '好友'}}</text>
</view>
<view class="share-title">{{item.title}}</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 class="cs-t">{{item.title}}</view>
<view class="cs-tags">
<text class="pill">{{item.typeText}}</text>
<text wx:if="{{item._invalid}}" class="pill pill--line">{{item.expireText}}</text>
<text wx:else class="cs-exp">{{item.expireText}}</text>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
</view>
+86 -109
View File
@@ -1,3 +1,4 @@
/** 分享列表:下划线分段 + 横向条目卡(失效项降透明) **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,166 +11,142 @@
height: 0;
}
.seg-wrap {
background: #fff;
padding: 20rpx 24rpx;
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.seg-control {
.cs-list {
padding: 28rpx var(--pad) 0;
display: flex;
background: #F2F2F7;
border-radius: 16rpx;
padding: 4rpx;
flex-direction: column;
}
.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;
.cs-card {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 24rpx;
gap: 26rpx;
padding: 32rpx 0;
border-bottom: 1rpx solid var(--hairline);
width: 100%;
align-items: flex-start;
transition: opacity .2s var(--ease), transform .2s var(--ease);
}
.share-card.invalid {
opacity: 0.5;
.cs-card:active {
transform: scale(.985);
}
.preview-grid {
display: flex;
flex-wrap: wrap;
width: 160rpx;
height: 160rpx;
gap: 8rpx;
.cs-card.is-dead {
opacity: .38;
}
.cs-thumb {
width: 144rpx;
height: 144rpx;
flex-shrink: 0;
border-radius: var(--r-img);
background: var(--paper-2);
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2rpx;
}
.pg-cell {
width: 76rpx;
height: 76rpx;
background: #F2F2F7;
border-radius: 12rpx;
.cs-thumb .c {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
background: var(--paper-2);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.pg-cell .icon {
.cs-thumb .c .icon {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
color: var(--art);
}
/* 穿搭分享封面 */
.preview-cover {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background: #F2F2F7;
.cs-thumb.is-photo {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #C7C7CC;
}
.preview-cover-img {
.cs-thumb.is-photo .ph {
width: 52%;
height: 52%;
color: var(--art);
}
.cs-thumb .pic {
width: 100%;
height: 100%;
}
.preview-cover-ph {
width: 64rpx;
height: 64rpx;
.cs-body {
flex: 1;
min-width: 0;
}
.owner-row {
.cs-owner {
display: flex;
align-items: center;
margin-bottom: 10rpx;
gap: 12rpx;
margin-bottom: 14rpx;
}
.owner-avatar {
width: 40rpx;
height: 40rpx;
.cs-owner .oav {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
background: #F2F2F7;
overflow: hidden;
}
.owner-avatar-placeholder {
background: var(--paper-3);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
overflow: hidden;
}
.owner-avatar-icon {
width: 24rpx;
height: 24rpx;
.cs-owner .oav .icon {
width: 20rpx;
height: 20rpx;
}
.owner-name {
font-size: 24rpx;
color: #8E8E93;
.cs-owner .onm {
font-size: 23rpx;
color: var(--ink-2);
}
.share-info {
flex: 1;
margin-left: 24rpx;
}
.share-title {
.cs-t {
font-family: var(--serif);
font-size: 30rpx;
font-weight: 600;
color: #000;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-type {
margin-top: 12rpx;
.cs-tags {
display: flex;
align-items: center;
gap: 14rpx;
margin-top: 18rpx;
flex-wrap: wrap;
}
.share-expire {
.cs-exp {
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;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
@@ -1,5 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -1,6 +1,7 @@
import { request } from '../../utils/request'
import { ClothesItem } from '../../types/models'
import { ProcessStatus } from '../../utils/config'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -122,8 +123,23 @@ Page({
sheetOptions: [] as string[],
sheetValue: '',
sheetValues: [] as string[],
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 删除确认:v2 玻璃弹窗
delVisible: false,
delText: '',
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
// 抽屉内容点击不穿透到遮罩
noop() {},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const shareUUID = query.share_uuid || ''
@@ -155,7 +171,7 @@ Page({
: `/clothes/${this.data.uuid}`
return request<ClothesItem>({ url })
.then((detail) => {
logger.debug(LOG, '详情加载成功, 处理状态=', detail && detail.image_process_status)
logger.debug(LOG, '详情加载成功, name=', detail && detail.name)
this.setData({ detail, loading: false })
})
.catch((e: any) => {
@@ -320,16 +336,22 @@ Page({
.catch((e: any) => logger.warn(LOG, '衣物信息保存失败, code=', e && e.code))
},
// 删除入口:打开 v2 玻璃确认弹窗
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
this.setData({
delVisible: true,
delText: canRefund ? '删除后将退回 1 次上传额度,确认删除?' : '确认删除?',
})
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/clothes/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '衣物删除成功')
@@ -337,14 +359,11 @@ Page({
setTimeout(
() =>
wx.navigateBack({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '衣物删除失败, code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '删除弹窗失败, err=', e && e.errMsg),
})
.catch((e2: any) => logger.warn(LOG, '衣物删除失败, code=', e2 && e2.code))
},
})
@@ -40,17 +40,21 @@ function tags(v) {
module.exports = { hex: hex, tags: tags }
</wxs>
<view class="page detail-page">
<navigation-bar title="衣物详情">
<view class="page page-flex">
<navigation-bar title="衣物详情" scrolled="{{navScrolled}}">
<view
wx:if="{{!readonly && imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
slot="right"
class="nav-action"
class="nav-action nav-action--ink"
bindtap="onToggleEdit"
>{{editMode ? '取消' : '编辑'}}</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<scroll-view
scroll-y
class="scroll-body {{editMode ? 'pb-dock' : 'pb-safe'}}"
bindscroll="onNavScroll"
>
<!-- 缺少衣物标识 -->
<empty-state
wx:if="{{notFound}}"
@@ -61,161 +65,173 @@ module.exports = { hex: hex, tags: tags }
bind:action="onBackWardrobe"
/>
<!-- 处理中:显示步骤进度 -->
<!-- 处理中:三步发丝线进度轨 -->
<block wx:elif="{{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 class="head">
<view class="eyebrow">Processing</view>
<view class="display">AI 正在识别你的衣物…</view>
<view class="cd-note">通常需要 10 秒左右,请稍候</view>
</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 class="up-preview">
<view class="ph icon icon-hanger"></view>
</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 class="up-steps">
<view class="up-step {{step > 0 ? 'is-done' : ''}} {{step === 0 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">排队中</text>
</view>
<view class="up-step {{step > 1 ? 'is-done' : ''}} {{step === 1 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">AI 抠图</text>
</view>
<view class="up-step {{step === 2 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">智能分类</text>
</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 class="up-preview">
<view class="up-bang">!</view>
</view>
<view class="card name-card">
<view class="name-text">{{detail.name || '未命名衣物'}}</view>
<view class="head">
<view class="display">识别失败</view>
<view class="cd-note">{{failReason || '未知原因'}} · 本次上传额度已自动退回,你可以删除或重新上传</view>
</view>
<view class="card fail-card">
<view class="fail-title">识别失败:{{failReason || '未知原因'}}</view>
<view class="fail-desc">本次上传额度已自动退回,你可以删除或重新上传</view>
<view class="up-duo">
<view class="btn btn--ghost" bindtap="onDelete">删除</view>
<view class="btn btn--ink" bindtap="onBackWardrobe">重新上传</view>
</view>
</block>
<!-- 正常详情 -->
<block wx:else>
<!-- 大图区 -->
<view class="img-box">
<!-- 大图:买手店目录式线稿/实拍 -->
<view class="cd-photo">
<image
wx:if="{{detail.processed_image}}"
class="img"
class="pic"
src="{{detail.processed_image}}"
mode="aspectFit"
/>
<view wx:else class="img-placeholder"><view class="icon icon-hanger"></view></view>
<view wx:else class="ph icon icon-hanger"></view>
</view>
<!-- 名称 -->
<view class="card name-card">
<view class="cd-name">
<input
wx:if="{{editMode}}"
class="input"
class="cd-input"
value="{{formData.name}}"
bindinput="onNameInput"
placeholder="衣物名称"
placeholder-class="input-ph"
/>
<view wx:else class="name-text">{{detail.name || '未命名衣物'}}</view>
<view wx:else class="t-lg">{{detail.name || '未命名衣物'}}</view>
</view>
<!-- 属性分组(基础属性 / 版型与材质 / 智能量化评估) -->
<view class="card attr-card" wx:for="{{attrGroups}}" wx:key="title" wx:for-item="group">
<view class="group-title">{{group.title}}</view>
<!-- 属性规格表 -->
<view class="cd-group" wx:for="{{attrGroups}}" wx:key="title" wx:for-item="group">
<view class="grp-hd">
<text class="gh-cn">{{group.title}}</text>
</view>
<view class="rows">
<view
class="attr-row hairline {{editMode ? 'edit' : ''}}"
class="spec {{editMode ? 'is-edit' : ''}}"
wx:for="{{group.attrs}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onAttrTap"
>
<view class="attr-label">{{item.label}}</view>
<view class="attr-value">
<!-- 评分:5 段圆条 + 分值 -->
<text class="spec-k">{{item.label}}</text>
<view class="spec-v">
<!-- 量化评分:细描边方点 -->
<block wx:if="{{item.type === 'score'}}">
<view class="score-dots">
<view class="dots">
<view
class="score-dot {{dotIdx < ((editMode ? formData[item.key] : detail[item.key]) || 0) ? 'on' : ''}} {{item.key === 'visual_impact' ? 'violet' : ''}}"
class="d {{dotIdx < ((editMode ? formData[item.key] : detail[item.key]) || 0) ? 'on' : ''}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:for-item="dot"
wx:for-index="dotIdx"
wx:key="*this"
></view>
</view>
<text class="score-num">{{(editMode ? formData[item.key] : detail[item.key]) || 0}}/5</text>
<text class="cd-score num">{{(editMode ? formData[item.key] : detail[item.key]) || 0}}/5</text>
</block>
<!-- 适用场合:胶囊标签 -->
<!-- 适用场合:无底色描边胶囊 -->
<block wx:elif="{{item.type === 'multi'}}">
<view
class="scene-list"
wx:if="{{fmt.tags(editMode ? formData.occasions : detail.occasions).length}}"
>
<view class="cd-tags" wx:if="{{fmt.tags(editMode ? formData.occasions : detail.occasions).length}}">
<text
class="tag tag-blue scene-tag"
class="pill pill--line"
wx:for="{{fmt.tags(editMode ? formData.occasions : detail.occasions)}}"
wx:for-item="scene"
wx:key="*this"
>{{scene}}</text>
</view>
<text wx:else class="attr-text weak">—</text>
<text wx:else class="c-3">—</text>
</block>
<!-- 普通文本 / 主色 -->
<block wx:else>
<text class="attr-text">{{(editMode ? formData[item.key] : detail[item.key]) || '—'}}</text>
<text class="spec-t">{{(editMode ? formData[item.key] : detail[item.key]) || '—'}}</text>
<view
wx:if="{{item.key === 'color_main' && fmt.hex(editMode ? formData.color_main : detail.color_main)}}"
class="color-dot"
style="background: {{fmt.hex(editMode ? formData.color_main : detail.color_main)}}"
class="dot"
style="background: {{fmt.hex(editMode ? formData.color_main : detail.color_main)}};"
></view>
</block>
</view>
<view wx:if="{{editMode}}" class="icon icon-chevron-right edit-arrow"></view>
<view wx:if="{{editMode}}" class="icon icon-chevron-right cd-arrow"></view>
</view>
</view>
</view>
<!-- 备注 -->
<view class="card remark-card">
<view class="group-title">备注信息</view>
<view class="remark-body">
<view class="cd-group">
<view class="grp-hd">
<text class="gh-cn">备注信息</text>
<text class="gh-en">Note</text>
</view>
<view class="cd-remark">
<textarea
wx:if="{{editMode}}"
class="textarea"
value="{{formData.remark}}"
bindinput="onRemarkInput"
placeholder="添加备注…"
placeholder-class="input-ph"
/>
<view wx:elif="{{detail.remark}}" class="remark-text">{{detail.remark}}</view>
<view wx:else class="remark-text weak">暂无备注</view>
<view wx:elif="{{detail.remark}}" class="t-bd">{{detail.remark}}</view>
<view wx:else class="c-3">暂无备注</view>
</view>
</view>
</block>
<!-- 底部操作:处理中隐藏,其他显示 -->
<view
wx:if="{{!readonly && !notFound && 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
wx:if="{{!readonly && !notFound && imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3 && imageStatus !== 5}}"
class="dock"
>
<block wx:if="{{editMode}}">
<view class="btn btn--ink btn--block" bindtap="onSave">保存</view>
</block>
<block wx:else>
<view class="dock-row">
<view class="btn--text" bindtap="onDelete">删除这件衣物</view>
</view>
</block>
</view>
<!-- 底部属性选择面板 -->
<view class="sheet-mask" wx:if="{{sheetVisible}}" bindtap="onSheetClose"></view>
<view class="sheet" wx:if="{{sheetVisible}}">
<view class="sheet-head">
<text class="sheet-title">{{sheetLabel}}</text>
<view class="sheet-done" bindtap="onSheetConfirm">完成</view>
<view wx:if="{{sheetVisible}}" class="scrim" bindtap="onSheetClose"></view>
<view wx:if="{{sheetVisible}}" class="sheet-wrap">
<view class="sheet" catchtap="noop">
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">{{sheetLabel}}</text>
<text class="sheet-x" bindtap="onSheetConfirm">完成</text>
</view>
<scroll-view scroll-y class="sheet-body">
<!-- 评分:大号点选圆点 -->
@@ -254,7 +270,7 @@ module.exports = { hex: hex, tags: tags }
data-name="{{item.name}}"
bindtap="onSheetColorTap"
>
<view class="sheet-color-dot" style="background: {{item.value}}"></view>
<view class="sheet-color-dot" style="background: {{item.value}};"></view>
</view>
</view>
<input
@@ -262,6 +278,7 @@ module.exports = { hex: hex, tags: tags }
value="{{sheetValue}}"
bindinput="onSheetInput"
placeholder="也可输入色值,如 #FFFFFF"
placeholder-class="input-ph"
/>
</block>
@@ -281,9 +298,21 @@ module.exports = { hex: hex, tags: tags }
value="{{sheetValue}}"
bindinput="onSheetInput"
placeholder="请输入{{sheetLabel}}"
placeholder-class="input-ph"
/>
</block>
</scroll-view>
<view class="safe-bottom"></view>
</view>
</view>
<!-- 删除确认:v2 玻璃弹窗 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="删除这件衣物"
content="{{delText}}"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 衣物详情:买手店目录式大图 + 规格表 + 悬浮底部操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,474 +11,159 @@
height: 0;
}
.nav-action {
font-size: 30rpx;
color: #007AFF;
padding: 0 8rpx;
.cd-note {
margin-top: 20rpx;
font-size: 25rpx;
color: var(--ink-2);
line-height: 1.7;
}
.img-box {
margin: 24rpx;
height: 640rpx;
background: #fff;
border-radius: 32rpx;
.cd-photo {
margin: 32rpx var(--pad) 0;
aspect-ratio: 4 / 5;
background: var(--paper-2);
border-radius: var(--r-img);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.img {
.cd-photo .ph {
width: 46%;
height: 46%;
color: var(--art);
}
.cd-photo .pic {
width: 100%;
height: 100%;
}
.img-placeholder {
color: #C7C7CC;
.cd-name {
padding: 48rpx var(--pad) 0;
}
.img-placeholder .icon {
width: 120rpx;
height: 120rpx;
}
.name-card {
margin: 0 24rpx 24rpx;
}
.name-text {
.cd-input {
font-family: var(--serif);
font-size: 40rpx;
font-weight: 700;
color: #000;
width: 100%;
padding-bottom: 12rpx;
border-bottom: 1rpx solid var(--hairline-2);
}
/* ===== 属性分组卡片 ===== */
.card.attr-card {
margin: 0 24rpx 24rpx;
padding: 0;
overflow: hidden;
.cd-group {
margin-top: 64rpx;
}
.group-title {
padding: 14rpx 32rpx;
font-size: 24rpx;
font-weight: 600;
color: #8E8E93;
background: rgba(242, 242, 247, 0.6);
border-bottom: 1rpx solid #E5E5EA;
.cd-group .grp-hd {
padding-bottom: 22rpx;
}
.attr-row {
display: flex;
align-items: center;
min-height: 92rpx;
padding: 0 32rpx;
box-sizing: border-box;
.spec.is-edit:active {
background: var(--fill);
}
.attr-row:last-child {
border-bottom: none;
.spec-t {
font-size: 29rpx;
}
.attr-row.edit:active {
background: #F2F2F7;
}
.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;
min-width: 0;
}
.attr-text {
font-size: 28rpx;
color: #3C3C43;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attr-text.weak {
color: #C7C7CC;
}
.color-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
.cd-score {
margin-left: 16rpx;
border: 1rpx solid #E5E5EA;
flex-shrink: 0;
font-size: 25rpx;
color: var(--ink-3);
}
/* ===== 评分:5 段圆条 ===== */
.score-dots {
display: flex;
align-items: center;
}
.score-dot {
width: 28rpx;
height: 12rpx;
border-radius: 6rpx;
background: #E5E5EA;
margin-left: 8rpx;
}
.score-dot:first-child {
margin-left: 0;
}
.score-dot.on {
background: #007AFF;
}
.score-dot.violet.on {
background: #5856D6;
}
.score-num {
font-size: 26rpx;
color: #8E8E93;
margin-left: 16rpx;
min-width: 60rpx;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ===== 适用场合标签 ===== */
.scene-list {
display: flex;
align-items: center;
justify-content: flex-end;
.cd-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 12rpx;
justify-content: flex-end;
}
.scene-tag {
margin-left: 8rpx;
}
.scene-tag:first-child {
margin-left: 0;
}
.edit-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
.cd-arrow {
width: 26rpx;
height: 26rpx;
color: var(--ink-4);
margin-left: 12rpx;
flex-shrink: 0;
}
/* ===== 备注卡片 ===== */
.card.remark-card {
margin: 0 24rpx 24rpx;
padding: 0;
overflow: hidden;
.cd-remark {
padding: 0 var(--pad);
}
.remark-body {
padding: 24rpx 32rpx;
}
.remark-text {
font-size: 28rpx;
color: #3C3C43;
line-height: 1.6;
}
.remark-text.weak {
color: #C7C7CC;
}
/* ===== 底部属性选择面板 ===== */
.sheet-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
animation: mask-in 0.2s ease;
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
max-height: 72vh;
display: flex;
flex-direction: column;
animation: sheet-up 0.25s ease;
}
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 12rpx;
}
.sheet-title {
font-size: 32rpx;
font-weight: 600;
color: #000;
}
.sheet-done {
font-size: 30rpx;
font-weight: 600;
color: #007AFF;
padding: 8rpx 0 8rpx 32rpx;
}
.sheet-done:active {
opacity: 0.6;
}
.sheet-body {
padding: 12rpx 32rpx 32rpx;
max-height: 56vh;
box-sizing: border-box;
}
/* 选项胶囊 */
.sheet-tags {
display: flex;
flex-wrap: wrap;
}
.sheet-tag {
padding: 16rpx 30rpx;
margin: 0 16rpx 16rpx 0;
border-radius: 18rpx;
background: #F2F2F7;
color: #3C3C43;
font-size: 28rpx;
line-height: 1;
}
.sheet-tag.on {
background: #007AFF;
color: #fff;
font-weight: 500;
}
.sheet-tag:active {
opacity: 0.8;
}
.sheet-input {
margin-top: 12rpx;
}
/* 色板 */
.sheet-colors {
display: flex;
flex-wrap: wrap;
}
.sheet-color {
width: 96rpx;
height: 96rpx;
margin: 0 20rpx 20rpx 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid transparent;
box-sizing: border-box;
}
.sheet-color.on {
border-color: #007AFF;
}
.sheet-color-dot {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
border: 1rpx solid #E5E5EA;
}
/* 评分点选 */
/* ===== 属性选择面板 ===== */
.sheet-score {
display: flex;
justify-content: space-between;
padding: 20rpx 0 8rpx;
gap: 20rpx;
padding: 32rpx var(--pad);
}
.sheet-score-dot {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: #F2F2F7;
color: #8E8E93;
font-size: 34rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 108rpx;
line-height: 108rpx;
text-align: center;
border-radius: var(--r-ctrl);
background: var(--fill);
font-size: 32rpx;
color: var(--ink-2);
transition: background .18s var(--ease), color .18s var(--ease);
}
.sheet-score-dot.on {
background: #007AFF;
background: var(--ink);
color: #fff;
}
.sheet-score-dot:active {
opacity: 0.8;
}
@keyframes sheet-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
.footer {
padding: 16rpx 24rpx 48rpx;
}
.delete-btn {
text-align: center;
padding: 24rpx 0;
}
/* 处理中 */
.processing-card {
margin: 24rpx;
padding: 64rpx 32rpx;
.sheet-tags {
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
padding: 32rpx var(--pad);
}
.proc-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #F2F2F7;
color: #C7C7CC;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
.sheet-tag {
padding: 16rpx 32rpx;
border-radius: var(--r-pill);
background: var(--fill);
font-size: 27rpx;
color: var(--ink-2);
transition: background .18s var(--ease), color .18s var(--ease);
}
.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;
.sheet-tag.on {
background: var(--ink);
color: #fff;
font-size: 72rpx;
font-weight: 700;
}
.sheet-colors {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 24rpx;
padding: 32rpx var(--pad) 8rpx;
}
.fail-card {
margin: 0 24rpx 24rpx;
padding: 24rpx 32rpx;
.sheet-color {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
box-shadow: inset 0 0 0 1rpx rgba(0, 0, 0, .12);
}
.fail-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
.sheet-color.on {
box-shadow: 0 0 0 4rpx var(--ink);
}
.fail-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
line-height: 1.5;
.sheet-color-dot {
width: 100%;
height: 100%;
border-radius: 50%;
}
.sheet-input {
margin: 24rpx var(--pad);
width: auto;
display: block;
}
+9 -2
View File
@@ -2,6 +2,7 @@ import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { HoroscopeData } from '../../types/models'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
import { STORAGE_KEYS } from '../../utils/config'
@@ -32,13 +33,19 @@ Page({
data: {
horoscope: null as HoroscopeData | null,
statDate: '',
luckyColorHex: '#007AFF',
// 幸运色是唯一允许出现彩色的业务数据;取不到色值时退回中性灰
luckyColorHex: '#AEAEB2',
loading: true,
noBirth: false,
error: false,
errorText: '',
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -72,7 +79,7 @@ Page({
this.setData({
horoscope: res,
statDate: this.formatStatDate(res.stat_date),
luckyColorHex: COLOR_MAP[res.lucky_color] || '#007AFF',
luckyColorHex: COLOR_MAP[res.lucky_color] || '#AEAEB2',
loading: false,
noBirth: false,
error: false,
+91 -50
View File
@@ -1,14 +1,23 @@
<view class="page page-flex">
<navigation-bar title="每日运势" back="{{false}}">
<navigation-bar title="每日运势" back="{{false}}" scrolled="{{navScrolled}}">
<view slot="right" class="nav-action" bindtap="onGoProfile">去设置生日</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<scroll-view scroll-y class="scroll-body pb-tab" bindscroll="onNavScroll">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Daily Fortune</view>
<view class="display">每日运势</view>
</view>
<!-- 骨架屏 -->
<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 wx:if="{{loading}}" class="hs-skel">
<view class="sk sk-ln" style="width: 38%; height: 28rpx;"></view>
<view class="sk hs-skel-num"></view>
<view class="sk sk-ln" style="width: 100%;"></view>
<view class="sk sk-ln" style="width: 100%;"></view>
<view class="sk sk-ln" style="width: 92%;"></view>
<view class="sk sk-ln" style="width: 84%;"></view>
</view>
<!-- 未设置生日 -->
@@ -31,72 +40,104 @@
bind:action="onRetry"
/>
<!-- 运势内容 -->
<view wx:elif="{{horoscope}}" 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>
<!-- 运势内容:杂志内页排版 -->
<block wx:elif="{{horoscope}}">
<view class="hs-hero">
<view class="hs-zodiac">
<view class="icon icon-sparkles"></view>
<text class="t-xl">{{horoscope.zodiac}}</text>
</view>
<text class="hs-date num">{{statDate}}</text>
</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="hs-score">
<view class="hs-score-main">
<view class="eyebrow">综合运势</view>
<view class="hs-num num">{{horoscope.overall_score}}.0</view>
</view>
<view class="hs-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"
class="icon {{index < horoscope.overall_score ? 'icon-star-fill' : 'icon-star'}}"
></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 class="hs-scales">
<view class="hs-scale">
<view class="hs-st">
<text class="k">爱情运势</text>
<text class="v num">{{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 class="scale">
<view class="scale-ticks">
<view class="tk" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this"></view>
</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 class="scale-line"></view>
<view class="scale-fill" style="width: {{horoscope.love_score * 20}}%;"></view>
<view class="scale-pip" style="left: {{horoscope.love_score * 20}}%;"></view>
</view>
</view>
<view class="daily-text">
<text class="daily-label">今日运势</text>
<text class="daily-content">{{horoscope.daily_text}}</text>
<view class="hs-scale">
<view class="hs-st">
<text class="k">工作运势</text>
<text class="v num">{{horoscope.career_score}}/5</text>
</view>
<view class="scale">
<view class="scale-ticks">
<view class="tk" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this"></view>
</view>
<view class="scale-line"></view>
<view class="scale-fill" style="width: {{horoscope.career_score * 20}}%;"></view>
<view class="scale-pip" style="left: {{horoscope.career_score * 20}}%;"></view>
</view>
</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 class="hs-scale">
<view class="hs-st">
<text class="k">财富运势</text>
<text class="v num">{{horoscope.wealth_score}}/5</text>
</view>
<view class="lucky-card">
<text class="lucky-number">{{horoscope.lucky_number}}</text>
<text class="lucky-label">幸运数字</text>
<view class="scale">
<view class="scale-ticks">
<view class="tk" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this"></view>
</view>
<view class="scale-line"></view>
<view class="scale-fill" style="width: {{horoscope.wealth_score * 20}}%;"></view>
<view class="scale-pip" style="left: {{horoscope.wealth_score * 20}}%;"></view>
</view>
</view>
</view>
<view class="gen-btn btn btn-outline" bindtap="onGenerate">生成今日推荐穿搭</view>
<!-- 今日运势:衬线引文排版 -->
<view class="hs-quote">
<view class="eyebrow">Today</view>
<view class="t-quote">{{horoscope.daily_text}}</view>
</view>
<!-- 幸运色 / 幸运数字:两栏发丝线小格 -->
<view class="hs-lucky">
<view class="hs-lucky-cell">
<view class="lk">幸运色</view>
<view class="lv">
<view class="dot" style="background: {{luckyColorHex}};"></view>
<text>{{horoscope.lucky_color}}</text>
</view>
</view>
<view class="hs-lucky-cell">
<view class="lk">幸运数字</view>
<view class="lv num">{{horoscope.lucky_number}}</view>
</view>
</view>
<view class="hs-foot">
<view class="btn btn--ghost btn--block" bindtap="onGenerate">生成今日推荐穿搭</view>
</view>
</block>
<view class="gap-64"></view>
</scroll-view>
</view>
+127 -164
View File
@@ -1,3 +1,4 @@
/** 运势:杂志内页 —— 巨大衬线星座名 / 超大数字 / 发丝线刻度尺 / 衬线引文 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,203 +11,165 @@
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 {
.hs-skel {
padding: 60rpx var(--pad) 0;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 24rpx;
color: #8E8E93;
gap: 44rpx;
}
.overall {
margin-top: 40rpx;
.hs-skel-num {
width: 56%;
height: 128rpx;
border-radius: 12rpx;
}
.overall-label {
font-size: 26rpx;
color: #8E8E93;
.hs-hero {
padding: 44rpx var(--pad) 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24rpx;
}
.overall-score {
.hs-zodiac {
display: flex;
align-items: center;
margin-top: 12rpx;
gap: 18rpx;
}
.score-num {
font-size: 120rpx;
font-weight: 700;
color: #000;
line-height: 1;
margin-right: 32rpx;
}
.stars {
display: flex;
gap: 8rpx;
}
.star {
.hs-zodiac .icon {
width: 40rpx;
height: 40rpx;
color: #C7C7CC;
color: var(--ink-2);
flex-shrink: 0;
}
.star.on {
color: #007AFF;
.hs-date {
font-size: 22rpx;
color: var(--ink-3);
letter-spacing: .08em;
}
.scores {
margin-top: 32rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
}
.score-row {
.hs-score {
padding: 56rpx var(--pad) 0;
display: flex;
align-items: center;
padding: 16rpx 0;
align-items: flex-end;
gap: 40rpx;
}
.score-label {
width: 160rpx;
font-size: 26rpx;
color: #000;
.hs-score-main {
min-width: 0;
}
.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 {
/* 单项评分块(行内纵向排布,由 .hs-scales 的 gap 控制间距) */
.hs-scale {
display: block;
}
.hs-num {
margin-top: 24rpx;
font-family: var(--serif);
font-size: 152rpx;
font-weight: 500;
line-height: .84;
letter-spacing: -.035em;
}
.hs-stars {
display: flex;
gap: 8rpx;
padding-bottom: 18rpx;
}
.hs-stars .icon {
width: 30rpx;
height: 30rpx;
color: var(--ink);
}
.hs-scales {
padding: 64rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 38rpx;
}
.hs-st {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 14rpx;
}
.hs-st .k {
font-size: 26rpx;
color: var(--ink-2);
}
.hs-st .v {
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .04em;
}
.hs-quote {
padding: 64rpx var(--pad) 0;
}
.hs-quote .t-quote {
margin-top: 26rpx;
}
.hs-lucky {
display: flex;
margin: 64rpx var(--pad) 0;
border-top: 1rpx solid var(--hairline);
border-bottom: 1rpx solid var(--hairline);
}
.hs-lucky-cell {
flex: 1;
padding: 32rpx 0 34rpx;
text-align: center;
position: relative;
}
.hs-lucky-cell + .hs-lucky-cell::before {
content: '';
position: absolute;
left: 0;
top: 32rpx;
bottom: 34rpx;
width: 1rpx;
background: var(--hairline);
}
.hs-lucky .lk {
font-size: 19rpx;
font-weight: 600;
color: #000;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink-3);
}
.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;
.hs-lucky .lv {
margin-top: 20rpx;
font-family: var(--serif);
font-size: 38rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
}
.lucky-dot {
width: 24rpx;
height: 24rpx;
.hs-lucky .lv .dot {
width: 22rpx;
height: 22rpx;
border-radius: 50%;
margin-right: 16rpx;
box-shadow: inset 0 0 0 1rpx rgba(0, 0, 0, .14);
}
.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%;
.hs-foot {
padding: 56rpx var(--pad) 0;
}
+3 -1
View File
@@ -1,3 +1,5 @@
{
"usingComponents": {}
"usingComponents": {
"v2-overlay": "/components/v2-overlay/index"
}
}
+22 -15
View File
@@ -21,6 +21,12 @@ Page({
userInfo: {} as UserInfo,
giftVisible: false,
giftItems: [] as GiftItem[],
// 生日引导:v2 玻璃弹窗
birthVisible: false,
birthButtons: [
{ text: '稍后再说', value: '' },
{ text: '去完善', value: 'ok', type: 'main' },
],
},
onLoad(query: Record<string, string>) {
@@ -180,29 +186,30 @@ Page({
},
showBirthGuide() {
wx.showModal({
title: '完善个人信息',
content: '设置生日后即可查看今日星座运势',
confirmText: '去完善',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
this.setData({ birthVisible: true })
},
onBirthCancel() {
this.setData({ birthVisible: false })
this.goAfterLogin()
},
onBirthConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ birthVisible: false })
if (e.detail.value !== 'ok') {
this.goAfterLogin()
return
}
// switchTab 不支持 query 传参,用 storage 标记让「我的」页 onShow 自动弹出编辑弹窗
wx.setStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT, 1)
wx.switchTab({
url: '/pages/profile/profile',
fail: (e) => {
logger.warn(LOG, 'switchTab 个人资料失败, err=', e && e.errMsg)
fail: (err) => {
logger.warn(LOG, 'switchTab 个人资料失败, err=', err && err.errMsg)
// 跳转失败时清除标记,避免下次进入「我的」页意外弹窗
wx.removeStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT)
},
})
} else {
this.goAfterLogin()
}
},
fail: (e) => logger.warn(LOG, '生日引导弹窗失败, err=', e && e.errMsg),
})
},
openAgreement() {
+45 -27
View File
@@ -1,29 +1,29 @@
<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 class="lg-hero">
<view class="icon lg-ic lg-ic1 icon-hanger"></view>
<view class="icon lg-ic lg-ic2 icon-shirt"></view>
<view class="icon lg-ic lg-ic3 icon-sparkles"></view>
<view class="icon lg-ic lg-ic4 icon-camera"></view>
<view class="icon lg-ic lg-ic5 icon-star"></view>
</view>
<!-- Logo 与产品名 -->
<view class="brand">
<view class="brand-logo">
<!-- 品牌区 -->
<view class="lg-top">
<view class="lg-logo">
<view class="icon icon-hanger"></view>
</view>
<view class="brand-name">纸条AI衣橱</view>
<view class="brand-slogan">把你的衣橱,交给 AI 打理</view>
<view class="lg-name">纸条AI衣橱</view>
<view class="lg-slogan">把你的衣橱,交给 AI 打理</view>
</view>
<!-- 底部登录区 -->
<view class="login-footer">
<view class="btn btn-primary login-btn {{loading ? 'disabled' : ''}}" bindtap="onLogin">
<view class="lg-foot">
<view class="btn btn--ink btn--block {{loading ? 'is-off' : ''}}" bindtap="onLogin">
<view class="wechat-icon"></view>
<text>{{loading ? '登录中…' : '微信一键登录'}}</text>
</view>
<view class="agreement">
<view class="lg-terms">
<text>登录即代表同意</text>
<text class="link" bindtap="openAgreement">《用户协议》</text>
<text>和</text>
@@ -31,20 +31,38 @@
</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 wx:if="{{giftVisible}}" class="scrim"></view>
<view wx:if="{{giftVisible}}" class="modal-wrap">
<view class="modal">
<view class="modal-body">
<view class="lg-gift-ic">
<view class="icon icon-sparkles"></view>
</view>
<view class="modal-title">欢迎加入纸条AI衣橱</view>
<view class="modal-text">新人礼包已到账,请查收</view>
<view class="lg-gift-list">
<view class="lg-gift-row" wx:for="{{giftItems}}" wx:key="name">
<view class="icon {{item.icon}}"></view>
<text class="gn">{{item.name}}</text>
<text class="gv num">{{item.value}}</text>
</view>
</view>
<view class="btn btn-primary gift-btn" bindtap="onGiftConfirm">开心收下</view>
</view>
<view class="modal-acts">
<view class="modal-act modal-act--main" bindtap="onGiftConfirm">开心收下</view>
</view>
</view>
</view>
<!-- 生日引导 -->
<v2-overlay
visible="{{birthVisible}}"
type="modal"
title="完善个人信息"
content="设置生日后即可查看今日星座运势"
buttons="{{birthButtons}}"
bind:confirm="onBirthConfirm"
bind:cancel="onBirthCancel"
/>
</view>
+86 -130
View File
@@ -1,194 +1,150 @@
/** 登录:全屏纯白 + 居中线稿 Logo + 衬线品牌名 + 底部实心黑胶囊 **/
.login-page {
min-height: 100vh;
background: #fff;
background: var(--paper);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 顶部插画区 */
.hero {
/* 装饰线稿 */
.lg-hero {
position: relative;
height: 480rpx;
background: #E8F2FF;
border-radius: 0 0 48rpx 48rpx;
overflow: hidden;
width: 100%;
height: 340rpx;
flex-shrink: 0;
}
.hero-icon {
.lg-ic {
position: absolute;
color: #007AFF;
opacity: 0.85;
color: var(--ink-4);
}
.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); }
.lg-ic1 { left: 5%; top: 12%; width: 60rpx; height: 60rpx; transform: rotate(-13deg); opacity: .52; }
.lg-ic2 { left: 27%; top: 62%; width: 42rpx; height: 42rpx; transform: rotate(9deg); opacity: .34; }
.lg-ic3 { right: 7%; top: 16%; width: 52rpx; height: 52rpx; transform: rotate(15deg); opacity: .46; }
.lg-ic4 { right: 24%; bottom: 4%; width: 40rpx; height: 40rpx; transform: rotate(-7deg); opacity: .3; }
.lg-ic5 { left: 13%; bottom: 8%; width: 34rpx; height: 34rpx; opacity: .28; }
/* 品牌区 */
.brand {
.lg-top {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
padding: 0 64rpx 120rpx;
}
.brand-logo {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: #007AFF;
color: #fff;
.lg-logo {
width: 124rpx;
height: 124rpx;
border-radius: 50%;
margin-bottom: 44rpx;
color: var(--ink);
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
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;
.lg-logo .icon {
width: 54rpx;
height: 54rpx;
}
.brand-name {
font-size: 48rpx;
font-weight: 700;
color: #000;
.lg-name {
font-family: var(--serif);
font-size: 54rpx;
font-weight: 500;
letter-spacing: .08em;
text-indent: .08em;
line-height: 1.2;
}
.brand-slogan {
font-size: 28rpx;
color: #8E8E93;
margin-top: 16rpx;
.lg-slogan {
margin-top: 24rpx;
font-size: 26rpx;
color: var(--ink-2);
letter-spacing: .03em;
}
/* 底部 */
.login-footer {
padding: 0 64rpx;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
}
.login-btn {
width: 100%;
.lg-foot {
padding: 0 56rpx calc(52rpx + env(safe-area-inset-bottom));
}
.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;
.lg-terms {
margin-top: 28rpx;
text-align: center;
font-size: 22rpx;
line-height: 1.75;
color: var(--ink-3);
}
.link {
color: #007AFF;
.lg-terms .link {
color: var(--ink-2);
text-decoration: underline;
text-underline-offset: 6rpx;
text-decoration-thickness: 1rpx;
text-decoration-color: var(--ink-4);
}
/* 新手礼包弹窗 */
.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;
/* 新手礼包 */
.lg-gift-ic {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FF9500, #FF2D55);
color: #fff;
margin: 0 auto 32rpx;
color: var(--ink);
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
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 {
.lg-gift-ic .icon {
width: 40rpx;
height: 40rpx;
color: #007AFF;
margin-right: 20rpx;
}
.lg-gift-list {
margin-top: 32rpx;
text-align: left;
}
.lg-gift-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid var(--hairline);
}
.lg-gift-row:last-child {
border-bottom: 0;
}
.lg-gift-row .icon {
width: 32rpx;
height: 32rpx;
color: var(--ink-2);
flex-shrink: 0;
}
.gift-item-name {
.lg-gift-row .gn {
flex: 1;
font-size: 28rpx;
color: #000;
}
.gift-item-val {
font-size: 26rpx;
color: #8E8E93;
}
.gift-btn {
width: 100%;
.lg-gift-row .gv {
font-size: 24rpx;
color: var(--ink-3);
}
+6
View File
@@ -3,6 +3,7 @@ import { QuotaItem, UserInfo, GoodsItem, QuotaFlowItem, PagedResult, VirtualPayR
import { QUOTA_NAMES, GoodsType, GOODS_TYPE_NAMES } from '../../utils/config'
import { fenToYuan, formatDate, formatDateTime } from '../../utils/format'
import { normalizePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -55,8 +56,13 @@ Page({
orderGoods: null as GoodsItem | null,
submitting: false, // 下单防连点:连点「立即支付」会重复创建虚拟支付订单
loading: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
logger.debug(LOG, 'onLoad, query=', JSON.stringify(query))
if (query.tab === 'flow') this.setData({ view: 'flow' })
+84 -56
View File
@@ -1,95 +1,123 @@
<view class="page mall-page">
<navigation-bar title="额度与会员" />
<view class="page page-flex">
<navigation-bar title="额度与会员" scrolled="{{navScrolled}}" />
<!-- 商城视图 -->
<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>
<scroll-view wx:if="{{view === 'mall'}}" scroll-y class="scroll-body pb-safe" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Membership</view>
<view class="display">额度与会员</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 class="ml-member">
<view class="ml-member-l">
<view class="icon icon-crown"></view>
<view class="ml-member-body">
<view class="ml-member-n">{{isVip ? '月度会员' : '开通会员'}}</view>
<view class="ml-member-d">{{isVip ? vipExpireText : '开通会员,解锁更多 AI 与上传权益'}}</view>
</view>
</view>
<view class="ml-member-b" bindtap="onTapMember">{{isVip ? '立即续费' : '立即开通'}}</view>
</view>
<!-- 我的额度:四栏发丝线数字条 -->
<view class="grp-hd ml-grp"><text class="gh-cn">我的额度</text><text class="gh-en">Quota</text></view>
<view class="stats">
<view class="stat" wx:for="{{quotas}}" wx:key="quota_type">
<view class="stat-n num">{{item.remain}}<text class="unit">/{{item.total}}</text></view>
<view class="stat-l">{{item._name}}</view>
<view class="track ml-track">
<view class="bar" 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 class="rows ml-flow-entry">
<view class="row" bindtap="onSwitchFlow">
<view class="icon row-ic icon-doc"></view>
<text class="row-lb">额度明细</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<!-- 商品筛选 -->
<scroll-view scroll-x class="goods-filter" show-scrollbar="{{false}}">
<view class="filter-row">
<view class="ml-filters">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{goodsFilters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
class="seg-item {{activeFilter === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onGoodsFilter"
>{{item.name}}</view>
</view>
</scroll-view>
</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 class="rows">
<view class="row ml-goods" wx:for="{{goods}}" wx:key="goods_code">
<view class="icon row-ic {{item._icon}}"></view>
<view class="ml-goods-body">
<view class="ml-goods-n">{{item.goods_name}}</view>
<view class="ml-goods-d">{{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 class="ml-goods-r">
<view class="ml-price num">{{item._priceText}}</view>
<view class="ml-origin num" wx:if="{{item.price > item.discount_price}}">{{item._originText}}</view>
</view>
<view class="btn btn--ink btn--sm ml-buy" data-goods="{{item}}" bindtap="onBuy">购买</view>
</view>
</view>
<view class="footer-space"></view>
<view class="gap-64"></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>
<scroll-view wx:else scroll-y class="scroll-body pb-safe" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Quota Ledger</view>
<view class="display">额度明细</view>
<view class="ml-back" bindtap="onSwitchMall">← 返回商城</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 class="ml-flows">
<view class="ml-flow-row" wx:for="{{flowList}}" wx:key="quota_flow_uuid">
<view class="ml-flow-l">
<view class="ml-flow-reason">{{item.reason || item._name}}</view>
<view class="ml-flow-time num">{{item._time}}</view>
</view>
<view class="ml-flow-r">
<view class="ml-flow-n num">{{item._sign}}</view>
<view class="ml-flow-after num">剩余 {{item.new_value}}</view>
</view>
</view>
</view>
<empty-state wx:if="{{!flowList.length}}" icon="icon-doc" title="暂无额度明细" />
<view class="gap-64"></view>
</scroll-view>
<!-- 订单确认弹窗 -->
<view wx:if="{{orderVisible}}" class="mask" bindtap="onCloseOrder">
<!-- 订单确认:底部玻璃抽屉 -->
<view wx:if="{{orderVisible}}" class="scrim" bindtap="onCloseOrder"></view>
<view wx:if="{{orderVisible}}" class="sheet-wrap">
<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 {{submitting ? 'disabled' : ''}}" bindtap="confirmPay">{{submitting ? '下单中…' : '立即支付'}}</view>
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">{{orderGoods.goods_name}}</text>
<text class="sheet-x" bindtap="onCloseOrder">取消</text>
</view>
<view class="sheet-body">
<view class="ml-order-d">{{orderGoods.description}}</view>
<view class="ml-order-a">实付 <text class="num">{{orderGoods._priceText}}</text></view>
</view>
<view class="sheet-foot">
<view
class="btn btn--ink btn--block {{submitting ? 'is-off' : ''}}"
bindtap="confirmPay"
>{{submitting ? '下单中…' : '立即支付'}}</view>
</view>
</view>
</view>
</view>
+137 -252
View File
@@ -1,7 +1,9 @@
.mall-page {
/** 额度与会员:唯一深色会员卡 + 四栏额度条 + 商品行 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,322 +11,205 @@
height: 0;
}
/* 会员卡 */
.member-card {
margin: 24rpx;
background: #E8F2FF;
/* 会员卡(全稿唯一深色块) */
.ml-member {
margin: 40rpx var(--pad) 0;
padding: 40rpx 36rpx;
border-radius: var(--r-ctrl);
background: var(--block);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.member-left {
.ml-member-l {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 24rpx;
}
.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;
.ml-member-l .icon {
width: 52rpx;
height: 52rpx;
flex-shrink: 0;
opacity: .9;
}
/* 额度网格 */
.quota-grid {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 20rpx;
.ml-member-body {
min-width: 0;
}
.quota-cell {
width: calc(50% - 10rpx);
.ml-member-n {
font-family: var(--serif);
font-size: 36rpx;
font-weight: 500;
letter-spacing: -.005em;
}
.ml-member-d {
margin-top: 10rpx;
font-size: 23rpx;
line-height: 1.5;
color: rgba(255, 255, 255, .66);
}
.ml-member-b {
flex-shrink: 0;
height: 68rpx;
padding: 0 30rpx;
border-radius: var(--r-pill);
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;
color: var(--block);
font-size: 26rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
transition: opacity .18s var(--ease);
}
.flow-entry .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
.ml-member-b:active {
opacity: .7;
}
/* 额度 */
.ml-grp {
margin-top: 64rpx;
}
.ml-track {
margin: 18rpx 24rpx 0;
}
.ml-flow-entry {
margin-top: 32rpx;
}
/* 商品筛选 */
.goods-filter {
background: #fff;
white-space: nowrap;
margin-bottom: 20rpx;
.ml-filters {
margin-top: 64rpx;
border-bottom: 1rpx solid var(--hairline);
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
/* 商品行 */
.ml-goods {
min-height: 152rpx;
padding-top: 20rpx;
padding-bottom: 20rpx;
}
.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 {
.ml-goods-body {
flex: 1;
min-width: 0;
}
.goods-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
.ml-goods-n {
font-size: 29rpx;
color: var(--ink);
}
.goods-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 6rpx;
.ml-goods-d {
margin-top: 8rpx;
font-size: 23rpx;
color: var(--ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.goods-right {
display: flex;
flex-direction: column;
align-items: flex-end;
.ml-goods-r {
text-align: right;
flex-shrink: 0;
}
.goods-price {
font-size: 30rpx;
font-weight: 700;
color: #007AFF;
.ml-price {
font-family: var(--serif);
font-size: 34rpx;
color: var(--ink);
}
.goods-origin {
font-size: 22rpx;
color: #C7C7CC;
.ml-origin {
margin-top: 4rpx;
font-size: 21rpx;
color: var(--ink-4);
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;
.ml-buy {
flex-shrink: 0;
margin-left: 20rpx;
}
.footer-space {
height: 40rpx;
}
/* 额度明细视图 */
.flow-head {
padding: 24rpx;
/* 额度明细 */
.ml-back {
margin-top: 20rpx;
font-size: 26rpx;
color: #007AFF;
color: var(--ink-2);
}
.flow-list {
padding: 0 24rpx;
.ml-flows {
padding: 40rpx var(--pad) 0;
}
.flow-row {
.ml-flow-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
padding: 24rpx;
align-items: flex-start;
gap: 24rpx;
padding: 32rpx 0;
border-bottom: 1rpx solid var(--hairline);
}
.flow-reason {
font-size: 28rpx;
color: #000;
.ml-flow-l {
flex: 1;
min-width: 0;
}
.flow-time {
font-size: 22rpx;
color: #C7C7CC;
.ml-flow-reason {
font-size: 29rpx;
color: var(--ink);
}
.ml-flow-time {
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;
color: var(--ink-3);
}
/* 订单弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
.ml-flow-r {
text-align: right;
flex-shrink: 0;
}
.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 {
.ml-flow-n {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
color: var(--ink);
}
.order-desc {
font-size: 24rpx;
color: #8E8E93;
text-align: center;
margin-top: 12rpx;
.ml-flow-after {
margin-top: 6rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.order-amount {
/* 订单抽屉 */
.ml-order-d {
padding: 0 var(--pad);
font-size: 26rpx;
line-height: 1.7;
color: var(--ink-2);
}
.ml-order-a {
margin: 40rpx var(--pad) 32rpx;
font-size: 28rpx;
color: #000;
text-align: center;
margin: 32rpx 0;
color: var(--ink-2);
}
.order-price {
font-size: 44rpx;
font-weight: 700;
color: #FF9500;
.ml-order-a .num {
font-family: var(--serif);
font-size: 48rpx;
color: var(--ink);
}
@@ -2,6 +2,7 @@ import { request } from '../../utils/request'
import { NotificationItem } from '../../types/models'
import { formatDateTime } from '../../utils/format'
import { syncTabBarUnread } from '../../utils/tabbar'
import { navPageScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -12,8 +13,13 @@ Page({
uuid: '',
item: {} as NotificationItem,
time: '',
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// 页面级滚动 → 导航收缩为玻璃小标题
onPageScroll: navPageScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid)
@@ -1,14 +1,18 @@
<view class="page detail-page">
<navigation-bar title="通知详情" />
<view class="page nd-page">
<navigation-bar title="通知详情" scrolled="{{navScrolled}}" />
<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="head">
<view class="eyebrow">{{item.msg_type === 1 ? 'Like' : item.msg_type === 2 ? 'Order' : 'System'}}</view>
<view class="t-xl nd-title">{{item.title}}</view>
<view class="nd-time num">{{time}}</view>
</view>
<view class="nd-body">
<view class="nd-ic">
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
</view>
<view class="t-bd nd-content">{{item.content}}</view>
</view>
<view class="detail-title">{{item.title}}</view>
<view class="detail-time">{{time}}</view>
<view class="detail-content">{{item.content}}</view>
</view>
<view class="gap-64"></view>
</view>
@@ -1,65 +1,43 @@
.detail-page {
padding: 24rpx;
box-sizing: border-box;
/** 通知详情:衬线标题 + 发丝线正文排版 **/
.nd-page {
padding-bottom: 40rpx;
}
.detail-card {
margin-top: 24rpx;
padding: 48rpx 40rpx;
.nd-title {
margin-top: 22rpx;
}
.icon-row {
display: flex;
justify-content: center;
margin-bottom: 32rpx;
.nd-time {
margin-top: 18rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .04em;
}
.n-icon {
width: 120rpx;
height: 120rpx;
.nd-body {
margin: 56rpx var(--pad) 0;
padding-top: 56rpx;
border-top: 1rpx solid var(--hairline);
}
.nd-ic {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: var(--fill);
color: var(--ink-2);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
.nd-ic .icon {
width: 44rpx;
height: 44rpx;
}
.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;
.nd-content {
line-height: 1.85;
color: var(--ink);
}
@@ -5,6 +5,7 @@ import { STORAGE_KEYS } from '../../utils/config'
import { formatRelativeTime } from '../../utils/format'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { getTabBarInstance } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -27,8 +28,13 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
@@ -1,53 +1,64 @@
<view class="page page-flex">
<navigation-bar title="通知" />
<navigation-bar title="通知" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
bindscrolltolower="onReachBottom"
>
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom" bindscroll="onNavScroll">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Notifications</view>
<view class="display">通知</view>
</view>
<!-- 下划线式筛选 -->
<view class="segbar">
<view class="seg">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
class="seg-item {{activeFilter === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="nt-skel">
<view class="nt-skel-item" wx:for="{{[1, 2, 3]}}" wx:key="*this">
<view class="sk sk-ln" style="width: 52%;"></view>
<view class="sk sk-ln" style="width: 86%;"></view>
<view class="sk sk-ln" style="width: 30%;"></view>
</view>
</view>
<!-- 空态 -->
<empty-state
wx:if="{{!loading && !list.length}}"
wx:elif="{{!list.length}}"
icon="icon-bell"
title="还没有消息"
desc="穿搭被点赞、订单动态都会出现在这里"
/>
<!-- 列表 -->
<view class="list">
<!-- 列表:未读左侧黑竖条 + 黑字标题,已读降为 ink-3 -->
<view wx:else class="nt-list">
<view
class="n-card card {{item.is_read === 1 ? 'unread' : ''}}"
class="nt-item {{item.is_read === 1 ? 'is-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="nt-ic">
<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 class="nt-body">
<view class="nt-t">{{item.title}}</view>
<view class="nt-c">{{item.content}}</view>
<view class="nt-time num">{{item._time}}</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-32"></view>
</scroll-view>
</view>
@@ -1,3 +1,4 @@
/** 通知列表:下划线筛选 + 未读黑竖条 + 发丝线分行 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,117 +11,104 @@
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
.nt-skel {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 52rpx;
}
.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;
.nt-skel-item {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
.nt-list {
padding: 8rpx var(--pad) 0;
}
.list {
padding: 24rpx;
}
.n-card {
.nt-item {
position: relative;
display: flex;
align-items: flex-start;
padding: 28rpx;
margin-bottom: 20rpx;
gap: 26rpx;
padding: 36rpx 0;
border-bottom: 1rpx solid var(--hairline);
}
.n-icon {
width: 80rpx;
height: 80rpx;
/* 未读:左侧 4rpx 黑竖条 */
.nt-item.is-unread::before {
content: '';
position: absolute;
left: -20rpx;
top: 44rpx;
width: 4rpx;
height: 32rpx;
border-radius: 2rpx;
background: var(--ink);
}
.nt-ic {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--fill);
color: var(--ink-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
.nt-ic .icon {
width: 36rpx;
height: 36rpx;
}
.n-icon.order {
background: #E8F2FF;
color: #007AFF;
}
.n-icon.sys {
background: #F2F2F7;
color: #8E8E93;
}
.n-icon .icon {
width: 44rpx;
height: 44rpx;
}
.n-body {
.nt-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 {
.nt-t {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.n-title.read {
color: #8E8E93;
font-weight: 500;
color: var(--ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.n-content {
.nt-item.is-unread .nt-t {
color: var(--ink);
font-weight: 600;
}
.nt-c {
margin-top: 10rpx;
font-size: 26rpx;
color: #3C3C43;
margin-top: 8rpx;
line-height: 1.6;
color: var(--ink-2);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.n-time {
.nt-time {
margin-top: 12rpx;
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
color: var(--ink-4);
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+62 -21
View File
@@ -2,6 +2,7 @@ import { request } from '../../utils/request'
import { PagedResult, OrderItem } from '../../types/models'
import { fenToYuan, formatDateTime, formatCountdown } from '../../utils/format'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -17,7 +18,14 @@ const STATUS_FILTERS = [
]
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' }
// 零彩色状态语义:待支付 = 实心黑;已支付/已退款 = 描边;已取消/已过期 = 降透明
const STATUS_CLS: Record<number, string> = {
1: 'pill--ink',
2: 'pill--line',
3: 'pill--dim',
4: 'pill--dim',
5: 'pill--line pill--dim',
}
Page({
data: {
@@ -29,8 +37,28 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 取消订单确认
cancelVisible: false,
cancelOrderNo: '',
cancelButtons: [
{ text: '再想想', value: '' },
{ text: '确认取消', value: 'ok', type: 'main' },
],
// 退款申请(抽屉 + 原因输入)
refundVisible: false,
refundOrderNo: '',
refundReason: '',
refundSubmitting: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
// 抽屉内容点击不穿透到遮罩
noop() {},
onShow() {
this.refresh()
this.startCountdown()
@@ -103,7 +131,7 @@ Page({
const list = raw.map((it) => ({
...it,
_statusText: STATUS_TEXT[it.order_status] || '',
_statusType: STATUS_TYPE[it.order_status] || 'gray',
_statusCls: STATUS_CLS[it.order_status] || 'pill--line',
_amount: fenToYuan(it.pay_amount),
_time: formatDateTime(it.created_at),
_orderTail: `…${it.order_no.slice(-4)}`,
@@ -137,21 +165,24 @@ Page({
onCancel(e: WechatMiniprogram.TouchEvent) {
const orderNo = e.currentTarget.dataset.no as string
wx.showModal({
title: '取消订单',
content: '确认取消该订单?',
success: (res) => {
if (!res.confirm) return
this.setData({ cancelVisible: true, cancelOrderNo: orderNo })
},
onCancelCancel() {
this.setData({ cancelVisible: false })
},
onCancelConfirm(ev: WechatMiniprogram.CustomEvent) {
const orderNo = this.data.cancelOrderNo
this.setData({ cancelVisible: false })
if (ev.detail.value !== 'ok' || !orderNo) return
request({ url: `/order/${orderNo}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消订单成功, orderNo=', orderNo)
wx.showToast({ title: '已取消', icon: 'success' })
this.refresh()
})
.catch((e: any) => logger.warn(LOG, '取消订单失败, orderNo=', orderNo, 'code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '取消订单弹窗失败, err=', e && e.errMsg),
})
.catch((err: any) => logger.warn(LOG, '取消订单失败, orderNo=', orderNo, 'code=', err && err.code))
},
onPay() {
@@ -160,26 +191,36 @@ Page({
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()
this.setData({ refundVisible: true, refundOrderNo: orderNo, refundReason: '' })
},
onRefundClose() {
this.setData({ refundVisible: false })
},
onRefundInput(e: WechatMiniprogram.Input) {
this.setData({ refundReason: e.detail.value })
},
onRefundSubmit() {
if (this.data.refundSubmitting) return
const orderNo = this.data.refundOrderNo
const reason = (this.data.refundReason || '').trim()
if (!reason) {
wx.showToast({ title: '请填写退款原因', icon: 'none' })
return
}
this.setData({ refundSubmitting: true })
request({ url: '/order/refund', method: 'POST', data: { order_no: orderNo, reason } })
.then(() => {
logger.info(LOG, '退款申请提交成功, orderNo=', orderNo)
wx.showToast({ title: '退款申请已提交,等待审核', icon: 'none' })
this.setData({ refundVisible: false, refundSubmitting: false })
this.refresh()
})
.catch((e: any) => logger.warn(LOG, '退款申请失败, orderNo=', orderNo, 'code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '退款弹窗失败, err=', e && e.errMsg),
.catch((err: any) => {
logger.warn(LOG, '退款申请失败, orderNo=', orderNo, 'code=', err && err.code)
this.setData({ refundSubmitting: false })
})
},
+78 -24
View File
@@ -1,19 +1,26 @@
<view class="page page-flex">
<navigation-bar title="我的订单" />
<navigation-bar title="我的订单" scrolled="{{navScrolled}}" />
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<scroll-view scroll-y class="scroll-body pb-safe" bindscrolltolower="onReachBottom" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Orders</view>
<view class="display">我的订单</view>
</view>
<!-- 下划线式筛选 -->
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
class="seg-item {{activeFilter === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
</view>
<empty-state
wx:if="{{!loading && !list.length}}"
@@ -24,32 +31,79 @@
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 class="od-list">
<view class="od-card" wx:for="{{list}}" wx:key="order_no">
<view class="od-top">
<text class="od-no num">订单 {{item._orderTail}}</text>
<text wx:if="{{item._payChannelText}}" class="pill">{{item._payChannelText}}</text>
<text class="pill {{item._statusCls}}">{{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">
<view class="od-goods">{{item.goods_name}}</view>
<view class="od-benefit" wx:if="{{item._benefit}}">{{item._benefit}}</view>
<view class="od-bottom">
<view class="od-amount">实付 <text class="num">{{item._amount}}</text></view>
<view class="od-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>
<text wx:if="{{item._countdown}}" class="od-countdown num">剩余 {{item._countdown}}</text>
<view
wx:if="{{item._canCancel}}"
class="btn btn--ghost btn--sm"
data-no="{{item.order_no}}"
bindtap="onCancel"
>取消订单</view>
<view class="btn btn--ink btn--sm" 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>
<view class="btn btn--ghost btn--sm" data-no="{{item.order_no}}" bindtap="onRefund">申请退款</view>
</block>
</view>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 取消订单确认 -->
<v2-overlay
visible="{{cancelVisible}}"
type="modal"
title="取消订单"
content="确认取消该订单?"
buttons="{{cancelButtons}}"
bind:confirm="onCancelConfirm"
bind:cancel="onCancelCancel"
/>
<!-- 退款申请:底部玻璃抽屉 -->
<view wx:if="{{refundVisible}}" class="scrim" bindtap="onRefundClose"></view>
<view wx:if="{{refundVisible}}" class="sheet-wrap">
<view class="sheet" catchtap="noop">
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">申请退款</text>
<text class="sheet-x" bindtap="onRefundClose">取消</text>
</view>
<view class="sheet-body">
<textarea
class="textarea od-refund-input"
value="{{refundReason}}"
bindinput="onRefundInput"
placeholder="请填写退款原因"
placeholder-class="input-ph"
maxlength="200"
/>
</view>
<view class="sheet-foot">
<view
class="btn btn--ink btn--block {{refundSubmitting ? 'is-off' : ''}}"
bindtap="onRefundSubmit"
>{{refundSubmitting ? '提交中…' : '提交申请'}}</view>
</view>
</view>
</view>
</view>
+58 -83
View File
@@ -1,3 +1,4 @@
/** 订单:下划线筛选 + 描边状态胶囊 + tabular 倒计时 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,113 +11,87 @@
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
.od-list {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 40rpx;
}
.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;
.od-card {
padding-bottom: 36rpx;
border-bottom: 1rpx solid var(--hairline);
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
.list {
padding: 24rpx;
}
.order-card {
margin-bottom: 20rpx;
padding: 28rpx;
}
.order-top {
.od-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
}
.order-no {
font-size: 24rpx;
color: #8E8E93;
.od-no {
flex: 1;
font-size: 23rpx;
color: var(--ink-3);
}
.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;
.od-goods {
margin-top: 24rpx;
font-family: var(--serif);
font-size: 34rpx;
line-height: 1.35;
}
.order-amount {
font-size: 26rpx;
color: #3C3C43;
.od-benefit {
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-2);
}
.order-amount .num {
font-size: 32rpx;
font-weight: 600;
color: #000;
.od-bottom {
margin-top: 28rpx;
display: flex;
align-items: center;
gap: 20rpx;
}
.order-actions {
.od-amount {
flex: 1;
font-size: 24rpx;
color: var(--ink-2);
}
.od-amount .num {
font-family: var(--serif);
font-size: 36rpx;
color: var(--ink);
}
.od-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.countdown {
font-size: 24rpx;
color: #8E8E93;
.od-countdown {
font-size: 23rpx;
color: var(--ink-3);
font-family: var(--serif);
}
.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;
.od-refund-input {
margin: 0 var(--pad);
width: auto;
display: block;
}
@@ -1,5 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+247 -50
View File
@@ -1,37 +1,187 @@
import { request } from '../../utils/request'
import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { OutfitItem, ClothesItem } from '../../types/models'
import { IsPublic } from '../../utils/config'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'outfit-detail'
// 接收者打开「分享者额度不足」链接后的自动返回倒计时(秒)
const SHARE_FAIL_BACK_SECONDS = 10
Page({
data: {
uuid: '',
isOwner: true,
// 仅显式 owner=1 视为属主:无参数链接(如微信默认分享路径)安全降级为非属主视角
isOwner: false,
outfit: {} as OutfitItem,
clothes: [] as ClothesItem[],
isLiked: false,
likeCount: 0,
isCollected: false,
loading: true,
// 页面状态:normal 正常;quota 接收者命中「分享者额度不足」;invalid 加载失败
pageState: 'normal' as 'normal' | 'quota' | 'invalid',
invalidTitle: '',
invalidDesc: '',
needLogin: false,
countdown: SHARE_FAIL_BACK_SECONDS,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
tipVisible: false,
tipTitle: '',
tipText: '',
tipButtons: [{ text: '我知道了', value: 'ok', type: 'main' }],
pubVisible: false,
pubButtons: [
{ text: '取消', value: '' },
{ text: '确认公开', value: 'ok', type: 'main' },
],
delVisible: false,
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
moreVisible: false,
moreItems: [
{ text: '编辑穿搭', value: 'edit' },
{ text: '删除穿搭', value: 'delete' },
],
// 额度不足引导:v2 玻璃弹窗
quotaVisible: false,
quotaTitle: '',
quotaText: '',
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const isOwner = query.owner !== '0'
logger.debug(LOG, 'onLoad, uuid=', uuid, 'isOwner=', isOwner)
const isOwner = query.owner === '1'
logger.debug(LOG, 'onLoad, uuid=', uuid, 'isOwner=', isOwner, 'shareFail=', query.share_fail)
this.setData({ uuid, isOwner })
if (uuid) this.fetchDetail()
else logger.warn(LOG, 'onLoad 缺少 uuid 参数')
// 分享者额度不足时分享出去的链接:不请求后端(也不依赖登录),直接进入提示态
if (query.share_fail === 'quota') {
this.showShareFailQuota()
return
}
if (!uuid) {
logger.warn(LOG, 'onLoad 缺少 uuid 参数')
this.setInvalid('链接不完整', '缺少穿搭标识,无法打开')
return
}
this.fetchDetail()
},
// 接收者命中「分享者额度不足」:弹提醒 + 倒计时后返回「我的」页
showShareFailQuota() {
this.setData({
pageState: 'quota',
loading: false,
countdown: SHARE_FAIL_BACK_SECONDS,
tipVisible: true,
tipTitle: '提示',
tipText: '给您分享的好友额度不足哦',
})
this.startBackCountdown()
},
onTipConfirm() {
this.setData({ tipVisible: false })
},
startBackCountdown() {
this.clearBackTimer()
;(this as any).backTimer = setInterval(() => {
const next = this.data.countdown - 1
if (next > 0) {
this.setData({ countdown: next })
return
}
this.clearBackTimer()
this.onBackProfile()
}, 1000)
},
clearBackTimer() {
const timer = (this as any).backTimer
if (timer) {
clearInterval(timer)
;(this as any).backTimer = null
}
},
onBackProfile() {
logger.info(LOG, '额度不足提示结束,返回「我的」页')
wx.switchTab({
url: '/pages/profile/profile',
fail: (e) => logger.warn(LOG, 'switchTab 我的失败, err=', e && e.errMsg),
})
},
onBackHome() {
wx.switchTab({
url: '/pages/wardrobe/wardrobe',
fail: (e) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e && e.errMsg),
})
},
onGoLogin() {
// 透传视角参数:属主登录失效后重登,仍回到属主视角
const owner = this.data.isOwner ? '1' : '0'
const redirect = `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=${owner}`
const loginUrl = `/pages/login/login?redirect=${encodeURIComponent(redirect)}`
wx.navigateTo({
url: loginUrl,
fail: () => {
wx.redirectTo({
url: loginUrl,
fail: () => wx.switchTab({ url: '/pages/profile/profile' }),
})
},
})
},
setInvalid(title: string, desc: string, needLogin = false) {
this.setData({
loading: false,
pageState: 'invalid',
invalidTitle: title,
invalidDesc: desc,
needLogin,
})
},
async fetchDetail() {
let outfit: OutfitItem
try {
const outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}` })
outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}`, showError: false })
} catch (e: any) {
const code = e && e.code
logger.warn(LOG, '穿搭详情加载失败, code=', code)
if (AUTH_EXPIRED_CODES.indexOf(code) >= 0) {
// 未登录:给「去登录」出口,登录成功后回到本页
this.setInvalid('请先登录', '登录后即可查看该穿搭', true)
} else if (code === 40004 || code === 40000) {
// 他人未公开 / 已删除:不暴露具体原因,统一提示内容不可见
this.setInvalid('内容不可见', '该穿搭可能已被删除或未公开')
} else {
this.setInvalid('加载失败', '请稍后重试')
}
return
}
logger.debug(LOG, '穿搭详情加载成功, 单品数=', outfit.items && outfit.items.length)
this.setData({ outfit })
this.setData({ outfit, loading: false })
// 封面与衣物缩略图直接使用穿搭明细里的抠图(processed_image),
// 避免非属主逐件请求 /clothes/:uuid 被拒(50005)导致图片与名称全部丢失。
@@ -55,11 +205,13 @@ Page({
this.setData({ clothes })
}
const like = await request<{ is_liked: boolean; like_count: number }>({
// 点赞状态:失败静默保持默认,不影响详情展示
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 })
.then((like) => this.setData({ isLiked: like.is_liked, likeCount: like.like_count }))
.catch((e: any) => logger.warn(LOG, '点赞状态拉取失败(保持默认), code=', e && e.code))
// 收藏状态:调用后端 /collect/:targetUUID 初始化,失败静默保持未收藏
request<{ is_collected: boolean; collect_count: number }>({
@@ -68,10 +220,6 @@ Page({
})
.then((collect) => this.setData({ isCollected: collect.is_collected }))
.catch((e: any) => logger.warn(LOG, '收藏状态拉取失败(保持未收藏), code=', e && e.code))
} catch (e: any) {
logger.warn(LOG, '穿搭详情加载失败, code=', e && e.code)
this.setData({ loading: false })
}
},
onLike() {
@@ -99,18 +247,20 @@ Page({
if (isPublic) {
this.doPublic(IsPublic.PRIVATE)
} else {
wx.showModal({
title: '公开到社区',
content: '公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度',
confirmText: '确认公开',
success: (res) => {
if (res.confirm) this.doPublic(IsPublic.PUBLIC)
},
fail: (e) => logger.warn(LOG, '公开弹窗失败, err=', e && e.errMsg),
})
this.setData({ pubVisible: true })
}
},
onPubCancel() {
this.setData({ pubVisible: false })
},
onPubConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ pubVisible: false })
if (e.detail.value !== 'ok') return
this.doPublic(IsPublic.PUBLIC)
},
doPublic(isPublic: number) {
// 后端 OutfitPublicRequest.OutfitUUID 为必填(binding required),请求体需携带
request({
@@ -123,18 +273,32 @@ Page({
this.setData({ 'outfit.is_public': isPublic })
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
})
.catch((e: any) => logger.warn(LOG, '公开状态变更失败, code=', e && e.code))
.catch((e: any) => {
logger.warn(LOG, '公开状态变更失败, code=', e && e.code)
// 社区分享额度不足(后端已回滚为私有):弹窗引导购买
if (e && e.code === 80000) {
this.setData({
quotaVisible: true,
quotaTitle: '社区分享额度不足',
quotaText: '购买额度包后可公开到社区',
})
}
})
},
onMore() {
wx.showActionSheet({
itemList: ['编辑穿搭', '删除穿搭'],
success: (res) => {
if (res.tapIndex === 0) this.onEdit()
else this.onDelete()
this.setData({ moreVisible: true })
},
fail: (e) => logger.debug(LOG, '取消更多操作, err=', e && e.errMsg),
})
onMoreCancel() {
this.setData({ moreVisible: false })
},
onMoreSelect(e: WechatMiniprogram.CustomEvent) {
this.setData({ moreVisible: false })
const value = e.detail.value
if (value === 'edit') this.onEdit()
else if (value === 'delete') this.onDelete()
},
onEdit() {
@@ -145,13 +309,16 @@ Page({
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
this.setData({ delVisible: true })
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '穿搭删除成功')
@@ -159,15 +326,12 @@ Page({
setTimeout(
() =>
wx.navigateBack({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '穿搭删除失败, code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '删除弹窗失败, err=', e && e.errMsg),
})
.catch((e2: any) => logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code))
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
@@ -184,14 +348,21 @@ Page({
})
},
// 分享前先创建穿搭分享记录拿到 share_uuid,链接指向分享详情页;
// 失败(如穿搭未公开)则回退到普通穿搭详情链接,保证分享动作仍可用。
// 分享采用微信官方「同步返回 + promise 字段」异步分享模式(基础库 2.12.0+):
// 同步返回兜底链接(3 秒内未 resolve 时生效),promise resolve 后以结果为准。
// 非属主不创建分享记录(对他人穿搭创建会被拒),直接转发普通详情链接。
onShareAppMessage(): any {
const name = this.data.outfit.outfit_name || '我的穿搭'
return request<{ share_uuid: string }>({
const fallback = `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`
if (!this.data.isOwner) {
return { title: name, path: fallback }
}
const promise = request<{ share_uuid: string }>({
url: '/outfit-share',
method: 'POST',
data: { outfit_uuid: this.data.uuid },
showError: false,
})
.then((res) => {
logger.info(LOG, '穿搭分享创建成功, share_uuid=', res.share_uuid)
@@ -201,11 +372,37 @@ Page({
}
})
.catch((e: any) => {
logger.warn(LOG, '穿搭分享创建失败,回退普通分享链接, code=', e && e.code)
return {
title: name,
path: `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`,
logger.warn(LOG, '穿搭分享创建失败, code=', e && e.code)
if (e && e.code === 80000) {
// 额度不足:提示去购买,链接指向「额度不足」提示页(接收者侧提示后自动返回)
this.setData({
quotaVisible: true,
quotaTitle: '分享额度不足',
quotaText: '购买额度包后可继续分享',
})
return { title: name, path: `${fallback}&share_fail=quota` }
}
// 其他失败:回退普通详情链接(接收者侧展示通用「内容不可见」空态)
return { title: name, path: fallback }
})
return { title: name, path: fallback, promise }
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
onUnload() {
this.clearBackTimer()
},
})
@@ -1,79 +1,179 @@
<view class="page detail-page">
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}">
<view wx:if="{{isOwner}}" slot="right" class="nav-more" bindtap="onMore">
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<view class="page page-flex">
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}" scrolled="{{navScrolled}}">
<view
wx:if="{{isOwner && pageState === 'normal' && !loading}}"
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>
<!-- 接收者:分享者额度不足(不请求后端,倒计时后返回「我的」) -->
<empty-state
wx:if="{{pageState === 'quota'}}"
icon="icon-user"
title="好友分享额度不足"
desc="给您分享的好友额度不足哦,{{countdown}} 秒后自动返回"
buttonText="立即返回"
bind:action="onBackProfile"
/>
<!-- 未登录:去登录后回本页 -->
<empty-state
wx:elif="{{pageState === 'invalid' && needLogin}}"
icon="icon-user"
title="{{invalidTitle}}"
desc="{{invalidDesc}}"
buttonText="去登录"
bind:action="onGoLogin"
/>
<!-- 其他加载失败:内容不可见 / 加载失败 -->
<empty-state
wx:elif="{{pageState === 'invalid'}}"
icon="icon-shirt"
title="{{invalidTitle}}"
desc="{{invalidDesc}}"
buttonText="返回首页"
bind:action="onBackHome"
/>
<block wx:else>
<scroll-view scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<!-- 无缝拼图封面 -->
<view class="od-cover">
<view class="collage {{cl.cls(clothes.length)}}">
<view class="cg" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{!clothes.length}}" class="cover-empty">
<view class="icon icon-hanger"></view>
<view wx:if="{{clothes.length > 4}}" class="cg-more">+{{clothes.length - 4}}</view>
<text class="cg-badge num">{{clothes.length}} 件</text>
</view>
</view>
<!-- 信息卡片 -->
<view class="card info-card">
<view class="title-row">
<text class="title">{{outfit.outfit_name}}</text>
<!-- 信息区 -->
<view class="od-info">
<view class="eyebrow">{{outfit.source === 2 ? 'AI Stylist' : 'My Look'}}</view>
<view class="t-xl od-title">{{outfit.outfit_name}}</view>
<view class="od-tags">
<text wx:if="{{outfit.scene}}" class="pill pill--line">{{outfit.scene}}</text>
<text class="pill {{outfit.source === 2 ? 'pill--ink' : ''}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
<text class="pill {{outfit.is_public === 2 ? 'pill--line' : ''}}">{{outfit.is_public === 2 ? '已公开' : '仅自己可见'}}</text>
</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 class="t-bd od-desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
</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="od-items">
<view class="grp-hd">
<text class="gh-cn">包含衣物</text>
<text class="gh-en">Items</text>
</view>
<scroll-view scroll-x class="od-items-scroll" show-scrollbar="{{false}}">
<view class="od-items-row">
<view
class="item-chip"
class="od-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 class="od-thumb">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<text class="od-chip-n">{{item.name || '未命名'}}</text>
</view>
</view>
</scroll-view>
</view>
<view class="footer-placeholder"></view>
<view class="gap-64"></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 class="dock">
<view class="dock-row">
<view class="dock-ic {{isLiked ? 'is-on' : ''}}" bindtap="onLike">
<view class="icon icon-heart"></view>
<text class="num">{{likeCount}}</text>
</view>
<view class="fb-btn" wx:if="{{!isOwner}}" bindtap="onCollect">
<view class="icon {{isCollected ? 'icon-star-fill collected' : 'icon-star'}}"></view>
<view class="dock-ic" wx:if="{{!isOwner}}" bindtap="onCollect">
<view class="icon {{isCollected ? 'icon-star-fill' : '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="spacer"></view>
<block wx:if="{{isOwner}}">
<view class="btn btn--ghost btn--sm" bindtap="onTogglePublic">{{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}</view>
<button class="od-share" open-type="share">
<view class="icon icon-share"></view>
</button>
</block>
</view>
</view>
</block>
<!-- 提示(分享者额度不足) -->
<v2-overlay
visible="{{tipVisible}}"
type="modal"
title="{{tipTitle}}"
content="{{tipText}}"
buttons="{{tipButtons}}"
bind:confirm="onTipConfirm"
/>
<!-- 公开到社区确认 -->
<v2-overlay
visible="{{pubVisible}}"
type="modal"
title="公开到社区"
content="公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度"
buttons="{{pubButtons}}"
bind:confirm="onPubConfirm"
bind:cancel="onPubCancel"
/>
<!-- 删除确认 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="确认删除这套穿搭?"
content="删除后不可恢复,但不会影响衣橱内的单品。"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
<!-- 更多操作 -->
<v2-overlay
visible="{{moreVisible}}"
type="actionsheet"
items="{{moreItems}}"
bind:select="onMoreSelect"
bind:cancel="onMoreCancel"
/>
<!-- 额度不足 -->
<v2-overlay
visible="{{quotaVisible}}"
type="modal"
title="{{quotaTitle}}"
content="{{quotaText}}"
buttons="{{quotaButtons}}"
bind:confirm="onQuotaConfirm"
bind:cancel="onQuotaCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 穿搭详情:无缝拼图封面 + 衬线大标题 + 横滑单品 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,233 +11,101 @@
height: 0;
}
.nav-more {
padding: 0 8rpx;
color: #000;
.od-cover {
padding: 32rpx var(--pad) 0;
}
/* 封面 */
.cover {
margin: 24rpx;
height: 480rpx;
background: #fff;
border-radius: 32rpx;
.od-info {
padding: 48rpx var(--pad) 0;
}
.od-title {
margin-top: 22rpx;
}
.od-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 26rpx;
}
.od-desc {
margin-top: 28rpx;
}
.od-items {
margin-top: 64rpx;
}
.od-items-scroll {
white-space: nowrap;
}
.od-items-row {
display: inline-flex;
gap: 28rpx;
padding: 0 var(--pad);
}
.od-chip {
display: flex;
flex-direction: column;
width: 160rpx;
}
.od-thumb {
width: 160rpx;
height: 160rpx;
background: var(--paper-2);
border-radius: var(--r-img);
overflow: hidden;
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;
.od-thumb .ph {
width: 52%;
height: 52%;
color: var(--art);
}
.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 {
.od-thumb .pic {
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;
.od-chip-n {
margin-top: 14rpx;
font-size: 22rpx;
color: var(--ink-3);
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;
/* 分享:原生 button 去默认外观 */
.od-share {
width: 88rpx;
height: 88rpx;
padding: 0;
margin: 0;
background: #fff;
border: 1rpx solid #E5E5EA;
border: 0;
background: var(--fill);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #007AFF;
color: var(--ink);
line-height: 1;
}
.fb-share::after {
.od-share::after {
border: none;
}
.fb-share .icon {
.od-share .icon {
width: 40rpx;
height: 40rpx;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
"clothes-card": "/components/clothes-card/clothes-card",
"v2-overlay": "/components/v2-overlay/index"
}
}
+24 -12
View File
@@ -1,6 +1,7 @@
import { request } from '../../utils/request'
import { OutfitItem, ClothesItem, PagedResult } from '../../types/models'
import { normalizePaged, hasMorePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -36,8 +37,19 @@ Page({
hasMore: true,
page: 1,
saving: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 删除确认:v2 玻璃弹窗
delVisible: false,
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid, '编辑态=', !!uuid)
@@ -233,13 +245,16 @@ Page({
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
this.setData({ delVisible: true })
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '穿搭删除成功')
@@ -247,14 +262,11 @@ Page({
setTimeout(
() =>
wx.navigateBack({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '穿搭删除失败, code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '删除弹窗失败, err=', e && e.errMsg),
})
.catch((e2: any) => logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code))
},
})
+121 -65
View File
@@ -1,114 +1,170 @@
<view class="page edit-page">
<navigation-bar title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}">
<view wx:if="{{view === 'select'}}" slot="right" class="nav-cancel" bindtap="backToForm">取消</view>
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<view class="page page-flex">
<navigation-bar
title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}"
scrolled="{{navScrolled}}"
>
<view wx:if="{{view === 'select'}}" slot="right" class="nav-action" bindtap="backToForm">取消</view>
</navigation-bar>
<!-- 表单视图 -->
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body">
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">{{isEdit ? 'Edit Look' : 'New Look'}}</view>
<view class="display">{{isEdit ? '编辑穿搭' : '创建穿搭'}}</view>
</view>
<!-- 拼图预览 -->
<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 class="oe-preview">
<view wx:if="{{selectedItems.length}}" class="collage {{cl.cls(selectedItems.length)}}">
<view class="cg" wx:for="{{selectedItems}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{selectedItems.length > 5}}" class="preview-more">+{{selectedItems.length - 5}}</view>
<view wx:if="{{selectedItems.length > 4}}" class="cg-more">+{{selectedItems.length - 4}}</view>
<text class="cg-badge num">{{selectedItems.length}} 件</text>
</view>
<view wx:else class="preview-empty">
<view wx:else class="oe-empty">
<view class="icon icon-hanger"></view>
<text class="pe-title">还没有选择衣物</text>
<text class="pe-desc">点击下方「选择衣物」添加你的单品</text>
<text class="oe-empty-t">还没有选择衣物</text>
<text class="oe-empty-d">点击下方「选择衣橱单品」添加你的单品</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 class="oe-form">
<view class="oe-field">
<view class="oe-label">
<text>穿搭名称 <text class="oe-req">*</text></text>
<text class="oe-count num">{{outfitName.length}}/32</text>
</view>
<input class="input" value="{{outfitName}}" bindinput="onNameInput" placeholder="给这套穿搭起个名字" maxlength="32" />
<input
class="input"
value="{{outfitName}}"
bindinput="onNameInput"
placeholder="给这套穿搭起个名字"
placeholder-class="input-ph"
maxlength="32"
/>
</view>
<view class="field">
<view class="field-label">
<view class="oe-field">
<view class="oe-label">
<text>穿搭描述</text>
<text class="count">{{outfitDesc.length}}/500</text>
<text class="oe-count num">{{outfitDesc.length}}/500</text>
</view>
<textarea class="textarea" value="{{outfitDesc}}" bindinput="onDescInput" placeholder="简单描述这套穿搭的灵感或亮点" maxlength="500" />
<textarea
class="textarea"
value="{{outfitDesc}}"
bindinput="onDescInput"
placeholder="简单描述这套穿搭的灵感或亮点"
placeholder-class="input-ph"
maxlength="500"
/>
</view>
<view class="field">
<view class="field-label">
<view class="oe-field">
<view class="oe-label">
<text>适合场景</text>
<text class="count">{{scene.length}}/100</text>
<text class="oe-count num">{{scene.length}}/100</text>
</view>
<input class="input" value="{{scene}}" bindinput="onSceneInput" placeholder="例如:通勤、约会、旅行" maxlength="100" />
<input
class="input"
value="{{scene}}"
bindinput="onSceneInput"
placeholder="例如:通勤、约会、旅行"
placeholder-class="input-ph"
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 class="grp-hd oe-grp"><text class="gh-cn">单品</text><text class="gh-en">Items</text></view>
<view class="rows">
<view class="row" bindtap="openSelect">
<view class="icon row-ic icon-shirt"></view>
<view class="row-lb">
<view>选择衣橱单品</view>
<view class="oe-entry-d">{{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>
<text class="row-vl">{{selectedUuids.length ? '已选 ' + selectedUuids.length + ' 件' : '未选择'}}</text>
<view class="icon row-ch 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>
<view class="gap-64"></view>
</scroll-view>
<!-- 多选视图 -->
<view wx:else class="select-view">
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<block wx:else>
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
class="seg-item {{activeCategory === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
</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>
<scroll-view scroll-y class="select-body pb-dock" bindscroll="onNavScroll">
<view wx:if="{{selectLoading}}" class="oe-grid">
<view class="oe-skel" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="sk sk-img"></view>
<view class="sk sk-ln" style="width: 62%;"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<view wx:else class="oe-grid">
<view class="oe-tile" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
<view class="gap-32"></view>
</scroll-view>
</block>
<view class="select-footer">
<view>
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
<view class="sf-desc">最多可选择 10 件单品</view>
<!-- 悬浮底部操作条 -->
<view class="dock">
<block wx:if="{{view === 'form'}}">
<view wx:if="{{isEdit}}" class="btn--text oe-del" bindtap="onDelete">删除穿搭</view>
<view
class="btn btn--ink btn--block {{(!outfitName || !selectedUuids.length || saving) ? 'is-off' : ''}}"
bindtap="onSave"
>{{saving ? '保存中…' : (isEdit ? '保存修改' : '保存')}}</view>
</block>
<block wx:else>
<view class="dock-row">
<view class="oe-count-wrap">
<view class="oe-count-t num">已选 {{selectedUuids.length}} 件</view>
<view class="oe-count-d">最多可选择 10 件单品</view>
</view>
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
<view class="btn btn--ink btn--sm oe-confirm" bindtap="confirmSelect">确定</view>
</view>
</block>
</view>
<!-- 删除确认 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="确认删除这套穿搭?"
content="删除后不可恢复,但不会影响衣橱内的单品。"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
</view>
+75 -219
View File
@@ -1,7 +1,9 @@
.edit-page {
/** 穿搭编辑:无缝拼图预览 + 发丝线表单 + 悬浮底部操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,288 +11,142 @@
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;
.oe-preview {
margin: 40rpx var(--pad) 0;
}
.preview-empty {
.oe-empty {
display: flex;
flex-direction: column;
align-items: center;
color: #C7C7CC;
justify-content: center;
aspect-ratio: 1 / 1;
background: var(--paper-2);
border-radius: var(--r-img);
color: var(--ink-4);
}
.preview-empty .icon {
.oe-empty .icon {
width: 96rpx;
height: 96rpx;
}
.pe-title {
font-size: 26rpx;
color: #8E8E93;
margin-top: 16rpx;
.oe-empty-t {
margin-top: 24rpx;
font-family: var(--serif);
font-size: 32rpx;
color: var(--ink-2);
}
.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;
.oe-empty-d {
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-3);
}
/* 表单 */
.form-card {
margin: 0 24rpx 24rpx;
.oe-form {
padding: 64rpx var(--pad) 0;
}
.field {
margin-bottom: 32rpx;
.oe-field {
margin-bottom: 40rpx;
}
.field:last-child {
.oe-field:last-child {
margin-bottom: 0;
}
.field-label {
.oe-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
font-weight: 600;
color: #1C1C1E;
color: var(--ink-2);
margin-bottom: 16rpx;
}
.req {
color: #FF3B30;
.oe-req {
color: var(--ink);
}
.count {
.oe-count {
font-size: 22rpx;
color: #8E8E93;
font-weight: 400;
color: var(--ink-3);
}
/* 选择衣物入口 */
.select-entry {
margin: 0 24rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.oe-grp {
margin-top: 64rpx;
}
.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;
.oe-entry-d {
margin-top: 6rpx;
font-size: 23rpx;
color: var(--ink-3);
}
/* 多选视图 */
.select-view {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
.segbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
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 {
.oe-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 40rpx var(--pad) 0;
}
.oe-skel {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
flex-direction: column;
gap: 18rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
.oe-tile {
display: block;
width: 100%;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
/* 底部操作条 */
.oe-del {
display: block;
text-align: center;
padding: 8rpx 0 28rpx;
}
.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));
.oe-count-wrap {
flex: 1;
min-width: 0;
}
.sf-count {
.oe-count-t {
font-size: 30rpx;
font-weight: 600;
color: #1C1C1E;
color: var(--ink);
}
.sf-desc {
font-size: 22rpx;
color: #8E8E93;
.oe-count-d {
margin-top: 4rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.sf-confirm {
width: 200rpx;
height: 80rpx;
.oe-confirm {
width: 240rpx;
height: 88rpx;
font-size: 28rpx;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -1,6 +1,7 @@
import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { OutfitShareDetail, OutfitItem } from '../../types/models'
import { ShareStatus } from '../../utils/config'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -10,14 +11,26 @@ Page({
data: {
uuid: '',
detail: null as OutfitShareDetail | null,
outfit: { items: [] } as OutfitItem,
outfit: { items: [] } as unknown as OutfitItem,
loading: true,
invalid: false,
invalidTitle: '该分享已取消',
invalidDesc: '分享链接已失效',
needLogin: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 取消分享确认:v2 玻璃弹窗
cancelVisible: false,
cancelContent: '取消后该分享链接将失效',
cancelButtons: [
{ text: '再想想', value: '' },
{ text: '取消分享', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid)
@@ -93,13 +106,16 @@ Page({
},
onCancel() {
wx.showModal({
title: '取消分享',
content: '取消后该分享链接将失效',
confirmText: '取消分享',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
this.setData({ cancelVisible: true })
},
onCancelCancel() {
this.setData({ cancelVisible: false })
},
onCancelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ cancelVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消分享成功')
@@ -107,15 +123,12 @@ Page({
;(this as any).backTimer = setTimeout(
() =>
wx.navigateBack({
fail: (e: any) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
fail: (err: any) => logger.warn(LOG, 'navigateBack 失败, err=', err && err.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '取消分享失败, code=', e && e.code))
},
fail: (e: any) => logger.warn(LOG, '取消分享弹窗失败, err=', e && e.errMsg),
})
.catch((err: any) => logger.warn(LOG, '取消分享失败, code=', err && err.code))
},
onBackHome() {
@@ -1,5 +1,15 @@
<view class="page detail-page">
<navigation-bar title="穿搭分享" />
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<view class="page page-flex">
<navigation-bar title="穿搭分享" scrolled="{{navScrolled}}" />
<empty-state
wx:if="{{invalid && needLogin}}"
@@ -19,69 +29,86 @@
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>
<scroll-view wx:else scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<!-- 分享者行 -->
<view class="head">
<view class="eyebrow">Shared Look</view>
<view class="display">{{outfit.outfit_name}}</view>
</view>
<view class="owner-info">
<view class="owner-name">{{detail.owner_name || '好友'}} 分享了穿搭</view>
<view class="owner-expire">共 {{outfit.items.length}} 件单品</view>
<view class="osd-owner">
<view class="osd-av">
<image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></view>
</view>
<view class="osd-owner-body">
<view class="osd-owner-n">{{detail.owner_name || '好友'}} 分享了穿搭</view>
<view class="osd-owner-e num">共 {{outfit.items.length}} 件单品</view>
</view>
</view>
<!-- 拼图封面 -->
<view class="cover">
<!-- 无缝拼图封面 -->
<view class="osd-cover">
<view class="collage {{cl.cls(outfit.items.length)}}">
<view class="cg" wx:for="{{outfit.items}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{outfit.items.length > 4}}" class="cg-more">+{{outfit.items.length - 4}}</view>
<text class="cg-badge num">{{outfit.items.length}} 件</text>
</view>
</view>
<!-- 信息区 -->
<view class="osd-info">
<view class="osd-tags">
<text wx:if="{{outfit.scene}}" class="pill pill--line">{{outfit.scene}}</text>
<text class="pill {{outfit.source === 2 ? 'pill--ink' : ''}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
</view>
<view class="t-bd osd-desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
</view>
<!-- 包含衣物:横滑(点击只读查看) -->
<view class="osd-items">
<view class="grp-hd">
<text class="gh-cn">包含衣物</text>
<text class="gh-en">Items</text>
</view>
<scroll-view scroll-x class="osd-items-scroll" show-scrollbar="{{false}}">
<view class="osd-items-row">
<view
class="cover-item cover-{{index}}"
wx:for="{{outfit.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-placeholder"></view>
</view>
<view wx:if="{{!outfit.items.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>
</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"
class="osd-chip"
wx:for="{{outfit.items}}"
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>
<view class="osd-thumb">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 转发出口 -->
<button class="share-btn" open-type="share">分享给好友</button>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃操作条 -->
<view class="dock" wx:if="{{!invalid}}">
<view wx:if="{{detail.is_owner}}" class="btn--text osd-cancel" bindtap="onCancel">取消分享</view>
<button class="btn btn--ink btn--block" open-type="share">分享给好友</button>
</view>
<!-- 取消分享确认 -->
<v2-overlay
visible="{{cancelVisible}}"
type="modal"
title="取消分享"
content="{{cancelContent}}"
buttons="{{cancelButtons}}"
bind:confirm="onCancelConfirm"
bind:cancel="onCancelCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 穿搭分享详情:分享者行 + 无缝拼图 + 横滑单品 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,193 +11,111 @@
height: 0;
}
/* 分享者信息 */
.owner-card {
margin: 24rpx;
.osd-owner {
display: flex;
align-items: center;
gap: 24rpx;
padding: 40rpx var(--pad) 0;
}
.avatar {
width: 96rpx;
height: 96rpx;
.osd-av {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
color: var(--ink-3);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
.osd-av .pic {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 56rpx;
height: 56rpx;
.osd-av .icon {
width: 36rpx;
height: 36rpx;
}
.owner-info {
margin-left: 24rpx;
.osd-owner-body {
min-width: 0;
}
.owner-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
.osd-owner-n {
font-size: 27rpx;
color: var(--ink);
}
.owner-expire {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
.osd-owner-e {
margin-top: 6rpx;
font-size: 22rpx;
color: var(--ink-3);
}
/* 封面 */
.cover {
margin: 0 24rpx 24rpx;
height: 480rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
.osd-cover {
padding: 48rpx var(--pad) 0;
}
.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;
.osd-info {
padding: 48rpx var(--pad) 0;
}
.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 {
.osd-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.desc {
font-size: 28rpx;
color: #3C3C43;
margin-top: 20rpx;
line-height: 1.6;
.osd-desc {
margin-top: 26rpx;
}
.items-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
margin-top: 32rpx;
.osd-items {
margin-top: 64rpx;
}
.items-scroll {
margin-top: 20rpx;
.osd-items-scroll {
white-space: nowrap;
}
.items-row {
.osd-items-row {
display: inline-flex;
gap: 24rpx;
padding: 0 var(--pad);
}
.item-chip {
display: flex;
flex-direction: column;
align-items: center;
width: 140rpx;
margin-right: 20rpx;
.osd-chip {
width: 160rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #F2F2F7;
}
.item-img-empty {
.osd-thumb {
width: 160rpx;
height: 160rpx;
background: var(--paper-2);
border-radius: var(--r-img);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.item-img-empty .icon {
width: 56rpx;
height: 56rpx;
.osd-thumb .ph {
width: 52%;
height: 52%;
color: var(--art);
}
/* 转发出口 */
.share-btn {
margin: 32rpx 24rpx 0;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
font-weight: 500;
color: var(--color-primary, #000);
background: #fff;
border-radius: 24rpx;
.osd-thumb .pic {
width: 100%;
height: 100%;
}
.share-btn::after {
border: none;
}
.cancel-btn {
.osd-cancel {
display: block;
text-align: center;
padding: 32rpx 0;
}
.footer-space {
height: 40rpx;
padding: 8rpx 0 28rpx;
}
+62 -12
View File
@@ -1,26 +1,49 @@
import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, OutfitItem } from '../../types/models'
import { PagedResult, OutfitItem, CommunityOutfitItem } from '../../types/models'
import { OutfitSource, IsPublic, STORAGE_KEYS } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { setTabBarSelected } from '../../utils/tabbar'
import { formatRelativeTime } from '../../utils/format'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'outfit'
// 拼图封面单元:无图时前端渲染线稿占位
type CoverCell = { k: string; img: string }
// 列表行:我的穿搭/已收藏来自 OutfitItem;社区来自 CommunityOutfitItem(额外带作者与互动计数)。
// 统一为交叉类型,模板按 activeTab 条件渲染差异字段。
type OutfitRow = OutfitItem & Partial<CommunityOutfitItem> & {
_likeCount?: number
_collectCount?: number
// cls 为胶囊类名(零彩色:pill--ink / pill--line / 空)
_status?: { text: string; cls: string }
_time?: string
_covers?: CoverCell[]
_collageCls?: string
_moreCount?: number
}
Page({
data: {
activeTab: 0,
list: [] as OutfitItem[],
list: [] as OutfitRow[],
loading: true,
refreshing: false,
loadingMore: false,
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -73,16 +96,16 @@ Page({
},
fetchList(page: number): Promise<void> {
const isCollect = this.data.activeTab === 1
const url = isCollect ? '/collect' : '/outfit'
const tab = this.data.activeTab
const url = tab === 1 ? '/collect' : tab === 2 ? '/outfit/community' : '/outfit'
const seq = ((this as any).reqSeq || 0) + 1
;(this as any).reqSeq = seq
if (page > 1) this.setData({ loadingMore: true })
return request<PagedResult<OutfitItem>>({ url, data: { page, page_size: this.data.pageSize } })
return request<PagedResult<OutfitRow>>({ url, data: { page, page_size: this.data.pageSize } })
.then(async (res) => {
const { list: raw, total } = normalizePaged<OutfitItem>(res)
const { list: raw, total } = normalizePaged<OutfitRow>(res)
logger.debug(LOG, '列表加载成功, url=', url, 'page=', page, '条数=', raw && raw.length)
const items = await this.decorateList(raw)
// decorateList 是异步的,期间可能已切换 tab:序号不匹配则丢弃
@@ -103,7 +126,18 @@ Page({
})
},
decorateList(list: OutfitItem[]): Promise<OutfitItem[]> {
decorateList(list: OutfitRow[]): Promise<OutfitRow[]> {
// 社区分段:作者与互动计数由后端一次性下发,本地仅补相对时间,避免逐条请求点赞接口
if (this.data.activeTab === 2) {
return Promise.resolve(
list.map((it) => ({
...it,
_likeCount: it.like_count || 0,
_collectCount: it.collect_count || 0,
_time: formatRelativeTime(it.created_at),
})),
)
}
return Promise.all(
list.map(async (it) => {
let likeCount = 0
@@ -116,15 +150,31 @@ Page({
} catch (e: any) {
logger.warn(LOG, '点赞数拉取失败, uuid=', it.outfit_uuid, 'code=', e && e.code)
}
return { ...it, _likeCount: likeCount, _status: this.getStatusText(it) }
return { ...it, ...this.decorateCover(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' }
// 无缝拼图封面:取前 4 件,超出用 +N 角标;不足 1 件时补一个线稿占位格
decorateCover(it: OutfitItem): { _covers: CoverCell[]; _collageCls: string; _moreCount: number } {
const items = it.items || []
const covers: CoverCell[] = items.slice(0, 4).map((c) => ({
k: c.clothes_uuid,
img: c.processed_image || '',
}))
if (!covers.length) covers.push({ k: 'empty', img: '' })
return {
_covers: covers,
_collageCls: `collage--${covers.length}`,
_moreCount: Math.max(items.length - 4, 0),
}
},
// 零彩色状态语义:AI 来源 = 黑底白字,已公开 = 描边,仅自己可见 = 浅灰
getStatusText(it: OutfitItem): { text: string; cls: string } {
if (it.source === OutfitSource.AI) return { text: 'AI 推荐', cls: 'pill--ink' }
if (it.is_public === IsPublic.PUBLIC) return { text: '已公开', cls: 'pill--line' }
return { text: '仅自己可见', cls: '' }
},
onCreate() {
+63 -41
View File
@@ -1,84 +1,106 @@
<view class="page page-flex">
<navigation-bar title="穿搭" back="{{false}}" />
<navigation-bar title="穿搭" back="{{false}}" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
class="scroll-body pb-float"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
bindscroll="onNavScroll"
>
<!-- 分段控件 -->
<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 class="head">
<view class="eyebrow">Styling</view>
<view class="display">穿搭</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 class="segbar">
<view class="seg">
<view class="seg-item {{activeTab === 0 ? 'is-on' : ''}}" data-index="0" bindtap="onTabChange">我的穿搭</view>
<view class="seg-item {{activeTab === 1 ? 'is-on' : ''}}" data-index="1" bindtap="onTabChange">已收藏</view>
<view class="seg-item {{activeTab === 2 ? 'is-on' : ''}}" data-index="2" bindtap="onTabChange">社区</view>
</view>
</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="list">
<view class="skeleton sk-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
<view wx:if="{{loading}}" class="of-skel">
<view class="of-skel-item" wx:for="{{[1, 2]}}" wx:key="*this">
<view class="sk sk-img sk-cover"></view>
<view class="sk sk-ln" style="width: 44%;"></view>
<view class="sk sk-ln" style="width: 72%;"></view>
</view>
</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-shirt"
title="{{activeTab === 0 ? '还没有穿搭' : '还没有收藏的穿搭'}}"
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : ''}}"
title="{{activeTab === 0 ? '还没有穿搭' : activeTab === 1 ? '还没有收藏的穿搭' : '还没有公开的穿搭'}}"
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : activeTab === 1 ? '在社区或 AI 推荐中点亮小星星,喜欢的穿搭会出现在这里' : '把喜欢的穿搭公开到社区,让大家看到你的搭配'}}"
buttonText="{{activeTab === 0 ? '创建穿搭' : ''}}"
bind:action="onCreate"
/>
<!-- 卡片流 -->
<view wx:else class="list">
<view wx:else class="of-list">
<view
class="outfit-card card"
class="of-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 class="of-author" wx:if="{{activeTab === 2}}">
<image wx:if="{{item.owner_avatar}}" class="of-av" src="{{item.owner_avatar}}" mode="aspectFill" />
<view wx:else class="of-av">
<view class="icon icon-user"></view>
</view>
<view wx:if="{{!item.items.length}}" class="icon icon-hanger"></view>
<text class="cover-count">{{item.items.length}} 件</text>
<text class="of-aname">{{item.owner_name || '穿搭达人'}}</text>
<text class="of-atime">{{item._time}}</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 class="collage {{item._collageCls}}">
<view class="cg" wx:for="{{item._covers}}" wx:for-item="c" wx:key="k">
<image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view 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 wx:if="{{item._moreCount > 0}}" class="cg-more">+{{item._moreCount}}</view>
<text class="cg-badge num">{{item.items.length}} 件</text>
</view>
<view class="of-trow">
<text class="of-title">{{item.outfit_name}}</text>
<text wx:if="{{activeTab !== 2}}" class="pill {{item._status.cls}}">{{item._status.text}}</text>
</view>
<view class="of-desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
<view class="of-meta">
<text class="pill pill--line" wx:if="{{item.scene}}">{{item.scene}}</text>
<text class="of-m">{{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 class="of-m">
<view class="icon icon-heart"></view>
<text class="num">{{item._likeCount || 0}}</text>
</view>
<view class="of-m" wx:if="{{activeTab === 2}}">
<view class="icon icon-star"></view>
<text class="num">{{item._collectCount || 0}}</text>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃双按钮条(社区为浏览场景,不展示管理入口) -->
<view class="float-duo" wx:if="{{activeTab !== 2}}">
<view class="btn btn--glass" bindtap="onCreate">创建穿搭</view>
<view class="btn btn--ink" bindtap="onAiRecommend">AI 推荐</view>
</view>
</view>
+97 -158
View File
@@ -1,3 +1,4 @@
/** 穿搭:下划线分段 + 无缝拼图卡 + 悬浮双按钮条 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,199 +11,137 @@
height: 0;
}
.topbar {
background: #fff;
padding: 20rpx 32rpx;
/* 分段条:吸顶玻璃 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.segment {
.of-list {
padding: 40rpx var(--pad) 0;
display: flex;
background: #F2F2F7;
border-radius: 20rpx;
padding: 6rpx;
flex-direction: column;
gap: 56rpx;
}
.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 {
.of-card {
display: block;
width: 100%;
height: 100%;
transition: transform .2s var(--ease);
}
.cover-ph {
width: 72rpx;
height: 72rpx;
color: #C7C7CC;
.of-card:active {
transform: scale(.985);
}
.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;
.of-skel {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 56rpx;
}
.card-body {
margin-top: 20rpx;
.of-skel-item {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.title-row {
.sk-cover {
aspect-ratio: 4 / 3;
}
/* 作者行(仅社区分段) */
.of-author {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.title {
font-size: 32rpx;
font-weight: 600;
color: #000;
.of-av {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: var(--paper-3);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.of-av .icon {
width: 26rpx;
height: 26rpx;
}
.of-aname {
font-size: 25rpx;
letter-spacing: .01em;
color: var(--ink);
}
.of-atime {
margin-left: auto;
font-size: 22rpx;
color: var(--ink-3);
}
.of-trow {
display: flex;
align-items: center;
gap: 18rpx;
margin-top: 24rpx;
}
.of-title {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 500;
line-height: 1.35;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.desc {
font-size: 26rpx;
color: #8E8E93;
margin-top: 12rpx;
.of-desc {
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.62;
color: var(--ink-2);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta-row {
.of-meta {
display: flex;
align-items: center;
margin-top: 16rpx;
gap: 22rpx;
margin-top: 24rpx;
}
.meta {
font-size: 22rpx;
color: #8E8E93;
margin-left: 12rpx;
}
.spacer {
flex: 1;
}
.meta-icon {
display: flex;
.of-m {
display: inline-flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: #8E8E93;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.heart {
width: 36rpx;
height: 36rpx;
color: #C7C7CC;
margin-right: 8rpx;
}
.no-more {
text-align: center;
font-size: 24rpx;
color: #C7C7CC;
padding: 24rpx 0 40rpx;
.of-m .icon {
width: 26rpx;
height: 26rpx;
}
+2 -1
View File
@@ -1,5 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"v2-overlay": "/components/v2-overlay/index"
}
}
+25 -5
View File
@@ -4,6 +4,7 @@ import { UserInfo, QuotaItem } from '../../types/models'
import { QuotaType, STORAGE_KEYS } from '../../utils/config'
import { formatDate } from '../../utils/format'
import { setTabBarSelected, setTabBarUnread } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -44,8 +45,21 @@ Page({
},
birthDateStr: '',
today: '',
// 四栏额度条:上传 / AI 推荐 / 社区分享 / 衣柜分享
quotaStats: [] as Array<{ key: number; label: string; remain: number; total: number }>,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 性别选择:v2 玻璃动作表
sexVisible: false,
sexItems: [
{ text: '男', value: 1 },
{ text: '女', value: 2 },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad() {
this.setToday()
},
@@ -162,11 +176,17 @@ Page({
},
onSexSelect() {
wx.showActionSheet({
itemList: ['男', '女'],
success: (res) => this.setData({ 'editForm.sex': res.tapIndex + 1 }),
fail: (e) => logger.debug(LOG, '取消性别选择, err=', e && e.errMsg),
})
this.setData({ sexVisible: true })
},
onSexPick(e: WechatMiniprogram.CustomEvent) {
const sex = Number(e.detail.value) || 0
logger.debug(LOG, '选择性别, sex=', sex)
this.setData({ 'editForm.sex': sex, sexVisible: false })
},
onSexCancel() {
this.setData({ sexVisible: false })
},
onBirthChange(e: WechatMiniprogram.CustomEvent) {
+131 -82
View File
@@ -1,97 +1,123 @@
<view class="page page-flex">
<navigation-bar title="我的" back="{{false}}" />
<navigation-bar title="我的" back="{{false}}" scrolled="{{navScrolled}}" />
<scroll-view scroll-y class="scroll-body">
<!-- 额度提示条 -->
<view wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="quota-banner" bindtap="onTapMall">
你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包 >
<scroll-view scroll-y class="scroll-body pb-tab" bindscroll="onNavScroll">
<!-- 页头 -->
<view class="head">
<view class="eyebrow">Profile</view>
</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 class="pf-hero">
<view class="pf-av">
<image wx:if="{{userInfo.avatar}}" class="pf-av-img" src="{{userInfo.avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></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 class="pf-info">
<view class="pf-name">{{userInfo.username || '微信用户'}}</view>
<view class="pf-sub">
<text wx:if="{{userInfo.sex === 1}}" class="pill">男</text>
<text wx:elif="{{userInfo.sex === 2}}" class="pill">女</text>
<text wx:if="{{userInfo.vip_type !== 1}}" class="pill pill--ink">月度会员</text>
<text wx:if="{{zodiac}}" class="pill pill--line">{{zodiac}}</text>
<text wx:else class="pill pill--line" 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 wx:if="{{unread > 0}}" class="menu-badge">{{unread > 99 ? '99+' : unread}}</view>
<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 class="pf-edit" bindtap="onEdit">
编辑资料
<view class="icon icon-chevron-right"></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 wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="pf-warn" bindtap="onTapMall">
<view class="icon icon-info"></view>
<text class="pf-warn-t">你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包</text>
<view class="icon icon-chevron-right"></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 class="pf-quota" bindtap="onTapMall">
<view class="pf-quota-hd">
<text class="eyebrow">Quota</text>
<text class="qk">我的额度</text>
<view class="icon icon-chevron-right"></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 class="stats">
<view class="stat" wx:for="{{quotaStats}}" wx:key="key">
<view class="stat-n num">{{item.remain}}<text class="unit">/{{item.total}}</text></view>
<view class="stat-l">{{item.label}}</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>
<!-- 账户 -->
<view class="grp-hd pf-grp"><text class="gh-cn">账户</text><text class="gh-en">Account</text></view>
<view class="rows">
<view class="row" bindtap="onTapOrder">
<view class="icon row-ic icon-doc"></view>
<text class="row-lb">我的订单</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapNotification">
<view class="icon row-ic icon-bell"></view>
<text class="row-lb">通知</text>
<text wx:if="{{unread > 0}}" class="row-badge">{{unread > 99 ? '99+' : unread}}</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapCollect">
<view class="icon row-ic icon-star"></view>
<text class="row-lb">我的收藏</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapFlow">
<view class="icon row-ic icon-gauge"></view>
<text class="row-lb">额度明细</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<!-- 设置 -->
<view class="grp-hd pf-grp"><text class="gh-cn">设置</text><text class="gh-en">Settings</text></view>
<view class="rows">
<view class="row" bindtap="onEdit">
<view class="icon row-ic icon-pencil"></view>
<text class="row-lb">编辑资料</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onEdit">
<view class="icon row-ic icon-calendar"></view>
<text class="row-lb">设置生日</text>
<text class="row-vl">{{birthDateStr || '未设置'}}</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapService">
<view class="icon row-ic icon-headphones"></view>
<text class="row-lb">客服与反馈</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapAbout">
<view class="icon row-ic icon-info"></view>
<text class="row-lb">关于纸条AI衣橱</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<view class="pf-ver">v1.0.0</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 编辑资料弹窗 -->
<view wx:if="{{editVisible}}" class="mask" bindtap="onCloseEdit">
<!-- 编辑资料:底部玻璃抽屉 -->
<view wx:if="{{editVisible}}" class="scrim" bindtap="onCloseEdit"></view>
<view wx:if="{{editVisible}}" class="sheet-wrap">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="sheet-title">编辑资料</view>
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">编辑资料</text>
<text class="sheet-x" bindtap="onCloseEdit">取消</text>
</view>
<view class="sheet-body">
<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>
@@ -101,26 +127,49 @@
<view class="form-field">
<text class="form-label">昵称</text>
<input class="input" type="nickname" value="{{editForm.username}}" bindinput="onNameInput" maxlength="32" placeholder="请输入昵称" />
<input
class="input form-input"
type="nickname"
value="{{editForm.username}}"
bindinput="onNameInput"
maxlength="32"
placeholder="请输入昵称"
placeholder-class="input-ph"
/>
</view>
<view class="form-field">
<view class="form-field" bindtap="onSexSelect">
<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 class="form-value">
<text class="{{editForm.sex ? '' : 'input-ph'}}">{{editForm.sex === 1 ? '男' : editForm.sex === 2 ? '女' : '请选择'}}</text>
<view class="icon icon-chevron-right form-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>
<text class="{{birthDateStr ? '' : 'input-ph'}}">{{birthDateStr || '请选择生日'}}</text>
<view class="icon icon-chevron-right form-arrow"></view>
</view>
</picker>
</view>
</view>
<view class="btn btn-primary sheet-btn {{avatarUploading ? 'btn-disabled' : ''}}" bindtap="onSaveProfile">{{avatarUploading ? '上传中…' : '保存'}}</view>
<view class="sheet-foot">
<view
class="btn btn--ink btn--block {{avatarUploading || saving ? 'is-off' : ''}}"
bindtap="onSaveProfile"
>{{avatarUploading ? '上传中…' : (saving ? '保存中…' : '保存')}}</view>
</view>
</view>
</view>
<!-- 性别选择:底部动作表 -->
<v2-overlay
visible="{{sexVisible}}"
type="actionsheet"
items="{{sexItems}}"
bind:select="onSexPick"
bind:cancel="onSexCancel"
/>
</view>
+116 -175
View File
@@ -1,3 +1,4 @@
/** 我的:大号头像 + 衬线昵称 + 四栏额度条 + 纯发丝线分组列表 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,198 +11,145 @@
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;
.pf-hero {
display: flex;
align-items: center;
gap: 32rpx;
padding: 48rpx var(--pad) 0;
}
.avatar {
width: 128rpx;
height: 128rpx;
.pf-av {
width: 144rpx;
height: 144rpx;
border-radius: 50%;
background: #F2F2F7;
flex-shrink: 0;
overflow: hidden;
background: var(--paper-3);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
.pf-av .icon {
width: 62rpx;
height: 62rpx;
}
.pf-av-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 72rpx;
height: 72rpx;
}
.user-info {
flex: 1;
margin-left: 24rpx;
.pf-info {
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;
.pf-name {
font-family: var(--serif);
font-size: 46rpx;
font-weight: 500;
letter-spacing: -.005em;
line-height: 1.2;
}
.menu-badge {
min-width: 32rpx;
height: 32rpx;
padding: 0 10rpx;
border-radius: 16rpx;
background: #FF3B30;
color: #fff;
font-size: 20rpx;
line-height: 32rpx;
text-align: center;
box-sizing: border-box;
.pf-sub {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.pf-edit {
margin-left: auto;
display: flex;
align-items: center;
gap: 2rpx;
font-size: 25rpx;
color: var(--ink-3);
flex-shrink: 0;
margin-left: 12rpx;
}
.menu-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
margin-left: 12rpx;
.pf-edit .icon {
width: 24rpx;
height: 24rpx;
}
.version {
/* 额度提醒:浅填充提示条,不依赖彩色 */
.pf-warn {
margin: 44rpx var(--pad) 0;
padding: 26rpx 30rpx;
border-radius: var(--r-ctrl);
background: var(--fill);
display: flex;
align-items: center;
gap: 20rpx;
font-size: 25rpx;
line-height: 1.55;
}
.pf-warn .icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
color: var(--ink-2);
}
.pf-warn-t {
flex: 1;
}
.pf-quota {
display: block;
width: 100%;
margin-top: 48rpx;
}
.pf-quota-hd {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 var(--pad) 26rpx;
}
.pf-quota-hd .qk {
font-size: 26rpx;
font-weight: 600;
}
.pf-quota-hd .icon {
width: 24rpx;
height: 24rpx;
color: var(--ink-4);
margin-left: auto;
}
.pf-grp {
margin-top: 68rpx;
}
.pf-ver {
padding: 52rpx 0 12rpx;
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;
color: var(--ink-4);
letter-spacing: .06em;
}
/* ===== 编辑资料抽屉 ===== */
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
margin: 0 auto;
margin: 16rpx auto 0;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-sizing: border-box;
color: #C7C7CC;
color: var(--ink-3);
}
.avatar-wrap .avatar-img {
@@ -210,8 +158,8 @@
}
.avatar-wrap .avatar-placeholder {
width: 88rpx;
height: 88rpx;
width: 72rpx;
height: 72rpx;
}
.avatar-btn {
@@ -233,28 +181,30 @@
.avatar-tip {
text-align: center;
font-size: 22rpx;
color: #8E8E93;
color: var(--ink-3);
margin: 16rpx 0 24rpx;
}
.form-field {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #F2F2F7;
padding: 24rpx var(--pad);
border-bottom: 1rpx solid var(--hairline);
}
.form-label {
width: 140rpx;
font-size: 28rpx;
color: #000;
color: var(--ink-2);
flex-shrink: 0;
}
.form-field .input {
.form-input {
flex: 1;
background: #fff;
height: auto;
background: transparent;
padding: 0;
font-size: 30rpx;
}
.form-value {
@@ -262,20 +212,11 @@
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
font-size: 30rpx;
}
.placeholder {
color: #C7C7CC;
}
.sheet-btn {
margin-top: 32rpx;
width: 100%;
}
.btn-disabled {
opacity: 0.5;
pointer-events: none;
.form-arrow {
width: 26rpx;
height: 26rpx;
color: var(--ink-4);
}
+2 -1
View File
@@ -1,5 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"v2-overlay": "/components/v2-overlay/index"
}
}
+60 -32
View File
@@ -1,5 +1,6 @@
import { request, uploadFile, NOT_LOGIN_CODE } from '../../utils/request'
import { ProcessStatus } from '../../utils/config'
import { navPageScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -12,17 +13,42 @@ Page({
clothesUuid: '',
failReason: '',
step: 0,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
srcVisible: false,
srcItems: [
{ text: '拍照', value: 'camera' },
{ text: '从相册选择', value: 'album' },
],
doneVisible: false,
doneButtons: [
{ text: '回到衣橱', value: 'back' },
{ text: '继续上传', value: 'again', type: 'main' },
],
quotaVisible: false,
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// 页面级滚动 → 导航收缩为玻璃小标题
onPageScroll: navPageScroll,
onChoose() {
wx.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
const sourceType: Array<'camera' | 'album'> = res.tapIndex === 0 ? ['camera'] : ['album']
this.chooseImage(sourceType)
this.setData({ srcVisible: true })
},
fail: (e) => logger.debug(LOG, '取消选择图片, err=', e && e.errMsg),
})
onSourceCancel() {
this.setData({ srcVisible: false })
},
onSourceSelect(e: WechatMiniprogram.CustomEvent) {
const value = e.detail.value as string
this.setData({ srcVisible: false })
if (value !== 'camera' && value !== 'album') return
this.chooseImage([value])
},
chooseImage(sourceType: Array<'camera' | 'album'>) {
@@ -66,17 +92,13 @@ Page({
// 后台继续轮询,仅用于更新步骤进度(不再自动跳详情)
this.pollStatus(this.data.clothesUuid)
// 立即弹窗让用户选择下一步,避免干等
wx.showModal({
title: '衣物已上传',
content: 'AI 正在识别中,可在衣橱查看进度',
confirmText: '继续上传',
cancelText: '回到衣橱',
success: (res) => {
this.setData({ doneVisible: true })
},
onDoneCancel() {
// 回到衣橱
this.setData({ doneVisible: false })
this.clearTimer()
if (res.confirm) {
logger.debug(LOG, '选择继续上传,重置状态')
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
} else {
wx.navigateBack({
delta: 1,
fail: (e) => {
@@ -87,10 +109,17 @@ Page({
})
},
})
}
},
fail: (e) => logger.warn(LOG, '上传完成弹窗失败, err=', e && e.errMsg),
})
onDoneConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ doneVisible: false })
this.clearTimer()
if (e.detail.value === 'again') {
logger.debug(LOG, '选择继续上传,重置状态')
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
return
}
this.onDoneCancel()
},
pollStatus(uuid: string) {
@@ -139,20 +168,19 @@ Page({
},
showQuotaModal() {
wx.showModal({
title: '上传额度不足',
content: '当前剩余 0 次,购买额度包后可继续上传',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.setData({ quotaVisible: true })
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (e) => logger.warn(LOG, 'navigateTo 商城失败, err=', e && e.errMsg),
})
}
},
fail: (e) => logger.warn(LOG, '额度弹窗失败, err=', e && e.errMsg),
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
+85 -40
View File
@@ -1,48 +1,93 @@
<view class="page upload-page">
<navigation-bar title="上传衣物" />
<view class="page up-page">
<navigation-bar title="上传衣物" scrolled="{{navScrolled}}" />
<!-- 初始态 -->
<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 wx:if="{{state === 'idle'}}">
<view class="head">
<view class="eyebrow">Upload</view>
<view class="display">上传衣物</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 class="up-drop" hover-class="up-drop-press" bindtap="onChoose">
<view class="icon icon-camera up-drop-ic"></view>
<view class="up-drop-t">拍照或从相册选择</view>
<view class="up-drop-d">支持 JPG / PNG,单张不超过 20MB</view>
<view class="up-drop-d">上传消耗 1 次上传额度</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 class="up-note">AI 将自动抠图并识别 15 项属性</view>
</view>
<!-- 处理中:实际图片 + 三段发丝线进度轨(排队中 / AI 抠图 / 智能分类) -->
<view wx:elif="{{state === 'processing'}}">
<view class="head">
<view class="eyebrow">Processing</view>
<view class="display">AI 正在识别你的衣物…</view>
<view class="up-note-left">通常需要 10 秒左右,请稍候</view>
</view>
<view class="up-preview">
<image wx:if="{{imagePath}}" class="pic" src="{{imagePath}}" mode="aspectFit" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view class="up-steps">
<view class="up-step {{step > 0 ? 'is-done' : ''}} {{step === 0 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">排队中</text>
</view>
<view class="up-step {{step > 1 ? 'is-done' : ''}} {{step === 1 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">AI 抠图</text>
</view>
<view class="up-step {{step === 2 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">智能分类</text>
</view>
</view>
</view>
<!-- 失败态:极简「!」+ 衬线标题 + 双按钮 -->
<view wx:else class="up-failed">
<view class="up-bang">!</view>
<view class="head">
<view class="display">识别失败</view>
<view class="up-note-left">{{failReason || '未知原因'}} · 本次上传额度已自动退回</view>
</view>
<view class="up-duo">
<view class="btn btn--ghost" bindtap="onRetry">重新上传</view>
<view class="btn btn--ink" bindtap="onManualEdit">手动填写信息</view>
</view>
</view>
<!-- 图片来源选择 -->
<v2-overlay
visible="{{srcVisible}}"
type="actionsheet"
items="{{srcItems}}"
bind:select="onSourceSelect"
bind:cancel="onSourceCancel"
/>
<!-- 上传完成 -->
<v2-overlay
visible="{{doneVisible}}"
type="modal"
title="衣物已上传"
content="AI 正在识别中,可在衣橱查看进度"
buttons="{{doneButtons}}"
bind:confirm="onDoneConfirm"
bind:cancel="onDoneCancel"
/>
<!-- 额度不足 -->
<v2-overlay
visible="{{quotaVisible}}"
type="modal"
title="上传额度不足"
content="当前剩余 0 次,购买额度包后可继续上传"
buttons="{{quotaButtons}}"
bind:confirm="onQuotaConfirm"
bind:cancel="onQuotaCancel"
/>
</view>
+43 -131
View File
@@ -1,156 +1,68 @@
.upload-page {
padding: 32rpx;
box-sizing: border-box;
/** 上传衣物:虚线投放框 / 处理中三步进度轨 / 失败双按钮 **/
.up-page {
min-height: 100vh;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}
/* 初始态 */
.upload-zone {
margin-top: 40rpx;
height: 520rpx;
border: 3rpx dashed #C7C7CC;
border-radius: 32rpx;
background: #fff;
.up-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: calc(100% - var(--pad) * 2);
margin: 56rpx var(--pad) 0;
padding: 108rpx 48rpx;
border: 1rpx dashed var(--hairline-2);
border-radius: var(--r-img);
transition: background .2s var(--ease), border-color .2s var(--ease);
}
.upload-camera {
width: 100rpx;
height: 100rpx;
color: #007AFF;
margin-bottom: 24rpx;
.up-drop-press {
background: var(--fill);
border-color: var(--ink-4);
}
.upload-title {
font-size: 30rpx;
color: #000;
.up-drop-ic {
width: 68rpx;
height: 68rpx;
color: var(--ink-3);
margin-bottom: 36rpx;
}
.up-drop-t {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 500;
}
.upload-desc {
font-size: 22rpx;
color: #8E8E93;
margin-top: 12rpx;
.up-drop-d {
margin-top: 18rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .01em;
}
.upload-tip {
.up-note {
margin-top: 44rpx;
text-align: center;
font-size: 24rpx;
color: #8E8E93;
margin-top: 24rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .01em;
}
/* 处理中 */
.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;
.up-note-left {
margin-top: 20rpx;
font-size: 25rpx;
color: var(--ink-2);
line-height: 1.7;
}
/* 失败态 */
.failed {
margin-top: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 40rpx;
.up-failed {
padding-top: 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;
.up-failed .up-bang {
margin-left: var(--pad);
}
+7 -1
View File
@@ -4,6 +4,7 @@ import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
import { QuotaType } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -32,8 +33,13 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -93,7 +99,7 @@ Page({
const list = raw.map((it) => ({
...it,
_statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '',
_statusType: 'blue',
_statusType: 'plain',
}))
const merged = mergePaged(this.data.list, list, page === 1)
this.setData({
+25 -16
View File
@@ -1,35 +1,43 @@
<view class="page page-flex">
<navigation-bar title="我的衣橱" back="{{false}}" />
<navigation-bar title="我的衣橱" back="{{false}}" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
class="scroll-body pb-tab"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
bindscroll="onNavScroll"
>
<!-- 分类筛选 -->
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Wardrobe</view>
<view class="display">我的衣橱</view>
<view class="wd-stat num">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
</view>
<!-- 下划线式极简分类条 -->
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
class="seg-item {{activeCategory === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 统计 -->
<view class="stat">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
</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 wx:if="{{loading}}" class="wd-grid">
<view class="wd-skel" wx:for="{{[1, 2, 3, 4, 5, 6]}}" wx:key="*this">
<view class="sk sk-img"></view>
<view class="sk sk-ln" style="width: 70%;"></view>
<view class="sk sk-ln" style="width: 46%;"></view>
</view>
</view>
@@ -44,8 +52,8 @@
/>
<!-- 衣物网格 -->
<view wx:else class="grid">
<view class="grid-item" wx:for="{{list}}" wx:key="clothes_uuid">
<view wx:else class="wd-grid">
<view class="wd-tile" wx:for="{{list}}" wx:key="clothes_uuid">
<clothes-card
item="{{item}}"
statusText="{{item._statusText}}"
@@ -55,7 +63,8 @@
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<view class="fab" bindtap="onUpload">
+30 -50
View File
@@ -1,3 +1,4 @@
/** 衣橱:衬线大标题 + 下划线分类条 + 无容器网格 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,65 +11,44 @@
height: 0;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
.wd-stat {
margin-top: 22rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .02em;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
/* 分类条:吸顶玻璃 + 下划线选中态 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.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;
.wd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 40rpx var(--pad) 0;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.stat {
padding: 8rpx 32rpx 0;
font-size: 22rpx;
color: #8E8E93;
}
.grid {
.wd-skel {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
flex-direction: column;
gap: 18rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
.wd-tile {
display: block;
width: 100%;
transition: transform .2s var(--ease);
}
.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;
.wd-tile:active {
transform: scale(.975);
}
+98 -48
View File
@@ -1,4 +1,4 @@
/** SF Symbols 风格线性图标库(CSS mask + currentColor 单色可换色)**/
/** 线性图标库(CSS mask + currentColor 单色可换色)· v2 统一 1.5px 细描边 **/
.icon {
display: inline-block;
@@ -14,68 +14,94 @@
mask-size: 100% 100%;
}
.icon-sm { width: 40rpx; height: 40rpx; }
.icon-xs { width: 28rpx; height: 28rpx; }
.icon-sm { width: 36rpx; height: 36rpx; }
.icon-md { width: 56rpx; height: 56rpx; }
.icon-lg { width: 72rpx; height: 72rpx; }
.icon-xl { width: 104rpx; height: 104rpx; }
/* 衣橱 / 衣架 */
.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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
}
/* 减号 */
.icon-minus {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%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='1.5' stroke-linecap='round' stroke-linejoin='round'%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");
-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='1.5' 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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
}
/* 左箭头(返回) */
.icon-chevron-left {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
}
/* 下箭头 */
.icon-chevron-down {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 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");
-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='1.5' 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='1.5' 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-image {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpath d='M21 15l-5-5L5 21'/%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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpath d='M21 15l-5-5L5 21'/%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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
}
/* 收藏 / 星(实心) */
@@ -86,78 +112,102 @@
/* 分享 */
.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");
-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='1.5' 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='1.5' 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-arrow-up-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7'/%3E%3Cpath d='M8 7h9v9'/%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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7'/%3E%3Cpath d='M8 7h9v9'/%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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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 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' 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");
-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='1.5' 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='1.5' 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");
-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='1.6'/%3E%3Ccircle cx='12' cy='12' r='1.6'/%3E%3Ccircle cx='12' cy='19' r='1.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='black'%3E%3Ccircle cx='12' cy='5' r='1.6'/%3E%3Ccircle cx='12' cy='12' r='1.6'/%3E%3Ccircle cx='12' cy='19' r='1.6'/%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");
-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='1.5' 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='1.5' 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-clock {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7v5l3 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='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7v5l3 2'/%3E%3C/svg%3E");
}
/* 只读 / 锁 */
.icon-lock {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='10' width='16' height='11' rx='2'/%3E%3Cpath d='M8 10V7a4 4 0 0 1 8 0v3'/%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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='10' width='16' height='11' rx='2'/%3E%3Cpath d='M8 10V7a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E");
}
/* 刷新 / 重试 */
.icon-refresh {
-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-3-6.7'/%3E%3Cpath d='M21 3v6h-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='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-3-6.7'/%3E%3Cpath d='M21 3v6h-6'/%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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
-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='1.5' 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='1.5' 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");
}
+15
View File
@@ -48,6 +48,21 @@ export interface OutfitItem {
items: Array<{ clothes_uuid: string; processed_image: string }>
}
// 社区穿搭列表项(/outfit/community 返回)
export interface CommunityOutfitItem {
outfit_uuid: string
outfit_name: string
outfit_desc: string
scene: string
source: number // 1 用户创建 2 AI 推荐
created_at: number // 秒级时间戳
items: Array<{ clothes_uuid: string; processed_image: string }>
owner_name: string
owner_avatar: string
like_count: number
collect_count: number
}
export interface GoodsItem {
id: number
goods_code: string
+59 -7
View File
@@ -111,46 +111,98 @@ export const GOODS_TYPE_NAMES: Record<number, string> = {
// 错误码统一文案(兜底,优先展示服务端 details[0])
export const ERROR_MESSAGES: Record<number, string> = {
80000: '额度不足',
80001: '额度记录不存在',
90401: 'AI 推荐次数已达上限',
90400: 'AI 推荐失败',
90300: '运势生成中,请稍后重试',
// 通用
10000: '请求参数错误',
10001: '资源不存在',
10002: '请求方式错误',
10003: '操作过于频繁,请稍后重试',
// 鉴权 / 用户
11000: '请先登录',
11001: '登录状态已失效',
11002: '登录状态已过期',
11003: '登录失败,请重试',
11004: '请先登录',
12000: '无权限操作',
20000: '用户参数错误',
21000: '用户名或密码错误',
21001: '账号已被禁用',
21002: '微信登录失败,请重试',
21003: '微信登录凭证无效',
21004: '注册暂未开放',
22000: '用户不存在',
23000: '用户已存在',
23001: '邮箱已被注册',
// 上传
30000: '上传文件为空',
30001: '不支持的文件类型',
30002: '文件大小超过限制',
30004: '文件不存在',
30005: '文件不是公开的',
30006: '文件权限不足',
30007: '文件处理中',
39000: '文件保存失败',
39001: '图片抠图处理失败',
39002: '图片分类处理失败',
// 穿搭
40000: '穿搭不存在',
40001: '创建穿搭失败',
40002: '删除穿搭失败',
40003: '更新穿搭失败',
40004: '无权操作该穿搭',
// 衣物
50000: '衣物不存在',
50001: '该衣物已被搭配使用,无法删除',
50002: '创建衣物失败',
50003: '删除衣物失败',
50004: '更新衣物失败',
50005: '无权操作该衣物',
40000: '穿搭不存在',
40004: '无权操作该穿搭',
// 商品
60000: '商品不存在',
60001: '商品已下架',
// 订单 / 退款
70000: '订单不存在',
70001: '订单状态不正确',
70002: '订单已支付',
70003: '订单已取消',
70004: '订单已过期',
70005: '虚拟支付未开通',
70006: '商品尚未发布为虚拟支付道具',
70007: '虚拟支付订单不支持取消',
70008: 'iOS 虚拟支付最低 1 元',
70009: '订单真实性校验失败',
71000: '退款记录不存在',
71001: '当前状态不允许退款',
71002: '退款金额不合法',
71003: '退款申请失败',
71004: 'iOS 订单请通过 App Store 申请退款',
79000: '创建订单失败',
79001: '取消订单失败',
79002: '更新订单失败',
79003: '创建虚拟支付订单失败',
// 额度 / 会员
80000: '额度不足',
80001: '额度记录不存在',
80002: '广告激励已领取',
80003: '无效的激励类型',
80004: '广告激励未开启',
81000: '会员记录不存在',
81001: '会员已过期',
81002: '会员开通失败',
89000: '额度更新失败',
// 通知 / 分享 / 社区 / 星座 / AI
90000: '通知不存在',
90100: '分享不存在',
90101: '分享已过期',
90102: '分享已取消',
90103: '穿搭分享不存在',
90104: '穿搭分享已取消',
90200: '目标内容不存在',
90201: '点赞操作失败',
90202: '收藏操作失败',
90300: '运势生成中,请稍后重试',
90400: 'AI 推荐失败',
90401: 'AI 推荐次数已达上限',
// 服务端
19000: '服务繁忙,请稍后重试',
19001: '服务繁忙,请稍后重试',
19002: '服务繁忙,请稍后重试',
+40
View File
@@ -0,0 +1,40 @@
import { logger } from './logger'
/**
* 导航滚动收缩共享 handler。
*
* v2 导航形态:进入页面是内容区的衬线大标题,向下滚动后收缩为居中玻璃小标题。
* 页面侧只接入一行(scroll-view 用 bindscroll,页面级滚动用 onPageScroll):
* Page({ onNavScroll: navScroll }) // <scroll-view bindscroll="onNavScroll">
* Page({ onPageScroll: navPageScroll }) // 页面级滚动
*
* 性能:只在跨越阈值时 setData 一次,绝不逐帧更新。
*/
// 日志模块名
const LOG = 'nav'
// 滚动超过该距离(px)即切换为玻璃小标题态
export const NAV_THRESHOLD = 24
function apply(ctx: any, scrollTop: number): void {
if (!ctx || !ctx.data) return
const next = scrollTop > NAV_THRESHOLD
if (next === !!ctx.data.navScrolled) return
// 仅在首次进入玻璃态时打点,避免滚动过程刷屏
if (next && !ctx.__navLogged) {
ctx.__navLogged = true
logger.debug(LOG, '导航进入玻璃态, scrollTop=', scrollTop)
}
ctx.setData({ navScrolled: next })
}
/** scroll-view 的 bindscroll 处理器 */
export function navScroll(this: any, e: { detail?: { scrollTop?: number } }): void {
apply(this, (e && e.detail && e.detail.scrollTop) || 0)
}
/** 页面级滚动的 onPageScroll 处理器 */
export function navPageScroll(this: any, e: { scrollTop?: number }): void {
apply(this, (e && e.scrollTop) || 0)
}
File diff suppressed because it is too large Load Diff