feat(mp): 积分商城 V2 重设计 — design-handoff 全流程

- 新增 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 安全清单扩展
This commit is contained in:
iven
2026-05-22 19:15:41 +08:00
parent 1d443ab894
commit 09013ab94a
21 changed files with 2268 additions and 701 deletions

View File

@@ -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<T>` 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 文件名防路径穿越
### 场景化指令

View File

@@ -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'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -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;
}
}
}

View File

@@ -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<CheckinCalendarProps> = ({
consecutiveDays,
}) => {
const daysUntilReward = 7 - consecutiveDays;
return (
<View className='checkin-calendar'>
{DAYS.map((d, i) => {
const isChecked = i < consecutiveDays;
const isToday = i === consecutiveDays - 1;
return (
<View key={i} className='checkin-calendar__day'>
<View
className={`checkin-calendar__dot ${
isChecked
? isToday
? 'checkin-calendar__dot--today'
: 'checkin-calendar__dot--checked'
: 'checkin-calendar__dot--empty'
}`}
>
{isChecked && <Text className='checkin-calendar__check'>&#10003;</Text>}
</View>
<Text className={`checkin-calendar__label ${isToday ? 'checkin-calendar__label--today' : ''}`}>
{d}
</Text>
</View>
);
})}
{daysUntilReward > 0 && (
<View className='checkin-calendar__tip'>
<Text className='checkin-calendar__tip-text'>
{daysUntilReward} 7 50
</Text>
</View>
)}
</View>
);
};
export default React.memo(CheckinCalendar);

View File

@@ -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;
}
}
}

View File

@@ -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<CheckinModalProps> = ({
visible,
consecutiveDays,
earnedPoints,
onClose,
}) => {
if (!visible) return null;
return (
<View className='checkin-modal'>
<View className='checkin-modal__overlay' onClick={onClose} />
<View className='checkin-modal__card'>
{/* 顶部装饰区 */}
<View className='checkin-modal__header'>
<View className='checkin-modal__header-deco' />
<Text className='checkin-modal__title'></Text>
<View className='checkin-modal__points-row'>
<Text className='checkin-modal__points-num'>+{earnedPoints}</Text>
<Text className='checkin-modal__points-unit'></Text>
</View>
{consecutiveDays > 0 && (
<Text className='checkin-modal__streak'>
{consecutiveDays}
</Text>
)}
</View>
{/* 7天日历 */}
<View className='checkin-modal__calendar'>
<Text className='checkin-modal__calendar-title'></Text>
<View className='checkin-modal__calendar-body'>
<CheckinCalendar
consecutiveDays={consecutiveDays}
earnedPoints={earnedPoints}
onClose={onClose}
/>
</View>
</View>
{/* 关闭按钮 */}
<View className='checkin-modal__footer'>
<View className='checkin-modal__btn' onClick={onClose}>
<Text className='checkin-modal__btn-text'></Text>
</View>
</View>
</View>
</View>
);
};
export default React.memo(CheckinModal);

View File

@@ -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;
}
}
}

View File

@@ -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<PointsCardProps> = ({
balance,
consecutiveDays,
checkedIn,
checkinLoading = false,
onCheckin,
}) => {
return (
<View className='points-card'>
{/* 装饰圆 */}
<View className='points-card__deco points-card__deco--1' />
<View className='points-card__deco points-card__deco--2' />
<View className='points-card__deco points-card__deco--3' />
<View className='points-card__body'>
<View className='points-card__left'>
<Text className='points-card__label'></Text>
<Text className='points-card__balance'>{balance.toLocaleString()}</Text>
{consecutiveDays > 0 && (
<Text className='points-card__streak'>
{consecutiveDays}
</Text>
)}
</View>
<View
className={`points-card__checkin ${checkedIn ? 'points-card__checkin--done' : ''}`}
onClick={() => !checkedIn && !checkinLoading && onCheckin?.()}
>
<Text className='points-card__checkin-text'>
{checkinLoading ? '...' : checkedIn ? '已签到' : '签到'}
</Text>
</View>
</View>
</View>
);
};
export default React.memo(PointsCard);

View File

@@ -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;
}
}
}

View File

@@ -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<string, string> = {
physical: 'product-card__thumb--physical',
service: 'product-card__thumb--service',
privilege: 'product-card__thumb--privilege',
};
const TYPE_LABELS: Record<string, string> = {
physical: '实物',
service: '服务券',
privilege: '权益',
};
const ProductCard: React.FC<ProductCardProps> = ({ product, onPress }) => {
const isSoldOut = product.stock <= 0;
const isLowStock = product.stock > 0 && product.stock <= 10;
return (
<View
className='product-card'
onClick={() => onPress?.(product)}
>
<View className='product-card__thumb-wrap'>
<View className={`product-card__thumb ${TYPE_BG_CLASS[product.product_type] || ''}`}>
<Text className='product-card__thumb-type'>{TYPE_LABELS[product.product_type] || '商品'}</Text>
</View>
{isSoldOut && (
<View className='product-card__soldout'>
<Text className='product-card__soldout-text'></Text>
</View>
)}
</View>
<View className='product-card__info'>
<Text className='product-card__name'>{product.name}</Text>
<View className='product-card__bottom'>
<View className='product-card__points-row'>
<Text className='product-card__points-num'>{product.points_cost}</Text>
<Text className='product-card__points-unit'></Text>
</View>
</View>
{isLowStock && (
<Text className='product-card__low-stock'>{product.stock}</Text>
)}
</View>
</View>
);
};
export default React.memo(ProductCard);

View File

@@ -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;
}
}

View File

@@ -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<string, string> = {
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<PointsProduct[]>([]);
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 (
<PageShell padding="none" safeBottom={false} scroll={false} className={`mall-page ${modeClass}`}>
{/* 积分余额卡片 */}
{/* 积分卡片 */}
<View className='mall-header'>
<View className='points-card'>
<View className='points-top'>
<Text className='points-label'></Text>
<PointsCard
balance={balance}
consecutiveDays={consecutiveDays}
checkedIn={checkedIn}
checkinLoading={checkinLoading}
onCheckin={handleCheckin}
/>
</View>
{/* 可滚动内容区 */}
<View className='mall-content'>
{/* 快捷操作 */}
<View className='mall-actions'>
{QUICK_ACTIONS.map((action) => (
<View
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
onClick={handleCheckin}
key={action.label}
className='mall-action'
onClick={() => handleAction(action.label)}
>
<Text className='checkin-btn-text'>
{checkinLoading ? '...' : checkinStatus?.checked_in_today ? '已签到' : '签到'}
</Text>
</View>
</View>
<Text className='points-balance'>{balance.toLocaleString()}</Text>
{checkinStatus && checkinStatus.consecutive_days > 0 && (
<Text className='points-streak'>
{checkinStatus.consecutive_days}
</Text>
)}
</View>
</View>
{/* 快捷操作 */}
<View className='mall-actions'>
<View className='mall-action' onClick={handleCheckin}>
<View className='mall-action-icon mall-action-icon--checkin'>
<Text className='mall-action-icon-text'></Text>
</View>
<Text className='mall-action-label'></Text>
</View>
{/* TODO: 积分任务功能待实现后恢复 */}
<View className='mall-action' onClick={() => safeNavigateTo('/pages/pkg-mall/orders/index')}>
<View className='mall-action-icon mall-action-icon--history'>
<Text className='mall-action-icon-text'></Text>
</View>
<Text className='mall-action-label'></Text>
</View>
</View>
{/* 商品类型切换 */}
<View className='type-tabs'>
{PRODUCT_TYPE_TABS.map((tab) => (
<View
key={tab.key}
className={`type-tab ${productType === tab.key ? 'active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text className={`type-tab-text ${productType === tab.key ? 'active' : ''}`}>
{tab.label}
</Text>
</View>
))}
</View>
{/* 商品列表 */}
{error ? (
<ErrorState onRetry={() => loadAll()} />
) : products.length === 0 && !loading ? (
<EmptyState icon='礼' text='暂无商品' hint='更多好物即将上架' />
) : (
<View className='product-grid'>
{products.map((item) => (
<View
key={item.id}
className='product-card'
onClick={() => handleProductClick(item)}
>
<View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
<Text className='product-image-char'>
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
</Text>
</View>
<View className='product-info'>
<Text className='product-name'>{item.name}</Text>
<View className='product-bottom'>
<View className='product-points'>
<Text className='product-points-char'>{item.points_cost}</Text>
<Text className='product-points-value'></Text>
</View>
{item.stock <= 0 ? (
<Text className='product-stock out'></Text>
) : item.stock <= 10 ? (
<Text className='product-stock low'>{item.stock}</Text>
) : null}
</View>
<View className={`mall-action-icon ${action.cls}`}>
<Text className='mall-action-icon-text'>{action.icon}</Text>
</View>
<Text className='mall-action-label'>{action.label}</Text>
</View>
))}
{loading && <Loading />}
{!loading && products.length >= total && total > 0 && (
<Loading text='没有更多了' />
)}
</View>
)}
{/* 分割线 */}
<View className='mall-divider' />
{/* 分类标签 */}
<View className='mall-tabs'>
<TabFilter
tabs={PRODUCT_TABS}
activeIndex={activeTab}
onChange={handleTabChange}
variant='pill'
/>
</View>
{/* 商品网格 */}
{error ? (
<ErrorState onRetry={() => loadAll()} />
) : products.length === 0 && !loading ? (
<EmptyState icon='礼' text='暂无商品' hint='更多好物即将上架' />
) : (
<View className='product-grid'>
{products.map((item) => (
<ProductCard
key={item.id}
product={item}
onPress={handleProductClick}
/>
))}
{loading && <Loading />}
{!loading && products.length >= total && total > 0 && (
<Loading text='没有更多了' />
)}
</View>
)}
</View>
{/* 签到弹窗 */}
<CheckinModal
visible={showCheckin}
consecutiveDays={consecutiveDays + (checkedIn ? 0 : 0)}
earnedPoints={10}
onClose={() => setShowCheckin(false)}
/>
</PageShell>
);
}

View File

@@ -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;
}

View File

@@ -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<string, string> = {
physical: '',
service: '',
privilege: '',
const TYPE_BG: Record<string, string> = {
physical: 'product-detail__hero--physical',
service: 'product-detail__hero--service',
privilege: 'product-detail__hero--privilege',
};
const TYPE_LABEL: Record<string, string> = {
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<PointsProduct | null>(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 (
<PageShell className={modeClass}>
<View className='product-detail-loading'>
<Text className='product-detail-loading-text'>...</Text>
<View className='product-detail__loading'>
<Text>...</Text>
</View>
</PageShell>
);
@@ -64,95 +64,100 @@ export default function ProductDetail() {
if (!product) {
return (
<PageShell className={modeClass}>
<View className='product-detail-empty'>
<Text className='product-detail-empty-text'></Text>
<View className='product-detail__empty'>
<Text></Text>
</View>
</PageShell>
);
}
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 (
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail-page ${modeClass}`}>
<View className='product-detail-scroll'>
{/* 商品图区域 */}
<View className={`product-detail-image product-detail-image--${productType}`}>
<Text className='product-detail-image-char'>{typeChar}</Text>
<Text className='product-detail-image-label'>{product.name}</Text>
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail ${modeClass}`}>
<View className='product-detail__scroll'>
{/* 商品图 */}
<View className={`product-detail__hero ${TYPE_BG[type] || ''}`}>
<Text className='product-detail__hero-type'>{TYPE_LABEL[type]}</Text>
</View>
{/* 商品信息卡 */}
<View className='product-detail-info'>
<Text className='product-detail-name'>{product.name}</Text>
<View className='product-detail-price-row'>
<Text className='product-detail-points'>
{product.points_cost.toLocaleString()}
</Text>
<Text className='product-detail-type-tag'>{typeLabel}</Text>
{/* 商品信息卡 */}
<View className='product-detail__info-card'>
<View className='product-detail__tags'>
<Text className='product-detail__type-badge'>{TYPE_LABEL[type]}</Text>
</View>
<Text className='product-detail__name'>{product.name}</Text>
<View className='product-detail__price-row'>
<Text className='product-detail__points-num'>{product.points_cost}</Text>
<Text className='product-detail__points-unit'></Text>
</View>
{product.description && (
<Text className='product-detail-desc'>{product.description}</Text>
<Text className='product-detail__desc'>{product.description}</Text>
)}
</View>
{/* 规格 */}
<View className='product-detail-specs'>
<View className='product-detail-spec-row'>
<Text className='product-detail-spec-label'></Text>
<Text className='product-detail-spec-value'>{typeLabel}</Text>
</View>
<View className='product-detail-spec-row'>
<Text className='product-detail-spec-label'></Text>
<Text className='product-detail-spec-value'>
{product.stock > 0 ? `${product.stock}` : '已兑完'}
</Text>
</View>
<View className='product-detail-spec-row'>
<Text className='product-detail-spec-label'></Text>
<Text className='product-detail-spec-value'>
{isService ? '到院核销' : '后台审核发货'}
</Text>
</View>
</View>
{/* 温馨提示 */}
<View className='product-detail-notice'>
<Text className='product-detail-notice-title'></Text>
<Text className='product-detail-notice-text'>
{isService
? '兑换成功后将生成核销码,请凭核销码到前台核销体验服务。'
: '兑换后需工作人员审核确认,审核通过后将在 7 个工作日内寄出。'}
{/* 库存/余额卡 */}
<View className='product-detail__status-card'>
<View className='product-detail__status-row'>
<Text className='product-detail__status-label'></Text>
<Text className={`product-detail__status-value ${
product.stock > 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}` : '已兑完'}
</Text>
<Text className='product-detail-notice-text'>退</Text>
</View>
<View className='product-detail__status-row'>
<Text className='product-detail__status-label'></Text>
<Text className={`product-detail__status-value ${canAfford ? 'product-detail__status-value--ok' : 'product-detail__status-value--danger'}`}>
{balance.toLocaleString()} ({canAfford ? '充足' : '不足'})
</Text>
</View>
</View>
{/* 温馨提示 */}
<View className='product-detail__notice'>
<Text className='product-detail__notice-text'>
{isService
? '兑换成功后将生成核销码,请凭核销码到前台核销。过期未核销订单将自动取消并退还积分。'
: '兑换后需工作人员审核确认,审核通过后 7 个工作日内寄出。积分一经兑换不可退回。'}
</Text>
</View>
</View>
{/* 底部操作栏 */}
<View className='product-detail-footer'>
<View className='product-detail-fav'>
<Text className='product-detail-fav-icon'></Text>
<View className='product-detail__footer'>
<View className='product-detail__footer-left'>
<Text className='product-detail__footer-hint'></Text>
<View className='product-detail__footer-price'>
<Text className='product-detail__footer-num'>{product.points_cost}</Text>
<Text className='product-detail__footer-unit'></Text>
</View>
</View>
<View
className={`product-detail-exchange-btn ${product.stock <= 0 ? 'disabled' : ''}`}
onClick={product.stock <= 0 ? undefined : handleExchange}
className={`product-detail__exchange-btn ${!canAfford || product.stock <= 0 ? 'product-detail__exchange-btn--disabled' : ''}`}
onClick={handleExchange}
>
<Text className='product-detail-exchange-text'>
{product.stock <= 0 ? '已兑完' : '立即兑换'}
<Text className='product-detail__exchange-text'>
{product.stock <= 0 ? '已兑完' : !canAfford ? '积分不足' : '立即兑换'}
</Text>
</View>
</View>

View File

@@ -0,0 +1,526 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HMS 小程序 — 积分商城 V2</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a1a; font-family: -apple-system, 'PingFang SC', sans-serif; display: flex; flex-direction: column; align-items: center; padding: 40px 20px; gap: 24px; }
.page-title { color: #999; font-size: 13px; letter-spacing: 0.15em; text-transform: uppercase; }
.note { color: #666; font-size: 12px; max-width: 800px; text-align: center; line-height: 1.8; }
.screens { display: flex; gap: 48px; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
.screen-wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.screen-label { color: #888; font-size: 12px; font-style: italic; }
</style>
</head>
<body>
<div class="page-title">HMS 小程序 · 积分商城 V2</div>
<div class="note">V2 重设计 — 增强积分卡片层级感、新增商品详情页和签到弹窗。温润东方风设计系统。</div>
<div id="root"></div>
<script type="text/babel">
// ─── iOS 设备框(来自 assets/ios_frame.jsx ───
const iosFrameStyles = {
wrapper: { display: 'inline-block', padding: 12, background: '#000', borderRadius: 60, boxShadow: '0 0 0 2px #1f2937, 0 20px 60px rgba(0,0,0,0.3)', position: 'relative' },
screen: { position: 'relative', borderRadius: 48, overflow: 'hidden', background: '#fff' },
statusBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 54, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 32px', fontSize: 16, fontWeight: 600, fontFamily: '-apple-system, "SF Pro Text", sans-serif', zIndex: 20, pointerEvents: 'none' },
dynamicIsland: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', width: 124, height: 36, background: '#000', borderRadius: 999, zIndex: 30 },
content: { position: 'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'auto' },
homeIndicator: { position: 'absolute', bottom: 10, left: '50%', transform: 'translateX(-50%)', width: 140, height: 5, background: 'rgba(0,0,0,0.3)', borderRadius: 999, zIndex: 10 },
};
function IosFrame({ children, width = 393, height = 852, time = '9:41', battery = 85, darkStatus = false }) {
const c = darkStatus ? '#fff' : '#000';
return (
<div style={iosFrameStyles.wrapper}>
<div style={{ ...iosFrameStyles.screen, width, height }}>
<div style={{ ...iosFrameStyles.statusBar, color: c }}>
<span>{time}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none"><path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill={c}/><path d="M3 7.5a7 7 0 0110 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M1 4.5a11 11 0 0114 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7"/></svg>
<div style={{ width: 26, height: 12, border: `1.5px solid ${c}`, borderRadius: 3, padding: 1, position: 'relative' }}>
<div style={{ width: `${battery}%`, height: '100%', background: c, borderRadius: 1 }} />
</div>
</div>
</div>
<div style={iosFrameStyles.dynamicIsland} />
<div style={iosFrameStyles.content}>{children}</div>
<div style={iosFrameStyles.homeIndicator} />
</div>
</div>
);
}
// ─── 设计 Token ───
const T = {
pri: '#C4623A', priL: '#F0DDD4', priD: '#8B3E1F',
bg: '#F5F0EB', card: '#FFFFFF', surface: '#EDE8E2',
tx: '#2D2A26', tx2: '#5A554F', tx3: '#78716C',
bd: '#E8E2DC', bdL: '#F0EBE5',
acc: '#5B7A5E', accL: '#E8F0E8',
wrn: '#C4873A', wrnL: '#FFF3E0',
dan: '#B54A4A', danL: '#FDEAEA',
white: '#FFFFFF',
serif: "Georgia, 'Times New Roman', serif",
sans: "-apple-system, 'PingFang SC', sans-serif",
r: 16, rSm: 12, rXs: 8, rPill: 20,
};
// ─── 图标组件 ───
function IconSvg({ paths, size = 24, color = '#fff' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{paths.map((d, i) => typeof d === 'string' ? <path key={i} d={d}/> : d)}
</svg>
);
}
function IconCheckin(props) { return <IconSvg {...props} paths={['M9 11l3 3L22 4','M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11']}/>; }
function IconTask(props) { return <IconSvg {...props} paths={[<rect x="3" y="3" width="18" height="18" rx="2" key="r"/>, 'M9 12l2 2 4-4']}/>; }
function IconHistory(props) { return <IconSvg {...props} paths={[<circle cx="12" cy="12" r="10" key="c"/>, 'M12 6v6l4 2']}/>; }
function IconArrowLeft(props) { return <IconSvg {...props} paths={['M15 18l-6-6 6-6']}/>; }
function IconChevronRight(props) { return <IconSvg {...props} size={14} paths={['M9 18l6-6-6-6']}/>; }
function IconClose(props) { return <IconSvg {...props} size={20} paths={['M18 6L6 18','M6 6l12 12']}/>; }
function IconCheck(props) { return <IconSvg {...props} size={14} paths={['M20 6L9 17l-5-5']}/>; }
function IconGift(props) { return <IconSvg {...props} paths={['M20 12v10H4V12','M2 7h20v5H2z','M12 22V7','M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7z','M12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z']}/>; }
// ─── 商品数据 ───
const PRODUCTS = [
{ id: 1, name: '健康体检套餐', points: 800, price: 299, type: 'service', tag: '热门', stock: 15 },
{ id: 2, name: '血压计', points: 1200, price: 199, type: 'physical', stock: 8 },
{ id: 3, name: '维生素D3', points: 300, price: 89, type: 'physical', stock: 50 },
{ id: 4, name: '专属健康顾问', points: 2000, price: 500, type: 'privilege', tag: '新品', stock: 3 },
{ id: 5, name: '运动手环', points: 2000, price: 399, type: 'physical', stock: 0 },
{ id: 6, name: '保温杯', points: 600, price: 128, type: 'physical', tag: '新品', stock: 20 },
];
const TYPE_LABELS = { physical: '实物', service: '服务券', privilege: '权益' };
const TYPE_COLORS = { physical: T.pri, service: T.acc, privilege: T.wrn };
const TYPE_BG = { physical: T.priL, service: T.accL, privilege: T.wrnL };
// ─── 商品占位图 ───
function ProductThumb({ name, type, large = false }) {
const sz = large ? 48 : 28;
return (
<div style={{
width: '100%', aspectRatio: large ? '4/3' : '1',
background: TYPE_BG[type] || T.surface,
borderRadius: large ? T.r : T.rSm,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
position: 'relative', gap: 4,
}}>
<IconGift size={sz} color={TYPE_COLORS[type] || T.pri}/>
<span style={{ fontSize: large ? 12 : 9, color: T.tx3, fontWeight: 500 }}>{TYPE_LABELS[type]}</span>
</div>
);
}
// ─────────────────────────────────────
// Screen 1: 商城主页
// ─────────────────────────────────────
function MallPage() {
const [activeCat, setActiveCat] = React.useState(0);
const cats = ['全部', '实物', '服务券', '权益'];
const catKeys = ['', 'physical', 'service', 'privilege'];
const filtered = activeCat === 0 ? PRODUCTS : PRODUCTS.filter(p => p.type === catKeys[activeCat]);
const balance = 1280;
const checkedIn = false;
const consecutiveDays = 5;
const actions = [
{ icon: <IconCheckin size={22}/>, label: '签到打卡', bg: T.acc, shadow: 'rgba(91,122,94,0.3)' },
{ icon: <IconTask size={22}/>, label: '积分任务', bg: T.pri, shadow: 'rgba(196,98,58,0.3)' },
{ icon: <IconHistory size={22}/>, label: '兑换记录', bg: T.wrn, shadow: 'rgba(196,135,58,0.3)' },
];
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
{/* 导航栏 */}
<div style={{ padding: '12px 20px 8px', display: 'flex', alignItems: 'center', gap: 8, background: T.bg }}>
<IconArrowLeft size={24} color={T.tx}/>
<div style={{ flex: 1, textAlign: 'center', fontFamily: T.serif, fontSize: 26, fontWeight: 700, color: T.tx, paddingRight: 24 }}>积分商城</div>
</div>
{/* 可滚动区域 */}
<div style={{ flex: 1, overflow: 'auto', padding: '8px 20px 24px' }}>
{/* 积分卡片 */}
<div style={{
background: `linear-gradient(135deg, ${T.pri} 0%, ${T.priD} 100%)`,
borderRadius: T.r, padding: '24px 24px 20px', marginBottom: 20,
boxShadow: `0 8px 24px rgba(196,98,58,0.25)`, position: 'relative', overflow: 'hidden',
}}>
{/* 装饰元素 */}
<div style={{ position: 'absolute', top: -20, right: -20, width: 100, height: 100, borderRadius: 50, background: 'rgba(255,255,255,0.08)' }}/>
<div style={{ position: 'absolute', bottom: -30, right: 40, width: 80, height: 80, borderRadius: 40, background: 'rgba(255,255,255,0.05)' }}/>
<div style={{ position: 'absolute', top: 20, right: 20, width: 40, height: 40, borderRadius: 20, background: 'rgba(255,255,255,0.06)' }}/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative', zIndex: 1 }}>
<div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)', marginBottom: 8, letterSpacing: 1 }}>我的积分</div>
<div style={{ fontFamily: T.serif, fontSize: 42, fontWeight: 700, color: '#fff', lineHeight: 1, letterSpacing: 2 }}>{balance.toLocaleString()}</div>
{consecutiveDays > 0 && (
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.65)', marginTop: 8 }}>
已连续签到 {consecutiveDays}
</div>
)}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
background: checkedIn ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
border: `1px solid rgba(255,255,255,${checkedIn ? '0.2' : '0.4'})`,
borderRadius: T.rPill, padding: '8px 16px',
fontSize: 13, color: '#fff', fontWeight: 500, cursor: 'pointer',
}}>
{checkedIn ? '已签到' : '签到'}
</div>
</div>
</div>
{/* 快捷操作 */}
<div style={{ display: 'flex', justifyContent: 'space-around', marginBottom: 24 }}>
{actions.map((a, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{
width: 52, height: 52, borderRadius: 26, background: a.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: `0 4px 12px ${a.shadow}`,
}}>
{a.icon}
</div>
<span style={{ fontSize: 12, color: T.tx2, fontWeight: 500 }}>{a.label}</span>
</div>
))}
</div>
{/* 分割线 */}
<div style={{ height: 1, background: T.bd, marginBottom: 16 }}/>
{/* 分类标签 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20, overflowX: 'auto' }}>
{cats.map((cat, i) => (
<div
key={i}
onClick={() => setActiveCat(i)}
style={{
padding: '7px 18px', borderRadius: T.rPill,
fontSize: 14, fontWeight: activeCat === i ? 600 : 400,
background: activeCat === i ? T.pri : T.surface,
color: activeCat === i ? '#fff' : T.tx2,
whiteSpace: 'nowrap', transition: 'all 0.2s', cursor: 'pointer',
boxShadow: activeCat === i ? '0 2px 8px rgba(196,98,58,0.25)' : 'none',
}}
>
{cat}
</div>
))}
</div>
{/* 商品网格 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{filtered.map(p => (
<div key={p.id} style={{
background: T.card, borderRadius: T.rSm, overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', cursor: 'pointer',
}}>
<div style={{ position: 'relative' }}>
<ProductThumb name={p.name} type={p.type}/>
{p.tag && (
<div style={{
position: 'absolute', top: 8, left: 8,
background: p.tag === '热门' ? T.dan : T.acc,
color: '#fff', fontSize: 10, fontWeight: 600,
padding: '2px 8px', borderRadius: 6,
}}>
{p.tag}
</div>
)}
{p.stock === 0 && (
<div style={{
position: 'absolute', inset: 0, background: 'rgba(255,255,255,0.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: T.tx3, fontWeight: 600,
}}>
已兑完
</div>
)}
</div>
<div style={{ padding: '10px 12px 14px' }}>
<div style={{
fontSize: 14, fontWeight: 600, color: T.tx, lineHeight: 1.4,
marginBottom: 8, height: 40,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>
{p.name}
</div>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: T.pri }}>{p.points}</span>
<span style={{ fontSize: 11, color: T.pri, fontWeight: 500 }}>积分</span>
</div>
<span style={{ fontSize: 12, color: T.tx3, textDecoration: 'line-through' }}>¥{p.price}</span>
</div>
{p.stock > 0 && p.stock <= 10 && (
<div style={{
fontSize: 11, color: T.wrn, fontWeight: 500, marginTop: 4,
background: T.wrnL, padding: '2px 8px', borderRadius: 6, display: 'inline-block',
}}>
仅剩{p.stock}
</div>
)}
</div>
</div>
))}
</div>
<div style={{ height: 16 }}/>
</div>
</div>
);
}
// ─────────────────────────────────────
// Screen 2: 商品详情页
// ─────────────────────────────────────
function ProductDetailPage() {
const product = PRODUCTS[0]; // 健康体检套餐
const balance = 1280;
const canAfford = balance >= product.points;
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
{/* 导航栏 */}
<div style={{ padding: '12px 20px 8px', display: 'flex', alignItems: 'center', gap: 8, background: T.bg }}>
<IconArrowLeft size={24} color={T.tx}/>
<div style={{ flex: 1, textAlign: 'center', fontSize: 17, fontWeight: 600, color: T.tx, paddingRight: 24 }}>商品详情</div>
</div>
{/* 内容 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{/* 商品大图 */}
<div style={{ padding: '0 20px', marginBottom: 16 }}>
<ProductThumb name={product.name} type={product.type} large/>
</div>
{/* 商品信息卡 */}
<div style={{ background: T.card, margin: '0 20px', borderRadius: T.r, padding: 20, marginBottom: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{product.tag && (
<span style={{
fontSize: 11, fontWeight: 600, color: '#fff',
background: product.tag === '热门' ? T.dan : T.acc,
padding: '2px 8px', borderRadius: 6,
}}>
{product.tag}
</span>
)}
<span style={{
fontSize: 11, fontWeight: 500, color: T.acc,
background: T.accL, padding: '2px 8px', borderRadius: 6,
}}>
{TYPE_LABELS[product.type]}
</span>
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: T.tx, lineHeight: 1.4, marginBottom: 12 }}>
{product.name}
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 8 }}>
<span style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.pri }}>{product.points}</span>
<span style={{ fontSize: 14, color: T.pri, fontWeight: 500 }}>积分</span>
<span style={{ fontSize: 14, color: T.tx3, textDecoration: 'line-through', marginLeft: 8 }}>¥{product.price}</span>
</div>
<div style={{ fontSize: 13, color: T.tx3, lineHeight: 1.6 }}>
由专业医疗团队提供的全面健康体检服务包含基础检查血液分析心电图等多项检查项目
</div>
</div>
{/* 库存状态 */}
<div style={{ background: T.card, margin: '0 20px', borderRadius: T.r, padding: '16px 20px', marginBottom: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 14, color: T.tx2 }}>库存状态</span>
<span style={{ fontSize: 14, fontWeight: 600, color: product.stock > 10 ? T.acc : product.stock > 0 ? T.wrn : T.dan }}>
{product.stock > 10 ? '充足' : product.stock > 0 ? `仅剩 ${product.stock}` : '已兑完'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<span style={{ fontSize: 14, color: T.tx2 }}>您的积分</span>
<span style={{ fontFamily: T.serif, fontSize: 16, fontWeight: 700, color: canAfford ? T.acc : T.dan }}>
{balance.toLocaleString()} {canAfford ? '(充足)' : '(不足)'}
</span>
</div>
</div>
{/* 温馨提示 */}
<div style={{ margin: '0 20px', padding: '12px 16px', background: T.wrnL, borderRadius: T.rSm, marginBottom: 24 }}>
<div style={{ fontSize: 12, color: T.wrn, lineHeight: 1.6 }}>
兑换后积分将立即扣除请在 30 天内到院核销过期未核销的订单将自动取消并退还积分
</div>
</div>
</div>
{/* 底部操作栏 */}
<div style={{
padding: '12px 20px', background: T.card,
borderTop: `1px solid ${T.bd}`, display: 'flex', alignItems: 'center', gap: 12,
}}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: T.tx3 }}>需要</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.pri }}>{product.points}</span>
<span style={{ fontSize: 12, color: T.pri }}>积分</span>
</div>
</div>
<div style={{
padding: '14px 32px', borderRadius: T.rPill,
background: canAfford ? T.pri : T.bd,
color: canAfford ? '#fff' : T.tx3,
fontSize: 16, fontWeight: 600,
cursor: canAfford ? 'pointer' : 'not-allowed',
boxShadow: canAfford ? '0 4px 12px rgba(196,98,58,0.3)' : 'none',
}}>
{canAfford ? '立即兑换' : '积分不足'}
</div>
</div>
</div>
);
}
// ─────────────────────────────────────
// Screen 3: 签到弹窗
// ─────────────────────────────────────
function CheckinPopupPage() {
const consecutiveDays = 5;
const earnedPoints = 10;
const days = ['一', '二', '三', '四', '五', '六', '日'];
const checkedDays = [true, true, true, true, true, false, false]; // 前5天已签
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
{/* 导航栏 */}
<div style={{ padding: '12px 20px 8px', display: 'flex', alignItems: 'center', gap: 8, background: T.bg }}>
<IconArrowLeft size={24} color={T.tx}/>
<div style={{ flex: 1, textAlign: 'center', fontFamily: T.serif, fontSize: 26, fontWeight: 700, color: T.tx, paddingRight: 24 }}>积分商城</div>
</div>
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{/* 半透明遮罩 */}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 5 }}/>
{/* 弹窗卡片 */}
<div style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
width: 320, background: T.card, borderRadius: T.r, zIndex: 10,
boxShadow: '0 20px 60px rgba(0,0,0,0.15)', overflow: 'hidden',
}}>
{/* 顶部装饰 */}
<div style={{
background: `linear-gradient(135deg, ${T.pri} 0%, ${T.priD} 100%)`,
padding: '24px 24px 20px', textAlign: 'center', position: 'relative', overflow: 'hidden',
}}>
<div style={{ position: 'absolute', top: -15, right: -15, width: 60, height: 60, borderRadius: 30, background: 'rgba(255,255,255,0.08)' }}/>
<div style={{ fontSize: 16, color: 'rgba(255,255,255,0.8)', marginBottom: 4 }}>签到成功</div>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span style={{ fontFamily: T.serif, fontSize: 36, fontWeight: 700, color: '#fff' }}>+{earnedPoints}</span>
<span style={{ fontSize: 14, color: 'rgba(255,255,255,0.8)' }}>积分</span>
</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.65)', marginTop: 8 }}>
已连续签到 {consecutiveDays}
</div>
</div>
{/* 7 天日历 */}
<div style={{ padding: '20px 20px 16px' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 12, textAlign: 'center' }}>
本周签到
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'center' }}>
{days.map((d, i) => {
const isChecked = checkedDays[i];
const isToday = i === consecutiveDays - 1 && isChecked;
return (
<div key={i} style={{
width: 36, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
}}>
<div style={{
width: 36, height: 36, borderRadius: 18,
background: isChecked ? (isToday ? T.pri : T.accL) : T.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: isToday ? `2px solid ${T.pri}` : 'none',
boxShadow: isToday ? `0 2px 8px rgba(196,98,58,0.3)` : 'none',
}}>
{isChecked ? (
<IconCheck size={14} color={isToday ? '#fff' : T.acc}/>
) : (
<div style={{ width: 6, height: 6, borderRadius: 3, background: T.bd }}/>
)}
</div>
<span style={{ fontSize: 11, color: isChecked ? T.tx : T.tx3, fontWeight: isToday ? 600 : 400 }}>
{d}
</span>
</div>
);
})}
</div>
{/* 连续签到奖励提示 */}
<div style={{
marginTop: 16, padding: '10px 14px', background: T.accL, borderRadius: T.rSm,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<IconCheck size={16} color={T.acc}/>
<span style={{ fontSize: 12, color: T.acc, fontWeight: 500 }}>
再坚持 2 连续 7 天签到额外奖励 50 积分
</span>
</div>
</div>
{/* 关闭按钮 */}
<div style={{ padding: '0 20px 20px', display: 'flex', justifyContent: 'center' }}>
<div style={{
width: '100%', padding: '12px 0', borderRadius: T.rPill,
background: T.pri, color: '#fff', fontSize: 15, fontWeight: 600,
textAlign: 'center', cursor: 'pointer',
boxShadow: '0 4px 12px rgba(196,98,58,0.3)',
}}>
我知道了
</div>
</div>
</div>
</div>
</div>
);
}
// ─── 渲染 ───
function App() {
return (
<div className="screens">
<div className="screen-wrap">
<span className="screen-label">商城主页</span>
<IosFrame time="9:41" battery={85} darkStatus={false}>
<MallPage />
</IosFrame>
</div>
<div className="screen-wrap">
<span className="screen-label">商品详情</span>
<IosFrame time="9:42" battery={85}>
<ProductDetailPage />
</IosFrame>
</div>
<div className="screen-wrap">
<span className="screen-label">签到成功弹窗</span>
<IosFrame time="9:43" battery={84}>
<CheckinPopupPage />
</IosFrame>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -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

View File

@@ -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. "需新建"的组件参考截图和布局描述从头实现

View File

@@ -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
}
}

View File

@@ -146,6 +146,8 @@
- [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型
- [[architecture]] — 架构决策 · 设计原则 · 技术选型
- [[permissions]] — **角色权限体系** · 7 角色 · 140+ 权限码 · Web/小程序权限矩阵 · 各角色可见菜单
### 业务层(继承自 ERP 底座)
- erp-auth — 用户/角色/权限/组织/部门/岗位 · JWT · RBAC · 行级数据权限
- erp-config — 字典/菜单/设置/编号规则/主题/语言