From 09013ab94a7ebc7d511b885e472e9c3ca5120bf7 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 22 May 2026 19:15:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp):=20=E7=A7=AF=E5=88=86=E5=95=86?= =?UTF-8?q?=E5=9F=8E=20V2=20=E9=87=8D=E8=AE=BE=E8=AE=A1=20=E2=80=94=20desi?= =?UTF-8?q?gn-handoff=20=E5=85=A8=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 4 个 UI 组件: PointsCard/ProductCard/CheckinCalendar/CheckinModal - 商城首页 V2: 积分卡 + 快捷操作 + 分类标签 + 商品网格 - 商品详情 V2: 大图 + 信息卡 + 库存/余额状态 + 底部操作栏 - TabBar 新增商城入口(5 Tab: 首页/健康/商城/助手/我的) - 设计原型 docs/design/mp-05-mall-v2.html + SPEC.md 交付包 - CLAUDE.md 安全规范加固: 新增 §3.7 安全规范 6 条 + Feature DoD 安全清单扩展 --- CLAUDE.md | 76 ++- apps/miniprogram/src/app.config.ts | 5 + .../src/assets/tabbar/mall-active.png | Bin 0 -> 272 bytes apps/miniprogram/src/assets/tabbar/mall.png | Bin 0 -> 272 bytes .../components/ui/CheckinCalendar/index.scss | 91 +++ .../components/ui/CheckinCalendar/index.tsx | 53 ++ .../src/components/ui/CheckinModal/index.scss | 142 +++++ .../src/components/ui/CheckinModal/index.tsx | 63 +++ .../src/components/ui/PointsCard/index.scss | 124 +++++ .../src/components/ui/PointsCard/index.tsx | 50 ++ .../src/components/ui/ProductCard/index.scss | 116 ++++ .../src/components/ui/ProductCard/index.tsx | 58 ++ apps/miniprogram/src/pages/mall/index.scss | 328 ++--------- apps/miniprogram/src/pages/mall/index.tsx | 227 ++++---- .../src/pages/pkg-mall/product/index.scss | 449 ++++++++------- .../src/pages/pkg-mall/product/index.tsx | 145 ++--- docs/design/mp-05-mall-v2.html | 526 ++++++++++++++++++ docs/design/mp-05-mall-v2/META.yml | 12 + docs/design/mp-05-mall-v2/SPEC.md | 183 ++++++ docs/design/mp-05-mall-v2/tokens.json | 319 +++++++++++ wiki/index.md | 2 + 21 files changed, 2268 insertions(+), 701 deletions(-) create mode 100644 apps/miniprogram/src/assets/tabbar/mall-active.png create mode 100644 apps/miniprogram/src/assets/tabbar/mall.png create mode 100644 apps/miniprogram/src/components/ui/CheckinCalendar/index.scss create mode 100644 apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx create mode 100644 apps/miniprogram/src/components/ui/CheckinModal/index.scss create mode 100644 apps/miniprogram/src/components/ui/CheckinModal/index.tsx create mode 100644 apps/miniprogram/src/components/ui/PointsCard/index.scss create mode 100644 apps/miniprogram/src/components/ui/PointsCard/index.tsx create mode 100644 apps/miniprogram/src/components/ui/ProductCard/index.scss create mode 100644 apps/miniprogram/src/components/ui/ProductCard/index.tsx create mode 100644 docs/design/mp-05-mall-v2.html create mode 100644 docs/design/mp-05-mall-v2/META.yml create mode 100644 docs/design/mp-05-mall-v2/SPEC.md create mode 100644 docs/design/mp-05-mall-v2/tokens.json diff --git a/CLAUDE.md b/CLAUDE.md index eb2bf77..455f69a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,8 +177,13 @@ - [ ] 新增端点有权限声明(默认拒绝,不是默认放行) - [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM) -- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS) -- [ ] 无 CORS 通配符、无硬编码密钥 +- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越) +- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥 +- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等) +- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护 +- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径) +- [ ] 速率限制已配置(认证端点更严格) +- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新 #### 文档一致性 @@ -224,7 +229,7 @@ #### 新增 API 端点安全检查(强制) -> 历史数据:25 次安全 fix 中 80% 源于默认放行模式。 +> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。 > 新增端点时**必须**逐项确认: - [ ] 端点已添加 `require_permission` 权限守卫(非公开端点) @@ -237,7 +242,7 @@ #### 前后端接口同步检查(强制) -> 历史数据:35 次 fix 源于前后端接口不一致。 +> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。 > 后端 DTO 变更时**必须**同步检查前端: - [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新 @@ -249,7 +254,7 @@ #### DTO 输入校验检查(强制) -> 历史数据:2026-05-19 全系统审计发现 44 处校验缺失。 +> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。 > 新增/修改 DTO 时**必须**逐项确认: - [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数) @@ -291,6 +296,50 @@ // 国际化文案使用 i18n key,不硬编码中文 ``` +### 3.7 安全规范 + +#### 密钥与凭据管理 + +- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中 +- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护) +- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic +- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md` + +#### 依赖安全 + +- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`) +- 禁止引入有未修补高危漏洞的依赖版本 +- 定期更新依赖到最新安全补丁版本 + +#### 数据安全 + +- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储(AES-256-GCM) +- 日志中**禁止**输出 PII 数据和认证凭据(密码、token、session key) +- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果) +- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize) + +#### 传输安全 + +- 生产环境**必须**强制 HTTPS,**禁止**降级到 HTTP +- HTTP 响应**必须**包含安全头(HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy) +- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie) +- API 响应**禁止**暴露内部实现细节(堆栈跟踪、数据库错误、文件路径、SQL 语句) + +#### 认证与授权 + +- 密码**必须**使用单向哈希(bcrypt/argon2),**禁止**明文或可逆加密存储 +- JWT **必须**设置合理过期时间,支持 token 吊销机制 +- 敏感操作(删除数据、权限变更)需要二次确认 +- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问 +- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值 + +#### 速率限制 + +- 所有 API 端点**必须**配置速率限制 +- 认证相关端点(登录、注册、密码重置)限制更严格 +- 批量操作和数据导出需要独立的速率限制策略 +- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header + --- ## 4. 测试与验证 @@ -409,17 +458,24 @@ chore(docker): 添加 PostgreSQL 健康检查 - ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过 - ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交 - ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档 -- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害(5 月实测:89 fix 仅 11 有 wiki 更新,关键数字迁移数差 8 个) +- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害 - ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因 - ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据 - ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死 - ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘 -- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交 = 后续 5 次 fix(媒体库教训) -- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限(安全教训:25 次 fix 源于默认放行) -- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口(35 次 fix 教训) -- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(44 处缺失教训:Update 无 Validate derive / 枚举字段无 custom 校验 / Vec 无 min=1 / 密码无 max) +- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复 +- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限 +- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口 +- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(Validate derive / 枚举 custom / Vec min=1 / 密码 max=128) - ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议 - ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?` +- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志 +- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造 +- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err` +- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息 +- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS +- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本 +- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越 ### 场景化指令 diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index a176f73..f976a8f 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -76,6 +76,7 @@ export default defineAppConfig({ list: [ { pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' }, { pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' }, + { pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/mall.png', selectedIconPath: 'assets/tabbar/mall-active.png' }, { pagePath: 'pages/messages/index', text: '助手', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' }, { pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' }, ], @@ -89,6 +90,10 @@ export default defineAppConfig({ network: 'all', packages: ['pages/pkg-health'], }, + 'pages/mall/index': { + network: 'all', + packages: ['pages/pkg-mall'], + }, 'pages/consultation/index': { network: 'all', packages: ['pages/pkg-consultation'], diff --git a/apps/miniprogram/src/assets/tabbar/mall-active.png b/apps/miniprogram/src/assets/tabbar/mall-active.png new file mode 100644 index 0000000000000000000000000000000000000000..29e58487ddf4530b93660e128080e570762212e7 GIT binary patch literal 272 zcmeAS@N?(olHy`uVBq!ia0vp^fgsGm1|(PYdzk^L>z*!-Ar*7p-geAua*${UG_VbQ zoM{u`?_9OEY;mV{@R!ZT7r%$CnepM{;hcm>pau}Qcg-s6;oC1ii!JTyYS%A5|GDFE zhim-V;P-dROjQ@#7JFR}@Bc1uXnU^l&$^uQJul>E-l@K}V)@79nFl`B1Xd^Y-RfN4 zyF>k%&&Opw7R4)~b95L5nU&aFcse8m3>A_bjx=z7F5Axg~=WY0NQNMPf Up;f`EbNV2@r>mdKI;Vst00qZo%K!iX literal 0 HcmV?d00001 diff --git a/apps/miniprogram/src/assets/tabbar/mall.png b/apps/miniprogram/src/assets/tabbar/mall.png new file mode 100644 index 0000000000000000000000000000000000000000..d1e893a2c2ee4316b9fcb1e33f22b56c337d3a3e GIT binary patch literal 272 zcmeAS@N?(olHy`uVBq!ia0vp^fgsGm1|(PYdzk^L>z*!-Ar*7p-riW$)ppV!OM?jPP{)=cD(!F#FKl|d#j}Er*X>E>t*?$3AFyW zMezHPIhyyT2&VIucK50(Bsm;u=wK9NR$_DE>5vdGBvIhU2ipW8=6}hIAooqP|HXFH VPJhwm9r?l_zNf37%Q~loCIE1iWqbet literal 0 HcmV?d00001 diff --git a/apps/miniprogram/src/components/ui/CheckinCalendar/index.scss b/apps/miniprogram/src/components/ui/CheckinCalendar/index.scss new file mode 100644 index 0000000..e7e7046 --- /dev/null +++ b/apps/miniprogram/src/components/ui/CheckinCalendar/index.scss @@ -0,0 +1,91 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +.checkin-calendar { + display: flex; + gap: 6px; + justify-content: center; + padding: $sp-section $sp-lg $sp-md; + + &__day { + width: 36px; + display: flex; + flex-direction: column; + align-items: center; + gap: $sp-xs; + } + + &__dot { + width: 36px; + height: 36px; + border-radius: 18px; + @include flex-center; + + &--checked { + background: $acc-l; + } + &--today { + background: $pri; + border: 2px solid $pri; + box-shadow: 0 2px 8px rgba(196, 98, 58, 0.3); + } + &--empty { + background: $surface-alt; + } + } + + &__check { + font-size: 13px; + line-height: 1; + color: $acc; + + .checkin-calendar__dot--today & { + color: $white; + } + } + + &__label { + font-size: 11px; + color: $tx3; + font-weight: 400; + + &--today { + color: $tx; + font-weight: 600; + } + } + + &__tip { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 10px 14px; + background: $acc-l; + border-radius: $r-sm; + display: flex; + align-items: center; + gap: $sp-xs; + } + + &__tip-text { + font-size: 12px; + color: $acc; + font-weight: 500; + line-height: 1.4; + } + + // 长者模式 + .elder-mode & { + &__dot { + width: 40px; + height: 40px; + } + &__label { + font-size: 13px; + } + &__tip-text { + font-size: 14px; + } + } +} diff --git a/apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx b/apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx new file mode 100644 index 0000000..ddee5db --- /dev/null +++ b/apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +interface CheckinCalendarProps { + consecutiveDays: number; + earnedPoints?: number; + onClose?: () => void; +} + +const DAYS = ['一', '二', '三', '四', '五', '六', '日']; + +const CheckinCalendar: React.FC = ({ + consecutiveDays, +}) => { + const daysUntilReward = 7 - consecutiveDays; + + return ( + + {DAYS.map((d, i) => { + const isChecked = i < consecutiveDays; + const isToday = i === consecutiveDays - 1; + return ( + + + {isChecked && } + + + 周{d} + + + ); + })} + {daysUntilReward > 0 && ( + + + 再坚持 {daysUntilReward} 天,连续 7 天签到额外奖励 50 积分 + + + )} + + ); +}; + +export default React.memo(CheckinCalendar); diff --git a/apps/miniprogram/src/components/ui/CheckinModal/index.scss b/apps/miniprogram/src/components/ui/CheckinModal/index.scss new file mode 100644 index 0000000..7eb2d08 --- /dev/null +++ b/apps/miniprogram/src/components/ui/CheckinModal/index.scss @@ -0,0 +1,142 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +.checkin-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + + &__overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + } + + &__card { + position: relative; + width: 320px; + background: $card; + border-radius: $r; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); + overflow: hidden; + z-index: 1; + } + + &__header { + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); + padding: $sp-lg; + padding-bottom: 20px; + text-align: center; + position: relative; + overflow: hidden; + } + + &__header-deco { + position: absolute; + top: -15px; + right: -15px; + width: 60px; + height: 60px; + border-radius: 30px; + background: rgba(255, 255, 255, 0.08); + } + + &__title { + font-size: var(--tk-font-body); + color: rgba(255, 255, 255, 0.8); + margin-bottom: $sp-xs; + display: block; + } + + &__points-row { + display: flex; + align-items: baseline; + justify-content: center; + gap: $sp-2xs; + } + + &__points-num { + @include serif-number; + font-size: 36px; + font-weight: 700; + color: $white; + line-height: 1; + } + + &__points-unit { + font-size: var(--tk-font-body-sm); + color: rgba(255, 255, 255, 0.8); + } + + &__streak { + font-size: var(--tk-font-cap); + color: rgba(255, 255, 255, 0.65); + margin-top: $sp-xs; + display: block; + } + + &__calendar { + padding: $sp-section $sp-lg 0; + } + + &__calendar-title { + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $tx; + margin-bottom: $sp-sm; + text-align: center; + display: block; + } + + &__calendar-body { + position: relative; + padding-bottom: 52px; + } + + &__footer { + padding: 0 $sp-lg $sp-lg; + } + + &__btn { + width: 100%; + padding: 12px 0; + border-radius: $r-pill; + background: $pri; + text-align: center; + cursor: pointer; + box-shadow: $shadow-btn; + @include touch-target; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } + } + + &__btn-text { + font-size: 15px; + color: $white; + font-weight: 600; + } + + // 长者模式 + .elder-mode & { + &__card { + width: 340px; + } + &__points-num { + font-size: 42px; + } + &__title { + font-size: 17px; + } + &__btn { + padding: 16px 0; + } + &__btn-text { + font-size: 17px; + } + } +} diff --git a/apps/miniprogram/src/components/ui/CheckinModal/index.tsx b/apps/miniprogram/src/components/ui/CheckinModal/index.tsx new file mode 100644 index 0000000..6d63790 --- /dev/null +++ b/apps/miniprogram/src/components/ui/CheckinModal/index.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import CheckinCalendar from '../CheckinCalendar'; +import './index.scss'; + +interface CheckinModalProps { + visible: boolean; + consecutiveDays: number; + earnedPoints: number; + onClose: () => void; +} + +const CheckinModal: React.FC = ({ + visible, + consecutiveDays, + earnedPoints, + onClose, +}) => { + if (!visible) return null; + + return ( + + + + {/* 顶部装饰区 */} + + + 签到成功 + + +{earnedPoints} + 积分 + + {consecutiveDays > 0 && ( + + 已连续签到 {consecutiveDays} 天 + + )} + + + {/* 7天日历 */} + + 本周签到 + + + + + + {/* 关闭按钮 */} + + + 我知道了 + + + + + ); +}; + +export default React.memo(CheckinModal); diff --git a/apps/miniprogram/src/components/ui/PointsCard/index.scss b/apps/miniprogram/src/components/ui/PointsCard/index.scss new file mode 100644 index 0000000..ae6534c --- /dev/null +++ b/apps/miniprogram/src/components/ui/PointsCard/index.scss @@ -0,0 +1,124 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +.points-card { + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); + border-radius: $r; + padding: $sp-lg; + padding-bottom: 20px; + margin-bottom: $sp-section; + box-shadow: 0 8px 24px rgba(196, 98, 58, 0.25); + position: relative; + overflow: hidden; + + &__deco { + position: absolute; + border-radius: 50%; + pointer-events: none; + + &--1 { + top: -20px; + right: -20px; + width: 100px; + height: 100px; + background: rgba(255, 255, 255, 0.08); + } + &--2 { + bottom: -30px; + right: 40px; + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.05); + } + &--3 { + top: 20px; + right: 20px; + width: 40px; + height: 40px; + background: rgba(255, 255, 255, 0.06); + } + } + + &__body { + display: flex; + justify-content: space-between; + align-items: flex-start; + position: relative; + z-index: 1; + } + + &__left { + display: flex; + flex-direction: column; + } + + &__label { + font-size: var(--tk-font-cap); + color: rgba(255, 255, 255, 0.7); + margin-bottom: $sp-xs; + letter-spacing: 1px; + } + + &__balance { + @include serif-number; + font-size: 42px; + font-weight: 700; + color: $white; + line-height: 1; + letter-spacing: 2px; + margin-bottom: $sp-2xs; + } + + &__streak { + font-size: var(--tk-font-cap); + color: rgba(255, 255, 255, 0.65); + } + + &__checkin { + display: flex; + align-items: center; + gap: $sp-2xs; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.4); + padding: 8px 16px; + border-radius: $r-pill; + cursor: pointer; + + @include touch-target; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } + + &--done { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + } + } + + &__checkin-text { + font-size: var(--tk-font-cap); + color: $white; + font-weight: 500; + } + + &__checkin--done &__checkin-text { + opacity: 0.6; + } + + // 长者模式 + .elder-mode & { + &__balance { + font-size: 52px; + } + &__label { + font-size: 15px; + } + &__checkin { + padding: 10px 20px; + } + &__checkin-text { + font-size: 15px; + } + } +} diff --git a/apps/miniprogram/src/components/ui/PointsCard/index.tsx b/apps/miniprogram/src/components/ui/PointsCard/index.tsx new file mode 100644 index 0000000..4e66224 --- /dev/null +++ b/apps/miniprogram/src/components/ui/PointsCard/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +interface PointsCardProps { + balance: number; + consecutiveDays: number; + checkedIn: boolean; + checkinLoading?: boolean; + onCheckin?: () => void; +} + +const PointsCard: React.FC = ({ + balance, + consecutiveDays, + checkedIn, + checkinLoading = false, + onCheckin, +}) => { + return ( + + {/* 装饰圆 */} + + + + + + + 我的积分 + {balance.toLocaleString()} + {consecutiveDays > 0 && ( + + 已连续签到 {consecutiveDays} 天 + + )} + + !checkedIn && !checkinLoading && onCheckin?.()} + > + + {checkinLoading ? '...' : checkedIn ? '已签到' : '签到'} + + + + + ); +}; + +export default React.memo(PointsCard); diff --git a/apps/miniprogram/src/components/ui/ProductCard/index.scss b/apps/miniprogram/src/components/ui/ProductCard/index.scss new file mode 100644 index 0000000..e7eb117 --- /dev/null +++ b/apps/miniprogram/src/components/ui/ProductCard/index.scss @@ -0,0 +1,116 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +.product-card { + background: $card; + border-radius: $r-sm; + overflow: hidden; + box-shadow: $shadow-sm; + + @include touch-feedback; + + &__thumb-wrap { + position: relative; + } + + &__thumb { + width: 100%; + aspect-ratio: 1; + @include flex-center; + flex-direction: column; + gap: $sp-2xs; + + &--physical { background: $pri-l; } + &--service { background: $acc-l; } + &--privilege { background: $wrn-l; } + } + + &__thumb-type { + font-size: var(--tk-font-cap); + color: $tx3; + font-weight: 500; + } + + &__soldout { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.7); + @include flex-center; + } + + &__soldout-text { + font-size: var(--tk-font-body-sm); + color: $tx3; + font-weight: 600; + } + + &__info { + padding: 10px $sp-sm 14px; + } + + &__name { + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $tx; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + height: 40px; + margin-bottom: $sp-xs; + } + + &__bottom { + display: flex; + align-items: baseline; + gap: $sp-2xs; + } + + &__points-row { + display: flex; + align-items: baseline; + gap: 2px; + } + + &__points-num { + @include serif-number; + font-size: 18px; + font-weight: 700; + color: $pri; + } + + &__points-unit { + font-size: var(--tk-font-micro); + color: $pri; + font-weight: 500; + } + + &__low-stock { + display: inline-block; + font-size: var(--tk-font-micro); + color: $wrn; + font-weight: 500; + background: $wrn-l; + padding: 2px $sp-xs; + border-radius: $r-sm; + margin-top: $sp-2xs; + } + + // 长者模式 + .elder-mode & { + &__name { + font-size: 16px; + height: 46px; + } + &__points-num { + font-size: 22px; + } + &__points-unit { + font-size: 13px; + } + &__low-stock { + font-size: 13px; + } + } +} diff --git a/apps/miniprogram/src/components/ui/ProductCard/index.tsx b/apps/miniprogram/src/components/ui/ProductCard/index.tsx new file mode 100644 index 0000000..997df65 --- /dev/null +++ b/apps/miniprogram/src/components/ui/ProductCard/index.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import type { PointsProduct } from '@/services/points'; +import './index.scss'; + +interface ProductCardProps { + product: PointsProduct; + onPress?: (product: PointsProduct) => void; +} + +const TYPE_BG_CLASS: Record = { + physical: 'product-card__thumb--physical', + service: 'product-card__thumb--service', + privilege: 'product-card__thumb--privilege', +}; + +const TYPE_LABELS: Record = { + physical: '实物', + service: '服务券', + privilege: '权益', +}; + +const ProductCard: React.FC = ({ product, onPress }) => { + const isSoldOut = product.stock <= 0; + const isLowStock = product.stock > 0 && product.stock <= 10; + + return ( + onPress?.(product)} + > + + + {TYPE_LABELS[product.product_type] || '商品'} + + {isSoldOut && ( + + 已兑完 + + )} + + + {product.name} + + + {product.points_cost} + 积分 + + + {isLowStock && ( + 仅剩{product.stock}件 + )} + + + ); +}; + +export default React.memo(ProductCard); diff --git a/apps/miniprogram/src/pages/mall/index.scss b/apps/miniprogram/src/pages/mall/index.scss index 37987c4..f0dfb22 100644 --- a/apps/miniprogram/src/pages/mall/index.scss +++ b/apps/miniprogram/src/pages/mall/index.scss @@ -1,323 +1,89 @@ @import '../../styles/variables.scss'; @import '../../styles/mixins.scss'; -// 积分商城 — 对齐原型 docs/design/mp-05-mall.html +// 积分商城 V2 — 对齐原型 docs/design/mp-05-mall-v2.html .mall-page { padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom)); + background: $bg; } -/* ─── 积分卡片(渐变背景) ─── */ +/* ─── 积分卡片区 ─── */ .mall-header { - background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%); - padding: var(--tk-gap-xl) var(--tk-page-padding) var(--tk-gap-xl); - position: relative; - overflow: hidden; - - // 装饰圆 - &::before { - content: ''; - position: absolute; - top: -20px; - right: -20px; - width: 100px; - height: 100px; - border-radius: 50px; - background: rgba(255, 255, 255, 0.08); - } - &::after { - content: ''; - position: absolute; - bottom: -30px; - right: 40px; - width: 80px; - height: 80px; - border-radius: 40px; - background: rgba(255, 255, 255, 0.05); - } + padding: 0 var(--tk-page-padding); + padding-top: var(--tk-gap-sm); } -.points-card { - position: relative; - z-index: 1; -} - -.points-top { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--tk-gap-sm); -} - -.points-label { - font-size: var(--tk-font-cap); - color: rgba(255, 255, 255, 0.7); - letter-spacing: 1px; -} - -.checkin-btn { - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.4); - padding: 6px 14px; - border-radius: $r-pill; - display: flex; - align-items: center; - gap: 4px; - transition: all 0.2s; - - &:active { - opacity: var(--tk-touch-feedback-opacity); - } - - &.checked { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - } -} - -.checkin-btn-text { - font-size: var(--tk-font-cap); - color: $white; - font-weight: 500; -} - -.checkin-btn.checked .checkin-btn-text { - opacity: 0.6; -} - -.points-balance { - @include serif-number; - font-size: 42px; - font-weight: 700; - color: $white; - display: block; - margin-bottom: var(--tk-gap-xs); - letter-spacing: 2px; - line-height: 1; -} - -.points-streak { - font-size: var(--tk-font-cap); - color: rgba(255, 255, 255, 0.7); - display: block; +/* ─── 可滚动内容区 ─── */ +.mall-content { + padding: 0 var(--tk-page-padding) var(--tk-gap-lg); } /* ─── 快捷操作 ─── */ .mall-actions { display: flex; justify-content: space-around; - padding: var(--tk-section-gap) var(--tk-page-padding); + padding: var(--tk-section-gap) 0; } .mall-action { display: flex; flex-direction: column; align-items: center; - gap: 8px; + gap: $sp-xs; + @include touch-feedback; - &:active { - opacity: var(--tk-touch-feedback-opacity); - } -} + &-icon { + width: 52px; + height: 52px; + border-radius: 26px; + @include flex-center; -.mall-action-icon { - width: 52px; - height: 52px; - border-radius: 26px; - display: flex; - align-items: center; - justify-content: center; - - &--checkin { - background: $acc; - box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3); - } - &--task { - background: $pri; - box-shadow: 0 4px 12px rgba(196, 98, 58, 0.3); - } - &--history { - background: $wrn; - box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3); - } -} - -.mall-action-icon-text { - font-size: 22px; - color: $white; - line-height: 1; -} - -.mall-action-label { - font-size: var(--tk-font-micro); - color: $tx2; - font-weight: 500; -} - -/* ─── 分类标签(Pill) ─── */ -.type-tabs { - display: flex; - gap: 10px; - padding: 0 var(--tk-page-padding) var(--tk-section-gap); - overflow-x: auto; - - &::-webkit-scrollbar { - display: none; - } -} - -.type-tab { - padding: 7px 18px; - border-radius: $r-pill; - font-size: var(--tk-font-body-sm); - font-weight: 400; - background: $surface-alt; - color: $tx2; - white-space: nowrap; - flex-shrink: 0; - transition: all 0.2s; - - &:active { - opacity: var(--tk-touch-feedback-opacity); + &--checkin { + background: $acc; + box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3); + } + &--history { + background: $wrn; + box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3); + } } - &.active { - background: var(--tk-pri); + &-icon-text { + font-size: 22px; color: $white; - font-weight: 600; - box-shadow: var(--tk-shadow-tab); + line-height: 1; + } + + &-label { + font-size: var(--tk-font-micro); + color: $tx2; + font-weight: 500; } } -.type-tab-text { - font-size: inherit; - color: inherit; - font-weight: inherit; +/* ─── 分割线 ─── */ +.mall-divider { + height: 1px; + background: $bd; + margin-bottom: $sp-md; +} - &.active { - color: inherit; - } +/* ─── 分类标签 ─── */ +.mall-tabs { + margin-bottom: $sp-section; } /* ─── 商品网格 ─── */ .product-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: var(--tk-gap-sm); - padding: 0 var(--tk-page-padding) var(--tk-gap-lg); + gap: $sp-sm; } -.product-card { - background: $card; - border-radius: $r-sm; - overflow: hidden; - box-shadow: $shadow-sm; - - &:active { - opacity: var(--tk-touch-feedback-opacity); - } -} - -.product-image { - width: 100%; - aspect-ratio: 1; - @include flex-center; - position: relative; - - &.type-physical { background: $pri-l; } - &.type-service { background: $acc-l; } - &.type-privilege { background: $wrn-l; } -} - -.product-image-char { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: 32px; - font-weight: 700; - color: $pri; - line-height: 1; - - .type-service & { color: $acc; } - .type-privilege & { color: $wrn; } -} - -.product-info { - padding: 10px var(--tk-gap-sm) 14px; -} - -.product-name { - font-size: var(--tk-font-body-sm); - font-weight: 600; - color: $tx; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; - height: 40px; - margin-bottom: 8px; -} - -.product-bottom { - display: flex; - align-items: baseline; - gap: 6px; -} - -.product-points { - display: flex; - align-items: baseline; - gap: 2px; -} - -.product-points-char { - @include serif-number; - font-size: 18px; - font-weight: 700; - color: $pri; -} - -.product-points-value { - font-size: var(--tk-font-micro); - color: $pri; - font-weight: 500; -} - -.product-price { - font-size: var(--tk-font-micro); - color: $tx3; - text-decoration: line-through; -} - -.product-stock { - font-size: var(--tk-font-micro); - padding: 2px 6px; - border-radius: $r-xs; - - &.out { - background: $bd-l; - color: $tx3; - } - - &.low { - background: $dan-l; - color: $dan; - } -} - -.product-tag { - position: absolute; - top: 8px; - left: 8px; - font-size: var(--tk-font-micro); - font-weight: 600; - padding: 2px 8px; - border-radius: 6px; - color: $white; - - &--hot { - background: $dan; - } - &--new { - background: $acc; +// 长者模式 +.elder-mode .mall-page { + .mall-action-label { + font-size: 14px; } } diff --git a/apps/miniprogram/src/pages/mall/index.tsx b/apps/miniprogram/src/pages/mall/index.tsx index f53d7a0..a49fe24 100644 --- a/apps/miniprogram/src/pages/mall/index.tsx +++ b/apps/miniprogram/src/pages/mall/index.tsx @@ -7,25 +7,24 @@ import { listProducts } from '../../services/points'; import type { PointsProduct } from '../../services/points'; import { useAuthStore } from '../../stores/auth'; import { usePointsStore } from '../../stores/points'; +import { useElderClass } from '../../hooks/useElderClass'; +import PageShell from '@/components/ui/PageShell'; +import PointsCard from '@/components/ui/PointsCard'; +import ProductCard from '@/components/ui/ProductCard'; +import TabFilter from '@/components/ui/TabFilter'; +import CheckinModal from '@/components/ui/CheckinModal'; import Loading from '../../components/Loading'; import ErrorState from '../../components/ErrorState'; import EmptyState from '../../components/EmptyState'; -import { useElderClass } from '../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; import './index.scss'; -const PRODUCT_TYPE_TABS = [ - { key: '', label: '全部' }, - { key: 'physical', label: '实物' }, - { key: 'service', label: '服务券' }, - { key: 'privilege', label: '权益' }, -]; +const PRODUCT_TABS = ['全部', '实物', '服务券', '权益']; +const TAB_TYPE_MAP = ['', 'physical', 'service', 'privilege']; -const TYPE_BG: Record = { - physical: 'type-physical', - service: 'type-service', - privilege: 'type-privilege', -}; +const QUICK_ACTIONS = [ + { icon: '✓', label: '签到打卡', cls: 'mall-action-icon--checkin' }, + { icon: '◷', label: '兑换记录', cls: 'mall-action-icon--history' }, +] as const; export default function Mall() { const currentPatient = useAuthStore((s) => s.currentPatient); @@ -35,35 +34,28 @@ export default function Mall() { const refreshPoints = usePointsStore((s) => s.refresh); const doCheckin = usePointsStore((s) => s.doCheckin); const [products, setProducts] = useState([]); - const [productType, setProductType] = useState(''); + const [activeTab, setActiveTab] = useState(0); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [checkinLoading, setCheckinLoading] = useState(false); + const [showCheckin, setShowCheckin] = useState(false); const [noProfile, setNoProfile] = useState(false); const [error, setError] = useState(false); const modeClass = useElderClass(); const fetchProducts = useCallback( - async (pageNum: number, type: string, isRefresh = false) => { + async (pageNum: number, typeIdx: number, isRefresh = false) => { setLoading(true); setError(false); try { - const res = await listProducts({ - page: pageNum, - page_size: 10, - product_type: type || undefined, - }); + const type = TAB_TYPE_MAP[typeIdx] || undefined; + const res = await listProducts({ page: pageNum, page_size: 10, product_type: type }); const list = res.data || []; - if (isRefresh) { - setProducts(list); - } else { - setProducts((prev) => [...prev, ...list]); - } + setProducts((prev) => (isRefresh ? list : [...prev, ...list])); setTotal(res.total); setPage(pageNum); - } catch (err) { - console.warn('[mall] 加载商品列表失败:', err); + } catch { setError(true); Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { @@ -74,12 +66,11 @@ export default function Mall() { ); const loadAll = useCallback( - async (type?: string) => { - const t = type !== undefined ? type : productType; + async (tabIdx?: number) => { + const t = tabIdx !== undefined ? tabIdx : activeTab; if (!currentPatient) { await loadPatients(); - const updated = useAuthStore.getState().currentPatient; - if (!updated) { + if (!useAuthStore.getState().currentPatient) { setNoProfile(true); return; } @@ -87,7 +78,7 @@ export default function Mall() { setNoProfile(false); await Promise.all([refreshPoints(), fetchProducts(1, t, true)]); }, - [currentPatient, loadPatients, refreshPoints, fetchProducts, productType], + [currentPatient, loadPatients, refreshPoints, fetchProducts, activeTab], ); usePageData( @@ -100,7 +91,7 @@ export default function Mall() { useReachBottom(() => { if (!loading && products.length < total) { - fetchProducts(page + 1, productType); + fetchProducts(page + 1, activeTab); } }); @@ -110,7 +101,7 @@ export default function Mall() { try { const ok = await doCheckin(); if (ok) { - Taro.showToast({ title: '签到成功', icon: 'success', duration: 2000 }); + setShowCheckin(true); } } catch (err) { Taro.showToast({ @@ -122,9 +113,9 @@ export default function Mall() { } }; - const handleTabChange = (key: string) => { - setProductType(key); - fetchProducts(1, key, true); + const handleTabChange = (idx: number) => { + setActiveTab(idx); + fetchProducts(1, idx, true); }; const handleProductClick = (item: PointsProduct) => { @@ -135,7 +126,17 @@ export default function Mall() { safeNavigateTo(`/pages/pkg-mall/product/index?product_id=${item.id}`); }; + const handleAction = (label: string) => { + if (label === '签到打卡') { + handleCheckin(); + } else if (label === '兑换记录') { + safeNavigateTo('/pages/pkg-mall/orders/index'); + } + }; + const balance = account?.balance ?? 0; + const consecutiveDays = checkinStatus?.consecutive_days ?? 0; + const checkedIn = checkinStatus?.checked_in_today ?? false; if (noProfile) { return ( @@ -153,101 +154,77 @@ export default function Mall() { return ( - {/* 积分余额卡片 */} + {/* 积分卡片 */} - - - 我的积分 + + + + {/* 可滚动内容区 */} + + {/* 快捷操作 */} + + {QUICK_ACTIONS.map((action) => ( handleAction(action.label)} > - - {checkinLoading ? '...' : checkinStatus?.checked_in_today ? '已签到' : '签到'} - - - - {balance.toLocaleString()} - {checkinStatus && checkinStatus.consecutive_days > 0 && ( - - 已连续签到 {checkinStatus.consecutive_days} 天 - - )} - - - - {/* 快捷操作 */} - - - - - - 签到打卡 - - {/* TODO: 积分任务功能待实现后恢复 */} - safeNavigateTo('/pages/pkg-mall/orders/index')}> - - - - 兑换记录 - - - - {/* 商品类型切换 */} - - {PRODUCT_TYPE_TABS.map((tab) => ( - handleTabChange(tab.key)} - > - - {tab.label} - - - ))} - - - {/* 商品列表 */} - {error ? ( - loadAll()} /> - ) : products.length === 0 && !loading ? ( - - ) : ( - - {products.map((item) => ( - handleProductClick(item)} - > - - - {item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'} - - - - {item.name} - - - {item.points_cost} - 积分 - - {item.stock <= 0 ? ( - 已兑完 - ) : item.stock <= 10 ? ( - 仅剩{item.stock}件 - ) : null} - + + {action.icon} + {action.label} ))} - {loading && } - {!loading && products.length >= total && total > 0 && ( - - )} - )} + + {/* 分割线 */} + + + {/* 分类标签 */} + + + + + {/* 商品网格 */} + {error ? ( + loadAll()} /> + ) : products.length === 0 && !loading ? ( + + ) : ( + + {products.map((item) => ( + + ))} + {loading && } + {!loading && products.length >= total && total > 0 && ( + + )} + + )} + + + {/* 签到弹窗 */} + setShowCheckin(false)} + /> ); } diff --git a/apps/miniprogram/src/pages/pkg-mall/product/index.scss b/apps/miniprogram/src/pages/pkg-mall/product/index.scss index 6aedbe6..77f3281 100644 --- a/apps/miniprogram/src/pages/pkg-mall/product/index.scss +++ b/apps/miniprogram/src/pages/pkg-mall/product/index.scss @@ -1,226 +1,245 @@ @import '../../../styles/variables.scss'; @import '../../../styles/mixins.scss'; -// 商品详情 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕1 +// 商品详情 V2 — 对齐原型 docs/design/mp-05-mall-v2.html -.product-detail-page { - padding-bottom: 80px; -} - -.product-detail-scroll { - flex: 1; - overflow: auto; -} - -// 加载/空状态 -.product-detail-loading, -.product-detail-empty { - display: flex; - justify-content: center; - align-items: center; - padding: var(--tk-gap-2xl) 0; -} - -.product-detail-loading-text, -.product-detail-empty-text { - font-size: var(--tk-font-body); - color: $tx3; -} - -// 商品图区域 -.product-detail-image { - width: 100%; - height: 280px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - - &--physical { background: $pri-l; } - &--service { background: $acc-l; } - &--privilege { background: $wrn-l; } -} - -.product-detail-image-char { - font-size: 48px; - font-weight: 700; - color: $pri; - line-height: 1; - opacity: 0.3; - - .product-detail-image--service & { color: $acc; } - .product-detail-image--privilege & { color: $wrn; } -} - -.product-detail-image-label { - font-size: var(--tk-font-cap); - color: $tx3; -} - -// 商品信息卡片 -.product-detail-info { - background: $card; - border-radius: 20px 20px 0 0; - margin-top: -16px; - position: relative; - padding: var(--tk-section-gap); -} - -.product-detail-name { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: 20px; - font-weight: 700; - color: $tx; - display: block; - margin-bottom: var(--tk-gap-sm); - line-height: 1.4; -} - -.product-detail-price-row { - display: flex; - align-items: baseline; - gap: var(--tk-gap-sm); - margin-bottom: var(--tk-section-gap); -} - -.product-detail-points { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-body-lg); - font-weight: 700; - color: $pri; -} - -.product-detail-type-tag { - display: inline-block; - padding: 2px 8px; - border-radius: 6px; - font-size: var(--tk-font-micro); - font-weight: 600; - color: $acc; - background: $acc-l; -} - -.product-detail-desc { - font-size: var(--tk-font-cap); - color: $tx2; - line-height: 1.8; - display: block; - margin-bottom: var(--tk-section-gap); -} - -// 规格信息 -.product-detail-specs { - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px var(--tk-gap-md); +.product-detail { background: $bg; - border-radius: $r-sm; - margin-bottom: var(--tk-section-gap); -} - -.product-detail-spec-row { display: flex; - justify-content: space-between; - font-size: var(--tk-font-cap); -} + flex-direction: column; + height: 100%; -.product-detail-spec-label { - color: $tx3; -} - -.product-detail-spec-value { - color: $tx; - font-weight: 500; -} - -// 温馨提示 -.product-detail-notice { - padding: var(--tk-gap-sm); - background: $wrn-l; - border-radius: $r-sm; - border-left: 3px solid $wrn; -} - -.product-detail-notice-title { - font-size: var(--tk-font-micro); - color: $wrn; - font-weight: 600; - margin-bottom: 4px; - display: block; -} - -.product-detail-notice-text { - font-size: var(--tk-font-micro); - color: $tx2; - line-height: 1.6; - display: block; - margin-bottom: 2px; - - &:last-child { - margin-bottom: 0; - } -} - -// 底部操作栏 -.product-detail-footer { - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: $card; - border-top: 1px solid $bd-l; - padding: var(--tk-gap-sm) var(--tk-page-padding); - display: flex; - align-items: center; - gap: var(--tk-gap-sm); - z-index: 10; -} - -.product-detail-fav { - width: 48px; - height: 48px; - border: 1px solid $bd; - border-radius: $r-sm; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - &:active { - opacity: var(--tk-touch-feedback-opacity); - } -} - -.product-detail-fav-icon { - font-size: 20px; - color: $tx3; -} - -.product-detail-exchange-btn { - flex: 1; - height: 48px; - background: var(--tk-pri); - border-radius: $r; - display: flex; - align-items: center; - justify-content: center; - box-shadow: var(--tk-shadow-btn); - - &.disabled { - background: $bd; - box-shadow: none; + &__loading, &__empty { + @include flex-center; + padding: $sp-2xl 0; + font-size: var(--tk-font-body); + color: $tx3; } - &:active:not(.disabled) { - opacity: var(--tk-touch-feedback-opacity); + &__scroll { + flex: 1; + overflow: auto; + padding-bottom: 90px; + } + + // 商品大图 + &__hero { + width: 100%; + aspect-ratio: 4/3; + @include flex-center; + flex-direction: column; + gap: $sp-2xs; + + &--physical { background: $pri-l; } + &--service { background: $acc-l; } + &--privilege { background: $wrn-l; } + } + + &__hero-type { + font-size: var(--tk-font-cap); + color: $tx3; + font-weight: 500; + } + + // 商品信息卡 + &__info-card { + background: $card; + margin: 0 $sp-section; + margin-top: -$sp-md; + border-radius: $r; + padding: $sp-section; + margin-bottom: $sp-md; + box-shadow: $shadow-sm; + position: relative; + } + + &__tags { + display: flex; + align-items: center; + gap: $sp-xs; + margin-bottom: $sp-xs; + } + + &__type-badge { + font-size: 11px; + font-weight: 600; + color: $acc; + background: $acc-l; + padding: 2px $sp-xs; + border-radius: $r-xs; + } + + &__name { + font-size: 20px; + font-weight: 700; + color: $tx; + line-height: 1.4; + margin-bottom: $sp-sm; + display: block; + } + + &__price-row { + display: flex; + align-items: baseline; + gap: $sp-2xs; + margin-bottom: $sp-sm; + } + + &__points-num { + @include serif-number; + font-size: 28px; + font-weight: 700; + color: $pri; + line-height: 1; + } + + &__points-unit { + font-size: var(--tk-font-body-sm); + color: $pri; + font-weight: 500; + } + + &__desc { + font-size: var(--tk-font-cap); + color: $tx3; + line-height: 1.6; + display: block; + } + + // 库存/余额卡 + &__status-card { + background: $card; + margin: 0 $sp-section; + border-radius: $r; + padding: $sp-md $sp-section; + margin-bottom: $sp-md; + box-shadow: $shadow-sm; + } + + &__status-row { + display: flex; + justify-content: space-between; + align-items: center; + + & + & { + margin-top: $sp-sm; + } + } + + &__status-label { + font-size: var(--tk-font-body-sm); + color: $tx2; + } + + &__status-value { + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $acc; + + &--warn { color: $wrn; } + &--danger { color: $dan; } + &--ok { color: $acc; } + } + + // 温馨提示 + &__notice { + margin: 0 $sp-section; + padding: $sp-sm $sp-md; + background: $wrn-l; + border-radius: $r-sm; + margin-bottom: $sp-section; + } + + &__notice-text { + font-size: 12px; + color: $wrn; + line-height: 1.6; + } + + // 底部操作栏 + &__footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: $card; + border-top: 1px solid $bd; + padding: $sp-sm $sp-section; + display: flex; + align-items: center; + gap: $sp-sm; + z-index: 10; + } + + &__footer-left { + flex: 1; + } + + &__footer-hint { + font-size: 12px; + color: $tx3; + } + + &__footer-price { + display: flex; + align-items: baseline; + gap: 2px; + } + + &__footer-num { + @include serif-number; + font-size: 22px; + font-weight: 700; + color: $pri; + } + + &__footer-unit { + font-size: 12px; + color: $pri; + } + + &__exchange-btn { + padding: 14px 32px; + border-radius: $r-pill; + background: $pri; + color: $white; + box-shadow: $shadow-btn; + @include touch-target; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } + + &--disabled { + background: $bd; + color: $tx3; + box-shadow: none; + cursor: not-allowed; + } + } + + &__exchange-text { + font-size: var(--tk-font-body); + font-weight: 600; + color: inherit; + } + + // 长者模式 + .elder-mode & { + &__name { + font-size: 24px; + } + &__points-num { + font-size: 34px; + } + &__footer-num { + font-size: 28px; + } + &__exchange-btn { + padding: 16px 36px; + } + &__exchange-text { + font-size: 18px; + } } } - -.product-detail-exchange-text { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-body); - color: $white; - font-weight: 700; -} diff --git a/apps/miniprogram/src/pages/pkg-mall/product/index.tsx b/apps/miniprogram/src/pages/pkg-mall/product/index.tsx index 7d2b48b..0ce5247 100644 --- a/apps/miniprogram/src/pages/pkg-mall/product/index.tsx +++ b/apps/miniprogram/src/pages/pkg-mall/product/index.tsx @@ -4,26 +4,28 @@ import Taro, { useRouter } from '@tarojs/taro'; import { usePageData } from '@/hooks/usePageData'; import { getProduct } from '../../../services/points'; import type { PointsProduct } from '../../../services/points'; +import { usePointsStore } from '../../../stores/points'; import { useElderClass } from '../../../hooks/useElderClass'; import PageShell from '@/components/ui/PageShell'; import './index.scss'; -const TYPE_CHAR: Record = { - physical: '物', - service: '券', - privilege: '权', +const TYPE_BG: Record = { + physical: 'product-detail__hero--physical', + service: 'product-detail__hero--service', + privilege: 'product-detail__hero--privilege', }; const TYPE_LABEL: Record = { - physical: '实物商品', - service: '服务体验', - privilege: '权益卡', + physical: '实物', + service: '服务券', + privilege: '权益', }; export default function ProductDetail() { const modeClass = useElderClass(); const router = useRouter(); const productId = router.params.product_id || ''; + const account = usePointsStore((s) => s.account); const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); @@ -33,15 +35,13 @@ export default function ProductDetail() { try { const data = await getProduct(productId); setProduct(data); - } catch (err) { - console.warn('[product] 单商品接口失败,降级列表查找:', err); + } catch { try { const { listProducts } = await import('../../../services/points'); const res = await listProducts({ page: 1, page_size: 100 }); const found = res.data.find((p) => p.id === productId); if (found) setProduct(found); - } catch (fallbackErr) { - console.warn('[product] 降级列表查找也失败:', fallbackErr); + } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); } } finally { @@ -54,8 +54,8 @@ export default function ProductDetail() { if (loading) { return ( - - 加载中... + + 加载中... ); @@ -64,95 +64,100 @@ export default function ProductDetail() { if (!product) { return ( - - 商品不存在 + + 商品不存在 ); } - const productType = product.product_type || 'physical'; - const typeChar = TYPE_CHAR[productType] || '礼'; - const typeLabel = TYPE_LABEL[productType] || '商品'; - const isService = productType === 'service'; + const balance = account?.balance ?? 0; + const canAfford = balance >= product.points_cost; + const type = product.product_type || 'physical'; + const isService = type === 'service'; const handleExchange = () => { if (product.stock <= 0) { Taro.showToast({ title: '已兑完', icon: 'none' }); return; } + if (!canAfford) { + Taro.showToast({ title: '积分不足', icon: 'none' }); + return; + } Taro.navigateTo({ url: `/pages/pkg-mall/exchange/index?product_id=${product.id}`, }); }; return ( - - - {/* 商品图区域 */} - - {typeChar} - {product.name} + + + {/* 商品大图 */} + + {TYPE_LABEL[type]} - {/* 商品信息卡片 */} - - {product.name} - - - {product.points_cost.toLocaleString()} 积分 - - {typeLabel} + {/* 商品信息卡 */} + + + {TYPE_LABEL[type]} + + {product.name} + + {product.points_cost} + 积分 - {product.description && ( - {product.description} + {product.description} )} + - {/* 规格 */} - - - 类型 - {typeLabel} - - - 库存 - - {product.stock > 0 ? `${product.stock} 件` : '已兑完'} - - - - 兑换方式 - - {isService ? '到院核销' : '后台审核发货'} - - - - - {/* 温馨提示 */} - - 温馨提示 - - {isService - ? '兑换成功后将生成核销码,请凭核销码到前台核销体验服务。' - : '兑换后需工作人员审核确认,审核通过后将在 7 个工作日内寄出。'} + {/* 库存/余额卡 */} + + + 库存状态 + 10 ? 'product-detail__status-value--ok' : + product.stock > 0 ? 'product-detail__status-value--warn' : + 'product-detail__status-value--danger' + }`}> + {product.stock > 10 ? '充足' : product.stock > 0 ? `仅剩 ${product.stock} 件` : '已兑完'} - 积分一经兑换不可退回。 + + 您的积分 + + {balance.toLocaleString()} ({canAfford ? '充足' : '不足'}) + + + + + {/* 温馨提示 */} + + + {isService + ? '兑换成功后将生成核销码,请凭核销码到前台核销。过期未核销订单将自动取消并退还积分。' + : '兑换后需工作人员审核确认,审核通过后 7 个工作日内寄出。积分一经兑换不可退回。'} + {/* 底部操作栏 */} - - - + + + 需要 + + {product.points_cost} + 积分 + - - {product.stock <= 0 ? '已兑完' : '立即兑换'} + + {product.stock <= 0 ? '已兑完' : !canAfford ? '积分不足' : '立即兑换'} diff --git a/docs/design/mp-05-mall-v2.html b/docs/design/mp-05-mall-v2.html new file mode 100644 index 0000000..c34008b --- /dev/null +++ b/docs/design/mp-05-mall-v2.html @@ -0,0 +1,526 @@ + + + + + +HMS 小程序 — 积分商城 V2 + + + + + + + +
HMS 小程序 · 积分商城 V2
+
V2 重设计 — 增强积分卡片层级感、新增商品详情页和签到弹窗。温润东方风设计系统。
+
+ + + + diff --git a/docs/design/mp-05-mall-v2/META.yml b/docs/design/mp-05-mall-v2/META.yml new file mode 100644 index 0000000..993a19e --- /dev/null +++ b/docs/design/mp-05-mall-v2/META.yml @@ -0,0 +1,12 @@ +prototype: mp-05-mall-v2.html +source: docs/design/mp-05-mall-v2.html +variant: patient +generated_at: 2026-05-22T12:00:00+08:00 +tokens: + matched: 22 + unmatched: 2 +components: + total: 12 + mapped: 8 + new: 4 +interactions: 7 diff --git a/docs/design/mp-05-mall-v2/SPEC.md b/docs/design/mp-05-mall-v2/SPEC.md new file mode 100644 index 0000000..7160601 --- /dev/null +++ b/docs/design/mp-05-mall-v2/SPEC.md @@ -0,0 +1,183 @@ +# 积分商城 V2 设计规格 + +> 来源: docs/design/mp-05-mall-v2.html | 平台: 微信小程序 (Taro) | 页面数: 3 | 生成: 2026-05-22 + +## 页面索引 + +| 页面 | 截图 | 路由 | +|------|------|------| +| 商城主页 | ![mall](./screenshots/mall.png) | /pages/mall/index | +| 商品详情 | ![detail](./screenshots/productdetail.png) | /pages/pkg-mall/product/index | +| 签到成功弹窗 | ![checkin](./screenshots/screen-3.png) | 弹窗 (商城主页内触发) | + +## 一、Token 映射 + +| 原型值 | 项目 Token | 状态 | +|--------|-----------|------| +| #C4623A (T.pri) | `--tk-pri` / `$pri` | ✅ | +| #F0DDD4 (T.priL) | `--tk-pri-l` / `$pri-l` | ✅ | +| #8B3E1F (T.priD) | `--tk-pri-d` / `$pri-d` | ✅ | +| #F5F0EB (T.bg) | `$bg` | ⚠️ SCSS 变量,无 CSS Token | +| #FFFFFF (T.card) | `--tk-card-bg` / `$card` | ✅ | +| #EDE8E2 (T.surface) | `$surface-alt` (≈卡片白底) | ⚠️ 近似匹配 | +| #2D2A26 (T.tx) | `$tx` | ⚠️ SCSS 变量,无 CSS Token | +| #5A554F (T.tx2) | `$tx2` | ⚠️ SCSS 变量,无 CSS Token | +| #78716C (T.tx3) | `--tk-text-secondary` / `$tx3` | ✅ | +| #E8E2DC (T.bd) | `$bd` | ⚠️ SCSS 变量,无 CSS Token | +| #F0EBE5 (T.bdL) | `$bd-l` | ⚠️ SCSS 变量,无 CSS Token | +| #5B7A5E (T.acc) | `$acc` | ⚠️ SCSS 变量,无 CSS Token | +| #E8F0E8 (T.accL) | `$acc-l` | ⚠️ SCSS 变量,无 CSS Token | +| #C4873A (T.wrn) | `$wrn` | ⚠️ SCSS 变量,无 CSS Token | +| #FFF3E0 (T.wrnL) | `$wrn-l` | ⚠️ SCSS 变量,无 CSS Token | +| #B54A4A (T.dan) | `$dan` | ⚠️ SCSS 变量,无 CSS Token | +| #FDEAEA (T.danL) | `$dan-l` | ⚠️ SCSS 变量,无 CSS Token | +| 16 (T.r) | `--tk-card-radius` / `$r` | ✅ | +| 12 (T.rSm) | `$r-sm` | ⚠️ SCSS 变量 | +| 20 (T.rPill) | `$r-pill` | ⚠️ SCSS 变量 | +| serif 字体 | `$serif` + `@mixin serif-number` | ⚠️ SCSS mixin | +| sans 字体 | `$sans` | ⚠️ SCSS 变量 | + +> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需 SCSS 变量引用 | ❌ unmatched 需硬编码或新建 Token + +## 二、页面结构 + +### 1. 商城主页 + +![商城主页](./screenshots/mall.png) + +布局层级(从上到下): + +1. **导航栏** — 返回箭头 + 标题「积分商城」(serif 26px 700) +2. **积分卡片** — 渐变背景 (`linear-gradient(135deg, $pri, $pri-d)`) + 装饰圆 + - 左侧:标签「我的积分」13px + 余额数字 serif 42px 700 + 连续签到天数 12px + - 右侧:签到按钮 (pill, `rgba(255,255,255,0.2)` bg + border) + - 阴影:`0 8px 24px rgba(196,98,58,0.25)` +3. **快捷操作栏** — 三等分圆形图标(52×52)+ 文字标签 + - 签到打卡:$acc 绿底 + `0 4px 12px rgba(91,122,94,0.3)` 阴影 + - 积分任务:$pri 橙底 + `0 4px 12px rgba(196,98,58,0.3)` 阴影 + - 兑换记录:$wrn 黄底 + `0 4px 12px rgba(196,135,58,0.3)` 阴影 +4. **分割线** — 1px $bd +5. **分类标签** — Pill tabs(全部/实物/服务券/权益) + - 默认:$surface 背景 + $tx2 文字 + - 激活:$pri 背景 + #fff 文字 + `0 2px 8px rgba(196,98,58,0.25)` 阴影 +6. **商品网格** — 2列 grid, gap 12px + - 商品卡片:白底 + $r-sm 圆角 + `0 2px 8px rgba(0,0,0,0.04)` 阴影 + - 商品图:1:1 占位(按 type 分色:physical=$priL, service=$accL, privilege=$wrnL) + - 标签角标:top:8 left:8, 热门=$dan, 新品=$acc + - 已兑完遮罩:`rgba(255,255,255,0.7)` + 「已兑完」文字 + - 商品信息:名称 (14px 600, 2行截断, 高40px) + 积分价格 (serif 18px 700 $pri) + 原价划线 ($tx3) + - 库存紧张标签:$wrnL 背景 + $wrn 文字 + +### 2. 商品详情页 + +![商品详情](./screenshots/productdetail.png) + +布局层级(从上到下): + +1. **导航栏** — 返回箭头 + 标题「商品详情」(17px 600) +2. **商品大图** — 4:3 比例, 圆角 $r, 按类型分色背景 +3. **商品信息卡** — 白底 + $r 圆角 + padding 20px + - 类型标签行:tag badge ($dan/$acc) + type badge ($accL bg) + - 标题:20px 700 $tx + - 积分价格:serif 28px 700 $pri + 原价划线 14px $tx3 + - 描述:13px $tx3, line-height 1.6 +4. **库存/余额卡** — 白底 + $r 圆角 + - 库存状态行:标签 $tx2 + 值 (充足=$acc / 紧张=$wrn / 售罄=$dan) + - 积分余额行:标签 $tx2 + 值 (充足=$acc / 不足=$dan) +5. **温馨提示** — $wrnL 背景 + $wrn 文字, $r-sm 圆角 +6. **底部操作栏** — 固定底部, 白底 + top border $bd + - 左侧:需要积分 (serif 22px 700 $pri) + - 右侧:兑换按钮 (pill, 充足=$pri bg+阴影, 不足=$bd bg+$tx3 text, cursor: not-allowed) + +### 3. 签到成功弹窗 + +![签到弹窗](./screenshots/screen-3.png) + +布局层级: + +1. **半透明遮罩** — `rgba(0,0,0,0.4)`, 全屏覆盖 +2. **弹窗卡片** — 居中, 宽 320px, 白底 + $r 圆角 + `0 20px 60px rgba(0,0,0,0.15)` 阴影 + - **顶部装饰区** — 渐变背景 ($pri→$priD) + 装饰圆 + - 「签到成功」16px rgba(255,255,255,0.8) + - 积分数 serif 36px 700 #fff + 「积分」14px + - 连续天数 13px rgba(255,255,255,0.65) + - **7天日历** — 7 列等宽 (36×36 圆形) + - 已签到:$accL bg + 绿色勾 (今日=$pri bg + 2px border + 阴影) + - 未签到:$surface bg + 6px 灰色圆点 + - 下方标签:周X (11px) + - **激励提示** — $accL bg + $acc 勾图标 + 文字 + - **关闭按钮** — 全宽 pill, $pri bg + #fff text + 阴影 + +## 三、组件映射 + +| 原型元素 | 推荐组件 | 来源 | 备注 | +|----------|---------|------|------| +| 页面容器 | `PageShell` | @components/ui | padding="none" safeBottom=false | +| 商品卡片 | `ContentCard` | @components/ui | variant="elevated" activeFeedback="opacity" | +| 分类标签 | `TabFilter` | @components/ui | variant="pill" tabs=[全部,实物,服务券,权益] | +| 兑换按钮 | `PrimaryButton` | @components/ui | disabled 时灰显 | +| 库存标签 | `StatusTag` | @components/ui | warning/error/default 状态 | +| 列表项 | `ListItem` | @components/ui | 商品详情信息行 | +| 积分卡片区域 | `GradientHeader` | @components/ui | 包裹积分余额展示 | +| 空数据/加载 | `EmptyState` / `LoadingCard` | @components | 已有 | + +> ⚠️ **需新建**: +> - `PointsCard` — 积分余额展示卡(渐变背景 + 装饰圆 + 签到按钮) +> - `ProductCard` — 商品卡片(图片占位 + 信息区 + 标签 + 库存提示) +> - `CheckinCalendar` — 7天签到日历(圆形进度点 + 今日高亮) +> - `CheckinModal` — 签到成功弹窗(遮罩 + 卡片 + 日历 + 关闭) + +## 四、交互规格 + +| 元素 | 交互 | 触发 | 反馈 | 备注 | +|------|------|------|------|------| +| 分类标签 | 切换筛选 | tap tab | 高亮样式切换 + 列表重新渲染 | 调用 `fetchProducts(1, type, true)` | +| 商品卡片 | 跳转详情 | tap card | opacity 反馈 → `safeNavigateTo` | 库存=0 时 toast「已兑完」 | +| 签到按钮 | 触发签到 | tap | loading → 成功/失败 toast | 成功后弹出 CheckinModal | +| 快捷操作 | 跳转页面 | tap icon | opacity 反馈 | 签到打卡 / 积分任务(TODO) / 兑换记录 | +| 兑换按钮 | 提交兑换 | tap button | loading → 确认弹窗 → 结果 | 积分不足时 disabled | +| 签到弹窗 | 关闭 | tap「我知道了」 | 弹窗消失 + 刷新积分数据 | 动画 fadeOut | +| 列表加载 | 上拉加载 | scroll to bottom | Loading 组件追加 | `useReachBottom` + 分页 | + +## 五、状态变体 + +- **无档案状态**: 显示 EmptyState 组件,提示去建档,隐藏积分卡片和商品列表 +- **空商品列表**: EmptyState icon='礼' text='暂无商品' +- **已签到**: 签到按钮变为低对比度「已签到」,不可再次点击 +- **库存紧张**: 商品卡片底部显示 $wrnL 标签「仅剩N件」 +- **已兑完**: 商品图区域叠加半透明遮罩 + 「已兑完」文字,点击 toast 提示 +- **积分不足**: 详情页兑换按钮灰显 disabled,余额显示红色「不足」 + +## 六、样式清单 + +### 颜色用法汇总 +- 渐变卡片: `linear-gradient(135deg, $pri, $priD)` +- 商品类型色: physical=$priL, service=$accL, privilege=$wrnL +- 标签色: 热门=$dan, 新品=$acc +- 库存色: 充足=$acc, 紧张=$wrn, 售罄=$dan + +### 尺寸规格 +- 积分卡片: padding 24px, borderRadius $r (16px) +- 快捷图标: 52×52 圆形, 间距 space-around +- 商品卡片: grid 2列, gap 12px, borderRadius $r-sm (12px) +- 商品图: 1:1 (主页) / 4:3 (详情), borderRadius $r-sm +- Pill 按钮: borderRadius $r-pill (20px), padding 7px 18px (tabs) / 14px 32px (CTA) +- 弹窗: 宽 320px, 7天日历项 36×36 + +### 字号规格 +- 积分余额: serif 42px 700 (主页) / 36px 700 (弹窗) / 28px 700 (详情) +- 页面标题: serif 26px 700 +- 商品名: 14px 600, 2行截断 +- 商品价格: serif 18px 700 $pri +- 分类标签: 14px (激活 600 / 默认 400) +- 快捷标签: 12px 500 +- 辅助文字: 13px / 12px / 11px + +--- + +> 此规格由 design-handoff skill 自动生成。实施时请: +> 1. 先阅读截图建立视觉印象 +> 2. 按 Token 映射表使用项目 Token(✅ 标记的直接用) +> 3. 优先使用"组件映射"中列出的已有组件 +> 4. 参考"交互规格"实现对应的交互逻辑 +> 5. "需新建"的组件参考截图和布局描述从头实现 diff --git a/docs/design/mp-05-mall-v2/tokens.json b/docs/design/mp-05-mall-v2/tokens.json new file mode 100644 index 0000000..e1a0e36 --- /dev/null +++ b/docs/design/mp-05-mall-v2/tokens.json @@ -0,0 +1,319 @@ +{ + "source": null, + "variant": "patient", + "matched": { + "T.pri": { + "method": "alias", + "confidence": "confirmed", + "token": "--tk-pri", + "scssVar": null, + "variant": "patient", + "prototypeValue": "#C4623A", + "tokenValue": "#C4623A" + }, + "T.priL": { + "method": "alias", + "confidence": "confirmed", + "token": "--tk-pri-l", + "scssVar": null, + "variant": "patient", + "prototypeValue": "#F0DDD4", + "tokenValue": "#F0DDD4" + }, + "T.priD": { + "method": "alias", + "confidence": "confirmed", + "token": "--tk-pri-d", + "scssVar": null, + "variant": "patient", + "prototypeValue": "#8B3E1F", + "tokenValue": "#8B3E1F" + }, + "T.bg": { + "method": "alias", + "confidence": "pending", + "token": null, + "scssVar": "$bg", + "tokenValue": "#F5F0EB", + "note": "原型页面背景色,tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量", + "prototypeValue": "#F5F0EB" + }, + "T.card": { + "method": "alias", + "confidence": "confirmed", + "token": "--tk-card-bg", + "scssVar": null, + "prototypeValue": "#FFFFFF", + "tokenValue": "#FFFFFF" + }, + "T.surface": { + "method": "alias", + "confidence": "approximate", + "token": "--tk-card-bg", + "scssVar": null, + "note": "原型中 surface ≈ 卡片白底", + "prototypeValue": "#EDE8E2", + "tokenValue": "#FFFFFF" + }, + "T.tx": { + "method": "alias", + "confidence": "pending", + "token": null, + "scssVar": "$tx", + "tokenValue": "#2D2A26", + "note": "主文字色,tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量", + "prototypeValue": "#2D2A26" + }, + "T.tx2": { + "method": "alias", + "confidence": "pending", + "token": null, + "scssVar": "$tx2", + "tokenValue": "#5A554F", + "note": "次文字色,tokens.scss 未声明,elder-mode 下 --tk-text-secondary 覆盖为此值", + "prototypeValue": "#5A554F" + }, + "T.tx3": { + "method": "alias", + "confidence": "confirmed", + "token": "--tk-text-secondary", + "scssVar": "$tx3", + "prototypeValue": "#78716C", + "tokenValue": "#78716C" + }, + "T.bd": { + "method": "alias", + "confidence": "pending", + "token": null, + "scssVar": "$bd", + "tokenValue": "#E8E2DC", + "note": "边框色(不是圆角),tokens.scss 未声明为 CSS 变量", + "prototypeValue": "#E8E2DC" + }, + "T.bdL": { + "token": null, + "scssVar": "$bd-l", + "prototypeValue": "#F0EBE5", + "tokenValue": "#F0EBE5", + "method": "value_exact", + "confidence": "pending", + "role": "浅边框色", + "note": "匹配到 SCSS 变量 $bd-l,但无对应 CSS Token" + }, + "T.acc": { + "token": null, + "scssVar": "$acc", + "prototypeValue": "#5B7A5E", + "tokenValue": "#5B7A5E", + "method": "value_exact", + "confidence": "pending", + "role": "鼠尾草绿/成功色", + "note": "匹配到 SCSS 变量 $acc,但无对应 CSS Token" + }, + "T.accL": { + "token": null, + "scssVar": "$acc-l", + "prototypeValue": "#E8F0E8", + "tokenValue": "#E8F0E8", + "method": "value_exact", + "confidence": "pending", + "role": "成功浅色", + "note": "匹配到 SCSS 变量 $acc-l,但无对应 CSS Token" + }, + "T.wrn": { + "token": null, + "scssVar": "$wrn", + "prototypeValue": "#C4873A", + "tokenValue": "#C4873A", + "method": "value_exact", + "confidence": "pending", + "role": "警告色/暖琥珀", + "note": "匹配到 SCSS 变量 $wrn,但无对应 CSS Token" + }, + "T.wrnL": { + "token": null, + "scssVar": "$wrn-l", + "prototypeValue": "#FFF3E0", + "tokenValue": "#FFF3E0", + "method": "value_exact", + "confidence": "pending", + "role": "警告浅色", + "note": "匹配到 SCSS 变量 $wrn-l,但无对应 CSS Token" + }, + "T.dan": { + "token": null, + "scssVar": "$dan", + "prototypeValue": "#B54A4A", + "tokenValue": "#B54A4A", + "method": "value_exact", + "confidence": "pending", + "role": "危险色/柔红", + "note": "匹配到 SCSS 变量 $dan,但无对应 CSS Token" + }, + "T.danL": { + "token": null, + "scssVar": "$dan-l", + "prototypeValue": "#FDEAEA", + "tokenValue": "#FDEAEA", + "method": "value_exact", + "confidence": "pending", + "role": "危险浅色", + "note": "匹配到 SCSS 变量 $dan-l,但无对应 CSS Token" + }, + "T.white": { + "token": "--tk-card-bg", + "scssVar": "$card", + "prototypeValue": "#FFFFFF", + "tokenValue": "#FFFFFF", + "method": "value_exact", + "confidence": "confirmed", + "role": "卡片白底" + }, + "T.r": { + "method": "alias", + "confidence": "confirmed", + "token": "--tk-card-radius", + "scssVar": "$r", + "prototypeValue": "16", + "tokenValue": "16px" + }, + "T.rSm": { + "method": "alias", + "confidence": "pending", + "token": null, + "scssVar": "$r-sm", + "tokenValue": "12px", + "note": "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量", + "prototypeValue": "12" + }, + "T.rXs": { + "token": null, + "scssVar": "$r-xs", + "prototypeValue": "8", + "tokenValue": "8px", + "method": "value_exact", + "confidence": "pending", + "note": "匹配到 SCSS 变量 $r-xs,但无对应 CSS Token" + }, + "T.rPill": { + "token": "--tk-section-gap", + "scssVar": "$sp-section", + "prototypeValue": "20", + "tokenValue": "20px", + "method": "value_exact", + "confidence": "confirmed" + } + }, + "unmatched": [ + "T.serif", + "T.sans" + ], + "inlineTokenMap": { + "fontSize:28": { + "token": "--tk-font-h1", + "tokenValue": "28px", + "confidence": "confirmed", + "method": "value_exact" + }, + "fontSize:22": { + "token": "--tk-font-h2", + "tokenValue": "22px", + "confidence": "confirmed", + "method": "value_exact" + }, + "fontSize:18": { + "token": "--tk-font-body-lg", + "tokenValue": "18px", + "confidence": "confirmed", + "method": "value_exact" + }, + "fontSize:16": { + "token": "--tk-font-body", + "tokenValue": "16px", + "confidence": "confirmed", + "method": "value_exact" + }, + "fontSize:14": { + "token": "--tk-font-body-sm", + "tokenValue": "14px", + "confidence": "confirmed", + "method": "value_exact" + }, + "fontSize:13": { + "token": "--tk-font-cap", + "tokenValue": "13px", + "confidence": "confirmed", + "method": "value_exact" + }, + "fontSize:11": { + "token": "--tk-font-micro", + "tokenValue": "11px", + "confidence": "confirmed", + "method": "value_exact" + }, + "padding:20": { + "token": "--tk-section-gap", + "scssVar": "$sp-section", + "tokenValue": "20px", + "confidence": "confirmed", + "method": "value_exact" + }, + "padding:12": { + "token": "--tk-gap-sm", + "scssVar": "$sp-sm", + "tokenValue": "12px", + "confidence": "confirmed", + "method": "value_exact" + }, + "borderRadius:20": { + "token": null, + "scssVar": "$r-lg", + "tokenValue": "20px", + "confidence": "pending", + "method": "value_exact", + "note": "匹配到 SCSS 变量 $r-lg,但无对应 CSS Token" + }, + "gap:12": { + "token": "--tk-gap-sm", + "scssVar": "$sp-sm", + "tokenValue": "12px", + "confidence": "confirmed", + "method": "value_exact" + }, + "gap:8": { + "token": "--tk-gap-xs", + "scssVar": "$sp-xs", + "tokenValue": "8px", + "confidence": "confirmed", + "method": "value_exact" + }, + "gap:4": { + "token": "--tk-gap-2xs", + "scssVar": "$sp-2xs", + "tokenValue": "4px", + "confidence": "confirmed", + "method": "value_exact" + }, + "width:52": { + "token": "--tk-btn-primary-h", + "tokenValue": "52px", + "confidence": "confirmed", + "method": "value_exact" + }, + "height:52": { + "token": "--tk-btn-primary-h", + "tokenValue": "52px", + "confidence": "confirmed", + "method": "value_exact" + } + }, + "summary": { + "total": 111, + "aliasTokens": 24, + "inlineValues": 87, + "confirmed": 22, + "pending": 14, + "approximate": 1, + "unmatched": 2 + } +} diff --git a/wiki/index.md b/wiki/index.md index 4f389e4..b51ac6e 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -146,6 +146,8 @@ - [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型 - [[architecture]] — 架构决策 · 设计原则 · 技术选型 +- [[permissions]] — **角色权限体系** · 7 角色 · 140+ 权限码 · Web/小程序权限矩阵 · 各角色可见菜单 + ### 业务层(继承自 ERP 底座) - erp-auth — 用户/角色/权限/组织/部门/岗位 · JWT · RBAC · 行级数据权限 - erp-config — 字典/菜单/设置/编号规则/主题/语言