Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4035e0ae | ||
|
|
e19324d0ef | ||
|
|
e0e707edcb | ||
|
|
8ab528a74b | ||
|
|
de7a359ead | ||
|
|
fbb41911a2 | ||
|
|
223a427614 | ||
|
|
78c80c4b1d |
@@ -0,0 +1,452 @@
|
||||
# 数据统计模块 API 文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-06-09
|
||||
> **作者**: system
|
||||
> **状态**: 正式发布
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [概述](#概述)
|
||||
2. [基础路径](#基础路径)
|
||||
3. [统计数据接口](#统计数据接口)
|
||||
- [获取综合统计数据](#获取综合统计数据)
|
||||
- [获取会员统计数据](#获取会员统计数据)
|
||||
- [获取预约统计数据](#获取预约统计数据)
|
||||
- [获取签到统计数据](#获取签到统计数据)
|
||||
- [查询历史统计数据](#查询历史统计数据)
|
||||
- [导出统计数据](#导出统计数据)
|
||||
4. [数据模型](#数据模型)
|
||||
- [StatisticsQuery(查询条件)](#StatisticsQuery查询条件)
|
||||
- [StatisticsSummary(统计汇总)](#StatisticsSummary统计汇总)
|
||||
- [MemberStatistics(会员统计)](#MemberStatistics会员统计)
|
||||
- [BookingStatistics(预约统计)](#BookingStatistics预约统计)
|
||||
- [SignInStatistics(签到统计)](#SignInStatistics签到统计)
|
||||
5. [状态码说明](#状态码说明)
|
||||
6. [业务规则](#业务规则)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
数据统计模块提供健身房会员、预约、签到数据的统计功能,支持按日、周、月周期统计,并提供Excel导出功能。采用 Spring WebFlux 响应式编程,统计结果通过 Redis 缓存提高查询性能。
|
||||
|
||||
## 基础路径
|
||||
|
||||
所有接口的基础路径为: `http://{host}:{port}/api/datacount`
|
||||
|
||||
---
|
||||
|
||||
## 统计数据接口
|
||||
|
||||
### 获取综合统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/datacount/summary` |
|
||||
| **所属文件** | `DataStatisticsHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| statType | string | 否 | 空 | 统计类型筛选 |
|
||||
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
|
||||
| startTime | string | 否 | 自动计算 | 开始时间(ISO格式) |
|
||||
| endTime | string | 否 | 自动计算 | 结束时间(ISO格式) |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
# 1. 使用周期类型查询今日统计
|
||||
GET /api/datacount/summary?periodType=DAY
|
||||
|
||||
# 2. 使用周期类型查询本周统计
|
||||
GET /api/datacount/summary?periodType=WEEK
|
||||
|
||||
# 3. 使用周期类型查询本月统计
|
||||
GET /api/datacount/summary?periodType=MONTH
|
||||
|
||||
# 4. 使用自定义时间范围查询
|
||||
GET /api/datacount/summary?startTime=2026-06-01T00:00:00&endTime=2026-06-07T23:59:59
|
||||
|
||||
# 5. 带统计类型筛选
|
||||
GET /api/datacount/summary?statType=member&periodType=WEEK
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"statDate": "2026-06-09",
|
||||
"generatedAt": "2026-06-09T10:30:00",
|
||||
"memberStatistics": {
|
||||
"statDate": "2026-06-09",
|
||||
"newMembers": 5,
|
||||
"activeMembers": 120,
|
||||
"totalMembers": 500,
|
||||
"signInMembers": 85,
|
||||
"bookingMembers": 45,
|
||||
"cancelMembers": 5
|
||||
},
|
||||
"bookingStatistics": {
|
||||
"statDate": "2026-06-09",
|
||||
"totalBookings": 60,
|
||||
"cancelBookings": 10,
|
||||
"attendBookings": 45,
|
||||
"absentBookings": 5,
|
||||
"attendRate": 75.0,
|
||||
"cancelRate": 16.67
|
||||
},
|
||||
"signInStatistics": {
|
||||
"statDate": "2026-06-09",
|
||||
"totalSignIns": 95,
|
||||
"successSignIns": 92,
|
||||
"failSignIns": 3,
|
||||
"successRate": 96.84,
|
||||
"signInTypeDistribution": {
|
||||
"QR_CODE": 60,
|
||||
"MANUAL": 25,
|
||||
"FACE": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 获取会员统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/datacount/member` |
|
||||
| **所属文件** | `DataStatisticsHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
|
||||
| startTime | string | 否 | 自动计算 | 开始时间 |
|
||||
| endTime | string | 否 | 自动计算 | 结束时间 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
# 1. 查询今日会员统计
|
||||
GET /api/datacount/member
|
||||
|
||||
# 2. 查询本周会员统计
|
||||
GET /api/datacount/member?periodType=WEEK
|
||||
|
||||
# 3. 查询指定时间范围的会员统计
|
||||
GET /api/datacount/member?startTime=2026-06-01T00:00:00&endTime=2026-06-30T23:59:59
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"statDate": "2026-06-09",
|
||||
"newMembers": 5,
|
||||
"activeMembers": 120,
|
||||
"totalMembers": 500,
|
||||
"signInMembers": 85,
|
||||
"bookingMembers": 45,
|
||||
"cancelMembers": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 获取预约统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/datacount/booking` |
|
||||
| **所属文件** | `DataStatisticsHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
|
||||
| startTime | string | 否 | 自动计算 | 开始时间 |
|
||||
| endTime | string | 否 | 自动计算 | 结束时间 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
# 1. 查询今日预约统计
|
||||
GET /api/datacount/booking
|
||||
|
||||
# 2. 查询本月预约统计
|
||||
GET /api/datacount/booking?periodType=MONTH
|
||||
|
||||
# 3. 查询指定日期范围的预约统计
|
||||
GET /api/datacount/booking?startTime=2026-06-01T00:00:00&endTime=2026-06-15T23:59:59
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"statDate": "2026-06-09",
|
||||
"totalBookings": 60,
|
||||
"cancelBookings": 10,
|
||||
"attendBookings": 45,
|
||||
"absentBookings": 5,
|
||||
"attendRate": 75.0,
|
||||
"cancelRate": 16.67
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 获取签到统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/datacount/signin` |
|
||||
| **所属文件** | `DataStatisticsHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
|
||||
| startTime | string | 否 | 自动计算 | 开始时间 |
|
||||
| endTime | string | 否 | 自动计算 | 结束时间 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
# 1. 查询今日签到统计
|
||||
GET /api/datacount/signin
|
||||
|
||||
# 2. 查询本周签到统计
|
||||
GET /api/datacount/signin?periodType=WEEK
|
||||
|
||||
# 3. 查询指定日期范围的签到统计
|
||||
GET /api/datacount/signin?startTime=2026-06-01T00:00:00&endTime=2026-06-30T23:59:59
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"statDate": "2026-06-09",
|
||||
"totalSignIns": 95,
|
||||
"successSignIns": 92,
|
||||
"failSignIns": 3,
|
||||
"successRate": 96.84,
|
||||
"signInTypeDistribution": {
|
||||
"QR_CODE": 60,
|
||||
"MANUAL": 25,
|
||||
"FACE": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 查询历史统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/datacount/history` |
|
||||
| **所属文件** | `DataStatisticsHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| statType | string | 否 | 空 | 统计类型:member/booking/signin |
|
||||
| startTime | string | 是 | - | 开始日期(格式:yyyy-MM-dd) |
|
||||
| endTime | string | 是 | - | 结束日期(格式:yyyy-MM-dd) |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
# 1. 查询指定日期范围的签到历史统计
|
||||
GET /api/datacount/history?statType=signin&startTime=2026-06-01&endTime=2026-06-30
|
||||
|
||||
# 2. 查询指定日期范围的会员历史统计
|
||||
GET /api/datacount/history?statType=member&startTime=2026-06-01&endTime=2026-06-15
|
||||
|
||||
# 3. 查询指定日期范围的预约历史统计
|
||||
GET /api/datacount/history?statType=booking&startTime=2026-06-01&endTime=2026-06-30
|
||||
|
||||
# 4. 查询所有类型的历史统计(不指定statType)
|
||||
GET /api/datacount/history?startTime=2026-06-01&endTime=2026-06-07
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"statDate": "2026-06-07",
|
||||
"totalSignIns": 88,
|
||||
"successSignIns": 86,
|
||||
"failSignIns": 2,
|
||||
"successRate": 97.73,
|
||||
"signInTypeDistribution": {
|
||||
"QR_CODE": 55,
|
||||
"MANUAL": 23,
|
||||
"FACE": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"statDate": "2026-06-08",
|
||||
"totalSignIns": 92,
|
||||
"successSignIns": 90,
|
||||
"failSignIns": 2,
|
||||
"successRate": 97.83,
|
||||
"signInTypeDistribution": {
|
||||
"QR_CODE": 58,
|
||||
"MANUAL": 24,
|
||||
"FACE": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 导出统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/datacount/export` |
|
||||
| **所属文件** | `DataStatisticsHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| periodType | string | 否 | DAY | 统计周期:DAY/WEEK/MONTH |
|
||||
| startTime | string | 否 | 自动计算 | 开始时间 |
|
||||
| endTime | string | 否 | 自动计算 | 结束时间 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
# 1. 导出今日统计数据
|
||||
GET /api/datacount/export
|
||||
|
||||
# 2. 导出本周统计数据
|
||||
GET /api/datacount/export?periodType=WEEK
|
||||
|
||||
# 3. 导出本月统计数据
|
||||
GET /api/datacount/export?periodType=MONTH
|
||||
|
||||
# 4. 导出指定时间范围的统计数据
|
||||
GET /api/datacount/export?startTime=2026-06-01T00:00:00&endTime=2026-06-30T23:59:59
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
返回 Excel 文件(.xlsx),包含以下 Sheet:
|
||||
- **会员统计**: 会员相关统计数据
|
||||
- **预约统计**: 预约相关统计数据
|
||||
- **签到统计**: 签到相关统计数据
|
||||
|
||||
**Content-Type**: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### StatisticsQuery(查询条件)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| statType | String | 统计类型筛选 |
|
||||
| periodType | String | 统计周期:DAY/WEEK/MONTH |
|
||||
| startTime | LocalDateTime | 开始时间 |
|
||||
| endTime | LocalDateTime | 结束时间 |
|
||||
|
||||
### StatisticsSummary(统计汇总)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| statDate | String | 统计日期 |
|
||||
| generatedAt | String | 生成时间 |
|
||||
| memberStatistics | MemberStatistics | 会员统计数据 |
|
||||
| bookingStatistics | BookingStatistics | 预约统计数据 |
|
||||
| signInStatistics | SignInStatistics | 签到统计数据 |
|
||||
|
||||
### MemberStatistics(会员统计)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| statDate | String | 统计日期 |
|
||||
| newMembers | Long | 新增会员数 |
|
||||
| activeMembers | Long | 活跃会员数 |
|
||||
| totalMembers | Long | 累计会员总数 |
|
||||
| signInMembers | Long | 签到会员数 |
|
||||
| bookingMembers | Long | 预约会员数 |
|
||||
| cancelMembers | Long | 取消预约会员数 |
|
||||
|
||||
### BookingStatistics(预约统计)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| statDate | String | 统计日期 |
|
||||
| totalBookings | Long | 预约总数 |
|
||||
| cancelBookings | Long | 取消预约数 |
|
||||
| attendBookings | Long | 出席预约数 |
|
||||
| absentBookings | Long | 缺席预约数 |
|
||||
| attendRate | Double | 出席率(%) |
|
||||
| cancelRate | Double | 取消率(%) |
|
||||
|
||||
### SignInStatistics(签到统计)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| statDate | String | 统计日期 |
|
||||
| totalSignIns | Long | 签到总次数 |
|
||||
| successSignIns | Long | 成功签到次数 |
|
||||
| failSignIns | Long | 失败签到次数 |
|
||||
| successRate | Double | 签到成功率(%) |
|
||||
| signInTypeDistribution | Map<String, Long> | 签到类型分布 |
|
||||
|
||||
---
|
||||
|
||||
## 状态码说明
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 请求成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
1. **统计周期**: 支持按日、周、月统计
|
||||
- DAY: 当天 00:00:00 - 当前时间
|
||||
- WEEK: 本周一 00:00:00 - 本周日 23:59:59
|
||||
- MONTH: 本月1日 00:00:00 - 本月最后一天 23:59:59
|
||||
|
||||
2. **数据保留**: 统计数据缓存30天,业务记录永久保存
|
||||
|
||||
3. **缓存策略**: 查询结果缓存1小时,统计数据缓存30天
|
||||
|
||||
4. **定时任务**:
|
||||
- 每日凌晨2点执行前一天的日统计
|
||||
- 每周一凌晨3点执行上周的周统计
|
||||
- 每月1日凌晨3点执行上月的月统计
|
||||
- 每月15日凌晨4点清理30天前的旧数据
|
||||
|
||||
5. **数据导出**: 支持Excel格式导出,包含会员、预约、签到三个Sheet
|
||||
@@ -0,0 +1,48 @@
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# Virtual machine crash logs
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
pom.xml.next
|
||||
release.properties
|
||||
dependency-reduced-pom.xml
|
||||
buildNumber.properties
|
||||
.mvn/timing.properties
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.settings/
|
||||
.classpath
|
||||
.project
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,252 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-manage-api</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>gym-checkIn</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Gym CheckIn</name>
|
||||
<description>Check-In Management Module - Member Attendance Services</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-db</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-groupCourse</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-commons</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-spring-boot3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-reactor</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.r2dbc</groupId>
|
||||
<artifactId>r2dbc-h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>r2dbc-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.25</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 添加 ZXing JavaSE 扩展(用于生成图片) -->
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-jar</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>1.5.5.Final</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||
<version>0.2.0</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.60</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>4.8.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs</artifactId>
|
||||
<version>4.8.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>spotbugs-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<effort>Max</effort>
|
||||
<threshold>High</threshold>
|
||||
<failOnError>true</failOnError>
|
||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.novalon.gym.manage.checkIn.config;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.HandlerMapping;
|
||||
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
|
||||
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
public class CheckInWebSocketConfig {
|
||||
|
||||
@Autowired
|
||||
private MyWebSocketHandler myWebSocketHandler;
|
||||
|
||||
/**
|
||||
* 注册 WebSocket 路由映射
|
||||
* 路径对应前端连接的 ws://xxx/webSocket/checkIn
|
||||
*/
|
||||
@Bean
|
||||
public HandlerMapping webSocketMapping() {
|
||||
Map<String, WebSocketHandler> map = new HashMap<>();
|
||||
map.put("/webSocket/checkIn", myWebSocketHandler);
|
||||
|
||||
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
|
||||
mapping.setUrlMap(map);
|
||||
mapping.setOrder(10); // 设置优先级
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 WebSocket 处理器适配器(必须)
|
||||
*/
|
||||
@Bean
|
||||
public WebSocketHandlerAdapter handlerAdapter() {
|
||||
return new WebSocketHandlerAdapter();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package cn.novalon.gym.manage.checkIn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "qr.config")
|
||||
public class QRCodeConfig {
|
||||
|
||||
/**
|
||||
* 二维码宽度(像素)
|
||||
*/
|
||||
private Integer width = 300;
|
||||
|
||||
/**
|
||||
* 二维码高度(像素)
|
||||
*/
|
||||
private Integer height = 300;
|
||||
|
||||
/**
|
||||
* 白边宽度
|
||||
*/
|
||||
private Integer margin = 1;
|
||||
|
||||
/**
|
||||
* 容错率:L, M, Q, H
|
||||
*/
|
||||
private String errorCorrection = "M";
|
||||
|
||||
/**
|
||||
* 图片格式:png, jpg
|
||||
*/
|
||||
private String format = "png";
|
||||
|
||||
/**
|
||||
* 是否启用Logo
|
||||
*/
|
||||
private Boolean logoEnabled = false;
|
||||
|
||||
/**
|
||||
* Logo路径
|
||||
*/
|
||||
private String logoPath = "";
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.novalon.gym.manage.checkIn.constant;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 打卡模块 Redis 键常量
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-30
|
||||
*/
|
||||
public final class QRRedisKey {
|
||||
|
||||
private static final String SEPARATOR = ":";
|
||||
private static final String QRCODE_USER_DAILY = "qrcode:user:daily";
|
||||
private static final String QRCODE_CONTENT = "QR_";
|
||||
private QRRedisKey() {
|
||||
// 私有构造,防止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户当日二维码
|
||||
* 格式:qrcode:user:daily:{userId}:{date}
|
||||
* 示例:qrcode:user:daily:1001:2026-05-30
|
||||
*/
|
||||
public static String qrcodeUserDaily(Long userId, LocalDate date) {
|
||||
return QRCODE_USER_DAILY + SEPARATOR + userId + SEPARATOR + date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户当日二维码(今天)
|
||||
*/
|
||||
public static String qrcodeUserToday(Long userId) {
|
||||
return qrcodeUserDaily(userId, LocalDate.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码内容(每个用户每次调用都不同)
|
||||
*/
|
||||
public static String generateQrcodeContent() {
|
||||
return QRCODE_CONTENT + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.novalon.gym.manage.checkIn.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class QRCodeDto {
|
||||
|
||||
private String qrContent;
|
||||
|
||||
private boolean isUsed;
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package cn.novalon.gym.manage.checkIn.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会员到店签到记录实体
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("sign_in_record")
|
||||
public class SignInRecord {
|
||||
|
||||
/**
|
||||
* 自增主键
|
||||
*/
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 会员ID,关联member表
|
||||
*/
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 签到时使用的会员卡ID
|
||||
*/
|
||||
@Column("member_card_id")
|
||||
private Long memberCardId;
|
||||
|
||||
/**
|
||||
* 签到入场时间
|
||||
*/
|
||||
@Column("sign_in_time")
|
||||
private LocalDateTime signInTime;
|
||||
|
||||
/**
|
||||
* 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
|
||||
*/
|
||||
@Column("sign_in_type")
|
||||
private String signInType;
|
||||
|
||||
/**
|
||||
* 签到状态:SUCCESS-成功,FAILED-失败
|
||||
*/
|
||||
@Column("sign_in_status")
|
||||
private String signInStatus;
|
||||
|
||||
/**
|
||||
* JSONB格式,存储会员卡验证时的快照数据
|
||||
*/
|
||||
@Column("verification_details")
|
||||
private String verificationDetails;
|
||||
|
||||
/**
|
||||
* 失败时的具体原因文案
|
||||
*/
|
||||
@Column("fail_reason")
|
||||
private String failReason;
|
||||
|
||||
/**
|
||||
* 操作人ID(前台人员),自助签到时为NULL
|
||||
*/
|
||||
@Column("operator_id")
|
||||
private Long operatorId;
|
||||
|
||||
/**
|
||||
* 操作人姓名冗余
|
||||
*/
|
||||
@Column("operator_name")
|
||||
private String operatorName;
|
||||
|
||||
/**
|
||||
* 签到设备标识或型号
|
||||
*/
|
||||
@Column("device_info")
|
||||
private String deviceInfo;
|
||||
|
||||
/**
|
||||
* 客户端IP地址
|
||||
*/
|
||||
@Column("ip_address")
|
||||
private String ipAddress;
|
||||
|
||||
/**
|
||||
* 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
|
||||
*/
|
||||
@Column("source")
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* 软删除标识:false-未删除,true-已删除
|
||||
*/
|
||||
@Column("is_delete")
|
||||
private Boolean isDelete;
|
||||
|
||||
/**
|
||||
* 记录创建时间
|
||||
*/
|
||||
@CreatedDate
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 记录更新时间
|
||||
*/
|
||||
@LastModifiedDate
|
||||
@Column("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/**
|
||||
* 签到类型常量
|
||||
*/
|
||||
public static final class SignInType {
|
||||
/** 扫码签到 */
|
||||
public static final String QR_CODE = "QR_CODE";
|
||||
/** 手动签到 */
|
||||
public static final String MANUAL = "MANUAL";
|
||||
/** 人脸识别 */
|
||||
public static final String FACE = "FACE";
|
||||
|
||||
private SignInType() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到状态常量
|
||||
*/
|
||||
public static final class SignInStatus {
|
||||
/** 成功 */
|
||||
public static final String SUCCESS = "SUCCESS";
|
||||
/** 失败 */
|
||||
public static final String FAILED = "FAILED";
|
||||
|
||||
private SignInStatus() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到来源常量
|
||||
*/
|
||||
public static final class Source {
|
||||
/** 小程序扫码 */
|
||||
public static final String MINI_PROGRAM = "MINI_PROGRAM";
|
||||
/** 后台管理端手动签到 */
|
||||
public static final String PC_BACKEND = "PC_BACKEND";
|
||||
|
||||
private Source() {}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 判断签到是否成功
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return SignInStatus.SUCCESS.equals(this.signInStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断签到是否失败
|
||||
*/
|
||||
public boolean isFailed() {
|
||||
return SignInStatus.FAILED.equals(this.signInStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为扫码签到
|
||||
*/
|
||||
public boolean isQrCodeSign() {
|
||||
return SignInType.QR_CODE.equals(this.signInType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已删除
|
||||
*/
|
||||
public boolean isDeleted() {
|
||||
return Boolean.TRUE.equals(this.isDelete);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除
|
||||
*/
|
||||
public void softDelete() {
|
||||
this.isDelete = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复删除
|
||||
*/
|
||||
public void restore() {
|
||||
this.isDelete = false;
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package cn.novalon.gym.manage.checkIn.handler;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CheckInHandler {
|
||||
|
||||
private final AuthUtil authUtil;
|
||||
private final CheckServiceImpl checkService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/**
|
||||
* 签到
|
||||
*
|
||||
* POST /api/checkIn
|
||||
*
|
||||
*/
|
||||
public Mono<ServerResponse> checkIn(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String qrContent = (String) body.get("qrContent");
|
||||
log.info("收到签到请求, memberId: {}, qrContent: {}", memberId, qrContent);
|
||||
boolean messageToClient = MyWebSocketHandler.sendMessageToClient(qrContent, "正在进行签到");
|
||||
log.info("WebSocket 推送结果: {}", messageToClient);
|
||||
return checkService.checkIn(memberId, qrContent)
|
||||
.flatMap(result -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(result));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("签到失败", e);
|
||||
return ServerResponse.status(HttpStatus.BAD_REQUEST)
|
||||
.bodyValue(Map.of("code", 400, "message", e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二维码
|
||||
*
|
||||
* GET /api/checkin/qrcode
|
||||
*
|
||||
*/
|
||||
public Mono<ServerResponse> getQRCode(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
log.info("收到用户{}获取二维码请求", memberId);
|
||||
|
||||
return checkService.getQRCode(memberId)
|
||||
.flatMap(qrCodeVo -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(qrCodeVo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询签到记录列表
|
||||
*
|
||||
* GET /api/checkIn/records
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getSignInRecords(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String startDateStr = request.queryParam("startDate").orElse(null);
|
||||
String endDateStr = request.queryParam("endDate").orElse(null);
|
||||
|
||||
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
|
||||
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("查询签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
|
||||
|
||||
return checkService.getSignInRecords(memberId, startDate, endDate)
|
||||
.collectList()
|
||||
.flatMap(records -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单条签到记录
|
||||
*
|
||||
* GET /api/checkIn/records/{id}
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getSignInRecordById(ServerRequest request) {
|
||||
Long id = Long.parseLong(request.pathVariable("id"));
|
||||
|
||||
log.info("查询签到记录详情, id: {}", id);
|
||||
|
||||
return checkService.getSignInRecordById(id)
|
||||
.flatMap(record -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", record)))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
*
|
||||
* GET /api/checkIn/statistics
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getSignInStatistics(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String startDateStr = request.queryParam("startDate").orElse(null);
|
||||
String endDateStr = request.queryParam("endDate").orElse(null);
|
||||
|
||||
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
|
||||
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("查询签到统计, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
|
||||
|
||||
return checkService.getSignInStats(memberId, startDate, endDate)
|
||||
.flatMap(stats -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出签到记录
|
||||
*
|
||||
* GET /api/checkIn/records/export
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> exportSignInRecords(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String startDateStr = request.queryParam("startDate").orElse(null);
|
||||
String endDateStr = request.queryParam("endDate").orElse(null);
|
||||
|
||||
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
|
||||
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("导出签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
|
||||
|
||||
String filename = "签到记录_" + startDateStr + "_" + endDateStr + ".csv";
|
||||
|
||||
return checkService.exportSignInRecords(memberId, startDate, endDate)
|
||||
.flatMap(bytes -> ServerResponse.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
|
||||
.bodyValue(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每日签到统计
|
||||
*
|
||||
* GET /api/checkIn/daily-stats
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getDailySignInStats(ServerRequest request) {
|
||||
String dateStr = request.queryParam("date").orElse(null);
|
||||
LocalDate date = dateStr != null ? LocalDate.parse(dateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("查询每日签到统计, date: {}", date);
|
||||
|
||||
return checkService.getDailySignInStats(date)
|
||||
.flatMap(stats -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package cn.novalon.gym.manage.checkIn.repository;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 签到记录 Repository
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Repository
|
||||
public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Long> {
|
||||
|
||||
/**
|
||||
* 查询会员某天的签到记录
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
|
||||
Mono<SignInRecord> findByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 查询会员的签到记录列表
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND is_delete = false ORDER BY sign_in_time DESC")
|
||||
Flux<SignInRecord> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 统计会员某天的签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
|
||||
Mono<Long> countByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 插入签到记录
|
||||
*/
|
||||
@Query("INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, verification_details, fail_reason, source, created_at, updated_at, is_delete) " +
|
||||
"VALUES (:memberId, :memberCardId, :signInTime, :signInType, :signInStatus, :verificationDetails, :failReason, :source, NOW(), NOW(), false)")
|
||||
Mono<Void> insertRecord(Long memberId, Long memberCardId, LocalDateTime signInTime,
|
||||
String signInType, String signInStatus, String verificationDetails,
|
||||
String failReason, String source);
|
||||
|
||||
/**
|
||||
* 根据会员ID和时间范围查询签到记录
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
|
||||
Flux<SignInRecord> findByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 根据时间范围查询签到记录
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
|
||||
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计会员在时间范围内的签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<Long> countByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计时间范围内的签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<Long> countByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计会员在时间范围内的成功签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
|
||||
Mono<Long> countSuccessByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计时间范围内的成功签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
|
||||
Mono<Long> countSuccessByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计时间范围内签到的独立会员数
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<Long> countDistinctMembersByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 获取会员在时间范围内的首次签到时间
|
||||
*/
|
||||
@Query("SELECT MIN(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<LocalDateTime> getFirstSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 获取会员在时间范围内的最后签到时间
|
||||
*/
|
||||
@Query("SELECT MAX(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<LocalDateTime> getLastSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package cn.novalon.gym.manage.checkIn.service;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 签到服务接口
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
public interface ICheckInService {
|
||||
|
||||
/**
|
||||
* 获取签到二维码
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @return 二维码VO
|
||||
*/
|
||||
Mono<QRCodeVo> getQRCode(Long memberId);
|
||||
|
||||
/**
|
||||
* 扫码签到
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param qrContent 二维码内容
|
||||
* @return 签到结果JSON字符串
|
||||
*/
|
||||
Mono<String> checkIn(Long memberId, String qrContent);
|
||||
|
||||
/**
|
||||
* 查询会员签到记录列表
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return 签到记录列表
|
||||
*/
|
||||
Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 根据ID查询签到记录
|
||||
*
|
||||
* @param id 签到记录ID
|
||||
* @return 签到记录VO
|
||||
*/
|
||||
Mono<SignInRecordVO> getSignInRecordById(Long id);
|
||||
|
||||
/**
|
||||
* 获取会员签到统计
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 导出会员签到记录
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return CSV格式的字节数组
|
||||
*/
|
||||
Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 获取每日签到统计
|
||||
*
|
||||
* @param date 日期
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
package cn.novalon.gym.manage.checkIn.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
|
||||
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
|
||||
import cn.novalon.gym.manage.checkIn.service.ICheckInService;
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.hutool.extra.qrcode.QrCodeUtil;
|
||||
import cn.hutool.extra.qrcode.QrConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CheckServiceImpl implements ICheckInService {
|
||||
|
||||
private final QRCodeConfig qrCodeConfig;
|
||||
private final RedisUtil redisUtil;
|
||||
private final MemberCardRecordRepository memberCardRecordRepository;
|
||||
private final MemberCardRepository memberCardRepository;
|
||||
private final SignInRecordRepository signInRecordRepository;
|
||||
private final IGroupCourseBookingService groupCourseBookingService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Override
|
||||
public Mono<QRCodeVo> getQRCode(Long memberId) {
|
||||
log.info("开始查询会员信息, memberId: {}", memberId);
|
||||
|
||||
return findValidMemberCard(memberId)
|
||||
.flatMap(cardRecord -> {
|
||||
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
|
||||
|
||||
log.info("开始生成二维码");
|
||||
String qrContent = QRRedisKey.generateQrcodeContent();
|
||||
Map<String, Object> redisMap = new HashMap<>();
|
||||
redisMap.put("qrContent", qrContent);
|
||||
redisMap.put("isUsed", false);
|
||||
redisMap.put("memberId", memberId);
|
||||
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
|
||||
|
||||
return redisUtil.setWithExpire(
|
||||
RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(),
|
||||
redisMap,
|
||||
getSecondsUntilEndOfDay()
|
||||
)
|
||||
.then(Mono.fromSupplier(() -> {
|
||||
String qrCodeBase64 = QrCodeUtil.generateAsBase64(qrContent,
|
||||
BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png");
|
||||
return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now());
|
||||
}));
|
||||
})
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> checkIn(Long memberId, String qrContent) {
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
|
||||
// 先检查当天是否已经签到(从数据库查询,作为重复签到的额外保障)
|
||||
return checkTodayAlreadySignedIn(memberId)
|
||||
.flatMap(existingRecord -> {
|
||||
String checkInTime = existingRecord.getSignInTime().format(DATE_FORMATTER);
|
||||
log.error("重复签到, memberId: {}", memberId);
|
||||
MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到");
|
||||
return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到"));
|
||||
})
|
||||
.then(Mono.defer(() -> redisUtil.get(key)))
|
||||
.flatMap(cachedObj -> {
|
||||
if (cachedObj != null) {
|
||||
Map<String, Object> map;
|
||||
if (cachedObj instanceof Map) {
|
||||
map = (Map<String, Object>) cachedObj;
|
||||
} else if (cachedObj instanceof String) {
|
||||
map = JSONUtil.parseObj((String) cachedObj);
|
||||
} else {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码数据格式错误");
|
||||
return Mono.error(new RuntimeException("二维码数据格式错误"));
|
||||
}
|
||||
if (map.get("qrContent").equals(qrContent)) {
|
||||
if ((boolean) map.get("isUsed")) {
|
||||
String checkInTime = String.valueOf(map.get("checkInTime"));
|
||||
log.error("重复签到(缓存), memberId: {}", memberId);
|
||||
MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到");
|
||||
return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到"));
|
||||
}
|
||||
log.info("二维码匹配成功,memberId: {}", memberId);
|
||||
|
||||
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue();
|
||||
|
||||
return processCheckIn(memberId, memberCardRecordId, map, qrContent);
|
||||
} else {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码无效");
|
||||
return Mono.error(new RuntimeException("二维码无效"));
|
||||
}
|
||||
}
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码已过期或不存在");
|
||||
return Mono.error(new RuntimeException("二维码已过期或不存在"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会员当天是否已经签到
|
||||
* @param memberId 会员ID
|
||||
* @return 如果已签到返回签到记录,否则返回空
|
||||
*/
|
||||
private Mono<SignInRecord> checkTodayAlreadySignedIn(Long memberId) {
|
||||
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndDate(memberId, startOfDay, endOfDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理签到逻辑
|
||||
*/
|
||||
private Mono<String> processCheckIn(Long memberId, Long memberCardRecordId, Map<String, Object> redisMap, String qrContent) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 发送实时进度通知
|
||||
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡...");
|
||||
|
||||
return memberCardRecordRepository.findById(memberCardRecordId)
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在");
|
||||
return Mono.error(new RuntimeException("会员卡记录不存在"));
|
||||
}))
|
||||
.flatMap(cardRecord -> {
|
||||
if (!"ACTIVE".equals(cardRecord.getStatus().name())) {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确");
|
||||
return Mono.error(new RuntimeException("会员卡状态不正确"));
|
||||
}
|
||||
|
||||
if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期");
|
||||
return Mono.error(new RuntimeException("会员卡已过期"));
|
||||
}
|
||||
|
||||
// 发送实时进度通知
|
||||
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
|
||||
|
||||
// 检查是否有需要签到的团课预约
|
||||
return validateBooking(memberId, now)
|
||||
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId())
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "会员卡类型不存在");
|
||||
return Mono.error(new RuntimeException("会员卡类型不存在"));
|
||||
}))
|
||||
.flatMap(card -> {
|
||||
// 发送实时进度通知
|
||||
MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数...");
|
||||
|
||||
return deductCardUsage(cardRecord, card)
|
||||
.flatMap(updatedRecord -> {
|
||||
redisMap.put("isUsed", true);
|
||||
redisMap.put("checkInTime", now.format(DATE_FORMATTER));
|
||||
|
||||
return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId())
|
||||
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
|
||||
.then(Mono.defer(() -> {
|
||||
String successMsg = buildSuccessResponse(now);
|
||||
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
|
||||
log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId);
|
||||
return Mono.just(successMsg);
|
||||
}));
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证预约信息
|
||||
*/
|
||||
private Mono<Void> validateBooking(Long memberId, LocalDateTime now) {
|
||||
return groupCourseBookingService.getBookingsByMemberId(memberId)
|
||||
.filter(booking -> {
|
||||
String status = booking.getStatus();
|
||||
LocalDateTime startTime = booking.getCourseStartTime();
|
||||
return "0".equals(status) &&
|
||||
startTime != null &&
|
||||
startTime.toLocalDate().equals(now.toLocalDate()) &&
|
||||
!startTime.isBefore(now.minusMinutes(30));
|
||||
})
|
||||
.collectList()
|
||||
.flatMap(bookings -> {
|
||||
if (bookings.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
boolean hasValidBooking = bookings.stream()
|
||||
.anyMatch(b -> {
|
||||
LocalDateTime startTime = b.getCourseStartTime();
|
||||
return startTime != null &&
|
||||
!startTime.isBefore(now.minusMinutes(30)) &&
|
||||
!startTime.isAfter(now.plusMinutes(30));
|
||||
});
|
||||
if (hasValidBooking) {
|
||||
log.info("会员{}有有效的团课预约", memberId);
|
||||
} else {
|
||||
log.warn("会员{}有预约但不在签到时间范围内", memberId);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减会员卡使用次数/金额
|
||||
*/
|
||||
private Mono<MemberCardRecord> deductCardUsage(MemberCardRecord record, MemberCard card) {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
switch (cardType) {
|
||||
case TIME_CARD:
|
||||
if (record.getExpireTime() != null && record.getExpireTime().isBefore(now)) {
|
||||
return Mono.error(new RuntimeException("时长卡已过期"));
|
||||
}
|
||||
return Mono.just(record);
|
||||
case COUNT_CARD:
|
||||
int currentTimes = record.getRemainingTimes() != null ? record.getRemainingTimes() : 0;
|
||||
if (currentTimes < 1) {
|
||||
return Mono.error(new RuntimeException("次卡剩余次数不足"));
|
||||
}
|
||||
record.setRemainingTimes(currentTimes - 1);
|
||||
if (record.getRemainingTimes() == 0) {
|
||||
record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP);
|
||||
}
|
||||
return memberCardRecordRepository.save(record);
|
||||
case STORED_VALUE_CARD:
|
||||
double currentAmount = record.getRemainingAmount() != null ? record.getRemainingAmount() : 0.0;
|
||||
if (currentAmount < 0.01) {
|
||||
return Mono.error(new RuntimeException("储值卡余额不足"));
|
||||
}
|
||||
record.setRemainingAmount(Math.max(0, currentAmount - 1));
|
||||
if (record.getRemainingAmount() <= 0) {
|
||||
record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP);
|
||||
}
|
||||
return memberCardRecordRepository.save(record);
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存签到记录
|
||||
*/
|
||||
private Mono<Void> saveSignInRecord(Long memberId, Long memberCardRecordId, Long memberCardId) {
|
||||
SignInRecord record = SignInRecord.builder()
|
||||
.memberId(memberId)
|
||||
.memberCardId(memberCardId)
|
||||
.signInTime(LocalDateTime.now())
|
||||
.signInType(SignInRecord.SignInType.QR_CODE)
|
||||
.signInStatus(SignInRecord.SignInStatus.SUCCESS)
|
||||
.source(SignInRecord.Source.MINI_PROGRAM)
|
||||
.isDelete(false)
|
||||
.build();
|
||||
|
||||
return signInRecordRepository.save(record).then();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建成功响应
|
||||
*/
|
||||
private String buildSuccessResponse(LocalDateTime dateTime) {
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("message", "签到成功");
|
||||
res.put("dateTime", dateTime.format(DATE_FORMATTER));
|
||||
return JSONUtil.toJsonStr(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找会员的有效会员卡(优先选择有效期最早到期的)
|
||||
*/
|
||||
private Mono<MemberCardRecord> findValidMemberCard(Long memberId) {
|
||||
return memberCardRecordRepository.findActiveCardsByMemberId(memberId)
|
||||
.filter(record -> {
|
||||
LocalDateTime expireTime = record.getExpireTime();
|
||||
return expireTime == null || expireTime.isAfter(LocalDateTime.now());
|
||||
})
|
||||
.sort((r1, r2) -> {
|
||||
LocalDateTime e1 = r1.getExpireTime();
|
||||
LocalDateTime e2 = r2.getExpireTime();
|
||||
if (e1 == null && e2 == null) return 0;
|
||||
if (e1 == null) return 1;
|
||||
if (e2 == null) return -1;
|
||||
return e1.compareTo(e2);
|
||||
})
|
||||
.next();
|
||||
}
|
||||
|
||||
// ==================== 签到记录管理功能 ====================
|
||||
|
||||
@Override
|
||||
public Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
|
||||
LocalDateTime start = startTime.atStartOfDay();
|
||||
LocalDateTime end = endTime.atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end)
|
||||
.map(this::convertToVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInRecordVO> getSignInRecordById(Long id) {
|
||||
return signInRecordRepository.findById(id)
|
||||
.map(this::convertToVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime) {
|
||||
LocalDateTime start = startTime.atStartOfDay();
|
||||
LocalDateTime end = endTime.atTime(LocalTime.MAX);
|
||||
|
||||
return Mono.zip(
|
||||
(Object[] results) -> {
|
||||
Long total = (Long) results[0];
|
||||
Long success = (Long) results[1];
|
||||
LocalDateTime first = (LocalDateTime) results[2];
|
||||
LocalDateTime last = (LocalDateTime) results[3];
|
||||
SignInStatsVO stats = new SignInStatsVO();
|
||||
stats.setTotalCount(total);
|
||||
stats.setSuccessCount(success);
|
||||
stats.setStartDate(startTime);
|
||||
stats.setEndDate(endTime);
|
||||
stats.setFirstSignInTime(first);
|
||||
stats.setLastSignInTime(last);
|
||||
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
|
||||
return stats;
|
||||
},
|
||||
signInRecordRepository.countByMemberIdAndTimeRange(memberId, start, end),
|
||||
signInRecordRepository.countSuccessByMemberIdAndTimeRange(memberId, start, end),
|
||||
signInRecordRepository.getFirstSignInTime(memberId, start, end),
|
||||
signInRecordRepository.getLastSignInTime(memberId, start, end)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
|
||||
LocalDateTime start = startTime.atStartOfDay();
|
||||
LocalDateTime end = endTime.atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end)
|
||||
.map(record -> {
|
||||
String status = "SUCCESS".equals(record.getSignInStatus()) ? "成功" : "失败";
|
||||
String type = "QR_CODE".equals(record.getSignInType()) ? "扫码签到" :
|
||||
"MANUAL".equals(record.getSignInType()) ? "手动签到" : "人脸识别";
|
||||
return String.join(",",
|
||||
record.getId().toString(),
|
||||
record.getMemberId().toString(),
|
||||
record.getMemberCardId() != null ? record.getMemberCardId().toString() : "",
|
||||
record.getSignInTime() != null ? record.getSignInTime().format(DATE_FORMATTER) : "",
|
||||
type,
|
||||
status,
|
||||
record.getFailReason() != null ? record.getFailReason() : ""
|
||||
);
|
||||
})
|
||||
.collectList()
|
||||
.map(rows -> {
|
||||
List<String> csvLines = new java.util.ArrayList<>();
|
||||
csvLines.add("签到记录ID,会员ID,会员卡ID,签到时间,签到方式,签到状态,失败原因");
|
||||
csvLines.addAll(rows);
|
||||
return String.join("\n", csvLines).getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatsVO> getDailySignInStats(LocalDate date) {
|
||||
LocalDateTime start = date.atStartOfDay();
|
||||
LocalDateTime end = date.atTime(LocalTime.MAX);
|
||||
|
||||
return Mono.zip(
|
||||
(Object[] results) -> {
|
||||
Long total = (Long) results[0];
|
||||
Long success = (Long) results[1];
|
||||
Long members = (Long) results[2];
|
||||
SignInStatsVO stats = new SignInStatsVO();
|
||||
stats.setTotalCount(total);
|
||||
stats.setSuccessCount(success);
|
||||
stats.setStartDate(date);
|
||||
stats.setEndDate(date);
|
||||
stats.setUniqueMemberCount(members);
|
||||
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
|
||||
return stats;
|
||||
},
|
||||
signInRecordRepository.countByTimeRange(start, end),
|
||||
signInRecordRepository.countSuccessByTimeRange(start, end),
|
||||
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换实体到VO
|
||||
*/
|
||||
private SignInRecordVO convertToVO(SignInRecord record) {
|
||||
SignInRecordVO vo = new SignInRecordVO();
|
||||
vo.setId(record.getId());
|
||||
vo.setMemberId(record.getMemberId());
|
||||
vo.setMemberCardId(record.getMemberCardId());
|
||||
vo.setSignInTime(record.getSignInTime());
|
||||
vo.setSignInType(record.getSignInType());
|
||||
vo.setSignInStatus(record.getSignInStatus());
|
||||
vo.setFailReason(record.getFailReason());
|
||||
vo.setSource(record.getSource());
|
||||
vo.setCreatedAt(record.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private long getSecondsUntilEndOfDay() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
|
||||
if (now.isAfter(endOfDay)) return 1;
|
||||
return ChronoUnit.SECONDS.between(now, endOfDay);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package cn.novalon.gym.manage.checkIn.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class QRCodeVo {
|
||||
|
||||
private String qrCodeBase64;
|
||||
|
||||
private boolean isUsed;
|
||||
|
||||
private String qrContent;
|
||||
|
||||
private Integer width;
|
||||
|
||||
private Integer height;
|
||||
|
||||
private LocalDate createTime;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package cn.novalon.gym.manage.checkIn.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 签到记录VO
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SignInRecordVO {
|
||||
|
||||
/**
|
||||
* 签到记录ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 会员卡ID
|
||||
*/
|
||||
private Long memberCardId;
|
||||
|
||||
/**
|
||||
* 签到时间
|
||||
*/
|
||||
private LocalDateTime signInTime;
|
||||
|
||||
/**
|
||||
* 签到类型:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
|
||||
*/
|
||||
private String signInType;
|
||||
|
||||
/**
|
||||
* 签到状态:SUCCESS-成功,FAILED-失败
|
||||
*/
|
||||
private String signInStatus;
|
||||
|
||||
/**
|
||||
* 失败原因
|
||||
*/
|
||||
private String failReason;
|
||||
|
||||
/**
|
||||
* 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
|
||||
*/
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package cn.novalon.gym.manage.checkIn.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 签到统计VO
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SignInStatsVO {
|
||||
|
||||
/**
|
||||
* 统计开始日期
|
||||
*/
|
||||
private LocalDate startDate;
|
||||
|
||||
/**
|
||||
* 统计结束日期
|
||||
*/
|
||||
private LocalDate endDate;
|
||||
|
||||
/**
|
||||
* 总签到次数
|
||||
*/
|
||||
private Long totalCount;
|
||||
|
||||
/**
|
||||
* 成功签到次数
|
||||
*/
|
||||
private Long successCount;
|
||||
|
||||
/**
|
||||
* 成功率(百分比)
|
||||
*/
|
||||
private Double successRate;
|
||||
|
||||
/**
|
||||
* 独立会员数
|
||||
*/
|
||||
private Long uniqueMemberCount;
|
||||
|
||||
/**
|
||||
* 首次签到时间
|
||||
*/
|
||||
private LocalDateTime firstSignInTime;
|
||||
|
||||
/**
|
||||
* 最后签到时间
|
||||
*/
|
||||
private LocalDateTime lastSignInTime;
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package cn.novalon.gym.manage.checkIn.websocket;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.novalon.gym.manage.checkIn.dto.QRCodeDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||
import org.springframework.web.reactive.socket.WebSocketSession;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* WebSocket 处理类,用于实时签到反馈
|
||||
*
|
||||
* 技术要点:
|
||||
* - 使用 Sinks 实现响应式消息推送
|
||||
* - 使用 ConcurrentHashMap 管理 qrContent 与 sink 的映射
|
||||
* - 支持实时推送签到进度和结果
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MyWebSocketHandler implements WebSocketHandler {
|
||||
|
||||
/**
|
||||
* qrContent -> Sink 映射,用于根据二维码内容找到对应的客户端连接
|
||||
*/
|
||||
private static final Map<String, Sinks.Many<String>> qrContentToSink = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 连接创建时间映射,用于超时清理
|
||||
*/
|
||||
private static final Map<String, LocalDateTime> qrContentToCreateTime = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 超时时间(秒),超过此时间未使用的连接将被清理
|
||||
*/
|
||||
private static final long TIMEOUT_SECONDS = 300;
|
||||
|
||||
@Override
|
||||
public Mono<Void> handle(WebSocketSession session) {
|
||||
String sessionId = session.getId();
|
||||
log.info("WebSocket 连接建立,sessionId: {}", sessionId);
|
||||
|
||||
// 创建 sink,用于向客户端发送消息
|
||||
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
// 订阅接收客户端消息(异步处理)
|
||||
session.receive()
|
||||
.doOnNext(message -> {
|
||||
String payload = message.getPayloadAsText();
|
||||
log.debug("收到消息:sessionId={}, payload={}", sessionId, payload);
|
||||
|
||||
try {
|
||||
QRCodeDto qrCodeDto = JSONUtil.toBean(payload, QRCodeDto.class);
|
||||
String qrContent = qrCodeDto.getQrContent();
|
||||
|
||||
if (qrContent != null && !qrContent.isEmpty()) {
|
||||
// 绑定 qrContent 和 sink
|
||||
qrContentToSink.put(qrContent, sink);
|
||||
qrContentToCreateTime.put(qrContent, LocalDateTime.now());
|
||||
log.info("绑定成功: qrContent={}, sessionId={}", qrContent, sessionId);
|
||||
|
||||
// 发送连接成功消息
|
||||
sink.tryEmitNext(buildMessage("CONNECTED", "签到监听已建立,请扫描二维码"));
|
||||
} else {
|
||||
sink.tryEmitNext(buildMessage("ERROR", "二维码内容为空"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析消息失败,sessionId={}", sessionId, e);
|
||||
sink.tryEmitNext(buildMessage("ERROR", "消息格式错误: " + e.getMessage()));
|
||||
}
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("接收消息出错,sessionId={}", sessionId, e);
|
||||
})
|
||||
.subscribe(); // 必须订阅,否则不会执行
|
||||
|
||||
// 发送流给客户端
|
||||
return session.send(sink.asFlux().map(session::textMessage))
|
||||
.doFinally(signal -> {
|
||||
// 连接关闭时清理映射
|
||||
qrContentToSink.entrySet().removeIf(entry -> entry.getValue() == sink);
|
||||
qrContentToCreateTime.entrySet().removeIf(entry -> {
|
||||
Sinks.Many<String> s = qrContentToSink.get(entry.getKey());
|
||||
return s == null || s == sink;
|
||||
});
|
||||
log.info("WebSocket 连接关闭,sessionId={}", sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 向客户端发送消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param message 消息内容
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public static boolean sendMessageToClient(String qrContent, String message) {
|
||||
// 先清理超时连接
|
||||
cleanupTimeoutConnections();
|
||||
|
||||
Sinks.Many<String> sink = qrContentToSink.get(qrContent);
|
||||
if (sink == null) {
|
||||
log.warn("未找到绑定的连接,qrContent: {}", qrContent);
|
||||
return false;
|
||||
}
|
||||
|
||||
Sinks.EmitResult result = sink.tryEmitNext(message);
|
||||
if (result.isSuccess()) {
|
||||
log.info("主动推送成功,qrContent: {}, message: {}", qrContent, message);
|
||||
return true;
|
||||
} else {
|
||||
log.warn("推送失败,qrContent: {}, result: {}", qrContent, result);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送签到进度消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param step 进度步骤
|
||||
* @param message 进度消息
|
||||
*/
|
||||
public static void sendProgress(String qrContent, String step, String message) {
|
||||
String progressMessage = buildMessage("PROGRESS", message);
|
||||
sendMessageToClient(qrContent, progressMessage);
|
||||
log.debug("发送进度消息: qrContent={}, step={}, message={}", qrContent, step, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送签到成功消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param memberId 会员ID
|
||||
* @param signInTime 签到时间
|
||||
*/
|
||||
public static void sendSuccess(String qrContent, Long memberId, String signInTime) {
|
||||
String successMessage = buildMessage("SUCCESS", "签到成功!欢迎光临\n会员ID: " + memberId + "\n签到时间: " + signInTime);
|
||||
sendMessageToClient(qrContent, successMessage);
|
||||
log.info("发送成功消息: qrContent={}, memberId={}", qrContent, memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送签到失败消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param reason 失败原因
|
||||
*/
|
||||
public static void sendFailure(String qrContent, String reason) {
|
||||
String failureMessage = buildMessage("FAILURE", "签到失败:" + reason);
|
||||
sendMessageToClient(qrContent, failureMessage);
|
||||
log.warn("发送失败消息: qrContent={}, reason={}", qrContent, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建标准消息格式
|
||||
*
|
||||
* @param type 消息类型
|
||||
* @param content 消息内容
|
||||
* @return 格式化后的消息字符串
|
||||
*/
|
||||
private static String buildMessage(String type, String content) {
|
||||
return JSONUtil.toJsonStr(Map.of(
|
||||
"type", type,
|
||||
"content", content,
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超时连接
|
||||
*/
|
||||
private static void cleanupTimeoutConnections() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
qrContentToCreateTime.entrySet().removeIf(entry -> {
|
||||
LocalDateTime createTime = entry.getValue();
|
||||
long secondsDiff = java.time.Duration.between(createTime, now).getSeconds();
|
||||
if (secondsDiff > TIMEOUT_SECONDS) {
|
||||
String qrContent = entry.getKey();
|
||||
qrContentToSink.remove(qrContent);
|
||||
log.debug("清理超时连接: qrContent={}, 超时时间={}秒", qrContent, secondsDiff);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接数
|
||||
*
|
||||
* @return 连接数
|
||||
*/
|
||||
public static int getConnectionCount() {
|
||||
cleanupTimeoutConnections();
|
||||
return qrContentToSink.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 二维码配置
|
||||
qr:
|
||||
config:
|
||||
width: 300 # 二维码宽度(像素)
|
||||
height: 300 # 二维码高度(像素)
|
||||
margin: 1 # 白边宽度(像素)
|
||||
format: png # 图片格式:png / jpg
|
||||
error-correction: L #容错率:L, M, Q, H,如果启用Logo(logo-enabled: true),必须设置为 H
|
||||
logo-enabled: false # 是否启用Logo(启用时error-correction必须为H)
|
||||
# logo-path: static/logo.png # Logo图片路径(支持相对路径或绝对路径)
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
package cn.novalon.gym.manage.checkin;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 签到模块接口测试类
|
||||
* 测试模块三(gym-checkIn)的所有接口
|
||||
*/
|
||||
class CheckInModuleTest {
|
||||
|
||||
@Mock
|
||||
private QRCodeConfig qrCodeConfig;
|
||||
|
||||
@Mock
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Mock
|
||||
private MemberCardRecordRepository memberCardRecordRepository;
|
||||
|
||||
@Mock
|
||||
private MemberCardRepository memberCardRepository;
|
||||
|
||||
@Mock
|
||||
private SignInRecordRepository signInRecordRepository;
|
||||
|
||||
@Mock
|
||||
private IGroupCourseBookingService groupCourseBookingService;
|
||||
|
||||
@Mock
|
||||
private MemberCard mockMemberCard;
|
||||
|
||||
@Mock
|
||||
private SignInRecord mockSignInRecord;
|
||||
|
||||
@Mock
|
||||
private MemberCardRecord mockMemberCardRecord;
|
||||
|
||||
private CheckServiceImpl checkService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
|
||||
memberCardRepository, signInRecordRepository, groupCourseBookingService);
|
||||
|
||||
when(mockMemberCard.getId()).thenReturn(1L);
|
||||
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
|
||||
|
||||
when(mockSignInRecord.getId()).thenReturn(1L);
|
||||
when(mockSignInRecord.getMemberId()).thenReturn(1L);
|
||||
when(mockSignInRecord.getMemberCardId()).thenReturn(1L);
|
||||
when(mockSignInRecord.getSignInTime()).thenReturn(LocalDateTime.now());
|
||||
when(mockSignInRecord.getSignInType()).thenReturn("QR_CODE");
|
||||
when(mockSignInRecord.getSignInStatus()).thenReturn("SUCCESS");
|
||||
when(mockSignInRecord.getSource()).thenReturn("MINI_PROGRAM");
|
||||
|
||||
when(mockMemberCardRecord.getMemberCardRecordId()).thenReturn(1L);
|
||||
when(mockMemberCardRecord.getMemberCardId()).thenReturn(1L);
|
||||
when(mockMemberCardRecord.getRemainingTimes()).thenReturn(10);
|
||||
when(mockMemberCardRecord.getRemainingAmount()).thenReturn(100.0);
|
||||
when(mockMemberCardRecord.getExpireTime()).thenReturn(LocalDateTime.now().plusDays(30));
|
||||
when(mockMemberCardRecord.getStatus()).thenReturn(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试1: 获取二维码 - getQRCode")
|
||||
void testGetQRCode() {
|
||||
when(memberCardRecordRepository.findActiveCardsByMemberId(1L))
|
||||
.thenReturn(Flux.just(mockMemberCardRecord));
|
||||
when(redisUtil.setWithExpire(any(String.class), any(Map.class), any(Long.class)))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
Mono<QRCodeVo> result = checkService.getQRCode(1L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(qrCodeVo -> {
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo.getQrContent());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试2: 签到 - checkIn")
|
||||
void testCheckIn() {
|
||||
Long memberId = 1L;
|
||||
Map<String, Object> qrData = new HashMap<>();
|
||||
qrData.put("qrContent", "test-qr-content");
|
||||
qrData.put("memberId", memberId);
|
||||
qrData.put("memberCardRecordId", 1L);
|
||||
qrData.put("isUsed", false);
|
||||
qrData.put("expireTime", System.currentTimeMillis() + 3600000);
|
||||
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||
when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord));
|
||||
when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Mono.just(mockMemberCard));
|
||||
when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord));
|
||||
when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true));
|
||||
when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty());
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(memberId, "test-qr-content");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.contains("签到成功"))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试3: 查询签到记录列表 - getSignInRecords")
|
||||
void testGetSignInRecords() {
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.just(mockSignInRecord));
|
||||
|
||||
Flux<SignInRecordVO> result = checkService.getSignInRecords(1L,
|
||||
LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextCount(1)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试4: 查询单条签到记录 - getSignInRecordById")
|
||||
void testGetSignInRecordById() {
|
||||
when(signInRecordRepository.findById(1L))
|
||||
.thenReturn(Mono.just(mockSignInRecord));
|
||||
|
||||
Mono<SignInRecordVO> result = checkService.getSignInRecordById(1L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(vo -> vo.getId() == 1L)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试5: 查询签到记录 - 记录不存在")
|
||||
void testGetSignInRecordById_NotFound() {
|
||||
when(signInRecordRepository.findById(999L))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<SignInRecordVO> result = checkService.getSignInRecordById(999L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试6: 获取签到统计 - getSignInStats")
|
||||
void testGetSignInStats() {
|
||||
when(signInRecordRepository.countByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(10L));
|
||||
when(signInRecordRepository.countSuccessByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(8L));
|
||||
when(signInRecordRepository.getFirstSignInTime(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(LocalDateTime.now().minusDays(29)));
|
||||
when(signInRecordRepository.getLastSignInTime(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(LocalDateTime.now()));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getSignInStats(1L,
|
||||
LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(10L, stats.getTotalCount());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(8L, stats.getSuccessCount());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试7: 获取每日签到统计 - getDailySignInStats")
|
||||
void testGetDailySignInStats() {
|
||||
when(signInRecordRepository.countByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(50L));
|
||||
when(signInRecordRepository.countSuccessByTimeRange(
|
||||
any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(45L));
|
||||
when(signInRecordRepository.countDistinctMembersByTimeRange(
|
||||
any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(30L));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getDailySignInStats(LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(50L, stats.getTotalCount());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(45L, stats.getSuccessCount());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(30L, stats.getUniqueMemberCount());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试8: 导出签到记录 - exportSignInRecords")
|
||||
void testExportSignInRecords() {
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.just(mockSignInRecord));
|
||||
|
||||
Mono<byte[]> result = checkService.exportSignInRecords(1L,
|
||||
LocalDate.now().minusDays(7), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(bytes -> bytes.length > 0)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试9: 签到失败 - 二维码无效")
|
||||
void testCheckIn_QRCodeInvalid() {
|
||||
Long memberId = 1L;
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(new HashMap<>()));
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(memberId, "invalid-qr");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试10: 签到失败 - 二维码不存在")
|
||||
void testCheckIn_QRCodeNotFound() {
|
||||
Long memberId = 1L;
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.empty());
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(memberId, "not-exist");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Test Configuration
|
||||
@@ -0,0 +1,33 @@
|
||||
HELP.md
|
||||
target/
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-manage-api</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-dataCount</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>gym-dataCount</name>
|
||||
<description>Data Statistics Module for Gym Management</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Common模块 -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 数据库模块 -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-db</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- WebFlux -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- R2dbc -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- POI for Excel export -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Gym Modules -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-groupCourse</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-checkIn</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package cn.novalon.gym.manage.datacount.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
/**
|
||||
* 数据统计模块自动配置
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ComponentScan(basePackages = "cn.novalon.gym.manage.datacount")
|
||||
public class DataCountAutoConfiguration {
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package cn.novalon.gym.manage.datacount.dao;
|
||||
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 数据统计 DAO - 使用 DatabaseClient 执行跨表聚合查询
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Repository
|
||||
public class DataStatisticsDao {
|
||||
|
||||
private final DatabaseClient databaseClient;
|
||||
|
||||
public DataStatisticsDao(DatabaseClient databaseClient) {
|
||||
this.databaseClient = databaseClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内新增会员数
|
||||
*/
|
||||
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计总会员数
|
||||
*/
|
||||
public Mono<Long> countTotalMembers() {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false")
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内的签到次数
|
||||
*/
|
||||
public Mono<Long> countSignIns(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内的成功签到次数
|
||||
*/
|
||||
public Mono<Long> countSuccessSignIns(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内签到的独立会员数
|
||||
*/
|
||||
public Mono<Long> countDistinctSignInMembers(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内的预约数
|
||||
*/
|
||||
public Mono<Long> countBookings(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND deleted_at IS NULL")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内的取消预约数
|
||||
*/
|
||||
public Mono<Long> countCancelBookings(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '1' AND deleted_at IS NULL")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内的出席预约数
|
||||
*/
|
||||
public Mono<Long> countAttendBookings(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '2' AND deleted_at IS NULL")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内的缺席预约数
|
||||
*/
|
||||
public Mono<Long> countAbsentBookings(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '3' AND deleted_at IS NULL")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内有预约的独立会员数
|
||||
*/
|
||||
public Mono<Long> countDistinctBookingMembers(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(DISTINCT member_id) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND deleted_at IS NULL")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内取消预约的独立会员数
|
||||
*/
|
||||
public Mono<Long> countDistinctCancelMembers(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT COUNT(DISTINCT member_id) FROM group_course_booking WHERE booking_time >= :startTime AND booking_time < :endTime AND status = '1' AND deleted_at IS NULL")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> row.get(0, Long.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按签到类型统计次数
|
||||
*/
|
||||
public Flux<SignInTypeCount> countSignInsByType(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return databaseClient.sql("SELECT sign_in_type as type, COUNT(*) as count FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false GROUP BY sign_in_type")
|
||||
.bind("startTime", startTime)
|
||||
.bind("endTime", endTime)
|
||||
.map(row -> {
|
||||
SignInTypeCount result = new SignInTypeCount();
|
||||
result.setType(row.get("type", String.class));
|
||||
result.setCount(row.get("count", Long.class));
|
||||
return result;
|
||||
})
|
||||
.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到类型计数结果
|
||||
*/
|
||||
public static class SignInTypeCount {
|
||||
private String type;
|
||||
private Long count;
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Long getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public void setCount(Long count) {
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 预约数据统计结果
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BookingStatistics {
|
||||
|
||||
/**
|
||||
* 统计日期
|
||||
*/
|
||||
private String statDate;
|
||||
|
||||
/**
|
||||
* 新增预约数
|
||||
*/
|
||||
private Long newBookings;
|
||||
|
||||
/**
|
||||
* 取消预约数
|
||||
*/
|
||||
private Long cancelBookings;
|
||||
|
||||
/**
|
||||
* 出席预约数
|
||||
*/
|
||||
private Long attendBookings;
|
||||
|
||||
/**
|
||||
* 缺席预约数
|
||||
*/
|
||||
private Long absentBookings;
|
||||
|
||||
/**
|
||||
* 预约出席率
|
||||
*/
|
||||
private Double attendanceRate;
|
||||
|
||||
/**
|
||||
* 取消率
|
||||
*/
|
||||
private Double cancelRate;
|
||||
|
||||
/**
|
||||
* 预约人数(独立会员数)
|
||||
*/
|
||||
private Long bookingMembers;
|
||||
|
||||
/**
|
||||
* 取消人数(独立会员数)
|
||||
*/
|
||||
private Long cancelMembers;
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 数据统计结果域对象
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DataStatistics {
|
||||
|
||||
/**
|
||||
* 统计类型:MEMBER-会员统计,BOOKING-预约统计,SIGN_IN-签到统计
|
||||
*/
|
||||
private String statType;
|
||||
|
||||
/**
|
||||
* 统计周期:DAY-日统计,WEEK-周统计,MONTH-月统计
|
||||
*/
|
||||
private String periodType;
|
||||
|
||||
/**
|
||||
* 统计日期(DAY时为具体日期,WEEK时为周开始日期,MONTH时为月份第一天)
|
||||
*/
|
||||
private LocalDateTime statDate;
|
||||
|
||||
/**
|
||||
* 统计数据值
|
||||
*/
|
||||
private Long count;
|
||||
|
||||
/**
|
||||
* 关联ID(如会员ID等)
|
||||
*/
|
||||
private Long relatedId;
|
||||
|
||||
/**
|
||||
* 扩展数据(JSON格式存储额外信息)
|
||||
*/
|
||||
private String extraData;
|
||||
|
||||
/**
|
||||
* 统计周期常量
|
||||
*/
|
||||
public static final class PeriodType {
|
||||
/** 日统计 */
|
||||
public static final String DAY = "DAY";
|
||||
/** 周统计 */
|
||||
public static final String WEEK = "WEEK";
|
||||
/** 月统计 */
|
||||
public static final String MONTH = "MONTH";
|
||||
|
||||
private PeriodType() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计类型常量
|
||||
*/
|
||||
public static final class StatType {
|
||||
/** 会员统计 */
|
||||
public static final String MEMBER = "MEMBER";
|
||||
/** 预约统计 */
|
||||
public static final String BOOKING = "BOOKING";
|
||||
/** 签到统计 */
|
||||
public static final String SIGN_IN = "SIGN_IN";
|
||||
|
||||
private StatType() {}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 会员数据统计结果
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MemberStatistics {
|
||||
|
||||
/**
|
||||
* 统计日期
|
||||
*/
|
||||
private String statDate;
|
||||
|
||||
/**
|
||||
* 新增会员数
|
||||
*/
|
||||
private Long newMembers;
|
||||
|
||||
/**
|
||||
* 活跃会员数(当天有签到或预约的会员)
|
||||
*/
|
||||
private Long activeMembers;
|
||||
|
||||
/**
|
||||
* 累计会员总数
|
||||
*/
|
||||
private Long totalMembers;
|
||||
|
||||
/**
|
||||
* 今日签到会员数
|
||||
*/
|
||||
private Long signInMembers;
|
||||
|
||||
/**
|
||||
* 今日预约会员数
|
||||
*/
|
||||
private Long bookingMembers;
|
||||
|
||||
/**
|
||||
* 今日取消预约会员数
|
||||
*/
|
||||
private Long cancelBookingMembers;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 签到数据统计结果
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SignInStatistics {
|
||||
|
||||
/**
|
||||
* 统计日期
|
||||
*/
|
||||
private String statDate;
|
||||
|
||||
/**
|
||||
* 签到总次数
|
||||
*/
|
||||
private Long totalSignIns;
|
||||
|
||||
/**
|
||||
* 成功签到次数
|
||||
*/
|
||||
private Long successSignIns;
|
||||
|
||||
/**
|
||||
* 失败签到次数
|
||||
*/
|
||||
private Long failedSignIns;
|
||||
|
||||
/**
|
||||
* 签到成功率
|
||||
*/
|
||||
private Double successRate;
|
||||
|
||||
/**
|
||||
* 签到人数(独立会员数)
|
||||
*/
|
||||
private Long signInMembers;
|
||||
|
||||
/**
|
||||
* 扫码签到次数
|
||||
*/
|
||||
private Long qrCodeSignIns;
|
||||
|
||||
/**
|
||||
* 手动签到次数
|
||||
*/
|
||||
private Long manualSignIns;
|
||||
|
||||
/**
|
||||
* 人脸识别签到次数
|
||||
*/
|
||||
private Long faceSignIns;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 统计数据查询请求
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StatisticsQuery {
|
||||
|
||||
/**
|
||||
* 统计类型:MEMBER-会员统计,BOOKING-预约统计,SIGN_IN-签到统计
|
||||
* 空表示所有类型
|
||||
*/
|
||||
private String statType;
|
||||
|
||||
/**
|
||||
* 统计周期:DAY-日统计,WEEK-周统计,MONTH-月统计
|
||||
*/
|
||||
private String periodType;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
private LocalDateTime endTime;
|
||||
|
||||
/**
|
||||
* 分页页码
|
||||
*/
|
||||
private Integer page;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
private Integer size;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 统计数据汇总
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StatisticsSummary {
|
||||
|
||||
/**
|
||||
* 统计日期
|
||||
*/
|
||||
private String statDate;
|
||||
|
||||
/**
|
||||
* 会员统计数据
|
||||
*/
|
||||
private MemberStatistics memberStatistics;
|
||||
|
||||
/**
|
||||
* 预约统计数据
|
||||
*/
|
||||
private BookingStatistics bookingStatistics;
|
||||
|
||||
/**
|
||||
* 签到统计数据
|
||||
*/
|
||||
private SignInStatistics signInStatistics;
|
||||
|
||||
/**
|
||||
* 统计数据生成时间
|
||||
*/
|
||||
private String generatedAt;
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package cn.novalon.gym.manage.datacount.handler;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.domain.*;
|
||||
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 数据统计 Handler
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Component
|
||||
@Tag(name = "数据统计", description = "数据统计相关操作")
|
||||
public class DataStatisticsHandler {
|
||||
|
||||
@Autowired
|
||||
private IDataStatisticsService dataStatisticsService;
|
||||
|
||||
@Operation(summary = "获取综合统计数据", description = "获取会员、预约、签到综合统计数据")
|
||||
public Mono<ServerResponse> getStatisticsSummary(ServerRequest request) {
|
||||
StatisticsQuery query = buildQueryFromRequest(request);
|
||||
|
||||
return dataStatisticsService.getStatisticsSummaryWithCache(query)
|
||||
.flatMap(summary -> ServerResponse.ok().bodyValue(summary))
|
||||
.onErrorResume(e -> {
|
||||
StatisticsSummary errorSummary = StatisticsSummary.builder()
|
||||
.statDate(LocalDateTime.now().toLocalDate().toString())
|
||||
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
|
||||
.build();
|
||||
return ServerResponse.ok().bodyValue(errorSummary);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "获取会员统计数据", description = "获取会员统计数据")
|
||||
public Mono<ServerResponse> getMemberStatistics(ServerRequest request) {
|
||||
StatisticsQuery query = buildQueryFromRequest(request);
|
||||
|
||||
return dataStatisticsService.getMemberStatistics(query)
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
|
||||
.onErrorResume(e -> ServerResponse.ok().bodyValue(MemberStatistics.builder().statDate(query.getStartTime() != null ? query.getStartTime().toLocalDate().toString() : LocalDateTime.now().toLocalDate().toString()).build()));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取预约统计数据", description = "获取预约统计数据")
|
||||
public Mono<ServerResponse> getBookingStatistics(ServerRequest request) {
|
||||
StatisticsQuery query = buildQueryFromRequest(request);
|
||||
|
||||
return dataStatisticsService.getBookingStatistics(query)
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
|
||||
.onErrorResume(e -> ServerResponse.ok().bodyValue(BookingStatistics.builder().statDate(query.getStartTime() != null ? query.getStartTime().toLocalDate().toString() : LocalDateTime.now().toLocalDate().toString()).build()));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取签到统计数据", description = "获取签到统计数据")
|
||||
public Mono<ServerResponse> getSignInStatistics(ServerRequest request) {
|
||||
StatisticsQuery query = buildQueryFromRequest(request);
|
||||
|
||||
return dataStatisticsService.getSignInStatistics(query)
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
|
||||
.onErrorResume(e -> ServerResponse.ok().bodyValue(SignInStatistics.builder().statDate(query.getStartTime() != null ? query.getStartTime().toLocalDate().toString() : LocalDateTime.now().toLocalDate().toString()).build()));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询历史统计数据", description = "查询历史统计数据")
|
||||
public Mono<ServerResponse> queryHistoricalStatistics(ServerRequest request) {
|
||||
StatisticsQuery query = buildQueryFromRequest(request);
|
||||
|
||||
return dataStatisticsService.queryHistoricalStatistics(query)
|
||||
.collectList()
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
|
||||
}
|
||||
|
||||
@Operation(summary = "导出统计数据", description = "导出统计数据为Excel文件")
|
||||
public Mono<ServerResponse> exportStatistics(ServerRequest request) {
|
||||
StatisticsQuery query = buildQueryFromRequest(request);
|
||||
|
||||
return dataStatisticsService.exportStatistics(query)
|
||||
.flatMap(bytes -> {
|
||||
String filename = "statistics_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + ".xlsx";
|
||||
return ServerResponse.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.bodyValue(bytes);
|
||||
})
|
||||
.onErrorResume(e -> ServerResponse.ok().bodyValue("导出失败: " + e.getMessage()));
|
||||
}
|
||||
|
||||
private StatisticsQuery buildQueryFromRequest(ServerRequest request) {
|
||||
StatisticsQuery.StatisticsQueryBuilder builder = StatisticsQuery.builder();
|
||||
|
||||
request.queryParam("statType").ifPresent(builder::statType);
|
||||
request.queryParam("periodType").ifPresent(builder::periodType);
|
||||
request.queryParam("startTime").ifPresent(startTimeStr -> {
|
||||
try {
|
||||
builder.startTime(LocalDateTime.parse(startTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
builder.startTime(LocalDateTime.parse(startTimeStr));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
request.queryParam("endTime").ifPresent(endTimeStr -> {
|
||||
try {
|
||||
builder.endTime(LocalDateTime.parse(endTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
builder.endTime(LocalDateTime.parse(endTimeStr));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package cn.novalon.gym.manage.datacount.scheduler;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.domain.DataStatistics;
|
||||
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 数据统计定时任务
|
||||
* 每日凌晨执行统计数据更新
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Component
|
||||
public class DataStatisticsScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataStatisticsScheduler.class);
|
||||
|
||||
@Autowired
|
||||
private IDataStatisticsService dataStatisticsService;
|
||||
|
||||
/**
|
||||
* 每日凌晨2点执行前一天的日统计数据
|
||||
* 使用cron表达式: 0 0 2 * * ?
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void executeDailyStatistics() {
|
||||
LocalDate yesterday = LocalDate.now().minusDays(1);
|
||||
log.info("Starting daily statistics task for date: {}", yesterday);
|
||||
|
||||
dataStatisticsService.executeDailyStatistics(yesterday)
|
||||
.subscribe(
|
||||
null,
|
||||
error -> log.error("Daily statistics task failed for date: {}", yesterday, error),
|
||||
() -> log.info("Daily statistics task completed for date: {}", yesterday)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每周一凌晨3点执行上周的周统计数据
|
||||
* 使用cron表达式: 0 0 3 ? * MON
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 ? * MON")
|
||||
public void executeWeeklyStatistics() {
|
||||
LocalDate lastWeek = LocalDate.now().minusWeeks(1);
|
||||
log.info("Starting weekly statistics task for week of: {}", lastWeek);
|
||||
|
||||
// 执行上周每天的统计数据汇总
|
||||
LocalDate weekStart = lastWeek.with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||
LocalDate weekEnd = lastWeek.with(java.time.temporal.TemporalAdjusters.nextOrSame(java.time.DayOfWeek.SUNDAY));
|
||||
|
||||
for (LocalDate date = weekStart; !date.isAfter(weekEnd); date = date.plusDays(1)) {
|
||||
final LocalDate statDate = date;
|
||||
dataStatisticsService.executeDailyStatistics(statDate)
|
||||
.block();
|
||||
}
|
||||
|
||||
log.info("Weekly statistics task completed for week: {} - {}", weekStart, weekEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每月1日凌晨3点执行上个月的月统计数据
|
||||
* 使用cron表达式: 0 0 3 1 * ?
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 1 * ?")
|
||||
public void executeMonthlyStatistics() {
|
||||
LocalDate lastMonth = LocalDate.now().minusMonths(1);
|
||||
log.info("Starting monthly statistics task for month: {}", lastMonth.getMonth());
|
||||
|
||||
// 执行上个月每天的统计数据补全
|
||||
LocalDate startOfMonth = lastMonth.withDayOfMonth(1);
|
||||
LocalDate endOfMonth = lastMonth.with(java.time.temporal.TemporalAdjusters.lastDayOfMonth());
|
||||
|
||||
for (LocalDate date = startOfMonth; !date.isAfter(endOfMonth); date = date.plusDays(1)) {
|
||||
final LocalDate statDate = date;
|
||||
dataStatisticsService.executeDailyStatistics(statDate)
|
||||
.block();
|
||||
}
|
||||
|
||||
log.info("Monthly statistics task completed for month: {}", lastMonth.getMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* 每月15日凌晨4点清理30天前的旧统计数据
|
||||
* 使用cron表达式: 0 0 4 15 * ?
|
||||
*/
|
||||
@Scheduled(cron = "0 0 4 15 * ?")
|
||||
public void cleanupOldStatistics() {
|
||||
LocalDate cutoffDate = LocalDate.now().minusDays(30);
|
||||
log.info("Starting cleanup old statistics task, removing data before: {}", cutoffDate);
|
||||
|
||||
// 清理Redis中的旧统计数据
|
||||
String pattern = "datacount:statistics:*:" + cutoffDate.toString();
|
||||
cn.novalon.gym.manage.common.util.RedisUtil redisUtil = null;
|
||||
try {
|
||||
// 这里可以通过注入的service来清理,但当前实现使用Redis缓存30天自动过期
|
||||
log.info("Old statistics cleanup completed, cutoff date: {}", cutoffDate);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to cleanup old statistics", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package cn.novalon.gym.manage.datacount.service;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.domain.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 数据统计服务接口
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
public interface IDataStatisticsService {
|
||||
|
||||
/**
|
||||
* 获取会员统计数据
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 会员统计数据
|
||||
*/
|
||||
Mono<MemberStatistics> getMemberStatistics(StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取预约统计数据
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 预约统计数据
|
||||
*/
|
||||
Mono<BookingStatistics> getBookingStatistics(StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取签到统计数据
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 签到统计数据
|
||||
*/
|
||||
Mono<SignInStatistics> getSignInStatistics(StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取综合统计数据(包含会员、预约、签到)
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 综合统计数据
|
||||
*/
|
||||
Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 查询历史统计数据
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 历史统计数据列表
|
||||
*/
|
||||
Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 执行每日统计数据更新(定时任务调用)
|
||||
*
|
||||
* @param statDate 统计日期
|
||||
* @return 更新结果
|
||||
*/
|
||||
Mono<Void> executeDailyStatistics(java.time.LocalDate statDate);
|
||||
|
||||
/**
|
||||
* 导出统计数据为Excel
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return Excel文件的字节数组
|
||||
*/
|
||||
Mono<byte[]> exportStatistics(StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取统计数据(带缓存)
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 统计数据
|
||||
*/
|
||||
Mono<StatisticsSummary> getStatisticsSummaryWithCache(StatisticsQuery query);
|
||||
}
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
package cn.novalon.gym.manage.datacount.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.datacount.dao.DataStatisticsDao;
|
||||
import cn.novalon.gym.manage.datacount.domain.*;
|
||||
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据统计服务实现类
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-09
|
||||
*/
|
||||
@Service
|
||||
public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataStatisticsServiceImpl.class);
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "datacount:statistics:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 3600; // 1小时
|
||||
|
||||
@Autowired
|
||||
private DataStatisticsDao dataStatisticsDao;
|
||||
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Value("${datacount.cache.expire-seconds:3600}")
|
||||
private long cacheExpireSeconds = 3600;
|
||||
|
||||
@Override
|
||||
public Mono<MemberStatistics> getMemberStatistics(StatisticsQuery query) {
|
||||
LocalDateTime startTime = getStartTime(query);
|
||||
LocalDateTime endTime = getEndTime(query);
|
||||
|
||||
Mono<Long> newMembersMono = dataStatisticsDao.countNewMembers(startTime, endTime);
|
||||
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
|
||||
Mono<Long> signInMembersMono = dataStatisticsDao.countDistinctSignInMembers(startTime, endTime);
|
||||
Mono<Long> bookingMembersMono = dataStatisticsDao.countDistinctBookingMembers(startTime, endTime);
|
||||
Mono<Long> cancelMembersMono = dataStatisticsDao.countDistinctCancelMembers(startTime, endTime);
|
||||
|
||||
return Mono.zip(newMembersMono, totalMembersMono, signInMembersMono, bookingMembersMono, cancelMembersMono)
|
||||
.map(tuple -> {
|
||||
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
|
||||
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
|
||||
long signInMembers = tuple.getT3() != null ? tuple.getT3() : 0L;
|
||||
long bookingMembers = tuple.getT4() != null ? tuple.getT4() : 0L;
|
||||
long cancelMembers = tuple.getT5() != null ? tuple.getT5() : 0L;
|
||||
|
||||
// 活跃会员数 = 有签到的 + 有预约的(去重后大概值)
|
||||
long activeMembers = signInMembers + bookingMembers;
|
||||
|
||||
return MemberStatistics.builder()
|
||||
.statDate(startTime.toLocalDate().toString())
|
||||
.newMembers(newMembers)
|
||||
.activeMembers(activeMembers)
|
||||
.totalMembers(totalMembers)
|
||||
.signInMembers(signInMembers)
|
||||
.bookingMembers(bookingMembers)
|
||||
.cancelBookingMembers(cancelMembers)
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<BookingStatistics> getBookingStatistics(StatisticsQuery query) {
|
||||
LocalDateTime startTime = getStartTime(query);
|
||||
LocalDateTime endTime = getEndTime(query);
|
||||
|
||||
Mono<Long> totalMono = dataStatisticsDao.countBookings(startTime, endTime);
|
||||
Mono<Long> cancelMono = dataStatisticsDao.countCancelBookings(startTime, endTime);
|
||||
Mono<Long> attendMono = dataStatisticsDao.countAttendBookings(startTime, endTime);
|
||||
Mono<Long> absentMono = dataStatisticsDao.countAbsentBookings(startTime, endTime);
|
||||
Mono<Long> bookingMembersMono = dataStatisticsDao.countDistinctBookingMembers(startTime, endTime);
|
||||
Mono<Long> cancelMembersMono = dataStatisticsDao.countDistinctCancelMembers(startTime, endTime);
|
||||
|
||||
return Mono.zip(totalMono, cancelMono, attendMono, absentMono, bookingMembersMono, cancelMembersMono)
|
||||
.map(tuple -> {
|
||||
long total = tuple.getT1() != null ? tuple.getT1() : 0L;
|
||||
long cancel = tuple.getT2() != null ? tuple.getT2() : 0L;
|
||||
long attend = tuple.getT3() != null ? tuple.getT3() : 0L;
|
||||
long absent = tuple.getT4() != null ? tuple.getT4() : 0L;
|
||||
long bookingMembers = tuple.getT5() != null ? tuple.getT5() : 0L;
|
||||
long cancelMembers = tuple.getT6() != null ? tuple.getT6() : 0L;
|
||||
|
||||
double attendanceRate = total > 0 ? (double) attend / total * 100 : 0;
|
||||
double cancelRate = total > 0 ? (double) cancel / total * 100 : 0;
|
||||
|
||||
return BookingStatistics.builder()
|
||||
.statDate(startTime.toLocalDate().toString())
|
||||
.newBookings(total)
|
||||
.cancelBookings(cancel)
|
||||
.attendBookings(attend)
|
||||
.absentBookings(absent)
|
||||
.attendanceRate(Math.round(attendanceRate * 100.0) / 100.0)
|
||||
.cancelRate(Math.round(cancelRate * 100.0) / 100.0)
|
||||
.bookingMembers(bookingMembers)
|
||||
.cancelMembers(cancelMembers)
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatistics> getSignInStatistics(StatisticsQuery query) {
|
||||
LocalDateTime startTime = getStartTime(query);
|
||||
LocalDateTime endTime = getEndTime(query);
|
||||
|
||||
Mono<Long> totalMono = dataStatisticsDao.countSignIns(startTime, endTime);
|
||||
Mono<Long> successMono = dataStatisticsDao.countSuccessSignIns(startTime, endTime);
|
||||
Mono<Long> distinctMembersMono = dataStatisticsDao.countDistinctSignInMembers(startTime, endTime);
|
||||
|
||||
return Mono.zip(totalMono, successMono, distinctMembersMono)
|
||||
.flatMap(tuple -> {
|
||||
long total = tuple.getT1() != null ? tuple.getT1() : 0L;
|
||||
long success = tuple.getT2() != null ? tuple.getT2() : 0L;
|
||||
long distinctMembers = tuple.getT3() != null ? tuple.getT3() : 0L;
|
||||
|
||||
double successRate = total > 0 ? (double) success / total * 100 : 0;
|
||||
|
||||
return dataStatisticsDao.countSignInsByType(startTime, endTime)
|
||||
.collectMap(
|
||||
DataStatisticsDao.SignInTypeCount::getType,
|
||||
DataStatisticsDao.SignInTypeCount::getCount
|
||||
)
|
||||
.defaultIfEmpty(new HashMap<>())
|
||||
.map(typeCountMap -> {
|
||||
long qrCode = getCountByType(typeCountMap, SignInRecord.SignInType.QR_CODE);
|
||||
long manual = getCountByType(typeCountMap, SignInRecord.SignInType.MANUAL);
|
||||
long face = getCountByType(typeCountMap, SignInRecord.SignInType.FACE);
|
||||
|
||||
return SignInStatistics.builder()
|
||||
.statDate(startTime.toLocalDate().toString())
|
||||
.totalSignIns(total)
|
||||
.successSignIns(success)
|
||||
.failedSignIns(total - success)
|
||||
.successRate(Math.round(successRate * 100.0) / 100.0)
|
||||
.signInMembers(distinctMembers)
|
||||
.qrCodeSignIns(qrCode)
|
||||
.manualSignIns(manual)
|
||||
.faceSignIns(face)
|
||||
.build();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private long getCountByType(Map<String, Long> typeCountMap, String type) {
|
||||
Long count = typeCountMap.get(type);
|
||||
return count != null ? count : 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
|
||||
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
|
||||
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
|
||||
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
|
||||
|
||||
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
|
||||
.map(tuple -> StatisticsSummary.builder()
|
||||
.statDate(LocalDateTime.now().toLocalDate().toString())
|
||||
.memberStatistics(tuple.getT1())
|
||||
.bookingStatistics(tuple.getT2())
|
||||
.signInStatistics(tuple.getT3())
|
||||
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
|
||||
// 历史统计数据查询(从Redis缓存中获取)
|
||||
String cacheKey = buildCacheKey(query);
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
.flatMapMany(json -> {
|
||||
try {
|
||||
java.util.List<DataStatistics> stats = objectMapper.readValue(json,
|
||||
objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class));
|
||||
return reactor.core.publisher.Flux.fromIterable(stats);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse historical statistics from cache", e);
|
||||
return reactor.core.publisher.Flux.empty();
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(reactor.core.publisher.Flux.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> executeDailyStatistics(LocalDate statDate) {
|
||||
log.info("Executing daily statistics for date: {}", statDate);
|
||||
|
||||
LocalDateTime dayStart = statDate.atStartOfDay();
|
||||
LocalDateTime dayEnd = statDate.plusDays(1).atStartOfDay();
|
||||
|
||||
StatisticsQuery query = StatisticsQuery.builder()
|
||||
.startTime(dayStart)
|
||||
.endTime(dayEnd)
|
||||
.build();
|
||||
|
||||
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
|
||||
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
|
||||
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
|
||||
|
||||
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
|
||||
.flatMap(tuple -> {
|
||||
MemberStatistics memberStats = tuple.getT1();
|
||||
BookingStatistics bookingStats = tuple.getT2();
|
||||
SignInStatistics signInStats = tuple.getT3();
|
||||
|
||||
String dateStr = statDate.toString();
|
||||
String memberKey = CACHE_KEY_PREFIX + "member:" + dateStr;
|
||||
String bookingKey = CACHE_KEY_PREFIX + "booking:" + dateStr;
|
||||
String signInKey = CACHE_KEY_PREFIX + "signin:" + dateStr;
|
||||
|
||||
try {
|
||||
String memberJson = objectMapper.writeValueAsString(memberStats);
|
||||
String bookingJson = objectMapper.writeValueAsString(bookingStats);
|
||||
String signInJson = objectMapper.writeValueAsString(signInStats);
|
||||
|
||||
return reactor.core.publisher.Flux.merge(
|
||||
redisUtil.setWithExpire(memberKey, memberJson, Duration.ofDays(30).getSeconds()),
|
||||
redisUtil.setWithExpire(bookingKey, bookingJson, Duration.ofDays(30).getSeconds()),
|
||||
redisUtil.setWithExpire(signInKey, signInJson, Duration.ofDays(30).getSeconds())
|
||||
).then();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to serialize statistics data", e);
|
||||
return Mono.empty();
|
||||
}
|
||||
})
|
||||
.then()
|
||||
.doOnSuccess(v -> log.info("Daily statistics completed for date: {}", statDate))
|
||||
.doOnError(e -> log.error("Failed to execute daily statistics for date: {}", statDate, e));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportStatistics(StatisticsQuery query) {
|
||||
return getStatisticsSummary(query)
|
||||
.flatMap(summary -> {
|
||||
try (Workbook workbook = new XSSFWorkbook()) {
|
||||
Sheet sheet = workbook.createSheet("数据统计报表");
|
||||
|
||||
CellStyle headerStyle = workbook.createCellStyle();
|
||||
Font headerFont = workbook.createFont();
|
||||
headerFont.setBold(true);
|
||||
headerStyle.setFont(headerFont);
|
||||
|
||||
createMainSheet(sheet, summary, headerStyle);
|
||||
|
||||
Sheet memberSheet = workbook.createSheet("会员统计");
|
||||
createMemberStatisticsSheet(memberSheet, summary.getMemberStatistics(), headerStyle);
|
||||
|
||||
Sheet bookingSheet = workbook.createSheet("预约统计");
|
||||
createBookingStatisticsSheet(bookingSheet, summary.getBookingStatistics(), headerStyle);
|
||||
|
||||
Sheet signInSheet = workbook.createSheet("签到统计");
|
||||
createSignInStatisticsSheet(signInSheet, summary.getSignInStatistics(), headerStyle);
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
workbook.write(outputStream);
|
||||
return Mono.just(outputStream.toByteArray());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to export statistics", e);
|
||||
return Mono.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void createMainSheet(Sheet sheet, StatisticsSummary summary, CellStyle headerStyle) {
|
||||
int rowNum = 0;
|
||||
Row row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("统计项");
|
||||
row.createCell(1).setCellValue("数值");
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("统计日期");
|
||||
row.createCell(1).setCellValue(summary.getStatDate());
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("新增会员数");
|
||||
row.createCell(1).setCellValue(summary.getMemberStatistics().getNewMembers());
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("活跃会员数");
|
||||
row.createCell(1).setCellValue(summary.getMemberStatistics().getActiveMembers());
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("累计会员总数");
|
||||
row.createCell(1).setCellValue(summary.getMemberStatistics().getTotalMembers());
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("新增预约数");
|
||||
row.createCell(1).setCellValue(summary.getBookingStatistics().getNewBookings());
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("预约出席率");
|
||||
row.createCell(1).setCellValue(summary.getBookingStatistics().getAttendanceRate() + "%");
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("签到总次数");
|
||||
row.createCell(1).setCellValue(summary.getSignInStatistics().getTotalSignIns());
|
||||
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("签到成功率");
|
||||
row.createCell(1).setCellValue(summary.getSignInStatistics().getSuccessRate() + "%");
|
||||
|
||||
sheet.autoSizeColumn(0);
|
||||
sheet.autoSizeColumn(1);
|
||||
}
|
||||
|
||||
private void createMemberStatisticsSheet(Sheet sheet, MemberStatistics stats, CellStyle headerStyle) {
|
||||
int rowNum = 0;
|
||||
Row row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("统计项");
|
||||
row.createCell(1).setCellValue("数值");
|
||||
|
||||
String[][] data = {
|
||||
{"统计日期", stats.getStatDate()},
|
||||
{"新增会员数", String.valueOf(stats.getNewMembers())},
|
||||
{"活跃会员数", String.valueOf(stats.getActiveMembers())},
|
||||
{"累计会员总数", String.valueOf(stats.getTotalMembers())},
|
||||
{"今日签到会员数", String.valueOf(stats.getSignInMembers())},
|
||||
{"今日预约会员数", String.valueOf(stats.getBookingMembers())},
|
||||
{"今日取消预约会员数", String.valueOf(stats.getCancelBookingMembers())}
|
||||
};
|
||||
|
||||
for (String[] rowData : data) {
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue(rowData[0]);
|
||||
row.createCell(1).setCellValue(rowData[1]);
|
||||
}
|
||||
|
||||
sheet.autoSizeColumn(0);
|
||||
sheet.autoSizeColumn(1);
|
||||
}
|
||||
|
||||
private void createBookingStatisticsSheet(Sheet sheet, BookingStatistics stats, CellStyle headerStyle) {
|
||||
int rowNum = 0;
|
||||
Row row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("统计项");
|
||||
row.createCell(1).setCellValue("数值");
|
||||
|
||||
String[][] data = {
|
||||
{"统计日期", stats.getStatDate()},
|
||||
{"新增预约数", String.valueOf(stats.getNewBookings())},
|
||||
{"取消预约数", String.valueOf(stats.getCancelBookings())},
|
||||
{"出席预约数", String.valueOf(stats.getAttendBookings())},
|
||||
{"缺席预约数", String.valueOf(stats.getAbsentBookings())},
|
||||
{"预约出席率", stats.getAttendanceRate() + "%"},
|
||||
{"取消率", stats.getCancelRate() + "%"},
|
||||
{"预约人数", String.valueOf(stats.getBookingMembers())},
|
||||
{"取消人数", String.valueOf(stats.getCancelMembers())}
|
||||
};
|
||||
|
||||
for (String[] rowData : data) {
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue(rowData[0]);
|
||||
row.createCell(1).setCellValue(rowData[1]);
|
||||
}
|
||||
|
||||
sheet.autoSizeColumn(0);
|
||||
sheet.autoSizeColumn(1);
|
||||
}
|
||||
|
||||
private void createSignInStatisticsSheet(Sheet sheet, SignInStatistics stats, CellStyle headerStyle) {
|
||||
int rowNum = 0;
|
||||
Row row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue("统计项");
|
||||
row.createCell(1).setCellValue("数值");
|
||||
|
||||
String[][] data = {
|
||||
{"统计日期", stats.getStatDate()},
|
||||
{"签到总次数", String.valueOf(stats.getTotalSignIns())},
|
||||
{"成功签到次数", String.valueOf(stats.getSuccessSignIns())},
|
||||
{"失败签到次数", String.valueOf(stats.getFailedSignIns())},
|
||||
{"签到成功率", stats.getSuccessRate() + "%"},
|
||||
{"签到人数", String.valueOf(stats.getSignInMembers())},
|
||||
{"扫码签到次数", String.valueOf(stats.getQrCodeSignIns())},
|
||||
{"手动签到次数", String.valueOf(stats.getManualSignIns())},
|
||||
{"人脸识别签到次数", String.valueOf(stats.getFaceSignIns())}
|
||||
};
|
||||
|
||||
for (String[] rowData : data) {
|
||||
row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue(rowData[0]);
|
||||
row.createCell(1).setCellValue(rowData[1]);
|
||||
}
|
||||
|
||||
sheet.autoSizeColumn(0);
|
||||
sheet.autoSizeColumn(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<StatisticsSummary> getStatisticsSummaryWithCache(StatisticsQuery query) {
|
||||
String cacheKey = buildCacheKey(query);
|
||||
|
||||
return redisUtil.get(cacheKey, StatisticsSummary.class)
|
||||
.switchIfEmpty(
|
||||
getStatisticsSummary(query)
|
||||
.flatMap(summary -> {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(summary);
|
||||
return redisUtil.setWithExpire(cacheKey, json, cacheExpireSeconds)
|
||||
.thenReturn(summary);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to serialize statistics summary", e);
|
||||
return Mono.just(summary);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private String buildCacheKey(StatisticsQuery query) {
|
||||
StringBuilder keyBuilder = new StringBuilder(CACHE_KEY_PREFIX);
|
||||
keyBuilder.append("summary:");
|
||||
|
||||
if (query.getStartTime() != null) {
|
||||
keyBuilder.append(query.getStartTime().toLocalDate().toString());
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
keyBuilder.append("_").append(query.getEndTime().toLocalDate().toString());
|
||||
}
|
||||
if (query.getStatType() != null) {
|
||||
keyBuilder.append("_").append(query.getStatType());
|
||||
}
|
||||
if (query.getPeriodType() != null) {
|
||||
keyBuilder.append("_").append(query.getPeriodType());
|
||||
}
|
||||
|
||||
return keyBuilder.toString();
|
||||
}
|
||||
|
||||
private LocalDateTime getStartTime(StatisticsQuery query) {
|
||||
if (query.getStartTime() != null) {
|
||||
return query.getStartTime();
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
String periodType = query.getPeriodType();
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
// 周统计:本周一
|
||||
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
// 月统计:本月第一天
|
||||
return today.withDayOfMonth(1).atStartOfDay();
|
||||
} else {
|
||||
// 日统计:当天零点
|
||||
return today.atStartOfDay();
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime getEndTime(StatisticsQuery query) {
|
||||
if (query.getEndTime() != null) {
|
||||
return query.getEndTime();
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
String periodType = query.getPeriodType();
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
// 周统计:本周日 23:59:59
|
||||
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
// 月统计:本月最后一天 23:59:59
|
||||
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||
} else {
|
||||
// 日统计:当前时间
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据周期类型调整时间范围
|
||||
* 用于定时任务中的周期统计
|
||||
*/
|
||||
private LocalDateTime[] adjustTimeRangeByPeriod(LocalDate date, String periodType) {
|
||||
LocalDateTime startTime;
|
||||
LocalDateTime endTime;
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
startTime = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
|
||||
endTime = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
startTime = date.withDayOfMonth(1).atStartOfDay();
|
||||
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||
} else {
|
||||
startTime = date.atStartOfDay();
|
||||
endTime = date.plusDays(1).atStartOfDay();
|
||||
}
|
||||
|
||||
return new LocalDateTime[]{startTime, endTime};
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
cn.novalon.gym.manage.datacount.config.DataCountAutoConfiguration
|
||||
+152
-200
File diff suppressed because one or more lines are too long
@@ -43,6 +43,16 @@
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-checkIn</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-dataCount</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
+2
-4
@@ -7,10 +7,7 @@ import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
|
||||
import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories;
|
||||
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@@ -26,7 +23,8 @@ import java.util.List;
|
||||
"cn.novalon.gym.manage.sys.audit.repository" ,
|
||||
"cn.novalon.gym.manage.gymmembercard.dao",
|
||||
"cn.novalon.gym.manage.member.repository",
|
||||
"cn.novalon.gym.manage.groupcourse.dao"
|
||||
"cn.novalon.gym.manage.groupcourse.dao",
|
||||
"cn.novalon.gym.manage.checkIn.repository"
|
||||
})
|
||||
@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository")
|
||||
public class ManageApplication {
|
||||
|
||||
+33
-2
@@ -1,6 +1,8 @@
|
||||
package cn.novalon.gym.manage.app.config;
|
||||
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
|
||||
import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler;
|
||||
import cn.novalon.gym.manage.file.handler.SysFileHandler;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
|
||||
@@ -66,7 +68,9 @@ public class SystemRouter {
|
||||
MemberCardRecordHandler memberCardRecordHandler,
|
||||
MemberCardTransactionHandler memberCardTransactionHandler,
|
||||
GroupCourseHandler groupCourseHandler,
|
||||
GroupCourseBookingHandler groupCourseBookingHandler) {
|
||||
GroupCourseBookingHandler groupCourseBookingHandler,
|
||||
CheckInHandler checkInHandler,
|
||||
DataStatisticsHandler dataStatisticsHandler) {
|
||||
|
||||
return route()
|
||||
// ========== 诊断路由 ==========
|
||||
@@ -272,9 +276,36 @@ public class SystemRouter {
|
||||
.POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse)
|
||||
.POST("/api/groupCourse/booking/{bookingId}/cancel", groupCourseBookingHandler::cancelBooking)
|
||||
.GET("/api/groupCourse/bookings/member/{memberId}", groupCourseBookingHandler::getBookingsByMemberId)
|
||||
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
|
||||
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
|
||||
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
|
||||
|
||||
// ========= 签到模块路由 ==========
|
||||
// ===== 签到核心功能 =====
|
||||
.POST("/api/checkIn", checkInHandler::checkIn)
|
||||
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
|
||||
|
||||
// ===== 签到记录管理 =====
|
||||
.GET("/api/checkIn/records", checkInHandler::getSignInRecords)
|
||||
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
|
||||
|
||||
// ===== 签到统计 =====
|
||||
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
|
||||
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
|
||||
|
||||
// ===== 签到数据导出 =====
|
||||
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
|
||||
|
||||
// ========================================
|
||||
// ========== 数据统计模块路由 ============
|
||||
// ========================================
|
||||
|
||||
// ===== 数据统计核心功能 =====
|
||||
.GET("/api/datacount/summary", dataStatisticsHandler::getStatisticsSummary)
|
||||
.GET("/api/datacount/member", dataStatisticsHandler::getMemberStatistics)
|
||||
.GET("/api/datacount/booking", dataStatisticsHandler::getBookingStatistics)
|
||||
.GET("/api/datacount/signin", dataStatisticsHandler::getSignInStatistics)
|
||||
.GET("/api/datacount/history", dataStatisticsHandler::queryHistoricalStatistics)
|
||||
.GET("/api/datacount/export", dataStatisticsHandler::exportStatistics)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ spring:
|
||||
name: gym-manage-api
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
web-application-type: reactive
|
||||
cache:
|
||||
type: none
|
||||
autoconfigure:
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-redis</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package cn.novalon.gym.manage.common.constant;
|
||||
|
||||
/**
|
||||
* Redis 缓存 Key 常量类
|
||||
* 统一管理项目中所有 Redis 缓存的 key 前缀
|
||||
*
|
||||
* @author auto-generated
|
||||
* @date 2026-05-30
|
||||
*/
|
||||
public final class RedisKeyConstants {
|
||||
|
||||
private RedisKeyConstants() {
|
||||
}
|
||||
|
||||
// ==================== 会员模块 ====================
|
||||
|
||||
/**
|
||||
* 会员信息缓存
|
||||
* 格式:member:info:{memberId}
|
||||
*/
|
||||
public static final String MEMBER_INFO = "member:info:";
|
||||
|
||||
/**
|
||||
* 会员详情缓存
|
||||
* 格式:member:detail:{memberId}
|
||||
*/
|
||||
public static final String MEMBER_DETAIL = "member:detail:";
|
||||
|
||||
/**
|
||||
* 会员卡类型缓存
|
||||
* 格式:member:card:{memberCardId}
|
||||
*/
|
||||
public static final String MEMBER_CARD = "member:card:";
|
||||
|
||||
/**
|
||||
* 会员卡记录缓存(包含剩余次数/金额)
|
||||
* 格式:member:card:record:{recordId}
|
||||
*/
|
||||
public static final String MEMBER_CARD_RECORD = "member:card:record:";
|
||||
|
||||
/**
|
||||
* 会员退款申请缓存
|
||||
* 格式:member:refund:{recordId}
|
||||
*/
|
||||
public static final String MEMBER_REFUND = "member:refund:";
|
||||
|
||||
// ==================== 签到模块 ====================
|
||||
|
||||
/**
|
||||
* 用户当日二维码缓存
|
||||
* 格式:qrcode:user:daily:{userId}:{date}
|
||||
* 示例:qrcode:user:daily:1:2026-05-30
|
||||
*/
|
||||
public static final String QRCODE_USER_DAILY = "qrcode:user:daily:";
|
||||
|
||||
// ==================== 微信模块 ====================
|
||||
|
||||
/**
|
||||
* 微信 access_token 缓存
|
||||
* 格式:wechat:access_token:{appType}
|
||||
* appType: miniapp(小程序), mp(公众号)
|
||||
*/
|
||||
public static final String WECHAT_ACCESS_TOKEN = "wechat:access_token:";
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
-- ============================================
|
||||
-- 会员到店签到记录表
|
||||
-- 版本: V13
|
||||
-- 描述: 创建sign_in_record表,用于记录会员签到信息
|
||||
-- ============================================
|
||||
|
||||
-- 创建签到记录表
|
||||
CREATE TABLE IF NOT EXISTS sign_in_record (
|
||||
id BIGSERIAL PRIMARY KEY, -- 自增主键
|
||||
member_id BIGINT NOT NULL, -- 会员ID,关联member表
|
||||
member_card_id BIGINT, -- 签到时使用的会员卡ID
|
||||
sign_in_time TIMESTAMP NOT NULL, -- 签到入场时间
|
||||
sign_in_type VARCHAR(20) NOT NULL, -- 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
|
||||
sign_in_status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', -- 签到状态:SUCCESS-成功,FAILED-失败
|
||||
verification_details TEXT, -- JSON格式,存储会员卡验证时的快照数据
|
||||
fail_reason VARCHAR(500), -- 失败时的具体原因文案
|
||||
operator_id BIGINT, -- 操作人ID(前台人员),自助签到时为NULL
|
||||
operator_name VARCHAR(100), -- 操作人姓名冗余
|
||||
device_info VARCHAR(200), -- 签到设备标识或型号
|
||||
ip_address VARCHAR(50), -- 客户端IP地址
|
||||
source VARCHAR(20) NOT NULL, -- 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
|
||||
is_delete BOOLEAN DEFAULT FALSE, -- 软删除标识:false-未删除,true-已删除
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录创建时间
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- 记录更新时间
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
-- 会员ID索引(加速按会员查询签到记录)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_id ON sign_in_record(member_id);
|
||||
|
||||
-- 签到时间索引(加速按时间范围查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_time ON sign_in_record(sign_in_time);
|
||||
|
||||
-- 签到状态索引(加速按状态筛选)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_status ON sign_in_record(sign_in_status);
|
||||
|
||||
-- 会员卡ID索引(加速按会员卡查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_card_id ON sign_in_record(member_card_id);
|
||||
|
||||
-- 操作人ID索引(加速按操作人查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_operator_id ON sign_in_record(operator_id);
|
||||
|
||||
-- 签到来源索引(加速按来源统计)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_source ON sign_in_record(source);
|
||||
|
||||
-- 软删除索引(加速查询未删除的记录)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_is_delete ON sign_in_record(is_delete);
|
||||
|
||||
-- 复合索引:会员ID + 签到时间(加速会员签到历史查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_time ON sign_in_record(member_id, sign_in_time);
|
||||
|
||||
-- 复合索引:签到状态 + 签到时间(加速统计数据查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_sign_in_record_status_time ON sign_in_record(sign_in_status, sign_in_time);
|
||||
|
||||
-- 添加表注释
|
||||
COMMENT ON TABLE sign_in_record IS '会员到店签到记录表';
|
||||
|
||||
-- 添加字段注释
|
||||
COMMENT ON COLUMN sign_in_record.id IS '自增主键';
|
||||
COMMENT ON COLUMN sign_in_record.member_id IS '会员ID,关联member表';
|
||||
COMMENT ON COLUMN sign_in_record.member_card_id IS '签到时使用的会员卡ID';
|
||||
COMMENT ON COLUMN sign_in_record.sign_in_time IS '签到入场时间';
|
||||
COMMENT ON COLUMN sign_in_record.sign_in_type IS '签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别';
|
||||
COMMENT ON COLUMN sign_in_record.sign_in_status IS '签到状态:SUCCESS-成功,FAILED-失败';
|
||||
COMMENT ON COLUMN sign_in_record.verification_details IS 'JSON格式,存储会员卡验证时的快照数据';
|
||||
COMMENT ON COLUMN sign_in_record.fail_reason IS '失败时的具体原因文案';
|
||||
COMMENT ON COLUMN sign_in_record.operator_id IS '操作人ID(前台人员),自助签到时为NULL';
|
||||
COMMENT ON COLUMN sign_in_record.operator_name IS '操作人姓名冗余';
|
||||
COMMENT ON COLUMN sign_in_record.device_info IS '签到设备标识或型号';
|
||||
COMMENT ON COLUMN sign_in_record.ip_address IS '客户端IP地址';
|
||||
COMMENT ON COLUMN sign_in_record.source IS '签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端';
|
||||
COMMENT ON COLUMN sign_in_record.is_delete IS '软删除标识:false-未删除,true-已删除';
|
||||
COMMENT ON COLUMN sign_in_record.created_at IS '记录创建时间';
|
||||
COMMENT ON COLUMN sign_in_record.updated_at IS '记录更新时间';
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
-- 数据统计模块测试数据
|
||||
-- 用于测试会员、预约、签到统计接口
|
||||
|
||||
-- 插入测试会员数据
|
||||
INSERT INTO member_user (id, member_no, nickname, phone, created_at, updated_at, is_deleted) VALUES
|
||||
(1001, 'M20260601001', '张三', '13800138001', '2026-06-01 08:00:00', '2026-06-01 08:00:00', false),
|
||||
(1002, 'M20260601002', '李四', '13800138002', '2026-06-01 09:00:00', '2026-06-01 09:00:00', false),
|
||||
(1003, 'M20260602003', '王五', '13800138003', '2026-06-02 10:00:00', '2026-06-02 10:00:00', false),
|
||||
(1004, 'M20260603004', '赵六', '13800138004', '2026-06-03 11:00:00', '2026-06-03 11:00:00', false),
|
||||
(1005, 'M20260609005', '钱七', '13800138005', '2026-06-09 08:00:00', '2026-06-09 08:00:00', false),
|
||||
(1006, 'M20260609006', '孙八', '13800138006', '2026-06-09 09:00:00', '2026-06-09 09:00:00', false),
|
||||
(1007, 'M20260609007', '周九', '13800138007', '2026-06-09 10:00:00', '2026-06-09 10:00:00', false),
|
||||
(1008, 'M20260604008', '吴十', '13800138008', '2026-06-04 14:00:00', '2026-06-04 14:00:00', false),
|
||||
(1009, 'M20260605009', '郑十一', '13800138009', '2026-06-05 15:00:00', '2026-06-05 15:00:00', false),
|
||||
(1010, 'M20260606010', '王十二', '13800138010', '2026-06-06 16:00:00', '2026-06-06 16:00:00', false),
|
||||
(1011, 'M20260607011', '陈十三', '13800138011', '2026-06-07 17:00:00', '2026-06-07 17:00:00', false),
|
||||
(1012, 'M20260608012', '刘十四', '13800138012', '2026-06-08 18:00:00', '2026-06-08 18:00:00', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试签到记录数据 (今天的数据)
|
||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
||||
(2001, 1001, '2026-06-09 08:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2002, 1002, '2026-06-09 08:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||
(2003, 1003, '2026-06-09 08:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2004, 1004, '2026-06-09 09:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2005, 1005, '2026-06-09 09:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||
(2006, 1006, '2026-06-09 09:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2007, 1007, '2026-06-09 10:00:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2008, 1008, '2026-06-09 10:15:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2009, 1009, '2026-06-09 10:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||
(2010, 1010, '2026-06-09 11:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2011, 1011, '2026-06-09 11:15:00', 'FACE', 'FAIL', 'MINI_PROGRAM', false),
|
||||
(2012, 1012, '2026-06-09 11:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试签到记录数据 (昨天的数据)
|
||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
||||
(2013, 1001, '2026-06-08 07:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2014, 1002, '2026-06-08 08:00:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||
(2015, 1003, '2026-06-08 08:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2016, 1004, '2026-06-08 09:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2017, 1005, '2026-06-08 09:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||
(2018, 1006, '2026-06-08 10:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2019, 1007, '2026-06-08 10:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2020, 1008, '2026-06-08 11:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试签到记录数据 (前天的数据)
|
||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
||||
(2021, 1001, '2026-06-07 07:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2022, 1002, '2026-06-07 07:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||
(2023, 1003, '2026-06-07 08:00:00', 'FACE', 'FAIL', 'MINI_PROGRAM', false),
|
||||
(2024, 1004, '2026-06-07 08:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||
(2025, 1005, '2026-06-07 09:00:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试团课数据
|
||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, created_at, updated_at) VALUES
|
||||
(3001, '瑜伽入门', 1, 1, '2026-06-09 08:00:00', '2026-06-09 09:00:00', 20, 15, 0, '健身房A区', 'https://example.com/yoga.jpg', '适合初学者的瑜伽课程', '2026-06-01 10:00:00', '2026-06-01 10:00:00'),
|
||||
(3002, '动感单车', 2, 2, '2026-06-09 09:30:00', '2026-06-09 10:30:00', 25, 20, 0, '健身房B区', 'https://example.com/spinning.jpg', '高强度有氧运动', '2026-06-01 11:00:00', '2026-06-01 11:00:00'),
|
||||
(3003, '普拉提', 3, 1, '2026-06-09 14:00:00', '2026-06-09 15:00:00', 15, 10, 0, '健身房C区', 'https://example.com/pilates.jpg', '核心力量训练', '2026-06-01 12:00:00', '2026-06-01 12:00:00')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试团课预约数据 (今天的数据)
|
||||
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
||||
(4001, 1001, 1, 3001, '2026-06-09 08:00:00', '2', '2026-06-08 20:00:00', '2026-06-09 08:30:00'),
|
||||
(4002, 1002, 2, 3001, '2026-06-09 08:00:00', '2', '2026-06-08 21:00:00', '2026-06-09 08:30:00'),
|
||||
(4003, 1003, 3, 3001, '2026-06-09 08:00:00', '3', '2026-06-08 22:00:00', '2026-06-09 09:00:00'),
|
||||
(4004, 1004, 4, 3002, '2026-06-09 09:30:00', '2', '2026-06-08 19:00:00', '2026-06-09 09:30:00'),
|
||||
(4005, 1005, 5, 3002, '2026-06-09 09:30:00', '1', '2026-06-08 20:30:00', '2026-06-09 09:00:00'),
|
||||
(4006, 1006, 6, 3002, '2026-06-09 09:30:00', '2', '2026-06-08 21:30:00', '2026-06-09 09:30:00'),
|
||||
(4007, 1007, 7, 3003, '2026-06-09 14:00:00', '2', '2026-06-08 22:30:00', '2026-06-09 14:00:00'),
|
||||
(4008, 1008, 8, 3003, '2026-06-09 14:00:00', '3', '2026-06-09 08:00:00', '2026-06-09 14:00:00'),
|
||||
(4009, 1009, 9, 3003, '2026-06-09 14:00:00', '2', '2026-06-09 09:00:00', '2026-06-09 14:00:00'),
|
||||
(4010, 1010, 10, 3001, '2026-06-09 08:00:00', '2', '2026-06-09 07:00:00', '2026-06-09 08:00:00'),
|
||||
(4011, 1011, 11, 3002, '2026-06-09 09:30:00', '1', '2026-06-09 08:30:00', '2026-06-09 09:00:00'),
|
||||
(4012, 1012, 12, 3003, '2026-06-09 14:00:00', '2', '2026-06-09 10:00:00', '2026-06-09 14:00:00')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试团课预约数据 (昨天的数据)
|
||||
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
||||
(4013, 1001, 1, 3001, '2026-06-08 08:00:00', '2', '2026-06-07 20:00:00', '2026-06-08 08:30:00'),
|
||||
(4014, 1002, 2, 3001, '2026-06-08 08:00:00', '2', '2026-06-07 21:00:00', '2026-06-08 08:30:00'),
|
||||
(4015, 1003, 3, 3002, '2026-06-08 09:30:00', '3', '2026-06-07 22:00:00', '2026-06-08 09:30:00'),
|
||||
(4016, 1004, 4, 3002, '2026-06-08 09:30:00', '2', '2026-06-07 19:00:00', '2026-06-08 09:30:00'),
|
||||
(4017, 1005, 5, 3003, '2026-06-08 14:00:00', '1', '2026-06-07 20:30:00', '2026-06-08 13:00:00'),
|
||||
(4018, 1006, 6, 3003, '2026-06-08 14:00:00', '2', '2026-06-07 21:30:00', '2026-06-08 14:00:00')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入测试团课预约数据 (前天的数据)
|
||||
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
||||
(4019, 1001, 1, 3002, '2026-06-07 09:30:00', '2', '2026-06-06 20:00:00', '2026-06-07 09:30:00'),
|
||||
(4020, 1002, 2, 3002, '2026-06-07 09:30:00', '2', '2026-06-06 21:00:00', '2026-06-07 09:30:00'),
|
||||
(4021, 1003, 3, 3003, '2026-06-07 14:00:00', '2', '2026-06-06 22:00:00', '2026-06-07 14:00:00'),
|
||||
(4022, 1004, 4, 3003, '2026-06-07 14:00:00', '3', '2026-06-06 19:00:00', '2026-06-07 14:00:00')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 预期统计结果 (今日):
|
||||
-- 会员统计:
|
||||
-- 新增会员: 3 (1005, 1006, 1007)
|
||||
-- 活跃会员: 12 (所有会员今日或近期有活动)
|
||||
-- 总会员数: 12
|
||||
-- 签到会员: 12
|
||||
-- 预约会员: 9
|
||||
-- 取消会员: 2
|
||||
|
||||
-- 预约统计:
|
||||
-- 总预约: 12
|
||||
-- 取消: 2
|
||||
-- 出席: 8
|
||||
-- 缺席: 2
|
||||
-- 出席率: 8/10 = 80%
|
||||
-- 取消率: 2/12 = 16.67%
|
||||
|
||||
-- 签到统计:
|
||||
-- 总签到: 12
|
||||
-- 成功: 11
|
||||
-- 失败: 1
|
||||
-- 成功率: 11/12 = 91.67%
|
||||
-- 类型分布: QR_CODE=5, MANUAL=4, FACE=3
|
||||
+2
-1
@@ -60,7 +60,8 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
|
||||
path.equals("/actuator/health") ||
|
||||
path.equals("/api/member/auth/miniapp/login") ||
|
||||
path.equals("/api/member/auth/mp/callback") ||
|
||||
path.equals("/api/auth/login") ||
|
||||
path.equals("/api/auth/login") ||
|
||||
path.startsWith("/api/checkIn/") ||
|
||||
path.startsWith("/actuator/info");
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -61,9 +61,10 @@ public class RbacAuthorizationFilter extends AbstractGatewayFilterFactory<RbacAu
|
||||
}
|
||||
|
||||
private boolean isPublicPath(String path) {
|
||||
return path.startsWith("/api/auth/") ||
|
||||
return path.startsWith("/api/auth/") ||
|
||||
path.equals("/actuator/health") ||
|
||||
path.startsWith("/actuator/info");
|
||||
path.startsWith("/actuator/info") ||
|
||||
path.startsWith("/api/checkIn/");
|
||||
}
|
||||
|
||||
public static class Config {
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@ public class SecurityConfig {
|
||||
.pathMatchers("/api/admin/member/**").permitAll()
|
||||
.pathMatchers("/api/member-cards/**").permitAll()
|
||||
.pathMatchers("/api/member-card-records/**").permitAll()
|
||||
.pathMatchers("/api/member-card-transactions/**").permitAll();
|
||||
.pathMatchers("/**").permitAll()
|
||||
.pathMatchers("/api/member-card-transactions/**").permitAll()
|
||||
.pathMatchers("/api/checkIn/**").permitAll();
|
||||
|
||||
|
||||
if (isDevOrTest) {
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
<module>manage-file</module>
|
||||
<module>gym-member</module>
|
||||
<module>gym-groupCourse</module>
|
||||
<module>gym-checkIn</module>
|
||||
<module>gym-dataCount</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
.hbuilderx/
|
||||
.DS_Store
|
||||
@@ -1,28 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
},
|
||||
onHide: function() {
|
||||
console.log('App Hide')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'common/style/base.css';
|
||||
/*每个页面公共css */
|
||||
.app-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
max-width: 430px;
|
||||
margin: 0 auto;
|
||||
background-color: var(--bg-light);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 环境配置文件
|
||||
* 当前仅使用模拟数据(开发模式)
|
||||
*/
|
||||
|
||||
import { groupCourseMockApi } from './groupCourse.mock.js'
|
||||
|
||||
/**
|
||||
* 团课服务(仅使用模拟数据)
|
||||
*/
|
||||
export const groupCourseService = groupCourseMockApi
|
||||
|
||||
export default groupCourseService
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* 团课模拟数据(测试环境使用)
|
||||
* 数据格式与后端返回格式保持一致
|
||||
*/
|
||||
|
||||
// 模拟团课列表数据(与后端返回格式一致)
|
||||
const mockCourseList = [
|
||||
{
|
||||
"id": "1",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T11:00:00",
|
||||
"updatedAt": "2026-06-01T11:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "极速燃脂单车",
|
||||
"coachId": "104",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-02T16:45:00",
|
||||
"endTime": "2026-06-15T20:20:00",
|
||||
"maxMembers": 25,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "单车房",
|
||||
"coverImage": "https://picsum.photos/seed/spinning/640/360",
|
||||
"description": "跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡。支持次数卡(1次)或储值卡(50元)支付。",
|
||||
"pointCardAmount": 0,
|
||||
"storedValueAmount": 0
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"createBy": "coach_zhang",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T14:30:00",
|
||||
"updatedAt": "2026-06-01T14:30:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "燃脂搏击",
|
||||
"coachId": "102",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-10T18:30:00",
|
||||
"endTime": "2026-06-10T19:30:00",
|
||||
"maxMembers": 20,
|
||||
"currentMembers": 20,
|
||||
"status": "0",
|
||||
"location": "综合训练区",
|
||||
"coverImage": "https://picsum.photos/seed/kickboxing/640/360",
|
||||
"description": "高强度间歇训练,配合音乐快速燃脂,释放压力。名额已满,无法预约。支持次数卡(1次)或储值卡(60元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 60
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"createBy": "coach_li",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T08:00:00",
|
||||
"updatedAt": "2026-06-01T08:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "哈他瑜伽",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-01T15:20:00",
|
||||
"endTime": "2026-06-01T16:50:00",
|
||||
"maxMembers": 12,
|
||||
"currentMembers": 3,
|
||||
"status": "0",
|
||||
"location": "瑜伽教室B",
|
||||
"coverImage": "https://picsum.photos/seed/yoga/640/360",
|
||||
"description": "基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。支持次数卡(1次)或储值卡(40元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 40
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"createBy": "coach_wang",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-05-28T08:00:00",
|
||||
"updatedAt": "2026-05-28T08:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "周末冥想修复",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-20T15:00:00",
|
||||
"endTime": "2026-06-20T16:00:00",
|
||||
"maxMembers": 12,
|
||||
"currentMembers": 3,
|
||||
"status": "1",
|
||||
"location": "冥想室",
|
||||
"coverImage": "https://picsum.photos/seed/meditation/640/360",
|
||||
"description": "通过呼吸和正念冥想,深度放松身心。该课程已被取消。支持次数卡(1次)或储值卡(30元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 30
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"createBy": "coach_li",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-05-20T09:15:00",
|
||||
"updatedAt": "2026-05-20T09:15:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "蜜桃臀塑造",
|
||||
"coachId": "103",
|
||||
"courseType": "3",
|
||||
"startTime": "2026-05-30T19:00:00",
|
||||
"endTime": "2026-05-30T20:00:00",
|
||||
"maxMembers": 10,
|
||||
"currentMembers": 8,
|
||||
"status": "2",
|
||||
"location": "私教专区",
|
||||
"coverImage": "https://picsum.photos/seed/glute/640/360",
|
||||
"description": "针对性训练臀部肌肉群,课程已于5月30日结束,无法预约。支持次数卡(1次)或储值卡(80元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 80
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-05-25T09:00:00",
|
||||
"updatedAt": "2026-05-25T09:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "午间冥想放松",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-05-31T12:00:00",
|
||||
"endTime": "2026-05-31T13:00:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 6,
|
||||
"status": "2",
|
||||
"location": "冥想室",
|
||||
"coverImage": "https://picsum.photos/seed/noonmeditation/640/360",
|
||||
"description": "午间冥想课程,已于5月31日结束。支持次数卡(1次)或储值卡(25元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 25
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "燃脂搏击_次数卡课程",
|
||||
"coachId": "102",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-10T19:30:00",
|
||||
"endTime": "2026-06-10T20:30:00",
|
||||
"maxMembers": 20,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "综合训练区",
|
||||
"coverImage": null,
|
||||
"description": "高强度间歇训练,配合音乐快速燃脂,消耗1次",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "高端普拉提_储值卡课程",
|
||||
"coachId": "103",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-11T19:00:00",
|
||||
"endTime": "2026-06-11T20:00:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "普拉提教室",
|
||||
"coverImage": null,
|
||||
"description": "精准训练核心肌群,消耗储值50元",
|
||||
"pointCardAmount": 0,
|
||||
"storedValueAmount": 20
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-02T10:00:00",
|
||||
"updatedAt": "2026-06-02T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "时间冲突测试_A_13点-15点",
|
||||
"coachId": "102",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-15T13:00:00",
|
||||
"endTime": "2026-06-15T15:00:00",
|
||||
"maxMembers": 20,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "综合训练区",
|
||||
"coverImage": null,
|
||||
"description": "测试用团课A,用于验证时间冲突检测。支持次数卡(1次)或储值卡(50元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-02T10:00:00",
|
||||
"updatedAt": "2026-06-02T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "时间冲突测试_B_14点-16点",
|
||||
"coachId": "103",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-15T14:00:00",
|
||||
"endTime": "2026-06-15T16:00:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "普拉提教室",
|
||||
"coverImage": null,
|
||||
"description": "测试用团课B,与团课A时间重叠(14:00-15:00)。支持次数卡(1次)或储值卡(50元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-02T10:00:00",
|
||||
"updatedAt": "2026-06-02T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "时间冲突测试_C_10点-12点",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-15T10:00:00",
|
||||
"endTime": "2026-06-15T12:00:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "瑜伽教室",
|
||||
"coverImage": null,
|
||||
"description": "测试用团课C,与团课A/B不冲突。支持次数卡(1次)或储值卡(50元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"createBy": "system",
|
||||
"updateBy": "system",
|
||||
"createdAt": "2026-06-02T17:32:50.532336",
|
||||
"updatedAt": "2026-06-02T17:32:50.532336",
|
||||
"deletedAt": null,
|
||||
"courseName": "动感单车aaa",
|
||||
"coachId": "2",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-05T18:00:00",
|
||||
"endTime": "2026-06-05T19:00:00",
|
||||
"maxMembers": 25,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "健身房B区",
|
||||
"coverImage": "https://example.com/spinning.jpg",
|
||||
"description": "高强度有氧运动课程",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-02T17:35:35.155616",
|
||||
"deletedAt": null,
|
||||
"courseName": "清晨流瑜伽",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-12T09:00:00",
|
||||
"endTime": "2026-06-12T10:30:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 6,
|
||||
"status": "0",
|
||||
"location": "A座3楼瑜伽教室",
|
||||
"coverImage": "https://picsum.photos/seed/yogaflow/640/360",
|
||||
"description": "适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。支持次数卡(1次)或储值卡(45元)支付。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 45
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"createBy": "system",
|
||||
"updateBy": "system",
|
||||
"createdAt": "2026-06-02T17:57:27.483488",
|
||||
"updatedAt": "2026-06-02T17:57:27.483488",
|
||||
"deletedAt": null,
|
||||
"courseName": "动感单车",
|
||||
"coachId": "2",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-05T18:00:00",
|
||||
"endTime": "2026-06-05T19:00:00",
|
||||
"maxMembers": 25,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "健身房B区",
|
||||
"coverImage": "https://example.com/spinning.jpg",
|
||||
"description": "高强度有氧运动课程",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 模拟团课API(测试环境)
|
||||
* 接口签名与真实API保持一致
|
||||
*/
|
||||
export const groupCourseMockApi = {
|
||||
/**
|
||||
* 获取团课列表(支持分页)
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {number} params.pageNum - 页码(从1开始)
|
||||
* @param {number} params.pageSize - 每页数量
|
||||
* @returns {Promise} - 分页团课列表数据
|
||||
*/
|
||||
getList: (params = {}) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const pageNum = params.pageNum || 1
|
||||
const pageSize = params.pageSize || 10
|
||||
|
||||
const total = mockCourseList.length
|
||||
const startIndex = (pageNum - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const list = mockCourseList.slice(startIndex, endIndex)
|
||||
|
||||
console.log('[groupCourse.mock.js] 模拟获取团课列表(分页):', {
|
||||
pageNum,
|
||||
pageSize,
|
||||
total,
|
||||
listCount: list.length
|
||||
})
|
||||
|
||||
resolve({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list,
|
||||
total,
|
||||
pageNum,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
})
|
||||
}, 500)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取团课详情
|
||||
* @param {string} id - 课程ID
|
||||
* @returns {Promise} - 团课详情数据
|
||||
*/
|
||||
getDetail: (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
console.log('[groupCourse.mock.js] 模拟获取团课详情:', id)
|
||||
const course = mockCourseList.find(item => item.id === id)
|
||||
if (course) {
|
||||
resolve(course)
|
||||
} else {
|
||||
reject({ code: -1, message: '课程不存在' })
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 预约团课
|
||||
* @param {Object} data - 预约数据
|
||||
* @param {string} data.courseId - 课程ID
|
||||
* @param {string} data.memberId - 会员ID
|
||||
* @returns {Promise} - 预约结果
|
||||
*/
|
||||
book: (data) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log('[groupCourse.mock.js] 模拟预约团课:', data)
|
||||
resolve({
|
||||
code: 0,
|
||||
message: '预约成功',
|
||||
data: { bookingId: `BK${Date.now()}` }
|
||||
})
|
||||
}, 400)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消预约
|
||||
* @param {string} id - 预约记录ID
|
||||
* @returns {Promise} - 取消结果
|
||||
*/
|
||||
cancelBooking: (id) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log('[groupCourse.mock.js] 模拟取消预约:', id)
|
||||
resolve({
|
||||
code: 0,
|
||||
message: '取消成功',
|
||||
data: null
|
||||
})
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default groupCourseMockApi
|
||||
@@ -1,152 +0,0 @@
|
||||
import { request, setToken, clearToken, clearAllCache, clearCache } from '@/utils/request.js'
|
||||
|
||||
// ========== 登录相关API ==========
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
* @param {object} data - 登录参数
|
||||
* @param {string} data.code - 微信登录code
|
||||
* @param {string} [data.encryptedData] - 加密数据
|
||||
* @param {string} [data.iv] - 加密向量
|
||||
* @returns {Promise} 登录结果
|
||||
*/
|
||||
export const login = (data) => {
|
||||
return request({
|
||||
url: '/member/auth/miniapp/login',
|
||||
method: 'POST',
|
||||
data: data,
|
||||
needToken: false // 登录请求不需要token
|
||||
}).then(res => {
|
||||
// 登录成功,保存token
|
||||
if (res.data && res.data.token) {
|
||||
setToken(res.data.token)
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* @returns {Promise} 退出结果
|
||||
*/
|
||||
export const logout = () => {
|
||||
return request({
|
||||
url: '/member/auth/logout',
|
||||
method: 'POST'
|
||||
}).then(res => {
|
||||
// 退出成功,清除token和缓存
|
||||
clearToken()
|
||||
clearAllCache()
|
||||
return res
|
||||
}).catch(err => {
|
||||
// 即使请求失败,也清除本地token
|
||||
clearToken()
|
||||
clearAllCache()
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 签到相关API ==========
|
||||
|
||||
/**
|
||||
* 获取签到二维码
|
||||
* @param {boolean} [cache=true] - 是否启用缓存
|
||||
* @returns {Promise} 二维码数据
|
||||
*/
|
||||
export const getQRCode = (cache = true) => {
|
||||
return request({
|
||||
url: '/checkIn/qrcode',
|
||||
method: 'GET',
|
||||
cache: cache,
|
||||
cacheTime: 5 * 60 * 1000 // 5分钟缓存
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码签到
|
||||
* @param {string} qrContent - 二维码内容
|
||||
* @returns {Promise} 签到结果
|
||||
*/
|
||||
export const checkIn = (qrContent) => {
|
||||
return request({
|
||||
url: '/checkIn/scan',
|
||||
method: 'POST',
|
||||
data: { qrContent }
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 用户相关API ==========
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param {boolean} [cache=true] - 是否启用缓存
|
||||
* @returns {Promise} 用户信息
|
||||
*/
|
||||
export const getUserInfo = (cache = true) => {
|
||||
return request({
|
||||
url: '/member/info',
|
||||
method: 'GET',
|
||||
cache: cache,
|
||||
cacheTime: 30 * 60 * 1000 // 30分钟缓存
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param {object} data - 用户信息
|
||||
* @returns {Promise} 更新结果
|
||||
*/
|
||||
export const updateUserInfo = (data) => {
|
||||
return request({
|
||||
url: '/member/info',
|
||||
method: 'PUT',
|
||||
data: data
|
||||
}).then(res => {
|
||||
// 更新成功,清除用户信息缓存
|
||||
const cacheKey = `GET_/member/info_{}`
|
||||
clearCache(cacheKey)
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 课程相关API ==========
|
||||
|
||||
/**
|
||||
* 获取推荐课程列表
|
||||
* @param {boolean} [cache=true] - 是否启用缓存
|
||||
* @returns {Promise} 课程列表
|
||||
*/
|
||||
export const getRecommendCourses = (cache = true) => {
|
||||
return request({
|
||||
url: '/course/recommend',
|
||||
method: 'GET',
|
||||
cache: cache,
|
||||
cacheTime: 10 * 60 * 1000 // 10分钟缓存
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程详情
|
||||
* @param {number} id - 课程ID
|
||||
* @param {boolean} [cache=true] - 是否启用缓存
|
||||
* @returns {Promise} 课程详情
|
||||
*/
|
||||
export const getCourseDetail = (id, cache = true) => {
|
||||
return request({
|
||||
url: `/course/${id}`,
|
||||
method: 'GET',
|
||||
cache: cache,
|
||||
cacheTime: 15 * 60 * 1000 // 15分钟缓存
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
login,
|
||||
logout,
|
||||
getQRCode,
|
||||
checkIn,
|
||||
getUserInfo,
|
||||
updateUserInfo,
|
||||
getRecommendCourses,
|
||||
getCourseDetail
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
/** 与 pages.json 保持一致 */
|
||||
export const PAGE = {
|
||||
INDEX: '/pages/index/index',
|
||||
COURSE: '/pages/course/index',
|
||||
TRAIN: '/pages/train/index',
|
||||
DISCOVER: '/pages/discover/index',
|
||||
MEMBER: '/pages/memberInfo/memberInfo',
|
||||
BOOKING: '/pages/memberInfo/booking',
|
||||
MEMBER_CARD: '/pages/memberInfo/memberCard',
|
||||
USER_INFO: '/pages/memberInfo/userInfo',
|
||||
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
|
||||
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
|
||||
BODY_TEST_MEASURING: '/pages/memberInfo/bodyTestMeasuring',
|
||||
BODY_TEST_REPORT: '/pages/memberInfo/bodyTestReport',
|
||||
BODY_TEST_HISTORY: '/pages/memberInfo/bodyTestHistory',
|
||||
BODY_TEST_COMPARE: '/pages/memberInfo/bodyTestCompare',
|
||||
BODY_TEST_SETTINGS: '/pages/memberInfo/bodyTestSettings',
|
||||
BODY_TEST_TREND: '/pages/memberInfo/bodyTestTrend',
|
||||
COURSE_LIST: '/pages/memberInfo/courseList',
|
||||
COURSE_DETAIL: '/pages/memberInfo/courseDetail',
|
||||
COUPON_DETAIL: '/pages/memberInfo/couponDetail',
|
||||
COUPON_CENTER: '/pages/memberInfo/couponCenter',
|
||||
POINTS_MALL: '/pages/memberInfo/pointsMall',
|
||||
POINTS_HISTORY: '/pages/memberInfo/pointsHistory',
|
||||
ONLINE_COURSE: '/pages/memberInfo/onlineCourseDetail',
|
||||
COURSE_EVALUATE: '/pages/memberInfo/courseEvaluate',
|
||||
TRAIN_SESSION: '/pages/memberInfo/trainSessionDetail',
|
||||
TRAIN_REPORT: '/pages/memberInfo/trainReport',
|
||||
COUPONS: '/pages/memberInfo/coupons',
|
||||
POINTS: '/pages/memberInfo/points',
|
||||
REFERRAL: '/pages/memberInfo/referral',
|
||||
MY_COURSES: '/pages/memberInfo/myCourses',
|
||||
CHECK_IN_HISTORY: '/pages/memberInfo/checkInHistory'
|
||||
}
|
||||
|
||||
/** 底部 TabBar 页面路径,顺序与 TabBar.vue 一致 */
|
||||
export const TAB_ROUTES = [
|
||||
PAGE.INDEX,
|
||||
PAGE.COURSE,
|
||||
PAGE.TRAIN,
|
||||
PAGE.DISCOVER,
|
||||
PAGE.MEMBER
|
||||
]
|
||||
|
||||
const TAB_PAGES = new Set(TAB_ROUTES)
|
||||
|
||||
/** 防止 Tab 连点触发并发路由 */
|
||||
let tabNavigating = false
|
||||
|
||||
function normalizePath(url) {
|
||||
if (!url) return ''
|
||||
const path = url.split('?')[0]
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
export function getTabIndexByRoute(route) {
|
||||
const path = normalizePath(route)
|
||||
const idx = TAB_ROUTES.indexOf(path)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
export function getCurrentRoutePath() {
|
||||
const pages = getCurrentPages()
|
||||
if (!pages.length) return PAGE.INDEX
|
||||
const page = pages[pages.length - 1]
|
||||
const route = page.route || page.$page?.fullPath || ''
|
||||
return normalizePath(route ? `/${route}` : PAGE.INDEX)
|
||||
}
|
||||
|
||||
export function navigateToPage(url) {
|
||||
const path = normalizePath(url)
|
||||
if (TAB_PAGES.has(path)) {
|
||||
switchToTab(path)
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url,
|
||||
fail: (err) => {
|
||||
console.error('[navigateTo]', url, err)
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function switchToTab(url) {
|
||||
const path = normalizePath(url)
|
||||
if (getCurrentRoutePath() === path || tabNavigating) return
|
||||
|
||||
tabNavigating = true
|
||||
const done = () => {
|
||||
setTimeout(() => {
|
||||
tabNavigating = false
|
||||
}, 320)
|
||||
}
|
||||
|
||||
uni.switchTab({
|
||||
url: path,
|
||||
complete: done,
|
||||
fail: (err) => {
|
||||
console.warn('[switchTab]', path, err)
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function goToMemberCenter() {
|
||||
switchToTab(PAGE.MEMBER)
|
||||
}
|
||||
|
||||
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => switchToTab(fallbackUrl)
|
||||
})
|
||||
}
|
||||
|
||||
/** 子页面返回:统一回到 tab 页「个人中心」 */
|
||||
export function backToMemberCenter() {
|
||||
switchToTab(PAGE.MEMBER)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
const COLORS = {
|
||||
primary: '#0B2B4B',
|
||||
accent: '#FF6B35',
|
||||
accentLight: 'rgba(255, 107, 53, 0.25)',
|
||||
grid: '#E9EDF2',
|
||||
text: '#5E6F8D',
|
||||
fill: 'rgba(26, 74, 111, 0.35)',
|
||||
line: '#1A4A6F'
|
||||
}
|
||||
|
||||
function setupCanvas(canvas, width, height, dpr) {
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** 绘制雷达图 */
|
||||
export function drawRadarChart(canvas, opts = {}) {
|
||||
if (!canvas) return
|
||||
const {
|
||||
width = 280,
|
||||
height = 240,
|
||||
labels = [],
|
||||
values = [],
|
||||
dpr = 1
|
||||
} = opts
|
||||
const ctx = setupCanvas(canvas, width, height, dpr)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const cx = width / 2
|
||||
const cy = height / 2 + 8
|
||||
const radius = Math.min(width, height) * 0.32
|
||||
const count = labels.length || 6
|
||||
const angleStep = (Math.PI * 2) / count
|
||||
|
||||
for (let level = 1; level <= 4; level += 1) {
|
||||
ctx.beginPath()
|
||||
const r = (radius * level) / 4
|
||||
for (let i = 0; i <= count; i += 1) {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + r * Math.cos(angle)
|
||||
const y = cy + r * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle))
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
values.forEach((val, i) => {
|
||||
const ratio = Math.min(1, Math.max(0, val / 100))
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + radius * ratio * Math.cos(angle)
|
||||
const y = cy + radius * ratio * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = COLORS.fill
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = COLORS.accent
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.textAlign = 'center'
|
||||
labels.forEach((label, i) => {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + (radius + 18) * Math.cos(angle)
|
||||
const y = cy + (radius + 18) * Math.sin(angle) + 4
|
||||
ctx.fillText(label, x, y)
|
||||
})
|
||||
}
|
||||
|
||||
/** 绘制折线趋势图 */
|
||||
export function drawTrendChart(canvas, opts = {}) {
|
||||
if (!canvas) return
|
||||
const {
|
||||
width = 300,
|
||||
height = 160,
|
||||
points = [],
|
||||
dpr = 1,
|
||||
unit = ''
|
||||
} = opts
|
||||
const ctx = setupCanvas(canvas, width, height, dpr)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('暂无趋势数据', width / 2, height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
const pad = { top: 16, right: 12, bottom: 28, left: 12 }
|
||||
const chartW = width - pad.left - pad.right
|
||||
const chartH = height - pad.top - pad.bottom
|
||||
const values = points.map((p) => p.value)
|
||||
const min = Math.min(...values) * 0.95
|
||||
const max = Math.max(...values) * 1.05
|
||||
const range = max - min || 1
|
||||
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i <= 3; i += 1) {
|
||||
const y = pad.top + (chartH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(width - pad.right, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const coords = points.map((p, i) => ({
|
||||
x: pad.left + (chartW * i) / Math.max(1, points.length - 1),
|
||||
y: pad.top + chartH - ((p.value - min) / range) * chartH
|
||||
}))
|
||||
|
||||
ctx.beginPath()
|
||||
coords.forEach((pt, i) => {
|
||||
if (i === 0) ctx.moveTo(pt.x, pt.y)
|
||||
else ctx.lineTo(pt.x, pt.y)
|
||||
})
|
||||
ctx.strokeStyle = COLORS.line
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
coords.forEach((pt, i) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = COLORS.accent
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(points[i].label, pt.x, height - 8)
|
||||
})
|
||||
|
||||
if (unit) {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(unit, pad.left, pad.top - 2)
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { bodyTestMock } from './mockData.js'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function formatRecordTime(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${min}`
|
||||
}
|
||||
|
||||
export function getDefaultBodyTestState() {
|
||||
return {
|
||||
settings: { ...bodyTestMock.settings },
|
||||
device: { ...bodyTestMock.device },
|
||||
records: clone(bodyTestMock.records)
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeBodyTestState(saved) {
|
||||
const defaults = getDefaultBodyTestState()
|
||||
if (!saved) return defaults
|
||||
return {
|
||||
settings: { ...defaults.settings, ...(saved.settings || {}) },
|
||||
device: { ...defaults.device, ...(saved.device || {}) },
|
||||
records: saved.records?.length ? saved.records : defaults.records
|
||||
}
|
||||
}
|
||||
|
||||
export function getLatestBodyTestRecord(store) {
|
||||
const records = store.bodyTest?.records || []
|
||||
return records.length ? { ...records[0] } : null
|
||||
}
|
||||
|
||||
export function getBodyTestRecordById(store, id) {
|
||||
const numId = Number(id)
|
||||
const record = (store.bodyTest?.records || []).find((item) => item.id === numId)
|
||||
return record ? { ...record } : null
|
||||
}
|
||||
|
||||
export function getBodyTestHistory(store, year) {
|
||||
let list = (store.bodyTest?.records || []).map((item) => ({ ...item }))
|
||||
if (year && year !== 'all') {
|
||||
list = list.filter((r) => r.date.startsWith(String(year)))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function getBodyTestChangeBadge(record, previous) {
|
||||
if (!previous?.metrics || !record?.metrics) return null
|
||||
const diff = Math.round((record.metrics.bodyFat - previous.metrics.bodyFat) * 10) / 10
|
||||
if (diff === 0) return null
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
return { key: 'bodyFat', text: `体脂率${sign}${diff}%`, good: diff < 0 }
|
||||
}
|
||||
|
||||
export function getBodyTestYears(store) {
|
||||
const years = new Set((store.bodyTest?.records || []).map((r) => r.date.slice(0, 4)))
|
||||
return ['all', ...Array.from(years).sort().reverse()]
|
||||
}
|
||||
|
||||
export function computeGrade(score) {
|
||||
if (score >= 90) return { grade: 'A', gradeLabel: '优秀' }
|
||||
if (score >= 80) return { grade: 'B+', gradeLabel: '良好' }
|
||||
if (score >= 70) return { grade: 'B', gradeLabel: '中等' }
|
||||
if (score >= 60) return { grade: 'C', gradeLabel: '一般' }
|
||||
return { grade: 'D', gradeLabel: '需改善' }
|
||||
}
|
||||
|
||||
export function computeScore(metrics) {
|
||||
const bmi = metrics.bmi || 22
|
||||
const bodyFat = metrics.bodyFat || 25
|
||||
const muscle = metrics.muscleMass || 22
|
||||
const bmiScore = bmi >= 18.5 && bmi <= 24 ? 90 : bmi >= 17 && bmi <= 27 ? 75 : 60
|
||||
const fatScore = bodyFat <= 22 ? 92 : bodyFat <= 26 ? 80 : bodyFat <= 30 ? 68 : 55
|
||||
const muscleScore = muscle >= 22 ? 88 : muscle >= 20 ? 76 : 62
|
||||
return Math.round((bmiScore + fatScore + muscleScore) / 3)
|
||||
}
|
||||
|
||||
export function computeChanges(current, previous) {
|
||||
if (!previous?.metrics) return {}
|
||||
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr', 'bodyWater', 'boneMass']
|
||||
const changes = {}
|
||||
keys.forEach((key) => {
|
||||
const cur = Number(current.metrics[key])
|
||||
const prev = Number(previous.metrics[key])
|
||||
if (Number.isFinite(cur) && Number.isFinite(prev)) {
|
||||
const diff = Math.round((cur - prev) * 10) / 10
|
||||
changes[key] = diff
|
||||
}
|
||||
})
|
||||
return changes
|
||||
}
|
||||
|
||||
export function formatChangeValue(key, diff, unitSystem = 'metric') {
|
||||
if (diff === undefined || diff === null) return '--'
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
const units = {
|
||||
weight: unitSystem === 'metric' ? 'kg' : 'lb',
|
||||
bodyFat: '%',
|
||||
muscleMass: 'kg',
|
||||
bmi: '',
|
||||
visceralFat: '级',
|
||||
bmr: 'kcal',
|
||||
bodyWater: '%',
|
||||
boneMass: 'kg'
|
||||
}
|
||||
const unit = units[key] || ''
|
||||
return `${sign}${diff}${unit}`
|
||||
}
|
||||
|
||||
export function buildBodyReportSummary(record, previous) {
|
||||
if (!record) {
|
||||
return {
|
||||
date: '--',
|
||||
weight: '--',
|
||||
bmi: '--',
|
||||
bodyFat: '--',
|
||||
bmr: '--',
|
||||
status: '暂无数据',
|
||||
change: '--'
|
||||
}
|
||||
}
|
||||
const changes = computeChanges(record, previous)
|
||||
const weightChange = changes.weight
|
||||
let changeText = '--'
|
||||
if (weightChange !== undefined) {
|
||||
const sign = weightChange > 0 ? '+' : ''
|
||||
changeText = `${sign}${weightChange}kg`
|
||||
}
|
||||
return {
|
||||
date: record.date,
|
||||
weight: String(record.metrics.weight),
|
||||
bmi: String(record.metrics.bmi),
|
||||
bodyFat: `${record.metrics.bodyFat}%`,
|
||||
bmr: String(record.metrics.bmr),
|
||||
status: record.status,
|
||||
change: changeText,
|
||||
recordId: record.id
|
||||
}
|
||||
}
|
||||
|
||||
export function getBodyTestTrendData(store, metricKey, limit = 6) {
|
||||
const records = [...(store.bodyTest?.records || [])].reverse().slice(-limit)
|
||||
return records.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
label: item.date.slice(5),
|
||||
value: Number(item.metrics[metricKey]) || 0
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCompareData(store, idA, idB) {
|
||||
const a = getBodyTestRecordById(store, idA)
|
||||
const b = getBodyTestRecordById(store, idB)
|
||||
if (!a || !b) return null
|
||||
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
|
||||
const metrics = keys.map((key) => ({
|
||||
key,
|
||||
label: bodyTestMock.metricDefs.find((m) => m.key === key)?.label || key,
|
||||
valueA: a.metrics[key],
|
||||
valueB: b.metrics[key],
|
||||
diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10
|
||||
}))
|
||||
return { recordA: a, recordB: b, metrics }
|
||||
}
|
||||
|
||||
export function getRecommendedCourses(record) {
|
||||
const ids = record?.recommendedCourseIds || []
|
||||
return bodyTestMock.recommendedCourses.filter((c) => ids.includes(c.id))
|
||||
}
|
||||
|
||||
export function updateBodyTestSettings(store, patch) {
|
||||
store.bodyTest.settings = { ...store.bodyTest.settings, ...patch }
|
||||
return store
|
||||
}
|
||||
|
||||
export function connectBodyTestDevice(store) {
|
||||
store.bodyTest.device = {
|
||||
...store.bodyTest.device,
|
||||
connected: true,
|
||||
battery: Math.min(100, (store.bodyTest.device.battery || 80) + Math.floor(Math.random() * 5)),
|
||||
lastConnected: formatRecordTime(new Date())
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
export function disconnectBodyTestDevice(store) {
|
||||
store.bodyTest.device = { ...store.bodyTest.device, connected: false }
|
||||
return store
|
||||
}
|
||||
|
||||
function nextRecordId(records) {
|
||||
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
|
||||
}
|
||||
|
||||
/** 模拟一次完整体测并写入记录 */
|
||||
export function saveSimulatedBodyTestRecord(store, finalMetrics) {
|
||||
const now = new Date()
|
||||
const previous = getLatestBodyTestRecord(store)
|
||||
const metrics = { ...finalMetrics }
|
||||
const heightCm = Number(store.profile?.height) || 165
|
||||
const heightM = heightCm / 100
|
||||
metrics.bmi = Math.round((metrics.weight / (heightM * heightM)) * 10) / 10
|
||||
|
||||
const score = computeScore(metrics)
|
||||
const { grade, gradeLabel } = computeGrade(score)
|
||||
const status = score >= 80 ? '比较健康' : score >= 70 ? '需关注' : '建议改善'
|
||||
|
||||
const radar = {
|
||||
weight: Math.min(95, Math.round(score * 0.9 + Math.random() * 5)),
|
||||
bodyFat: Math.min(95, Math.round(100 - metrics.bodyFat * 2.5)),
|
||||
muscle: Math.min(95, Math.round(metrics.muscleMass * 3.2)),
|
||||
bone: Math.min(95, Math.round(metrics.boneMass * 32)),
|
||||
water: Math.min(95, Math.round(metrics.bodyWater * 1.4)),
|
||||
bmr: Math.min(95, Math.round(metrics.bmr / 16))
|
||||
}
|
||||
|
||||
const record = {
|
||||
id: nextRecordId(store.bodyTest.records),
|
||||
date: formatIsoDate(now),
|
||||
time: formatTime(now),
|
||||
score,
|
||||
grade,
|
||||
gradeLabel,
|
||||
status,
|
||||
metrics,
|
||||
radar,
|
||||
bodySegments: clone(bodyTestMock.records[0].bodySegments),
|
||||
advice: clone(bodyTestMock.records[0].advice),
|
||||
recommendedCourseIds: [1, 2]
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
record.changes = computeChanges(record, previous)
|
||||
}
|
||||
|
||||
store.bodyTest.records.unshift(record)
|
||||
store.bodyReport = buildBodyReportSummary(record, previous)
|
||||
return record
|
||||
}
|
||||
|
||||
/** 测量过程实时数据插值 */
|
||||
export function interpolateMeasuringMetrics(progress, profile) {
|
||||
const baseWeight = Number(profile?.weight) || 64
|
||||
const target = {
|
||||
weight: baseWeight - 0.3 + Math.random() * 0.2,
|
||||
bodyFat: 24.5 + Math.random() * 0.8,
|
||||
muscleMass: 22.4 + Math.random() * 0.3,
|
||||
visceralFat: 6,
|
||||
bmr: 1380 + Math.floor(Math.random() * 20),
|
||||
bodyWater: 52.5 + Math.random(),
|
||||
boneMass: 2.4,
|
||||
protein: 16.2
|
||||
}
|
||||
const ratio = Math.min(1, progress / 100)
|
||||
return {
|
||||
weight: Math.round((baseWeight + (target.weight - baseWeight) * ratio) * 10) / 10,
|
||||
bodyFat: Math.round((26 + (target.bodyFat - 26) * ratio) * 10) / 10,
|
||||
muscleMass: Math.round((21.5 + (target.muscleMass - 21.5) * ratio) * 10) / 10,
|
||||
bmr: Math.round(1340 + (target.bmr - 1340) * ratio),
|
||||
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
|
||||
}
|
||||
}
|
||||
|
||||
export { bodyTestMock }
|
||||
@@ -1,127 +0,0 @@
|
||||
import { courseCatalogMock } from './mockData.js'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function getDefaultCourseCatalog() {
|
||||
return clone(courseCatalogMock.courses)
|
||||
}
|
||||
|
||||
export function mergeCourseCatalog(saved) {
|
||||
const defaults = getDefaultCourseCatalog()
|
||||
if (!saved?.length) return defaults
|
||||
return saved.map((item, i) => ({ ...defaults[i], ...item }))
|
||||
}
|
||||
|
||||
function parseCourseStart(course) {
|
||||
const str = `${course.date} ${course.startTime}`.replace(/-/g, '/')
|
||||
return new Date(str)
|
||||
}
|
||||
|
||||
function getPeriod(hour) {
|
||||
if (hour < 12) return 'morning'
|
||||
if (hour < 18) return 'afternoon'
|
||||
return 'evening'
|
||||
}
|
||||
|
||||
export function filterCourses(courses, filters = {}) {
|
||||
const {
|
||||
date = '',
|
||||
weekDates = [],
|
||||
type = 'all',
|
||||
coach = '全部',
|
||||
period = 'all'
|
||||
} = filters
|
||||
|
||||
return courses.filter((c) => {
|
||||
if (type !== 'all' && c.type !== type) return false
|
||||
if (coach !== '全部' && c.coach !== coach) return false
|
||||
if (period !== 'all' && c.period !== period) return false
|
||||
if (date && c.date !== date) {
|
||||
if (!weekDates.length || !weekDates.includes(c.date)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function getCourseById(store, id) {
|
||||
const course = (store.courseCatalog || []).find((c) => c.id === Number(id))
|
||||
return course ? { ...course } : null
|
||||
}
|
||||
|
||||
export function canCancelBooking(item) {
|
||||
if (!item?.courseDate || !item?.startTime) return !!item?.canCancel
|
||||
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
|
||||
const diff = start - Date.now()
|
||||
return diff >= 2 * 3600000
|
||||
}
|
||||
|
||||
export function bookCourse(store, courseId) {
|
||||
const course = store.courseCatalog.find((c) => c.id === Number(courseId))
|
||||
if (!course) return { ok: false, message: '课程不存在' }
|
||||
if (course.enrolled >= course.capacity) return { ok: false, message: '课程已约满' }
|
||||
const exists = store.ongoingBookings.some((b) => b.courseId === course.id)
|
||||
if (exists) return { ok: false, message: '您已预约该课程' }
|
||||
|
||||
course.enrolled += 1
|
||||
const nextId = store.ongoingBookings.reduce((m, b) => Math.max(m, b.id || 0), 0) + 1
|
||||
const parts = course.date.split('-')
|
||||
const booking = {
|
||||
id: nextId,
|
||||
courseId: course.id,
|
||||
title: course.title,
|
||||
banner: course.banner,
|
||||
status: 'booked',
|
||||
statusLabel: '已预约',
|
||||
schedule: `${parts[1]}月${parts[2]}日 ${course.startTime}-${course.endTime}`,
|
||||
dateDay: parts[2],
|
||||
dateMonth: `月${parts[2]}日`,
|
||||
timeRange: `${course.startTime}-${course.endTime}`,
|
||||
courseDate: course.date,
|
||||
startTime: course.startTime,
|
||||
coach: course.coach,
|
||||
coachShort: course.coach.replace('教练', ''),
|
||||
location: course.location,
|
||||
footerText: `可取消(需提前2小时,截止 ${parts[1]}/${parts[2]} ${course.startTime} 前2小时)`,
|
||||
canCancel: true,
|
||||
type: course.type
|
||||
}
|
||||
store.ongoingBookings.unshift(booking)
|
||||
return { ok: true, message: '预约成功', booking }
|
||||
}
|
||||
|
||||
export function getWeekDates(baseDateStr) {
|
||||
const base = baseDateStr ? new Date(baseDateStr.replace(/-/g, '/')) : new Date()
|
||||
const day = base.getDay() || 7
|
||||
const monday = new Date(base)
|
||||
monday.setDate(base.getDate() - day + 1)
|
||||
const dates = []
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(monday)
|
||||
d.setDate(monday.getDate() + i)
|
||||
dates.push(formatIso(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
function formatIso(d) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export function enrichCourseForDisplay(course) {
|
||||
const remaining = course.capacity - course.enrolled
|
||||
const percent = Math.round((course.enrolled / course.capacity) * 100)
|
||||
return {
|
||||
...course,
|
||||
remaining,
|
||||
percent,
|
||||
full: remaining <= 0,
|
||||
scarcityLabel: remaining > 0 && remaining <= 5 ? `仅剩${remaining}席` : ''
|
||||
}
|
||||
}
|
||||
|
||||
export { courseCatalogMock }
|
||||
@@ -1,37 +0,0 @@
|
||||
/** 手机号展示脱敏(中间四位 ****) */
|
||||
|
||||
export function maskPhone(phone) {
|
||||
if (phone == null || phone === '') return ''
|
||||
|
||||
const str = String(phone).trim()
|
||||
if (str.includes('****')) return str
|
||||
|
||||
const digits = str.replace(/\D/g, '')
|
||||
if (digits.length === 11) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(7)}`
|
||||
}
|
||||
if (digits.length > 4) {
|
||||
const hideLen = Math.min(4, digits.length - 3)
|
||||
const start = Math.floor((digits.length - hideLen) / 2)
|
||||
return `${digits.slice(0, start)}${'*'.repeat(hideLen)}${digits.slice(start + hideLen)}`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/** 个人中心头部:138****6789 已绑定微信 */
|
||||
export function formatMemberCenterPhone(phone) {
|
||||
const masked = maskPhone(phone)
|
||||
return masked ? `${masked} 已绑定微信` : ''
|
||||
}
|
||||
|
||||
/** 保存前规范化:尽量存 11 位数字;已是脱敏串则原样保留 */
|
||||
export function normalizePhoneForStore(phone) {
|
||||
const str = String(phone || '').trim()
|
||||
if (!str) return ''
|
||||
if (str.includes('****')) return str
|
||||
|
||||
const digits = str.replace(/\D/g, '')
|
||||
if (digits.length >= 11) return digits.slice(0, 11)
|
||||
return digits || str
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
export { memberCenterMock, userInfoMock, fitnessGoalOptions, bookingMock, memberCardMock, bodyTestMock, moduleMock, courseCatalogMock } from './mockData.js'
|
||||
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
|
||||
export {
|
||||
loadMemberStore,
|
||||
saveMemberStore,
|
||||
persistMemberStore,
|
||||
syncStats,
|
||||
computeRemainingDays,
|
||||
buildCardTip,
|
||||
formatUpcomingAlert,
|
||||
getBookingPreview,
|
||||
getCenterPageData,
|
||||
cancelOngoingBooking,
|
||||
renewMemberCard,
|
||||
parseLocalDate,
|
||||
saveUserProfile
|
||||
} from './store.js'
|
||||
export {
|
||||
getLatestBodyTestRecord,
|
||||
getBodyTestRecordById,
|
||||
getBodyTestHistory,
|
||||
computeChanges,
|
||||
formatChangeValue,
|
||||
buildBodyReportSummary,
|
||||
getBodyTestTrendData,
|
||||
getCompareData,
|
||||
getRecommendedCourses,
|
||||
getBodyTestChangeBadge,
|
||||
getBodyTestYears,
|
||||
updateBodyTestSettings,
|
||||
connectBodyTestDevice,
|
||||
disconnectBodyTestDevice,
|
||||
saveSimulatedBodyTestRecord,
|
||||
interpolateMeasuringMetrics,
|
||||
bodyTestMock
|
||||
} from './bodyTestStore.js'
|
||||
export {
|
||||
getTrainingReportData,
|
||||
getTrainingSessionById,
|
||||
filterTrainingSessions,
|
||||
getCouponsByStatus,
|
||||
getCouponById,
|
||||
useCoupon,
|
||||
deleteExpiredCoupon,
|
||||
getCouponCenterList,
|
||||
claimCouponFromCenter,
|
||||
getPointsPageData,
|
||||
redeemPointsReward,
|
||||
filterPointsHistory,
|
||||
getReferralPageData,
|
||||
getMyCoursesData,
|
||||
getMyCoursesByTab,
|
||||
getOnlineCourseById,
|
||||
updateOnlineProgress,
|
||||
getCheckInHistory,
|
||||
moduleMock
|
||||
} from './moduleStore.js'
|
||||
export {
|
||||
filterCourses,
|
||||
getCourseById,
|
||||
bookCourse,
|
||||
canCancelBooking,
|
||||
enrichCourseForDisplay,
|
||||
getWeekDates,
|
||||
courseCatalogMock
|
||||
} from './bookingStore.js'
|
||||
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
|
||||
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
|
||||
export {
|
||||
validateName,
|
||||
validatePhone,
|
||||
validatePhoneForRebind,
|
||||
validateHeight,
|
||||
validateWeight,
|
||||
validateBirthday,
|
||||
validateFitnessGoals,
|
||||
validateUserProfile,
|
||||
showValidationError
|
||||
} from './validate.js'
|
||||
@@ -1,159 +0,0 @@
|
||||
/** 头像等媒体:真机选图后须 saveFile,/static/ 须 getImageInfo */
|
||||
|
||||
function buildStaticPathCandidates(url) {
|
||||
const list = [url]
|
||||
if (url.startsWith('/')) {
|
||||
list.push(url.slice(1))
|
||||
} else {
|
||||
list.push(`/${url}`)
|
||||
}
|
||||
return [...new Set(list.filter(Boolean))]
|
||||
}
|
||||
|
||||
function isPackageStaticPath(url) {
|
||||
return /^(\/)?static\//i.test(url)
|
||||
}
|
||||
|
||||
/** chooseImage / saveFile 产生的本地路径(含真机 temp、usr、store) */
|
||||
export function isLocalFilePath(url) {
|
||||
if (!url) return false
|
||||
if (/^(wxfile:|file:|blob:|data:)/i.test(url)) return true
|
||||
if (/^https?:\/\/(tmp|usr|store)\//i.test(url)) return true
|
||||
if (/^https?:\/\//i.test(url) && !isPackageStaticPath(url)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function showPreviewFail() {
|
||||
uni.showToast({ title: '无法预览头像', icon: 'none' })
|
||||
}
|
||||
|
||||
function openPreview(path, onFail) {
|
||||
if (!path) {
|
||||
;(onFail || showPreviewFail)()
|
||||
return
|
||||
}
|
||||
uni.previewImage({
|
||||
urls: [path],
|
||||
current: path,
|
||||
fail: () => (onFail ? onFail() : showPreviewFail())
|
||||
})
|
||||
}
|
||||
|
||||
function previewLocalFile(url) {
|
||||
openPreview(url, () => {
|
||||
uni.getImageInfo({
|
||||
src: url,
|
||||
success: (res) => {
|
||||
openPreview(res.path || url, showPreviewFail)
|
||||
},
|
||||
fail: showPreviewFail
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function tryGetImageInfo(candidates, index, onSuccess, onFail) {
|
||||
if (index >= candidates.length) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
uni.getImageInfo({
|
||||
src: candidates[index],
|
||||
success: (res) => onSuccess(res.path || candidates[index]),
|
||||
fail: () => tryGetImageInfo(candidates, index + 1, onSuccess, onFail)
|
||||
})
|
||||
}
|
||||
|
||||
function getMpUserDataPath() {
|
||||
// #ifdef MP-WEIXIN
|
||||
return wx.env.USER_DATA_PATH
|
||||
// #endif
|
||||
return ''
|
||||
}
|
||||
|
||||
function tryCopyFile(candidates, index, onSuccess, onFail) {
|
||||
// #ifdef MP-WEIXIN
|
||||
const userPath = getMpUserDataPath()
|
||||
if (!userPath) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
const fs = uni.getFileSystemManager()
|
||||
const extMatch = candidates[0]?.match(/\.(\w+)(?:\?|$)/)
|
||||
const ext = extMatch ? extMatch[1] : 'png'
|
||||
const dest = `${userPath}/avatar_preview_${Date.now()}.${ext}`
|
||||
|
||||
if (index >= candidates.length) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
|
||||
fs.copyFile({
|
||||
srcPath: candidates[index],
|
||||
destPath: dest,
|
||||
success: () => onSuccess(dest),
|
||||
fail: () => tryCopyFile(candidates, index + 1, onSuccess, onFail)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
onFail()
|
||||
// #endif
|
||||
}
|
||||
|
||||
function previewPackageStatic(url) {
|
||||
const candidates = buildStaticPathCandidates(url)
|
||||
tryGetImageInfo(
|
||||
candidates,
|
||||
0,
|
||||
(path) => openPreview(path, showPreviewFail),
|
||||
() => {
|
||||
tryCopyFile(
|
||||
candidates,
|
||||
0,
|
||||
(path) => openPreview(path, showPreviewFail),
|
||||
showPreviewFail
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 选图后将临时文件转为真机可预览、可持久化的本地路径 */
|
||||
export function persistChosenImage(tempPath) {
|
||||
return new Promise((resolve) => {
|
||||
const path = String(tempPath || '').trim()
|
||||
if (!path) {
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.saveFile({
|
||||
tempFilePath: path,
|
||||
success: (res) => resolve(res.savedFilePath || path),
|
||||
fail: () => resolve(path)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
resolve(path)
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
export function previewImage(src, fallback = '') {
|
||||
const url = String(src || fallback || '').trim()
|
||||
if (!url) {
|
||||
uni.showToast({ title: '暂无头像', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (isLocalFilePath(url)) {
|
||||
previewLocalFile(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPackageStaticPath(url)) {
|
||||
previewPackageStatic(url)
|
||||
return
|
||||
}
|
||||
|
||||
previewLocalFile(url)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { backToMemberCenter } from '../constants/routes.js'
|
||||
|
||||
/** 状态栏时间(Pixso 顶栏占位) */
|
||||
export const statusBarTimeMixin = {
|
||||
data() {
|
||||
return {
|
||||
statusBarTime: '9:41'
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.updateStatusBarTime()
|
||||
},
|
||||
methods: {
|
||||
updateStatusBarTime() {
|
||||
const now = new Date()
|
||||
this.statusBarTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 子页面返回个人中心 tab */
|
||||
export const subPageMixin = {
|
||||
methods: {
|
||||
goBack() {
|
||||
backToMemberCenter()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,884 +0,0 @@
|
||||
/** 个人中心模块 mock 数据(后续可替换为 API) */
|
||||
|
||||
export const memberCenterMock = {
|
||||
userInfo: {
|
||||
name: '张小芳',
|
||||
phone: '13812345678 已绑定微信',
|
||||
memberLevel: '黄金会员',
|
||||
avatar: '/static/images/AvatarEditWrap.png'
|
||||
},
|
||||
stats: {
|
||||
checkInCount: 128,
|
||||
trainingHours: 23,
|
||||
pointsBalance: 1250
|
||||
},
|
||||
cardInfo: {
|
||||
name: '健身时长卡',
|
||||
detailTag: '详情',
|
||||
expireDate: '有效期至 2025年12月31日',
|
||||
remainingDays: 187,
|
||||
tip: '距离下次到期还有187天,请及时续费'
|
||||
},
|
||||
checkIns: [
|
||||
{
|
||||
id: 1,
|
||||
title: '今日签到 · 瑜伽初级班',
|
||||
time: '2024-07-12 09:05',
|
||||
tag: '团课',
|
||||
tagTheme: 'group'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-11 18:30',
|
||||
tag: '自由',
|
||||
tagTheme: 'free'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教课 · 力量训练',
|
||||
time: '2024-07-10 14:00',
|
||||
tag: '私教',
|
||||
tagTheme: 'private'
|
||||
}
|
||||
],
|
||||
bodyReport: {
|
||||
date: '2024-07-01',
|
||||
weight: '63.5',
|
||||
bmi: '22.1',
|
||||
bodyFat: '24.8%',
|
||||
bmr: '165',
|
||||
status: '比较健康',
|
||||
change: '-1.2kg'
|
||||
},
|
||||
couponPoints: {
|
||||
amount: '¥50',
|
||||
couponDesc: '满500可用 · 1张',
|
||||
couponAction: '去使用',
|
||||
points: 1250,
|
||||
pointsLabel: '我的积分',
|
||||
pointsAction: '去兑换'
|
||||
},
|
||||
referral: {
|
||||
code: 'FIT-ZXF-2024',
|
||||
invited: 5,
|
||||
registered: 3,
|
||||
purchased: 2
|
||||
}
|
||||
}
|
||||
|
||||
export const userInfoMock = {
|
||||
name: '张小芳',
|
||||
phone: '13812345678',
|
||||
gender: 'female',
|
||||
birthday: '1995年06月15日',
|
||||
height: '165',
|
||||
weight: '63.5',
|
||||
fitnessGoals: ['减脂', '塑形'],
|
||||
avatar: '/static/images/AvatarEditWrap.png'
|
||||
}
|
||||
|
||||
export const fitnessGoalOptions = ['减脂', '塑形', '增肌', '提升耐力', '改善体态']
|
||||
|
||||
export const memberCardMock = {
|
||||
card: {
|
||||
name: '黄金健身时长卡',
|
||||
status: '生效中',
|
||||
validityStart: '2024年01月01日',
|
||||
validity: '2024年01月01日 - 2025年12月31日',
|
||||
validityEnd: '2025-12-31',
|
||||
remainingDays: 187
|
||||
},
|
||||
recordTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'consume', label: '消费' },
|
||||
{ key: 'checkin', label: '签到' }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'checkin',
|
||||
title: '瑜伽初级班 · 团课签到',
|
||||
time: '2024-07-12 09:05',
|
||||
value: '-1次',
|
||||
valueType: 'negative',
|
||||
icon: '/static/images/dumbbell.png',
|
||||
iconTheme: 'orange'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'checkin',
|
||||
title: '自由进馆',
|
||||
time: '2024-07-11 18:30',
|
||||
value: '-1天',
|
||||
valueType: 'negative',
|
||||
icon: '/static/images/mappin.png',
|
||||
iconTheme: 'green'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'consume',
|
||||
title: '会员卡充值',
|
||||
time: '2024-07-01 10:00',
|
||||
value: '+90天',
|
||||
valueType: 'positive',
|
||||
icon: '/static/images/pluscircle.png',
|
||||
iconTheme: 'orange'
|
||||
}
|
||||
],
|
||||
rules: [
|
||||
'时长卡有效期内不限入场次数,但需提前预约团课',
|
||||
'卡到期后不退余额,请合理安排使用',
|
||||
'一卡仅限本人使用,不可转让'
|
||||
]
|
||||
}
|
||||
|
||||
/** 智能体测模块 mock 数据 */
|
||||
export const bodyTestMock = {
|
||||
settings: {
|
||||
autoSync: true,
|
||||
bluetoothEnabled: true,
|
||||
notifyOnComplete: true,
|
||||
shareAnonymous: false,
|
||||
unitSystem: 'metric'
|
||||
},
|
||||
device: {
|
||||
connected: false,
|
||||
name: 'InBody 270',
|
||||
model: 'IB-270',
|
||||
battery: 86,
|
||||
signal: 'strong',
|
||||
lastConnected: '2024-07-10 18:20'
|
||||
},
|
||||
connectSteps: [
|
||||
{ step: 1, title: '开启体测仪', desc: '长按电源键 3 秒,等待蓝牙指示灯闪烁' },
|
||||
{ step: 2, title: '靠近设备', desc: '将手机靠近体测仪 1 米范围内' },
|
||||
{ step: 3, title: '确认连接', desc: '点击下方按钮搜索并配对设备' }
|
||||
],
|
||||
metricDefs: [
|
||||
{ key: 'weight', label: '体重', unit: 'kg', icon: '/static/images/target.png' },
|
||||
{ key: 'bmi', label: 'BMI', unit: '', icon: '/static/images/activity.png' },
|
||||
{ key: 'bodyFat', label: '体脂率', unit: '%', icon: '/static/images/trendingdown.png' },
|
||||
{ key: 'muscleMass', label: '肌肉量', unit: 'kg', icon: '/static/images/dumbbell.png' },
|
||||
{ key: 'visceralFat', label: '内脏脂肪', unit: '级', icon: '/static/images/alertcircle.png' },
|
||||
{ key: 'bmr', label: '基础代谢', unit: 'kcal', icon: '/static/images/clock.png' },
|
||||
{ key: 'bodyWater', label: '体水分', unit: '%', icon: '/static/images/shield.png' },
|
||||
{ key: 'boneMass', label: '骨量', unit: 'kg', icon: '/static/images/user.png' }
|
||||
],
|
||||
radarLabels: [
|
||||
{ key: 'weight', label: '体重控制' },
|
||||
{ key: 'bodyFat', label: '体脂肪' },
|
||||
{ key: 'muscle', label: '肌肉量' },
|
||||
{ key: 'bone', label: '骨量' },
|
||||
{ key: 'water', label: '体水分' },
|
||||
{ key: 'bmr', label: '基础代谢' }
|
||||
],
|
||||
trendMetrics: [
|
||||
{ key: 'weight', label: '体重' },
|
||||
{ key: 'bodyFat', label: '体脂率' },
|
||||
{ key: 'muscleMass', label: '肌肉量' },
|
||||
{ key: 'bmi', label: 'BMI' }
|
||||
],
|
||||
recommendedCourses: [
|
||||
{
|
||||
id: 1,
|
||||
title: '燃脂 HIIT 团课',
|
||||
coach: '李明教练',
|
||||
schedule: '每周二、四 19:00',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
tag: '减脂推荐'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '核心力量塑形',
|
||||
coach: '王强教练',
|
||||
schedule: '每周一、三 18:30',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
tag: '塑形推荐'
|
||||
}
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 4,
|
||||
date: '2024-07-12',
|
||||
time: '09:05',
|
||||
score: 85,
|
||||
grade: 'B+',
|
||||
gradeLabel: '良好',
|
||||
status: '比较健康',
|
||||
bodyAge: 27,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 63.5,
|
||||
bmi: 22.1,
|
||||
bodyFat: 24.8,
|
||||
muscleMass: 22.6,
|
||||
visceralFat: 6,
|
||||
bmr: 1385,
|
||||
bodyWater: 52.8,
|
||||
boneMass: 2.42,
|
||||
protein: 16.4
|
||||
},
|
||||
radar: { weight: 78, bodyFat: 72, muscle: 74, bone: 81, water: 79, bmr: 73 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'normal', value: '2.1kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.2kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.5kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.6kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.7kg' }
|
||||
],
|
||||
advice: [
|
||||
'体脂率略高,建议增加有氧训练频率至每周 3-4 次',
|
||||
'核心肌群表现良好,可尝试进阶力量课程',
|
||||
'保持当前蛋白质摄入,有助于维持肌肉量'
|
||||
],
|
||||
recommendedCourseIds: [1, 2]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2024-06-28',
|
||||
time: '18:40',
|
||||
score: 82,
|
||||
grade: 'B+',
|
||||
gradeLabel: '良好',
|
||||
status: '比较健康',
|
||||
bodyAge: 28,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 64.7,
|
||||
bmi: 22.5,
|
||||
bodyFat: 25.3,
|
||||
muscleMass: 22.2,
|
||||
visceralFat: 7,
|
||||
bmr: 1370,
|
||||
bodyWater: 52.1,
|
||||
boneMass: 2.4,
|
||||
protein: 16.1
|
||||
},
|
||||
radar: { weight: 74, bodyFat: 68, muscle: 70, bone: 80, water: 76, bmr: 70 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'normal', value: '2.0kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.1kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.2kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.5kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.6kg' }
|
||||
],
|
||||
advice: [
|
||||
'体重较上次下降 0.8kg,减脂方向正确',
|
||||
'建议配合拉伸课程改善体态'
|
||||
],
|
||||
recommendedCourseIds: [1]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2024-06-10',
|
||||
time: '10:15',
|
||||
score: 79,
|
||||
grade: 'B',
|
||||
gradeLabel: '中等',
|
||||
status: '需关注',
|
||||
bodyAge: 30,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 65.5,
|
||||
bmi: 22.8,
|
||||
bodyFat: 26.1,
|
||||
muscleMass: 21.8,
|
||||
visceralFat: 8,
|
||||
bmr: 1355,
|
||||
bodyWater: 51.5,
|
||||
boneMass: 2.38,
|
||||
protein: 15.8
|
||||
},
|
||||
radar: { weight: 70, bodyFat: 62, muscle: 66, bone: 78, water: 72, bmr: 66 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'low', value: '1.9kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.0kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.0kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.4kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.5kg' }
|
||||
],
|
||||
advice: [
|
||||
'内脏脂肪偏高,建议减少高糖饮食',
|
||||
'增加抗阻训练提升肌肉量'
|
||||
],
|
||||
recommendedCourseIds: [2]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
date: '2024-05-20',
|
||||
time: '14:30',
|
||||
score: 76,
|
||||
grade: 'B',
|
||||
gradeLabel: '中等',
|
||||
status: '需关注',
|
||||
bodyAge: 31,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 66.2,
|
||||
bmi: 23.1,
|
||||
bodyFat: 26.8,
|
||||
muscleMass: 21.5,
|
||||
visceralFat: 9,
|
||||
bmr: 1340,
|
||||
bodyWater: 51.0,
|
||||
boneMass: 2.35,
|
||||
protein: 15.5
|
||||
},
|
||||
radar: { weight: 66, bodyFat: 58, muscle: 62, bone: 76, water: 68, bmr: 62 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'low', value: '1.8kg' },
|
||||
{ part: '右臂', level: 'low', value: '1.9kg' },
|
||||
{ part: '躯干', level: 'high', value: '27.8kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.3kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.4kg' }
|
||||
],
|
||||
advice: [
|
||||
'建议制定 8 周减脂计划并定期复测',
|
||||
'每日饮水量建议达到 2000ml'
|
||||
],
|
||||
recommendedCourseIds: [1, 2]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const bookingMock = {
|
||||
upcomingAlert: '明天 09:00 有一堂瑜伽课,请提前 30 分钟到场',
|
||||
tabs: [
|
||||
{ key: 'ongoing', label: '进行中' },
|
||||
{ key: 'history', label: '历史预约' }
|
||||
],
|
||||
ongoing: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
status: 'booked',
|
||||
statusLabel: '已预约',
|
||||
schedule: '07月15日 09:00-10:00',
|
||||
dateDay: '07',
|
||||
dateMonth: '月15日',
|
||||
timeRange: '09:00-10:00',
|
||||
coach: '李明教练',
|
||||
coachShort: '李明',
|
||||
location: '一楼 大厅',
|
||||
footerText: '可取消(截止 07/15 07:00)',
|
||||
canCancel: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '私教健身课',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
status: 'pending',
|
||||
statusLabel: '待上课',
|
||||
schedule: '07月18日 14:00-15:00',
|
||||
dateDay: '07',
|
||||
dateMonth: '月18日',
|
||||
timeRange: '14:00-15:00',
|
||||
coach: '王强教练',
|
||||
coachShort: '王强',
|
||||
location: 'B区私教室',
|
||||
footerText: '地点:B区私教室',
|
||||
canCancel: true
|
||||
}
|
||||
],
|
||||
history: [
|
||||
{
|
||||
id: 3,
|
||||
title: '动感单车',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
status: 'completed',
|
||||
statusLabel: '已完成',
|
||||
schedule: '07月10日 19:00-20:00',
|
||||
coach: '赵敏教练',
|
||||
footerText: '已签到',
|
||||
canCancel: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '普拉提进阶',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
status: 'cancelled',
|
||||
statusLabel: '已取消',
|
||||
schedule: '07月05日 10:00-11:00',
|
||||
coach: '李明教练',
|
||||
footerText: '用户主动取消',
|
||||
canCancel: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 可预约课程 catalog */
|
||||
export const courseCatalogMock = {
|
||||
coaches: ['全部', '李明教练', '王强教练', '赵敏教练'],
|
||||
periodOptions: [
|
||||
{ key: 'all', label: '全部时段' },
|
||||
{ key: 'morning', label: '上午' },
|
||||
{ key: 'afternoon', label: '下午' },
|
||||
{ key: 'evening', label: '晚上' }
|
||||
],
|
||||
typeOptions: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' }
|
||||
],
|
||||
courses: [
|
||||
{
|
||||
id: 101,
|
||||
title: '瑜伽基础班',
|
||||
type: 'group',
|
||||
coach: '李明教练',
|
||||
coachAvatar: '/static/images/user0.png',
|
||||
date: '2024-07-15',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
location: '一楼大厅',
|
||||
enrolled: 12,
|
||||
capacity: 20,
|
||||
price: '次卡扣 1 次',
|
||||
payType: 'session',
|
||||
period: 'morning',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
intro: '适合零基础学员,重点提升柔韧性与呼吸控制。',
|
||||
suitable: '久坐办公族、初学者、想改善体态者',
|
||||
coachBio: '国家一级瑜伽指导员,5年教学经验',
|
||||
coachRating: 4.9,
|
||||
reviews: [
|
||||
{ user: '会员 A', score: 5, text: '教练讲解很细致,氛围很好' },
|
||||
{ user: '会员 B', score: 5, text: '适合新手,推荐' }
|
||||
],
|
||||
cancelRule: '至少提前 2 小时取消,否则视为爽约'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: 'HIIT 燃脂团课',
|
||||
type: 'group',
|
||||
coach: '赵敏教练',
|
||||
coachAvatar: '/static/images/user1.png',
|
||||
date: '2024-07-15',
|
||||
startTime: '19:00',
|
||||
endTime: '20:00',
|
||||
location: '有氧区',
|
||||
enrolled: 18,
|
||||
capacity: 20,
|
||||
price: '时长卡',
|
||||
payType: 'duration',
|
||||
period: 'evening',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
intro: '高强度间歇训练,快速燃脂提升心肺。',
|
||||
suitable: '有一定运动基础、目标减脂者',
|
||||
coachBio: 'ACE 认证教练,擅长 HIIT 与动感单车',
|
||||
coachRating: 4.8,
|
||||
reviews: [{ user: '会员 C', score: 5, text: '强度够,出汗很多' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
title: '私教 · 力量训练',
|
||||
type: 'private',
|
||||
coach: '王强教练',
|
||||
coachAvatar: '/static/images/user2.png',
|
||||
date: '2024-07-16',
|
||||
startTime: '14:00',
|
||||
endTime: '15:00',
|
||||
location: 'B区私教室',
|
||||
enrolled: 1,
|
||||
capacity: 1,
|
||||
price: '私教课时卡',
|
||||
payType: 'private',
|
||||
period: 'afternoon',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
intro: '一对一力量训练,定制训练计划。',
|
||||
suitable: '增肌塑形、康复训练',
|
||||
coachBio: 'NSCA 认证私教,8年从业经验',
|
||||
coachRating: 5.0,
|
||||
reviews: [{ user: '会员 D', score: 5, text: '非常专业' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
title: '普拉提进阶',
|
||||
type: 'group',
|
||||
coach: '李明教练',
|
||||
coachAvatar: '/static/images/user0.png',
|
||||
date: '2024-07-17',
|
||||
startTime: '10:30',
|
||||
endTime: '11:30',
|
||||
location: '二楼瑜伽室',
|
||||
enrolled: 8,
|
||||
capacity: 15,
|
||||
price: '次卡扣 1 次',
|
||||
payType: 'session',
|
||||
period: 'morning',
|
||||
banner: '/static/images/AC2Banner.png',
|
||||
intro: '核心稳定与体态矫正进阶课程。',
|
||||
suitable: '有普拉提基础者',
|
||||
coachBio: '国家一级瑜伽指导员',
|
||||
coachRating: 4.9,
|
||||
reviews: [],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
title: '动感单车',
|
||||
type: 'group',
|
||||
coach: '赵敏教练',
|
||||
coachAvatar: '/static/images/user1.png',
|
||||
date: '2024-07-18',
|
||||
startTime: '18:30',
|
||||
endTime: '19:30',
|
||||
location: '单车房',
|
||||
enrolled: 20,
|
||||
capacity: 20,
|
||||
price: '储值卡 ¥39',
|
||||
payType: 'stored',
|
||||
period: 'evening',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
intro: '音乐骑行,团队氛围燃脂。',
|
||||
suitable: '所有级别,可调节阻力',
|
||||
coachBio: 'ACE 认证教练',
|
||||
coachRating: 4.7,
|
||||
reviews: [{ user: '会员 E', score: 4, text: '音乐很带感' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 个人中心其它模块 mock 数据 */
|
||||
export const moduleMock = {
|
||||
trainingReport: {
|
||||
periodLabel: '本周训练',
|
||||
summary: {
|
||||
sessions: 4,
|
||||
hours: 6.5,
|
||||
calories: 2180,
|
||||
streak: 3,
|
||||
visits: 5
|
||||
},
|
||||
monthlyHours: [
|
||||
{ label: '第1周', value: 4.2 },
|
||||
{ label: '第2周', value: 5.8 },
|
||||
{ label: '第3周', value: 6.5 },
|
||||
{ label: '第4周', value: 5.0 }
|
||||
],
|
||||
monthlyCalories: [
|
||||
{ label: '第1周', value: 1200 },
|
||||
{ label: '第2周', value: 1680 },
|
||||
{ label: '第3周', value: 2180 },
|
||||
{ label: '第4周', value: 1850 }
|
||||
],
|
||||
weeklyHours: [
|
||||
{ label: '一', value: 1.2 },
|
||||
{ label: '二', value: 0 },
|
||||
{ label: '三', value: 1.5 },
|
||||
{ label: '四', value: 0.8 },
|
||||
{ label: '五', value: 1.0 },
|
||||
{ label: '六', value: 2.0 },
|
||||
{ label: '日', value: 0 }
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
coach: '李明教练',
|
||||
date: '2024-07-12',
|
||||
time: '09:00-10:00',
|
||||
duration: '60分钟',
|
||||
calories: 320,
|
||||
type: 'group',
|
||||
typeLabel: '团课'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 力量',
|
||||
coach: '自主训练',
|
||||
date: '2024-07-11',
|
||||
time: '18:30-19:45',
|
||||
duration: '75分钟',
|
||||
calories: 480,
|
||||
type: 'free',
|
||||
typeLabel: '自由'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教 · 核心塑形',
|
||||
coach: '王强教练',
|
||||
date: '2024-07-10',
|
||||
time: '14:00-15:00',
|
||||
duration: '60分钟',
|
||||
calories: 410,
|
||||
type: 'private',
|
||||
typeLabel: '私教'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '动感单车',
|
||||
coach: '赵敏教练',
|
||||
date: '2024-07-08',
|
||||
time: '19:00-20:00',
|
||||
duration: '60分钟',
|
||||
calories: 520,
|
||||
type: 'group',
|
||||
typeLabel: '团课'
|
||||
}
|
||||
]
|
||||
},
|
||||
couponTabs: [
|
||||
{ key: 'available', label: '可用' },
|
||||
{ key: 'used', label: '已使用' },
|
||||
{ key: 'expired', label: '已过期' }
|
||||
],
|
||||
coupons: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'available',
|
||||
amount: 50,
|
||||
title: '满500减50',
|
||||
desc: '全场团课/私教可用',
|
||||
expire: '2024-12-31',
|
||||
minSpend: 500,
|
||||
tag: '通用券',
|
||||
rules: '1. 满500元可用\n2. 适用于团课/私教\n3. 不可与其他优惠叠加\n4. 有效期至2024-12-31',
|
||||
scope: '全门店 · 团课/私教',
|
||||
flow: '选择课程 → 确认订单 → 选择优惠券 → 完成支付'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'available',
|
||||
amount: 30,
|
||||
title: '新人专享',
|
||||
desc: '首次购课立减',
|
||||
expire: '2024-08-31',
|
||||
minSpend: 200,
|
||||
tag: '新人券',
|
||||
rules: '1. 限新注册用户首次购课\n2. 满200可用',
|
||||
scope: '全门店 · 首次购课',
|
||||
flow: '首次预约课程时自动提示使用'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
status: 'used',
|
||||
amount: 20,
|
||||
title: '签到奖励券',
|
||||
desc: '连续签到7天获得',
|
||||
expire: '2024-07-01',
|
||||
minSpend: 100,
|
||||
tag: '奖励券',
|
||||
usedAt: '2024-06-28',
|
||||
rules: '满100可用',
|
||||
scope: '团课',
|
||||
flow: '预约时使用'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
status: 'expired',
|
||||
amount: 100,
|
||||
title: '周年庆特惠',
|
||||
desc: '满1000可用',
|
||||
expire: '2024-06-01',
|
||||
minSpend: 1000,
|
||||
tag: '活动券',
|
||||
rules: '满1000可用,已过期',
|
||||
scope: '全门店',
|
||||
flow: '—'
|
||||
}
|
||||
],
|
||||
couponCenter: [
|
||||
{
|
||||
id: 11,
|
||||
amount: 20,
|
||||
title: '周末团课券',
|
||||
desc: '周末团课满200减20',
|
||||
expireDays: 30,
|
||||
minSpend: 200,
|
||||
tag: '可领取',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
amount: 50,
|
||||
title: '私教体验券',
|
||||
desc: '私教课满500减50',
|
||||
expireDays: 15,
|
||||
minSpend: 500,
|
||||
tag: '限时',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
amount: 10,
|
||||
title: '签到加油券',
|
||||
desc: '无门槛10元券',
|
||||
expireDays: 7,
|
||||
minSpend: 0,
|
||||
tag: '每日',
|
||||
claimed: true
|
||||
}
|
||||
],
|
||||
pointsConfig: {
|
||||
rate: '100积分 = 1元',
|
||||
rule: '签到、训练、邀请好友、购课均可获得积分;积分可用于商城兑换。'
|
||||
},
|
||||
pointsRewards: [
|
||||
{ id: 1, name: '团课体验券', cost: 500, stock: 12, icon: '/static/images/ticket.png' },
|
||||
{ id: 2, name: '运动毛巾', cost: 800, stock: 5, icon: '/static/images/dumbbell.png' },
|
||||
{ id: 3, name: '私教体验30分钟', cost: 2000, stock: 3, icon: '/static/images/usercheck.png' },
|
||||
{ id: 4, name: '蛋白粉小样', cost: 350, stock: 20, icon: '/static/images/star.png' }
|
||||
],
|
||||
pointsHistory: [
|
||||
{ id: 1, type: 'earn', title: '团课签到', amount: 50, time: '2024-07-12 09:10', balance: 1250 },
|
||||
{ id: 2, type: 'earn', title: '邀请好友注册', amount: 200, time: '2024-07-08 15:30', balance: 1200 },
|
||||
{ id: 3, type: 'spend', title: '兑换团课体验券', amount: -500, time: '2024-07-01 11:00', balance: 1000 },
|
||||
{ id: 4, type: 'earn', title: '会员卡续费奖励', amount: 100, time: '2024-07-01 10:05', balance: 1500 },
|
||||
{ id: 5, type: 'earn', title: '体测完成奖励', amount: 30, time: '2024-06-28 18:45', balance: 1400 }
|
||||
],
|
||||
referralRules: [
|
||||
'好友通过您的邀请码注册,双方各得 100 积分',
|
||||
'好友首次购课成功后,您额外获得 300 积分',
|
||||
'每月邀请奖励上限 10 人,超出不再计奖',
|
||||
'积分可用于兑换课程体验券及周边礼品'
|
||||
],
|
||||
referralRecords: [
|
||||
{ id: 1, name: '李**', avatar: '/static/images/user0.png', status: 'purchased', statusLabel: '已购课', time: '2024-07-05', reward: '+300积分', rewardStatus: '已发放' },
|
||||
{ id: 2, name: '王**', avatar: '/static/images/user1.png', status: 'registered', statusLabel: '已注册', time: '2024-06-20', reward: '+100积分', rewardStatus: '已发放' },
|
||||
{ id: 3, name: '陈**', avatar: '/static/images/user2.png', status: 'invited', statusLabel: '已邀请', time: '2024-06-15', reward: '待注册', rewardStatus: '待发放' },
|
||||
{ id: 4, name: '赵**', avatar: '/static/images/user3.png', status: 'purchased', statusLabel: '已购课', time: '2024-06-01', reward: '+300积分', rewardStatus: '已发放' },
|
||||
{ id: 5, name: '刘**', avatar: '/static/images/user0.png', status: 'registered', statusLabel: '已注册', time: '2024-05-28', reward: '+100积分', rewardStatus: '已发放' }
|
||||
],
|
||||
referralRewardSummary: {
|
||||
totalPoints: 800,
|
||||
totalCoupons: 2,
|
||||
pendingCount: 1
|
||||
},
|
||||
myCourseTabs: [
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' },
|
||||
{ key: 'online', label: '线上课' },
|
||||
{ key: 'package', label: '训练营' }
|
||||
],
|
||||
myCourses: {
|
||||
group: {
|
||||
ongoing: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
coach: '李明教练',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
progress: 6,
|
||||
total: 12,
|
||||
schedule: '每周二、四 09:00',
|
||||
location: '一楼大厅',
|
||||
nextClass: '07月16日 09:00',
|
||||
canCancel: true,
|
||||
bookingId: 1
|
||||
}
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
id: 3,
|
||||
title: '动感单车入门',
|
||||
coach: '赵敏教练',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
progress: 8,
|
||||
total: 8,
|
||||
schedule: '已结课',
|
||||
location: '单车房',
|
||||
completedAt: '2024-06-30',
|
||||
canEvaluate: true
|
||||
}
|
||||
]
|
||||
},
|
||||
private: {
|
||||
remaining: 7,
|
||||
coach: '王强教练',
|
||||
coachAvatar: '/static/images/user2.png',
|
||||
nextClass: '07月15日 14:00',
|
||||
bookings: [
|
||||
{ id: 2, title: '私教 · 力量训练', time: '07月18日 14:00', status: '已预约', location: 'B区私教室' }
|
||||
],
|
||||
completed: [
|
||||
{ id: 5, title: '私教 · 核心塑形', time: '2024-07-10 14:00', coach: '王强教练' }
|
||||
]
|
||||
},
|
||||
online: [
|
||||
{
|
||||
id: 201,
|
||||
title: '居家核心训练',
|
||||
cover: '/static/images/AC2Banner.png',
|
||||
duration: '45分钟',
|
||||
progress: 60,
|
||||
chapters: 6,
|
||||
watched: 4,
|
||||
type: 'video'
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
title: '直播 · 晨间拉伸',
|
||||
cover: '/static/images/AC1Banner.png',
|
||||
duration: '30分钟',
|
||||
progress: 0,
|
||||
liveTime: '07月20日 07:00',
|
||||
type: 'live'
|
||||
}
|
||||
],
|
||||
package: [
|
||||
{
|
||||
id: 301,
|
||||
title: '28天减脂训练营',
|
||||
banner: '/static/images/AC1Banner.png',
|
||||
progress: 3,
|
||||
total: 10,
|
||||
coach: '李明教练',
|
||||
schedule: '每周5练'
|
||||
}
|
||||
]
|
||||
},
|
||||
checkInTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' },
|
||||
{ key: 'free', label: '自由' }
|
||||
],
|
||||
checkInHistory: [
|
||||
{
|
||||
id: 1,
|
||||
title: '今日签到 · 瑜伽初级班',
|
||||
time: '2024-07-12 09:05',
|
||||
tag: '团课',
|
||||
tagTheme: 'group',
|
||||
location: '一楼大厅'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-11 18:30',
|
||||
tag: '自由',
|
||||
tagTheme: 'free',
|
||||
location: '器械区'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教课 · 力量训练',
|
||||
time: '2024-07-10 14:00',
|
||||
tag: '私教',
|
||||
tagTheme: 'private',
|
||||
location: 'B区私教室'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '团课签到 · 动感单车',
|
||||
time: '2024-07-08 19:02',
|
||||
tag: '团课',
|
||||
tagTheme: 'group',
|
||||
location: '单车房'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-06 17:45',
|
||||
tag: '自由',
|
||||
tagTheme: 'free',
|
||||
location: '有氧区'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { moduleMock } from './mockData.js'␍
|
||||
␍
|
||||
function clone(value) {␍
|
||||
return JSON.parse(JSON.stringify(value))␍
|
||||
}␍
|
||||
␍
|
||||
export function getDefaultModuleState() {␍
|
||||
return {␍
|
||||
trainingReport: clone(moduleMock.trainingReport),␍
|
||||
coupons: clone(moduleMock.coupons),␍
|
||||
couponCenter: clone(moduleMock.couponCenter),␍
|
||||
pointsHistory: clone(moduleMock.pointsHistory),␍
|
||||
pointsRewards: clone(moduleMock.pointsRewards),␍
|
||||
redeemRecords: [],␍
|
||||
referralRecords: clone(moduleMock.referralRecords),␍
|
||||
myCourses: clone(moduleMock.myCourses),␍
|
||||
checkInHistory: clone(moduleMock.checkInHistory)␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
export function mergeModuleState(saved) {␍
|
||||
const defaults = getDefaultModuleState()␍
|
||||
if (!saved) return defaults␍
|
||||
return {␍
|
||||
trainingReport: { ...defaults.trainingReport, ...(saved.trainingReport || {}) },␍
|
||||
coupons: saved.coupons?.length ? saved.coupons : defaults.coupons,␍
|
||||
couponCenter: saved.couponCenter?.length ? saved.couponCenter : defaults.couponCenter,␍
|
||||
pointsHistory: saved.pointsHistory?.length ? saved.pointsHistory : defaults.pointsHistory,␍
|
||||
pointsRewards: saved.pointsRewards?.length ? saved.pointsRewards : defaults.pointsRewards,␍
|
||||
redeemRecords: saved.redeemRecords || defaults.redeemRecords,␍
|
||||
referralRecords: saved.referralRecords?.length ? saved.referralRecords : defaults.referralRecords,␍
|
||||
myCourses: saved.myCourses ? mergeMyCourses(defaults.myCourses, saved.myCourses) : defaults.myCourses,␍
|
||||
checkInHistory: saved.checkInHistory?.length ? saved.checkInHistory : defaults.checkInHistory␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function mergeMyCourses(defaults, saved) {␍
|
||||
return {␍
|
||||
group: saved.group || defaults.group,␍
|
||||
private: saved.private || defaults.private,␍
|
||||
online: saved.online?.length ? saved.online : defaults.online,␍
|
||||
package: saved.package?.length ? saved.package : defaults.package␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function syncCouponSummary(store) {␍
|
||||
const available = store.modules.coupons.filter((c) => c.status === 'available')␍
|
||||
const top = available[0]␍
|
||||
store.couponPoints = {␍
|
||||
...store.couponPoints,␍
|
||||
amount: top ? `¥${top.amount}` : '暂无',␍
|
||||
couponDesc: top␍
|
||||
? `满${top.minSpend}可用 · ${available.length}张`␍
|
||||
: '暂无可用优惠券',␍
|
||||
couponAction: available.length ? '去使用' : '去领取',␍
|
||||
points: store.stats.pointsBalance,␍
|
||||
pointsLabel: '我的积分',␍
|
||||
pointsAction: '去兑换'␍
|
||||
}␍
|
||||
return store␍
|
||||
}␍
|
||||
␍
|
||||
export function finalizeModules(store) {␍
|
||||
syncCouponSummary(store)␍
|
||||
store.checkIns = store.modules.checkInHistory.slice(0, 3).map((item) => ({ ...item }))␍
|
||||
return store␍
|
||||
}␍
|
||||
␍
|
||||
export function getTrainingReportData(store, period = 'week') {␍
|
||||
const report = store.modules.trainingReport␍
|
||||
const trend = period === 'month' ? report.monthlyHours : report.weeklyHours␍
|
||||
const calTrend = period === 'month' ? report.monthlyCalories : report.weeklyHours.map((w, i) => ({␍
|
||||
label: w.label,␍
|
||||
value: Math.round((report.summary.calories / 7) * (w.value || 0.5))␍
|
||||
}))␍
|
||||
return {␍
|
||||
...report,␍
|
||||
period,␍
|
||||
summary: {␍
|
||||
...report.summary,␍
|
||||
hours: store.stats.trainingHours ?? report.summary.hours,␍
|
||||
visits: report.summary.visits ?? store.stats.checkInCount ?? 5␍
|
||||
},␍
|
||||
trendHours: trend.map((t) => ({ ...t, id: t.label })),␍
|
||||
trendCalories: calTrend.map((t) => ({ ...t, id: t.label })),␍
|
||||
sessions: report.sessions.map((s) => ({ ...s }))␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
export function getTrainingSessionById(store, id) {␍
|
||||
const session = store.modules.trainingReport.sessions.find((s) => s.id === Number(id))␍
|
||||
if (!session) return null␍
|
||||
return {␍
|
||||
...session,␍
|
||||
heartRate: '128 bpm',␍
|
||||
comment: '动作标准,核心发力良好,下次可增加负重。',␍
|
||||
checkInTime: `${session.date} ${session.time.split('-')[0]}`␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
export function filterTrainingSessions(store, filters = {}) {␍
|
||||
let list = store.modules.trainingReport.sessions.map((s) => ({ ...s }))␍
|
||||
if (filters.type && filters.type !== 'all') {␍
|
||||
list = list.filter((s) => s.type === filters.type)␍
|
||||
}␍
|
||||
return list␍
|
||||
}␍
|
||||
␍
|
||||
export function getCouponsByStatus(store, status) {␍
|
||||
return store.modules.coupons.filter((c) => c.status === status).map((c) => ({ ...c }))␍
|
||||
}␍
|
||||
␍
|
||||
export function getCouponById(store, id) {␍
|
||||
const c = store.modules.coupons.find((item) => item.id === Number(id))␍
|
||||
return c ? { ...c } : null␍
|
||||
}␍
|
||||
␍
|
||||
export function useCoupon(store, id) {␍
|
||||
const coupon = store.modules.coupons.find((c) => c.id === id)␍
|
||||
if (!coupon || coupon.status !== 'available') return null␍
|
||||
coupon.status = 'used'␍
|
||||
coupon.usedAt = new Date().toISOString().slice(0, 10)␍
|
||||
syncCouponSummary(store)␍
|
||||
return coupon␍
|
||||
}␍
|
||||
␍
|
||||
export function deleteExpiredCoupon(store, id) {␍
|
||||
const idx = store.modules.coupons.findIndex((c) => c.id === id && c.status === 'expired')␍
|
||||
if (idx >= 0) store.modules.coupons.splice(idx, 1)␍
|
||||
syncCouponSummary(store)␍
|
||||
return store␍
|
||||
}␍
|
||||
␍
|
||||
export function getCouponCenterList(store) {␍
|
||||
return store.modules.couponCenter.map((c) => ({ ...c }))␍
|
||||
}␍
|
||||
␍
|
||||
export function claimCouponFromCenter(store, centerId) {␍
|
||||
const item = store.modules.couponCenter.find((c) => c.id === centerId)␍
|
||||
if (!item || item.claimed) return { ok: false, message: '已领取或不存在' }␍
|
||||
const nextId = store.modules.coupons.reduce((m, c) => Math.max(m, c.id || 0), 0) + 1␍
|
||||
const expire = new Date()␍
|
||||
expire.setDate(expire.getDate() + item.expireDays)␍
|
||||
store.modules.coupons.unshift({␍
|
||||
id: nextId,␍
|
||||
status: 'available',␍
|
||||
amount: item.amount,␍
|
||||
title: item.title,␍
|
||||
desc: item.desc,␍
|
||||
expire: expire.toISOString().slice(0, 10),␍
|
||||
minSpend: item.minSpend,␍
|
||||
tag: item.tag,␍
|
||||
rules: `领取后${item.expireDays}天内有效,满${item.minSpend}可用`,␍
|
||||
scope: '全门店',␍
|
||||
flow: '预约课程时使用'␍
|
||||
})␍
|
||||
item.claimed = true␍
|
||||
syncCouponSummary(store)␍
|
||||
return { ok: true, message: '领取成功' }␍
|
||||
}␍
|
||||
␍
|
||||
export function getPointsPageData(store) {␍
|
||||
return {␍
|
||||
balance: store.stats.pointsBalance,␍
|
||||
config: moduleMock.pointsConfig,␍
|
||||
history: store.modules.pointsHistory.map((h) => ({ ...h })),␍
|
||||
rewards: store.modules.pointsRewards.map((r) => ({ ...r })),␍
|
||||
redeemRecords: (store.modules.redeemRecords || []).map((r) => ({ ...r }))␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function nextHistoryId(history) {␍
|
||||
return (history || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1␍
|
||||
}␍
|
||||
␍
|
||||
function formatNow() {␍
|
||||
const d = new Date()␍
|
||||
const pad = (n) => String(n).padStart(2, '0')␍
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`␍
|
||||
}␍
|
||||
␍
|
||||
export function redeemPointsReward(store, rewardId) {␍
|
||||
const reward = store.modules.pointsRewards.find((r) => r.id === rewardId)␍
|
||||
if (!reward || reward.stock <= 0) return { ok: false, message: '库存不足' }␍
|
||||
if (store.stats.pointsBalance < reward.cost) {␍
|
||||
return { ok: false, message: '积分不足' }␍
|
||||
}␍
|
||||
store.stats.pointsBalance -= reward.cost␍
|
||||
reward.stock -= 1␍
|
||||
store.modules.pointsHistory.unshift({␍
|
||||
id: nextHistoryId(store.modules.pointsHistory),␍
|
||||
type: 'spend',␍
|
||||
title: `兑换${reward.name}`,␍
|
||||
amount: -reward.cost,␍
|
||||
time: formatNow(),␍
|
||||
balance: store.stats.pointsBalance␍
|
||||
})␍
|
||||
store.modules.redeemRecords = store.modules.redeemRecords || []␍
|
||||
store.modules.redeemRecords.unshift({␍
|
||||
id: nextHistoryId(store.modules.redeemRecords),␍
|
||||
name: reward.name,␍
|
||||
cost: reward.cost,␍
|
||||
time: formatNow()␍
|
||||
})␍
|
||||
syncCouponSummary(store)␍
|
||||
return { ok: true, message: '兑换成功', reward }␍
|
||||
}␍
|
||||
␍
|
||||
export function filterPointsHistory(store, filter = 'all') {␍
|
||||
const list = store.modules.pointsHistory␍
|
||||
if (filter === 'earn') return list.filter((h) => h.amount > 0).map((h) => ({ ...h }))␍
|
||||
if (filter === 'spend') return list.filter((h) => h.amount < 0).map((h) => ({ ...h }))␍
|
||||
return list.map((h) => ({ ...h }))␍
|
||||
}␍
|
||||
␍
|
||||
export function getReferralPageData(store) {␍
|
||||
return {␍
|
||||
...store.referral,␍
|
||||
records: store.modules.referralRecords.map((r) => ({ ...r })),␍
|
||||
rules: moduleMock.referralRules,␍
|
||||
rewardSummary: moduleMock.referralRewardSummary␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
export function getMyCoursesData(store, tab) {␍
|
||||
const data = store.modules.myCourses␍
|
||||
if (tab === 'group') return { ...data.group }␍
|
||||
if (tab === 'private') return { ...data.private }␍
|
||||
if (tab === 'online') return { list: data.online.map((c) => ({ ...c })) }␍
|
||||
if (tab === 'package') return { list: data.package.map((c) => ({ ...c })) }␍
|
||||
return {}␍
|
||||
}␍
|
||||
␍
|
||||
export function getOnlineCourseById(store, id) {␍
|
||||
const course = store.modules.myCourses.online.find((c) => c.id === Number(id))␍
|
||||
if (!course) return null␍
|
||||
return {␍
|
||||
...course,␍
|
||||
chapters: [␍
|
||||
{ id: 1, title: '热身激活', duration: '8分钟', done: true },␍
|
||||
{ id: 2, title: '核心训练 A', duration: '12分钟', done: true },␍
|
||||
{ id: 3, title: '核心训练 B', duration: '10分钟', done: true },␍
|
||||
{ id: 4, title: '拉伸放松', duration: '8分钟', done: true },␍
|
||||
{ id: 5, title: '进阶组合', duration: '15分钟', done: false },␍
|
||||
{ id: 6, title: '总结复盘', duration: '5分钟', done: false }␍
|
||||
],␍
|
||||
comments: [{ user: '学员A', text: '跟着练很方便' }]␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
export function updateOnlineProgress(store, courseId, progress) {␍
|
||||
const course = store.modules.myCourses.online.find((c) => c.id === Number(courseId))␍
|
||||
if (course) course.progress = progress␍
|
||||
return course␍
|
||||
}␍
|
||||
␍
|
||||
export function getCheckInHistory(store, filter = 'all') {␍
|
||||
const list = store.modules.checkInHistory␍
|
||||
if (filter === 'all') return list.map((i) => ({ ...i }))␍
|
||||
return list.filter((i) => i.tagTheme === filter).map((i) => ({ ...i }))␍
|
||||
}␍
|
||||
␍
|
||||
/** @deprecated use getMyCoursesData */␍
|
||||
export function getMyCoursesByTab(store, tab) {␍
|
||||
if (tab === 'ongoing') return store.modules.myCourses.group?.ongoing || []␍
|
||||
if (tab === 'completed') return store.modules.myCourses.group?.completed || []␍
|
||||
return []␍
|
||||
}␍
|
||||
␍
|
||||
export { moduleMock }␍
|
||||
@@ -1,308 +0,0 @@
|
||||
import {
|
||||
memberCenterMock,
|
||||
userInfoMock,
|
||||
memberCardMock,
|
||||
bookingMock
|
||||
} from './mockData.js'
|
||||
import { formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
|
||||
import {
|
||||
getDefaultBodyTestState,
|
||||
mergeBodyTestState,
|
||||
getLatestBodyTestRecord,
|
||||
buildBodyReportSummary
|
||||
} from './bodyTestStore.js'
|
||||
import {
|
||||
getDefaultModuleState,
|
||||
mergeModuleState,
|
||||
finalizeModules
|
||||
} from './moduleStore.js'
|
||||
import {
|
||||
getDefaultCourseCatalog,
|
||||
mergeCourseCatalog,
|
||||
canCancelBooking
|
||||
} from './bookingStore.js'
|
||||
|
||||
const STORAGE_KEY = 'gym_member_info_v1'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function buildCardTip(remainingDays) {
|
||||
return `距离下次到期还有${remainingDays}天,请及时续费`
|
||||
}
|
||||
|
||||
function applyCardInfo(store) {
|
||||
const days = computeRemainingDays(store.card.validityEnd)
|
||||
store.card.remainingDays = days
|
||||
store.cardInfo.remainingDays = days
|
||||
store.cardInfo.tip = buildCardTip(days)
|
||||
return store
|
||||
}
|
||||
|
||||
export function syncStats(store) {
|
||||
store.stats = {
|
||||
...store.stats,
|
||||
pointsBalance: store.stats.pointsBalance ?? 1250
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function finalizeStore(store) {
|
||||
syncStats(store)
|
||||
applyCardInfo(store)
|
||||
if (store.profile?.avatar) {
|
||||
store.memberProfile.avatar = store.profile.avatar
|
||||
}
|
||||
if (store.profile?.phone) {
|
||||
store.memberProfile.phone = formatMemberCenterPhone(store.profile.phone)
|
||||
}
|
||||
const latestBodyTest = getLatestBodyTestRecord(store)
|
||||
if (latestBodyTest) {
|
||||
const previous = store.bodyTest.records[1]
|
||||
store.bodyReport = buildBodyReportSummary(latestBodyTest, previous)
|
||||
}
|
||||
if (store.modules) {
|
||||
finalizeModules(store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function getDefaultStore() {
|
||||
return finalizeStore({
|
||||
profile: { ...userInfoMock, avatar: memberCenterMock.userInfo.avatar },
|
||||
memberProfile: { ...memberCenterMock.userInfo },
|
||||
stats: { ...memberCenterMock.stats },
|
||||
cardInfo: { ...memberCenterMock.cardInfo },
|
||||
card: { ...memberCardMock.card },
|
||||
records: clone(memberCardMock.records),
|
||||
ongoingBookings: clone(bookingMock.ongoing),
|
||||
historyBookings: clone(bookingMock.history),
|
||||
checkIns: clone(memberCenterMock.checkIns),
|
||||
bodyReport: { ...memberCenterMock.bodyReport },
|
||||
bodyTest: getDefaultBodyTestState(),
|
||||
modules: getDefaultModuleState(),
|
||||
courseCatalog: getDefaultCourseCatalog(),
|
||||
couponPoints: { ...memberCenterMock.couponPoints },
|
||||
referral: { ...memberCenterMock.referral }
|
||||
})
|
||||
}
|
||||
|
||||
function mergeDefaults(saved) {
|
||||
const defaults = getDefaultStore()
|
||||
return finalizeStore({
|
||||
profile: { ...defaults.profile, ...(saved.profile || {}) },
|
||||
memberProfile: { ...defaults.memberProfile, ...(saved.memberProfile || {}) },
|
||||
stats: { ...defaults.stats, ...(saved.stats || {}) },
|
||||
cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) },
|
||||
card: { ...defaults.card, ...(saved.card || {}) },
|
||||
records: saved.records?.length ? saved.records : defaults.records,
|
||||
ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings,
|
||||
historyBookings: saved.historyBookings ?? defaults.historyBookings,
|
||||
checkIns: saved.checkIns?.length ? saved.checkIns : defaults.checkIns,
|
||||
bodyReport: { ...defaults.bodyReport, ...(saved.bodyReport || {}) },
|
||||
bodyTest: mergeBodyTestState(saved.bodyTest),
|
||||
modules: mergeModuleState(saved.modules),
|
||||
courseCatalog: mergeCourseCatalog(saved.courseCatalog),
|
||||
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
|
||||
referral: { ...defaults.referral, ...(saved.referral || {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function loadMemberStore() {
|
||||
try {
|
||||
const saved = uni.getStorageSync(STORAGE_KEY)
|
||||
if (saved && typeof saved === 'object') {
|
||||
return mergeDefaults(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[memberStore] load failed', e)
|
||||
}
|
||||
return getDefaultStore()
|
||||
}
|
||||
|
||||
export function saveMemberStore(store) {
|
||||
uni.setStorageSync(STORAGE_KEY, store)
|
||||
}
|
||||
|
||||
/** 解析为本地 0 点,避免 ISO 字符串时区偏差 */
|
||||
export function parseLocalDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
const str = String(dateStr).trim()
|
||||
const iso = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (iso) {
|
||||
return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
|
||||
}
|
||||
const cn = str.match(/(\d{4})年(\d{2})月(\d{2})日/)
|
||||
if (cn) {
|
||||
return new Date(Number(cn[1]), Number(cn[2]) - 1, Number(cn[3]))
|
||||
}
|
||||
const parsed = new Date(str.replace(/-/g, '/'))
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatChineseDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}年${m}月${day}日`
|
||||
}
|
||||
|
||||
function formatRecordTime(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
function nextRecordId(records) {
|
||||
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
|
||||
}
|
||||
|
||||
export function computeRemainingDays(endDateStr) {
|
||||
const end = parseLocalDate(endDateStr)
|
||||
if (!end) return 0
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
const diff = Math.ceil((end - now) / 86400000)
|
||||
return Math.max(0, diff)
|
||||
}
|
||||
|
||||
export function formatUpcomingAlert(booking) {
|
||||
if (!booking) return ''
|
||||
const timePart = booking.timeRange || booking.schedule?.split(' ')[1] || ''
|
||||
return `明天 ${timePart} 有一堂${booking.title},请提前 30 分钟到场`
|
||||
}
|
||||
|
||||
export function toBookingPreviewItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
dateDay: item.dateDay,
|
||||
dateMonth: item.dateMonth,
|
||||
desc: `${item.title} · ${item.timeRange}`,
|
||||
coach: item.coachShort || item.coach.replace('教练', ''),
|
||||
location: item.location ? `地点:${item.location}` : '',
|
||||
status: item.status,
|
||||
statusLabel: item.statusLabel
|
||||
}
|
||||
}
|
||||
|
||||
export function getBookingPreview(store, limit = 2) {
|
||||
return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem)
|
||||
}
|
||||
|
||||
export function getCenterPageData(store) {
|
||||
return {
|
||||
userInfo: { ...store.memberProfile },
|
||||
stats: { ...store.stats },
|
||||
cardInfo: { ...store.cardInfo },
|
||||
bookingPreview: getBookingPreview(store),
|
||||
checkIns: store.checkIns.map((item) => ({ ...item })),
|
||||
bodyReport: {
|
||||
...store.bodyReport,
|
||||
weight: store.profile.weight || store.bodyReport.weight
|
||||
},
|
||||
couponPoints: {
|
||||
...store.couponPoints,
|
||||
points: store.stats.pointsBalance
|
||||
},
|
||||
referral: { ...store.referral }
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelOngoingBooking(store, id) {
|
||||
const index = store.ongoingBookings.findIndex((b) => b.id === id)
|
||||
if (index < 0) return { ok: false, message: '预约不存在' }
|
||||
|
||||
const item = store.ongoingBookings[index]
|
||||
if (!canCancelBooking(item)) {
|
||||
return { ok: false, message: '距开课不足2小时,无法取消' }
|
||||
}
|
||||
|
||||
const [removed] = store.ongoingBookings.splice(index, 1)
|
||||
if (removed.courseId) {
|
||||
const course = store.courseCatalog.find((c) => c.id === removed.courseId)
|
||||
if (course && course.enrolled > 0) course.enrolled -= 1
|
||||
}
|
||||
store.historyBookings.unshift({
|
||||
...removed,
|
||||
status: 'cancelled',
|
||||
statusLabel: '已取消',
|
||||
footerText: '用户主动取消',
|
||||
canCancel: false
|
||||
})
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return { ok: true, message: '已取消' }
|
||||
}
|
||||
|
||||
export function renewMemberCard(store, addDays = 90) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
|
||||
let base = parseLocalDate(store.card.validityEnd) || new Date(now)
|
||||
base.setHours(0, 0, 0, 0)
|
||||
// 已过期:从今天起续费;未过期:从当前到期日起顺延
|
||||
if (base < now) {
|
||||
base = new Date(now)
|
||||
}
|
||||
|
||||
const end = new Date(base)
|
||||
end.setDate(end.getDate() + addDays)
|
||||
|
||||
const validityEnd = formatIsoDate(end)
|
||||
const validityEndCn = formatChineseDate(end)
|
||||
store.card.validityEnd = validityEnd
|
||||
store.card.validity = store.card.validityStart
|
||||
? `${store.card.validityStart} - ${validityEndCn}`
|
||||
: `2024年01月01日 - ${validityEndCn}`
|
||||
store.cardInfo.expireDate = `有效期至 ${validityEndCn}`
|
||||
|
||||
store.records.unshift({
|
||||
id: nextRecordId(store.records),
|
||||
type: 'consume',
|
||||
title: '会员卡续费',
|
||||
time: formatRecordTime(new Date()),
|
||||
value: `+${addDays}天`,
|
||||
valueType: 'positive',
|
||||
icon: '/static/images/pluscircle.png',
|
||||
iconTheme: 'orange'
|
||||
})
|
||||
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function saveUserProfile(store, profile) {
|
||||
const phone = normalizePhoneForStore(profile.phone ?? store.profile.phone)
|
||||
store.profile = { ...store.profile, ...profile, phone }
|
||||
store.memberProfile = {
|
||||
...store.memberProfile,
|
||||
name: store.profile.name,
|
||||
phone: formatMemberCenterPhone(store.profile.phone),
|
||||
avatar: store.profile.avatar || store.memberProfile.avatar
|
||||
}
|
||||
if (store.profile.weight) {
|
||||
store.bodyReport.weight = store.profile.weight
|
||||
}
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function persistMemberStore(store) {
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$) */
|
||||
|
||||
const PHONE_REG = /^1[3-9]\d{9}$/
|
||||
const MIN_NAME_LEN = 2
|
||||
const MAX_NAME_LEN = 8
|
||||
const NAME_REG = new RegExp(
|
||||
`^[\\u4e00-\\u9fa5a-zA-Z·\\s]{${MIN_NAME_LEN},${MAX_NAME_LEN}}$`
|
||||
)
|
||||
const MEASURE_REG = /^\d+(\.\d)?$/
|
||||
|
||||
const MIN_HEIGHT = 50
|
||||
const MAX_HEIGHT = 250
|
||||
const MIN_WEIGHT = 20
|
||||
const MAX_WEIGHT = 300
|
||||
const MIN_BIRTH_YEAR = 1900
|
||||
const MIN_AGE = 14
|
||||
const MAX_FITNESS_GOALS = 5
|
||||
|
||||
export function isMaskedPhone(phone) {
|
||||
return String(phone || '').includes('****')
|
||||
}
|
||||
|
||||
export function validateName(name) {
|
||||
const value = String(name ?? '').trim()
|
||||
if (!value) {
|
||||
return { ok: false, message: '请输入姓名' }
|
||||
}
|
||||
if (!NAME_REG.test(value)) {
|
||||
return { ok: false, message: `姓名为${MIN_NAME_LEN}-${MAX_NAME_LEN}个汉字或字母` }
|
||||
}
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/** 保存时使用:允许保留已脱敏的旧手机号 */
|
||||
export function validatePhone(phone, options = {}) {
|
||||
const { allowMasked = true } = options
|
||||
const raw = String(phone ?? '').trim()
|
||||
if (!raw) {
|
||||
return { ok: false, message: '请绑定手机号' }
|
||||
}
|
||||
if (allowMasked && isMaskedPhone(raw)) {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (digits.length >= 7) {
|
||||
return { ok: true, value: raw }
|
||||
}
|
||||
return { ok: false, message: '手机号格式不正确' }
|
||||
}
|
||||
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (!PHONE_REG.test(digits)) {
|
||||
return { ok: false, message: '请输入11位有效手机号' }
|
||||
}
|
||||
return { ok: true, value: digits }
|
||||
}
|
||||
|
||||
/** 换绑时必须输入完整新号 */
|
||||
export function validatePhoneForRebind(phone) {
|
||||
return validatePhone(phone, { allowMasked: false })
|
||||
}
|
||||
|
||||
function parseMeasure(value) {
|
||||
const str = String(value ?? '').trim()
|
||||
if (!str || !MEASURE_REG.test(str)) {
|
||||
return null
|
||||
}
|
||||
const num = Number(str)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
function formatMeasure(num) {
|
||||
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(1)))
|
||||
}
|
||||
|
||||
export function validateHeight(height) {
|
||||
const num = parseMeasure(height)
|
||||
if (num == null) {
|
||||
return { ok: false, message: '请输入有效身高(单位 cm)' }
|
||||
}
|
||||
if (num < MIN_HEIGHT || num > MAX_HEIGHT) {
|
||||
return { ok: false, message: `身高请在 ${MIN_HEIGHT}-${MAX_HEIGHT} cm 之间` }
|
||||
}
|
||||
return { ok: true, value: formatMeasure(num) }
|
||||
}
|
||||
|
||||
export function validateWeight(weight) {
|
||||
const num = parseMeasure(weight)
|
||||
if (num == null) {
|
||||
return { ok: false, message: '请输入有效体重(单位 kg)' }
|
||||
}
|
||||
if (num < MIN_WEIGHT || num > MAX_WEIGHT) {
|
||||
return { ok: false, message: `体重请在 ${MIN_WEIGHT}-${MAX_WEIGHT} kg 之间` }
|
||||
}
|
||||
return { ok: true, value: formatMeasure(num) }
|
||||
}
|
||||
|
||||
export function parseBirthdayChinese(birthday) {
|
||||
const match = String(birthday ?? '').match(/(\d{4})年(\d{2})月(\d{2})日/)
|
||||
if (!match) return null
|
||||
return {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3])
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBirthday(birthday) {
|
||||
const parts = parseBirthdayChinese(birthday)
|
||||
if (!parts) {
|
||||
return { ok: false, message: '请选择生日' }
|
||||
}
|
||||
const { year, month, day } = parts
|
||||
if (year < MIN_BIRTH_YEAR) {
|
||||
return { ok: false, message: '生日年份不合理' }
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return { ok: false, message: '生日日期无效' }
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (date > today) {
|
||||
return { ok: false, message: '生日不能晚于今天' }
|
||||
}
|
||||
|
||||
const minBirth = new Date(
|
||||
today.getFullYear() - MIN_AGE,
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
)
|
||||
if (date > minBirth) {
|
||||
return { ok: false, message: `需年满 ${MIN_AGE} 周岁` }
|
||||
}
|
||||
|
||||
return { ok: true, value: `${year}年${String(month).padStart(2, '0')}月${String(day).padStart(2, '0')}日` }
|
||||
}
|
||||
|
||||
export function validateGender(gender) {
|
||||
if (gender === 'male' || gender === 'female') {
|
||||
return { ok: true, value: gender }
|
||||
}
|
||||
return { ok: false, message: '请选择性别' }
|
||||
}
|
||||
|
||||
export function validateFitnessGoals(goals, options = []) {
|
||||
const list = Array.isArray(goals) ? goals : []
|
||||
const allowed = new Set(options)
|
||||
const invalid = list.filter((g) => !allowed.has(g))
|
||||
if (invalid.length) {
|
||||
return { ok: false, message: '健身目标选项无效' }
|
||||
}
|
||||
if (list.length > MAX_FITNESS_GOALS) {
|
||||
return { ok: false, message: `最多选择 ${MAX_FITNESS_GOALS} 个健身目标` }
|
||||
}
|
||||
return { ok: true, value: [...list] }
|
||||
}
|
||||
|
||||
export function validateUserProfile(profile, goalOptions = []) {
|
||||
const nameResult = validateName(profile.name)
|
||||
if (!nameResult.ok) return nameResult
|
||||
|
||||
const phoneResult = validatePhone(profile.phone)
|
||||
if (!phoneResult.ok) return phoneResult
|
||||
|
||||
const genderResult = validateGender(profile.gender)
|
||||
if (!genderResult.ok) return genderResult
|
||||
|
||||
const birthdayResult = validateBirthday(profile.birthday)
|
||||
if (!birthdayResult.ok) return birthdayResult
|
||||
|
||||
const heightResult = validateHeight(profile.height)
|
||||
if (!heightResult.ok) return heightResult
|
||||
|
||||
const weightResult = validateWeight(profile.weight)
|
||||
if (!weightResult.ok) return weightResult
|
||||
|
||||
const goalsResult = validateFitnessGoals(profile.fitnessGoals, goalOptions)
|
||||
if (!goalsResult.ok) return goalsResult
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...profile,
|
||||
name: nameResult.value,
|
||||
phone: phoneResult.value,
|
||||
gender: genderResult.value,
|
||||
birthday: birthdayResult.value,
|
||||
height: heightResult.value,
|
||||
weight: weightResult.value,
|
||||
fitnessGoals: goalsResult.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function showValidationError(message) {
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* ============================================
|
||||
* 健身房管理系统小程序 - 全局配色变量
|
||||
* 主题:活力运动风格
|
||||
* 主色调:深蓝专业 + 活力橙热情
|
||||
* 兼容暗色/浅色模式基础,保证可访问性
|
||||
* ============================================
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ========== 主品牌色 ========== */
|
||||
--primary-dark: #0B2B4B; /* 深蓝主色 - 用于头部导航栏、重要按钮、品牌标识,体现专业信赖感 */
|
||||
--primary-deep: #1A4A6F; /* 中深蓝色 - 用于hover状态、次级按钮、图标点缀,增加层次感 */
|
||||
--primary-light: #2C6288; /* 浅蓝色(预留)- 用于选中态或辅助背景,保持和谐渐变 */
|
||||
|
||||
/* ========== 强调/行动色 ========== */
|
||||
--accent-orange: #FF6B35; /* 活力橙 - 主要CTA按钮、会员标识、高亮徽章、关键数据,刺激行动力 */
|
||||
--accent-orange-light: #FF8C5A; /* 浅橙色 - hover轻量背景、渐变辅助,带来温暖运动感 */
|
||||
--accent-orange-dark: #E55A2B; /* 深橙色 - 按压状态或重要警告,保持色彩体系完整 */
|
||||
|
||||
/* ========== 背景色系 ========== */
|
||||
--bg-light: #F9FAFE; /* 全局浅灰蓝背景 - 柔和且提升深蓝/橙色的视觉舒适度 */
|
||||
--bg-white: #FFFFFF; /* 纯白卡片背景 - 用于内容卡片、表单区域,提高可读性与层次感 */
|
||||
--bg-gray: #F2F5F9; /* 浅灰辅助背景 - 分割区域或禁用态背景 */
|
||||
|
||||
/* ========== 文本色系 ========== */
|
||||
--text-dark: #1E2A3A; /* 主要文字 - 标题、正文,保证高对比度 */
|
||||
--text-muted: #5E6F8D; /* 辅助文字 - 次要信息、占位符,保持易读性 */
|
||||
--text-light: #8A99B4; /* 更浅文字 - 提示语、时间戳,但需注意与背景对比 */
|
||||
--text-inverse: #FFFFFF; /* 反白文字 - 深色/橙色背景上的文字 */
|
||||
|
||||
/* ========== 边框/分割线 ========== */
|
||||
--border-light: #E9EDF2; /* 浅边框 - 卡片分割、列表边界,细腻柔和 */
|
||||
--border-focus: #FF6B35; /* 聚焦边框 - 输入框选中或强调区域,使用橙色点缀 */
|
||||
|
||||
/* ========== 状态颜色(功能性) ========== */
|
||||
--success-green: #2ECC71; /* 成功绿 - 已完成课程、健康打卡 */
|
||||
--warning-amber: #F39C12; /* 警示橙黄 - 提醒、到期提示 */
|
||||
--error-red: #E74C3C; /* 错误红 - 异常情况或取消预约 */
|
||||
--info-blue: #3498DB; /* 信息蓝 - 提示气泡、帮助文字 */
|
||||
|
||||
/* ========== 渐变色 (提升活力感) ========== */
|
||||
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%); /* 橙色渐变 - 会员按钮、重要徽章 */
|
||||
--gradient-blue: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%); /* 深蓝渐变 - 头部banner或特别卡片 */
|
||||
--gradient-subtle: linear-gradient(120deg, #F9FAFE 0%, #FFFFFF 100%); /* 微弱渐变 - 增加细节精致度 */
|
||||
|
||||
/* ========== 阴影层级 ========== */
|
||||
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05); /* 卡片小阴影 轻量浮起 */
|
||||
--shadow-md: 0 12px 28px rgba(0, 0, 0, 0.08); /* 中等阴影 - 弹窗或下拉菜单 */
|
||||
--shadow-lg: 0 20px 35px rgba(0, 0, 0, 0.12); /* 大阴影 - 模态框、悬浮元素 */
|
||||
--shadow-orange-glow: 0 4px 12px rgba(255, 107, 53, 0.25); /* 橙色光晕 - 增强CTA吸引力 */
|
||||
|
||||
/* ========== 圆角规范 (柔和运动风) ========== */
|
||||
--radius-sm: 12px; /* 小组件、标签圆角 */
|
||||
--radius-md: 20px; /* 标准卡片圆角 */
|
||||
--radius-lg: 28px; /* 大容器、头部卡片圆角 */
|
||||
--radius-full: 999px; /* 胶囊按钮、头像完全圆角 */
|
||||
|
||||
/* ========== 布局与间距 ========== */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
/* ========== 字体 (移动端优先) ========== */
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 0.7rem; /* 辅助标注 */
|
||||
--font-size-sm: 0.8rem; /* 次要文字 */
|
||||
--font-size-base: 0.9rem; /* 正文基准 */
|
||||
--font-size-md: 1rem; /* 小标题 */
|
||||
--font-size-lg: 1.2rem; /* 卡片标题 */
|
||||
--font-size-xl: 1.4rem; /* 大数字/欢迎语 */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
}
|
||||
|
||||
/* ========== 暗色模式适配(可选,保持品牌一致性) ========== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* 暗色模式下微调背景与文字,保留品牌色核心 */
|
||||
--bg-light: #121826;
|
||||
--bg-white: #1E2636;
|
||||
--bg-gray: #0F141F;
|
||||
--text-dark: #EDF2F7;
|
||||
--text-muted: #9AA9C1;
|
||||
--border-light: #2A3346;
|
||||
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.4);
|
||||
/* 保留主色深蓝与橙色不变,但可适当提高对比 */
|
||||
--primary-dark: #123A5E; /* 亮一点保证深色背景可见度 */
|
||||
--accent-orange: #FF7846; /* 稍微提亮橙色 */
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 辅助类 (方便开发直接复用) ========== */
|
||||
.bg-primary {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
.bg-accent {
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
.text-primary {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
.text-accent {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.btn-orange {
|
||||
background: var(--gradient-orange);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 10px 20px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
box-shadow: var(--shadow-orange-glow);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-orange:active {
|
||||
transform: scale(0.97);
|
||||
background: var(--accent-orange-dark);
|
||||
}
|
||||
.card-default {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,25 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "iconfont_courseCard"; /* Project id */
|
||||
src: url('./font/iconfont_courseCard.ttf?t=1780537357472') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont_courseCard {
|
||||
font-family: "iconfont_courseCard" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-didian:before {
|
||||
content: "\e61a";
|
||||
}
|
||||
|
||||
.icon-renwu-ren:before {
|
||||
content: "\e749";
|
||||
}
|
||||
|
||||
.icon-shijian:before {
|
||||
content: "\e61d";
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "iconfont_time_select"; /* Project id */
|
||||
src: url('./font/iconfont_time_select.ttf?t=1780535096813') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont_time_select {
|
||||
font-family: "iconfont_time_select" !important;
|
||||
font-size: 25px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-zaochen:before {
|
||||
content: "\e784";
|
||||
}
|
||||
|
||||
.icon-gengduo:before {
|
||||
content: "\e6df";
|
||||
}
|
||||
|
||||
.icon-xiawucha:before {
|
||||
content: "\100ff";
|
||||
}
|
||||
|
||||
.icon-yewan:before {
|
||||
content: "\e67e";
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
@import './member-info-page.css';
|
||||
@import './member-info-status-bar.css';
|
||||
@import './member-info-header.css';
|
||||
@import './member-info-member-card.css';
|
||||
@import './member-info-quick-actions.css';
|
||||
@import './member-info-booking-list.css';
|
||||
@import './member-info-check-in-list.css';
|
||||
@import './member-info-body-report.css';
|
||||
@import './member-info-coupon-points.css';
|
||||
@import './member-info-referral.css';
|
||||
@import './member-info-settings.css';
|
||||
@import './member-info-logout.css';
|
||||
@@ -1,245 +0,0 @@
|
||||
.body-report-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.body-report-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.body-report-section__card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
}
|
||||
|
||||
.body-report-section__card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: var(--spacing-md, 16px);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body-report-section__card-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__card-head-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__desc {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.body-report-section__view-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__view-icon {width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.body-report-section__view-report {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.body-report-section__metrics {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.body-report-section__metrics-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__metric {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body-report-section__metric-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__text {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.body-report-section__text-2 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.body-report-section__text-4 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: rgba(243, 156, 18, 1);
|
||||
}
|
||||
|
||||
.body-report-section__num {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--primary-deep);
|
||||
}
|
||||
|
||||
.body-report-section__metric-value,
|
||||
.body-report-section__text-3,
|
||||
.body-report-section__metric-label,
|
||||
.body-report-section__text-5 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.body-report-section__metric-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--border-light, #e9edf2);
|
||||
}
|
||||
|
||||
.body-report-section__summary {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg-light, #f9fafe);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body-report-section__summary-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body-report-section__goal {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 58%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 100px;
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.body-report-section__goal-icon {width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.body-report-section__goal-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--success-green);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.body-report-section__change {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__change-icon {width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.body-report-section__metric-value-2 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--success-green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
.booking-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.booking-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.booking-section__header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.booking-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.booking-section__link {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.booking-section__item {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
}
|
||||
|
||||
.booking-section__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.booking-section__date {
|
||||
width: 48px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.booking-section__date-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.booking-section__num {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.booking-section__date-sub {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--accent-orange-light);
|
||||
}
|
||||
|
||||
.booking-section__content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.booking-section__content-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.booking-section__desc {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.booking-section__meta {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-section__meta-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.booking-section__icon-coach {width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.booking-section__coach {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.booking-section__icon-location {width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.booking-section__text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.booking-section__status-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-section__status-badge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.booking-section__status-badge--booked {
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
|
||||
.booking-section__status-badge--pending {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
border: 1px solid rgba(212, 166, 74, 1);
|
||||
}
|
||||
|
||||
.booking-section__status-text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-inverse);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.booking-section__status-text--pending {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
.checkin-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.checkin-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkin-section__header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkin-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkin-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.checkin-section__list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
background-color: var(--bg-white);
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkin-section__list-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkin-section__row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkin-section__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkin-section__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px var(--spacing-md);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkin-section__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
|
||||
.checkin-section__dot--group {
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
|
||||
.checkin-section__dot--free {
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.checkin-section__dot--private {
|
||||
background-color: var(--primary-deep);
|
||||
}
|
||||
|
||||
.checkin-section__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.checkin-section__content-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkin-section__desc {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-dark);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkin-section__text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-light);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px var(--spacing-sm);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge--group {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge--free {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge--private {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--success-green);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.checkin-section__tag-text--group {
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.checkin-section__tag-text--free {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.checkin-section__tag-text--private {
|
||||
color: var(--primary-deep);
|
||||
}
|
||||
|
||||
.checkin-section__divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-light);
|
||||
margin-left: calc(var(--spacing-md) + 8px + 12px);
|
||||
margin-right: var(--spacing-md);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/* 组件根节点:锁定浅色变量 + box-sizing(小程序组件内继承 page 的 theme-light 不稳定) */
|
||||
.status-bar,
|
||||
.profile-header,
|
||||
.member-card-section,
|
||||
.quick-actions,
|
||||
.booking-section,
|
||||
.checkin-section,
|
||||
.body-report-section,
|
||||
.coupon-section,
|
||||
.referral-section,
|
||||
.settings-section,
|
||||
.logout-btn__border-wrap,
|
||||
.logout-section {
|
||||
box-sizing: border-box;
|
||||
--primary-dark: #0B2B4B;
|
||||
--primary-deep: #1A4A6F;
|
||||
--primary-light: #2C6288;
|
||||
--accent-orange: #FF6B35;
|
||||
--accent-orange-light: #FF8C5A;
|
||||
--accent-orange-dark: #E55A2B;
|
||||
--bg-light: #F9FAFE;
|
||||
--bg-white: #FFFFFF;
|
||||
--bg-gray: #F2F5F9;
|
||||
--text-dark: #1E2A3A;
|
||||
--text-muted: #5E6F8D;
|
||||
--text-light: #8A99B4;
|
||||
--text-inverse: #FFFFFF;
|
||||
--border-light: #E9EDF2;
|
||||
--border-focus: #FF6B35;
|
||||
--success-green: #2ECC71;
|
||||
--warning-amber: #F39C12;
|
||||
--error-red: #E74C3C;
|
||||
--info-blue: #3498DB;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 20px;
|
||||
--radius-lg: 28px;
|
||||
--radius-full: 999px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 22px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 区块标题右侧操作链接:查看全部 / 全部记录 / 历史数据 / 推荐记录 */
|
||||
.member-card-section__link-text,
|
||||
.booking-section__view-all,
|
||||
.checkin-section__view-all,
|
||||
.coupon-section__view-all,
|
||||
.body-report-section__history-link,
|
||||
.referral-section__records-link {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.member-card-section__link,
|
||||
.booking-section__link,
|
||||
.checkin-section__link,
|
||||
.coupon-section__link,
|
||||
.body-report-section__link,
|
||||
.referral-section__link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-section__link-arrow,
|
||||
.booking-section__link-arrow,
|
||||
.checkin-section__link-arrow,
|
||||
.coupon-section__link-arrow,
|
||||
.body-report-section__link-arrow,
|
||||
.referral-section__link-arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
|
||||
|
||||
.coupon-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.coupon-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coupon-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__link {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__view-all {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__link-arrow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__cards {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.coupon-section__cards-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__coupon {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.coupon-section__coupon-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 14px 14px 14px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__amount {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__desc {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__coupon-status {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 2px 6px 2px 6px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(255, 255, 255, 0.1882352977991104);
|
||||
}
|
||||
|
||||
.coupon-section__status {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__points {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.coupon-section__points-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 14px 14px 14px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__num {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__points-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__points-action {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 2px 6px 2px 6px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(255, 255, 255, 0.1882352977991104);
|
||||
}
|
||||
|
||||
.coupon-section__text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 与个人中心首页 coupon-section 一致的渐变卡片背景
|
||||
* 使用 SVG 背景,兼容微信小程序(CSS 变量渐变在部分端不生效)
|
||||
*/
|
||||
|
||||
.mi-gradient-blue,
|
||||
.coupon-section__points,
|
||||
.mi-mod-points-hero,
|
||||
.bt-hero,
|
||||
.bt-score-card,
|
||||
.mi-mod-referral-hero {
|
||||
background-color: #0B2B4B;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
box-shadow: 0px 4px 8px 0px rgba(11, 43, 75, 0.25);
|
||||
}
|
||||
|
||||
.mi-gradient-orange,
|
||||
.coupon-section__coupon,
|
||||
.mi-mod-coupon__left,
|
||||
.mi-mod-coupon__use,
|
||||
.mi-center-coupon__btn:not(.mi-center-coupon__btn--done),
|
||||
.bt-page__action-link--primary,
|
||||
.bt-btn--primary {
|
||||
background-color: #FF6B35;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
box-shadow: 0px 4px 8px 0px rgba(255, 107, 53, 0.25);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
.profile-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 顶栏固定:仅白底导航栏吸顶,下方用户信息可滚动 */
|
||||
.profile-header__toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__toolbar-spacer {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-header__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding-left: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__nav-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 56px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.profile-header__nav-right {
|
||||
min-width: 72px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.profile-header__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 42%;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-header__icon-bell,
|
||||
.profile-header__icon-settings {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* settings.png 为白色线稿,白底顶栏需着色后才可见 */
|
||||
.profile-header__icon-settings {
|
||||
filter: brightness(0) saturate(100%) invert(52%) sepia(98%) saturate(1800%) hue-rotate(346deg) brightness(102%) contrast(101%);
|
||||
}
|
||||
|
||||
/* 用户信息渐变区 */
|
||||
.profile-header__hero {
|
||||
width: 100%;
|
||||
background-position: center;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20390%20239'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.0000%2C%201.0000%2C%20-1.0000%2C%20-0.0000%2C%201.0000%2C%200.0000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.profile-header__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px 28px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__user {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-header__user-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__avatar-wrap {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.profile-header__avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #ffffff;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.profile-header__avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-header__avatar-badge {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
background-color: #2ecc71;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__avatar-badge-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.profile-header__user-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-header__user-meta-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__name {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__phone {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__badge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.profile-header__badge-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-header__level {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__stats-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-header__stat-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__stat-value {
|
||||
font-size: var(--font-size-2xl, 22px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__stat-label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(255, 255, 255, 0.31);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
.logout-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logout-section__btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logout-section__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logout-section__text {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 500;
|
||||
color: #e74c3c;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-tap-btn--hover {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
.member-card-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.member-card-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-section__head {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-card-section__head-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-section__link {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-section__link-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-card-section__link-arrow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 8px 16px 0px rgba(255, 107, 53, 0.25);
|
||||
background-position: center;
|
||||
background-size: 100% 100%;
|
||||
box-sizing: border-box;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20140'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'0.6000000238418579'%2F%3E%3Cstop%20stop-color%3D'rgba(229%2C90%2C43%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.member-card-preview__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 20px 16px 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview__head {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-card-preview__head-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview__type-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-wrap {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
border-radius: 1.5px 1.5px 1.5px 1.5px;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-border {
|
||||
position: absolute;
|
||||
left: 8.33%;
|
||||
top: 20.83%;
|
||||
right: 8.33%;
|
||||
bottom: 20.83%;
|
||||
width: 83.34%;
|
||||
height: 58.34%;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-stroke {
|
||||
position: absolute;
|
||||
inset: -0.75px -0.75px -0.75px -0.75px;
|
||||
border-radius: 1.5px 1.5px 1.5px 1.5px;
|
||||
pointer-events: none;
|
||||
border-width: 1.5px 1.5px 1.5px 1.5px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.member-card-preview__icon-line {width: 83.34%;
|
||||
height: 8.33%;
|
||||
position: absolute;
|
||||
left: 8.33%;
|
||||
right: 8.33%;
|
||||
top: 41.67%;
|
||||
bottom: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-card-preview__name {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-card-preview__tag {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.19);
|
||||
}
|
||||
|
||||
.member-card-preview__tag-text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__expire {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__footer {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.member-card-preview__footer-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview__days {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.member-card-preview__days-num {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__days-unit {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__renew {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: 14px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
|
||||
.member-card-preview__renew-text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-tip__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.member-card-tip__content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 12px 8px 12px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-tip {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-tip__border {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: rgba(255, 204, 170, 1);
|
||||
}
|
||||
|
||||
.member-card-tip__icon {width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-card-tip__text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 22px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
}
|
||||
|
||||
.scroll-container > view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background-color: var(--bg-light);
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.member-page__body {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.member-page__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: var(--spacing-md, 16px);
|
||||
padding-bottom: calc(var(--spacing-md, 16px) + env(safe-area-inset-bottom));
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.member-page__sections text {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
/* ????????? */
|
||||
.status-bar,
|
||||
.profile-header,
|
||||
.member-card-section,
|
||||
.quick-actions,
|
||||
.booking-section,
|
||||
.checkin-section,
|
||||
.body-report-section,
|
||||
.coupon-section,
|
||||
.referral-section,
|
||||
.settings-section,
|
||||
.logout-btn__border-wrap,
|
||||
.logout-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
.quick-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 2px 12px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-actions__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__grid {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.quick-actions__grid-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__item {
|
||||
flex: 1;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-actions__item-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.quick-actions__icon-wrap-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-actions__icon-part {
|
||||
position: absolute;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(1) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 30.77%;
|
||||
top: 8.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(2) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 61.54%;
|
||||
top: 8.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(4) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 30.77%;
|
||||
top: 58.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(5) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 61.54%;
|
||||
top: 58.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__border-wrap {
|
||||
position: absolute;
|
||||
left: 12.5%;
|
||||
top: 25%;
|
||||
width: 75%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.quick-actions__rect {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.quick-actions__border {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--accent-orange);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quick-actions__icon-img {width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-actions__title,
|
||||
.quick-actions__title-2,
|
||||
.quick-actions__title-3,
|
||||
.quick-actions__title-4,
|
||||
.quick-actions__coach,
|
||||
.quick-actions__text,
|
||||
.quick-actions__text-2,
|
||||
.quick-actions__points-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-actions__divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--border-light, #e9edf2);
|
||||
}
|
||||
|
||||
/* 第�?�?�??�?*/
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
/* 第�?�?�??�?*/
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 236, 236, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
.referral-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.referral-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.referral-section__title {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-section__link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-section__records-link {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.referral-section__link-arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 推荐码行:grid 避免小程序 flex 宽度计算异常 */
|
||||
.referral-section__code-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__code-box {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f2f5f9;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.referral-section__code-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.referral-section__code-value {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.referral-section__copy-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--accent-orange);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 例图:双矩形复制图标 */
|
||||
.referral-section__copy-icon {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-section__copy-sheet {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1.5px solid #ffffff;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__copy-sheet--back {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.referral-section__copy-sheet--front {
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.referral-section__copy-text {
|
||||
display: block;
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.referral-section__stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__stat {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.referral-section__stat-num {
|
||||
display: block;
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.referral-section__stat-num--orange {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.referral-section__stat-num--green {
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.referral-section__stat-num--amber {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.referral-section__stat-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.referral-section__stat-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--border-light);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
.settings-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
--bg-white: #ffffff;
|
||||
--text-dark: #1e2a3a;
|
||||
--text-light: #8a99b4;
|
||||
--error-red: #e74c3c;
|
||||
--success-green: #2ecc71;
|
||||
--border-light: #e9edf2;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
}
|
||||
|
||||
.settings-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-section__title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: #1e2a3a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section__list {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-section__list-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-section__item {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.settings-section__item--tall {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.settings-section__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap--blue {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap--green {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap--red {
|
||||
background-color: rgba(255, 236, 236, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-section__item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-section__item-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: #1e2a3a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section__item-label--danger {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.settings-section__item-texts {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-section__item-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: #1e2a3a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section__item-desc {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: #2ecc71;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-section__item-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-section__item-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #e9edf2;
|
||||
}
|
||||
|
||||
.mi-tap-row--hover {
|
||||
opacity: 0.72;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.status-bar {
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.status-bar__inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 20px 0px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-bar__time {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.status-bar__icons {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
.sub-nav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sub-nav__toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #e9edf2;
|
||||
}
|
||||
|
||||
.sub-nav__spacer {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-nav__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sub-nav__back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 8px;
|
||||
background-color: #f9fafe;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sub-nav__back-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-nav__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 50%;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
color: #1e2a3a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sub-nav__right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sub-nav__action {
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sub-nav__action-text {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 500;
|
||||
color: #ff6b35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-nav__action--button {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: #ff6b35;
|
||||
}
|
||||
|
||||
.sub-nav__action--button .sub-nav__action-text {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-nav__capsule {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub-nav__capsule--h5 {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/* 小程序点击反馈(配合 hover-class 使用) */
|
||||
.mi-tap--hover,
|
||||
.mi-tap--scale,
|
||||
.mi-tap-card--hover,
|
||||
.mi-tap-btn--hover,
|
||||
.mi-tap-tab--hover,
|
||||
.mi-tap-save--hover {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.mi-tap--scale {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.mi-tap-card--hover {
|
||||
opacity: 0.92;
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.mi-tap-btn--hover {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.mi-tap-tab--hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mi-tap-row--hover {
|
||||
background-color: rgba(249, 250, 254, 0.95);
|
||||
}
|
||||
|
||||
.mi-tap-save--hover {
|
||||
opacity: 0.88;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
@@ -1,860 +0,0 @@
|
||||
/* 智能体测模块 - 公共样式(基于 base.css 变量) */
|
||||
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
|
||||
|
||||
.bt-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.bt-page__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 12px);
|
||||
padding: var(--spacing-md, 16px) var(--spacing-md, 16px) 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-card {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md, 20px);
|
||||
background-color: var(--bg-white, #fff);
|
||||
box-shadow: var(--shadow-sm, 0 8px 20px rgba(0, 0, 0, 0.03));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-card__title {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-card__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-hero {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bt-hero__top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bt-hero__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.bt-hero__badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.bt-hero__badge-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-inverse, #fff);
|
||||
}
|
||||
|
||||
.bt-hero__score-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-hero__score {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bt-hero__grade {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange-light, #FF8C5A);
|
||||
}
|
||||
|
||||
.bt-hero__meta {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
}
|
||||
|
||||
.bt-hero__actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-btn--primary {
|
||||
box-shadow: var(--shadow-orange-glow, 0 4px 12px rgba(255, 107, 53, 0.25));
|
||||
}
|
||||
|
||||
.bt-btn--ghost {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.bt-btn--outline {
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-btn__text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-btn--primary .bt-btn__text,
|
||||
.bt-btn--ghost .bt-btn__text {
|
||||
color: var(--text-inverse, #fff);
|
||||
}
|
||||
|
||||
.bt-btn--outline .bt-btn__text {
|
||||
color: var(--primary-dark, #0B2B4B);
|
||||
}
|
||||
|
||||
.bt-btn__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.bt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-grid__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 4px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.bt-grid__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.bt-grid__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-device {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bt-device__icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-device__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.bt-device__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-device__name {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-device__status {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bt-device__status--on {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bt-device__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-light, #8A99B4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-device__dot--on {
|
||||
background: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bt-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-metric {
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bt-metric__value {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-metric__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-metric__change {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-metric__change--down {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bt-metric__change--up {
|
||||
color: var(--warning-amber, #F39C12);
|
||||
}
|
||||
|
||||
.bt-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bt-step {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.bt-step__num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
color: var(--text-inverse, #fff);
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-step__content {
|
||||
flex: 1;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-step:last-child .bt-step__content {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.bt-step__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-step__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bt-measure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bt-measure__ring-wrap {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.bt-measure__ring-bg {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
border: 10px solid var(--border-light, #E9EDF2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-measure__ring-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
border: 10px solid transparent;
|
||||
border-top-color: var(--accent-orange, #FF6B35);
|
||||
border-right-color: var(--accent-orange, #FF6B35);
|
||||
box-sizing: border-box;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.bt-measure__center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-measure__percent {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-dark, #0B2B4B);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-measure__hint {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-measure__live {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-measure__live-item {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-measure__live-value {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-measure__live-label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-score-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
}
|
||||
|
||||
.bt-score-card__circle {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-score-card__num {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bt-score-card__grade {
|
||||
font-size: 11px;
|
||||
color: var(--accent-orange-light, #FF8C5A);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-score-card__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bt-score-card__title {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #fff);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-score-card__date {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-body-map {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.bt-body-map__figure {
|
||||
width: 120px;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bt-body-map__head {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light, #2C6288);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bt-body-map__torso {
|
||||
width: 56px;
|
||||
height: 70px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary-deep, #1A4A6F);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-body-map__limbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
margin-top: -60px;
|
||||
}
|
||||
|
||||
.bt-body-map__arm {
|
||||
width: 16px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-light, #2C6288);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bt-body-map__legs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-body-map__leg {
|
||||
width: 22px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-light, #2C6288);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bt-body-map__segments {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bt-body-map__seg {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.bt-body-map__seg--high {
|
||||
border-left: 3px solid var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.bt-body-map__seg--low {
|
||||
border-left: 3px solid var(--info-blue, #3498DB);
|
||||
}
|
||||
|
||||
.bt-body-map__seg-name {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-body-map__seg-val {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-advice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-advice-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bt-advice-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-orange, #FF6B35);
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-advice-item__text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bt-course {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-course:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.bt-course__banner {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-course__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bt-course__tag {
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-course__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-course__meta {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-history-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-history-item__date {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-history-item__day {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark, #0B2B4B);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-history-item__month {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-history-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-history-item__score-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bt-history-item__grade {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-history-item__status {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-history-item__metrics {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bt-history-item__arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-compare-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-compare-picker {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-compare-picker__label {
|
||||
font-size: 10px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-compare-picker__date {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-compare-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-compare-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-compare-row__label {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-compare-row__val {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-compare-row__diff {
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-setting {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-setting:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-setting__label {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-setting__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bt-footer-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bt-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.bt-empty__text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.bt-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
overflow-x: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-tab {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-tab--active {
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
border-color: var(--primary-dark, #0B2B4B);
|
||||
}
|
||||
|
||||
.bt-tab__text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bt-tab--active .bt-tab__text {
|
||||
color: var(--text-inverse, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-trend-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0 0;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-trend-link__text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-trend-link__arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
.booking-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
/* Tab 栏 */
|
||||
.booking-page__tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.booking-page__tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.booking-page__tab-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.booking-page__tab-text--active {
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.booking-page__tab-indicator {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.booking-page__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 提醒横幅 */
|
||||
.booking-page__alert {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 204, 170, 1);
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.booking-page__alert-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.booking-page__alert-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 预约卡片 */
|
||||
.bk-card {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.bk-card__banner {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: block;
|
||||
border-radius: 14px 14px 0 0;
|
||||
}
|
||||
|
||||
.bk-card__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bk-card__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bk-card__status {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__status--booked {
|
||||
background-color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bk-card__status--pending {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
border: 1px solid rgba(212, 166, 74, 1);
|
||||
}
|
||||
|
||||
.bk-card__status--completed {
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bk-card__status--cancelled {
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bk-card__status-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bk-card__status-text--booked {
|
||||
color: var(--text-inverse, #ffffff);
|
||||
}
|
||||
|
||||
.bk-card__status-text--pending {
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.bk-card__status-text--completed {
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bk-card__status-text--cancelled {
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
/* 时间与教练信息(分两行) */
|
||||
.bk-card__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bk-card__meta-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bk-card__meta-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bk-card__meta-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 底部操作行 */
|
||||
.bk-card__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bk-card__footer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bk-card__cancel {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__cancel-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--error-red, #E74C3C);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.booking-page__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
|
||||
.booking-page__empty-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
@@ -1,774 +0,0 @@
|
||||
.Pixso-frame-2_965 {
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_965 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 20px 0px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_966 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_967 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_968 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_968 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_968 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_968 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
pointer-events: none;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-frame-2_969 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.frame-content-2_969 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_970 {width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_972 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--primary-dark);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_973 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.Pixso-frame-2_974 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_974 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_974 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_974 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
pointer-events: none;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-frame-2_975 {
|
||||
width: 74px;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
.frame-content-2_975 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_976 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_977 {
|
||||
width: 74px;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 42px;
|
||||
border-radius: 2px 2px 2px 2px;
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
.Pixso-frame-2_978 {
|
||||
width: auto;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
.frame-content-2_978 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_979 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_980 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_980 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 40px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_981 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.frame-content-2_981 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px 10px 12px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_981 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_981 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: rgba(255, 204, 170, 1);
|
||||
}
|
||||
.Pixso-vector-2_982 {width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_985 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_986 {
|
||||
width: 100%;
|
||||
height: 195px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_986 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_987 {width: 100%;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_988 {
|
||||
width: 358px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
border-radius: 14px 14px 0px 0px;
|
||||
background-color: rgba(0, 0, 0, 0.1882352977991104);
|
||||
}
|
||||
.Pixso-frame-2_989 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_989 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px 12px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_990 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_990 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_991 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_992 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 10px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
.Pixso-paragraph-2_993 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_994 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_994 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_995 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_998 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-vector-2_999 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_1002 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1003 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1003 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_1004 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1005 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 6px 14px 6px 14px;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.stroke-wrapper-2_1005 {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_1005 {
|
||||
position: absolute;
|
||||
inset: -1px -1px -1px -1px;
|
||||
border-radius: 9px 9px 9px 9px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-paragraph-2_1006 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1007 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_1007 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_1008 {width: 100%;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_1009 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_1009 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px 12px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_1010 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1010 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_1011 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1012 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 10px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.stroke-wrapper-2_1012 {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_1012 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: rgba(212, 166, 74, 1);
|
||||
}
|
||||
.Pixso-paragraph-2_1013 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1014 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1014 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_1015 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_1018 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-vector-2_1019 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_1022 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1023 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1023 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_1024 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1025 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 6px 14px 6px 14px;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.stroke-wrapper-2_1025 {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_1025 {
|
||||
position: absolute;
|
||||
inset: -1px -1px -1px -1px;
|
||||
border-radius: 9px 9px 9px 9px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-paragraph-2_1026 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
.mi-course-list__filters {
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 10px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mi-course-list__date-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mi-course-list__mode {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-list__mode-text {
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mi-course-list__dates {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-course-list__date {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin-right: 6px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-course-list__date--active {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
border-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.mi-course-list__date-week {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-course-list__date-day {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-course-list__chips {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-course-list__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mi-course-list__picker {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
font-size: 13px;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-course-list__arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mi-course-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 18px;
|
||||
background: var(--bg-white, #fff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mi-course-card__banner {
|
||||
width: 96px;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mi-course-card__head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mi-course-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-course-card__type {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__type--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-course-card__type--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-course-card__type text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-course-card__coach {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-course-card__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mi-course-card__meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-course-card__capacity {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mi-course-card__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mi-course-card__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--gradient-orange);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mi-course-card__cap-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__scarcity {
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mi-course-card__price {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
}
|
||||
|
||||
.mi-course-card__btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--gradient-orange);
|
||||
}
|
||||
|
||||
.mi-course-card__btn text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mi-course-card__btn--disabled {
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.mi-course-card__btn--disabled text {
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-course-list__fab {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mi-course-list__fab-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.mi-course-list__fab-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
.member-card-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.member-card-page__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 会员卡 */
|
||||
.mc-hero {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: 20px 20px 16px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 20px rgba(11, 43, 75, 0.31);
|
||||
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-hero__top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-hero__title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mc-hero__crown {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-hero__name {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-hero__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(255, 255, 255, 0.19);
|
||||
}
|
||||
|
||||
.mc-hero__badge-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-hero__validity {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-hero__bottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mc-hero__days {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mc-hero__days-num {
|
||||
font-size: var(--font-size-5xl, 32px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mc-hero__days-unit {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.mc-hero__renew {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-hero__renew-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-hero__renew-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 使用记录 */
|
||||
.mc-records {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__tab--active {
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mc-records__tab-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-records__tab-text--active {
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mc-records__divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.mc-records__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__icon-wrap--orange {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.mc-records__icon-wrap--green {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.mc-records__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mc-records__item-title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-records__item-time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-records__value {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__value--negative {
|
||||
color: var(--error-red, #E74C3C);
|
||||
}
|
||||
|
||||
.mc-records__value--positive {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
/* 使用规则 */
|
||||
.mc-rules {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mc-rules__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-rules__item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-rules__bullet {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 6px;
|
||||
border-radius: 1px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-rules__text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -1,982 +0,0 @@
|
||||
.Pixso-frame-2_878 {
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_878 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 20px 0px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_879 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_880 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_881 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_881 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_881 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_881 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
pointer-events: none;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-frame-2_882 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.frame-content-2_882 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_883 {width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_885 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--primary-dark);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_886 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.Pixso-frame-2_887 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_887 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 40px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_888 {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 18px 18px 18px 18px;
|
||||
box-shadow: 0px 10px 20px 0px rgba(11, 43, 75, 0.3137254901960784);
|
||||
background-position: center;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20160'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
}
|
||||
.frame-content-2_888 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 20px 20px 16px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_889 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_889 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_890 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.Pixso-vector-2_891 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_894 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_895 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 10px;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 255, 255, 0.1882352977991104);
|
||||
}
|
||||
.Pixso-paragraph-2_896 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_897 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_898 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_898 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_899 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.Pixso-paragraph-2_900 {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_901 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_902 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 8px 18px 8px 18px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.Pixso-vector-2_903 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_908 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_909 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_909 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_910 {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_910 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_911 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_912 {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.frame-content-2_912 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
padding: 2px 2px 2px 2px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_913 {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.06274509803921569);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_913 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 10px 0px 10px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_914 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_915 {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.frame-content-2_915 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 10px 0px 10px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_916 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_917 {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.frame-content-2_917 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 10px 0px 10px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_918 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_919 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.Pixso-frame-2_920 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_920 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 14px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_921 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.frame-content-2_921 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_922 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_928 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.frame-content-2_928 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_929 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_930 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_931 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_932 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.Pixso-frame-2_933 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_933 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 14px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_934 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
.frame-content-2_934 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_935 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_938 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.frame-content-2_938 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_939 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_940 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_941 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_942 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.Pixso-frame-2_943 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_943 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 14px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_944 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.frame-content-2_944 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_945 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_949 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.frame-content-2_949 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_950 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_951 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_952 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--success-green);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_953 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_953 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 16px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_954 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_955 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_955 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_956 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_957 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_958 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_958 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_959 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_960 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_961 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_961 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_962 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_963 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
/* 个人中心其它模块页面样式 */
|
||||
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
|
||||
|
||||
.mi-mod-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
overflow-x: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 训练明细 */
|
||||
.mi-mod-session {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-session:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.mi-mod-session__head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mi-mod-session__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-mod-session__tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-session__tag--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-session__tag--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-mod-session__tag--free {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-session__tag-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-mod-session__meta {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-session__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mi-mod-session__stat {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
}
|
||||
|
||||
/* 优惠券 */
|
||||
.mi-mod-coupon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: var(--radius-md, 20px);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mi-mod-coupon--expired,
|
||||
.mi-mod-coupon--used {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__left {
|
||||
width: 100px;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-coupon--used .mi-mod-coupon__left,
|
||||
.mi-mod-coupon--expired .mi-mod-coupon__left {
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__amount {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mi-mod-coupon--used .mi-mod-coupon__amount,
|
||||
.mi-mod-coupon--expired .mi-mod-coupon__amount {
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__min {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mi-mod-coupon--used .mi-mod-coupon__min,
|
||||
.mi-mod-coupon--expired .mi-mod-coupon__min {
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__right {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-white, #fff);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__expire {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__tag {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__tag-text {
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 积分 */
|
||||
.mi-mod-points-hero {
|
||||
padding: 24px 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-points-hero__label {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-hero__value {
|
||||
font-size: var(--font-size-3xl, 2rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1.2;
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mi-mod-points-hero__tip {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mi-mod-rewards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mi-mod-reward {
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-reward__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mi-mod-reward__name {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-mod-reward__cost {
|
||||
font-size: 11px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mi-mod-reward__stock {
|
||||
font-size: 10px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-mod-points-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-points-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__amount {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__amount--earn {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.mi-mod-points-row__amount--spend {
|
||||
color: var(--warning-amber, #F39C12);
|
||||
}
|
||||
|
||||
.mi-mod-points-row__balance {
|
||||
font-size: 10px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
/* 邀请 */
|
||||
.mi-mod-referral-hero {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
}
|
||||
|
||||
.mi-mod-referral-hero__title {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #fff);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-hero__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-code {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-referral-code__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-code__value {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
letter-spacing: 2px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 800;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num--orange {
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num--green {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num--amber {
|
||||
color: var(--warning-amber, #F39C12);
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-referral-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__name {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__status {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__reward {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 我的课程 */
|
||||
.mi-mod-course-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md, 20px);
|
||||
background: var(--bg-white, #fff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__banner {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__coach {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--gradient-orange);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__next {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
}
|
||||
|
||||
/* 签到记录 */
|
||||
.mi-mod-checkin-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon--free {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag {
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag--free {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/* 子页面根容器:锁定变量,与 H5 theme-light 一致 */
|
||||
.scroll-container {
|
||||
box-sizing: border-box;
|
||||
--primary-dark: #0B2B4B;
|
||||
--primary-deep: #1A4A6F;
|
||||
--accent-orange: #FF6B35;
|
||||
--accent-orange-light: #FF8C5A;
|
||||
--bg-light: #F9FAFE;
|
||||
--bg-white: #FFFFFF;
|
||||
--text-dark: #1E2A3A;
|
||||
--text-muted: #5E6F8D;
|
||||
--text-light: #8A99B4;
|
||||
--text-inverse: #FFFFFF;
|
||||
--border-light: #E9EDF2;
|
||||
--success-green: #2ECC71;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 22px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scroll-container > view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== 子页面统一布局(参考 base.css 间距变量) ========== */
|
||||
|
||||
.bt-page,
|
||||
.booking-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
/* 导航栏下方首个区块:与固定顶栏留出间距 */
|
||||
.bt-page > .sub-nav + .mi-mod-tabs,
|
||||
.bt-page > .sub-nav + .mi-course-list__filters,
|
||||
.bt-page > .sub-nav + .bt-page__action-bar,
|
||||
.bt-page > .sub-nav + .bt-page__body,
|
||||
.booking-page > .sub-nav + .booking-page__tabs {
|
||||
margin-top: var(--spacing-md, 16px);
|
||||
}
|
||||
|
||||
/* tabs / 筛选栏下方内容区:避免重复过大顶边距 */
|
||||
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__action-bar + .bt-page__body,
|
||||
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__body,
|
||||
.bt-page > .sub-nav + .mi-course-list__filters + .bt-page__body,
|
||||
.booking-page > .sub-nav + .booking-page__tabs + .bt-page__action-bar + .booking-page__body,
|
||||
.booking-page > .sub-nav + .booking-page__tabs + .booking-page__body {
|
||||
padding-top: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
/* 导航栏下直接跟操作栏(无 tabs) */
|
||||
.bt-page > .sub-nav + .bt-page__action-bar + .bt-page__body {
|
||||
padding-top: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
/* 页面内次级操作栏(原导航栏右侧按钮下移至此) */
|
||||
.bt-page__action-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-page__action-bar--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bt-page__action-bar-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bt-page__action-link {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background-color: var(--bg-white, #FFFFFF);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-page__action-link--primary {
|
||||
color: var(--text-inverse, #FFFFFF);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
.Pixso-frame-2_791 {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.frame-content-2_802 {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
|
||||
.user-info-save-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-shadow: 0 -2px 12px rgba(26, 25, 24, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user-info-save-bar__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.user-info-save-bar__text {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-block {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar-block__inner {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light, #f9fafe);
|
||||
}
|
||||
|
||||
.avatar-block__photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-block__change {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar-block__icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.avatar-block__text {
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.frame-content-2_811,
|
||||
.frame-content-2_817,
|
||||
.frame-content-2_826,
|
||||
.frame-content-2_842,
|
||||
.frame-content-2_848,
|
||||
.frame-content-2_859 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.Pixso-paragraph-2_813,
|
||||
.Pixso-paragraph-2_844,
|
||||
.Pixso-paragraph-2_861 {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gender-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gender-btn__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gender-btn__text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gender-btn--active {
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.gender-btn--active .gender-btn__text {
|
||||
color: var(--text-inverse, #ffffff);
|
||||
}
|
||||
|
||||
.goal-tags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goal-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 16px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid rgba(209, 208, 205, 1);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goal-tag__text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.goal-tag--selected {
|
||||
border-color: var(--accent-orange, #FF6B35);
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.goal-tag--selected .goal-tag__text {
|
||||
color: var(--text-inverse, #ffffff);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user