Author SHA1 Message Date
liwentao 7e4035e0ae 完成模块4,4.1基础信息统计 2026-06-09 23:04:43 +08:00
liwentao e19324d0ef 测试git 2026-06-09 18:25:04 +08:00
futureandfujia e0e707edcb 添加签到表迁移文件 2026-06-09 17:29:10 +08:00
liwentao 8ab528a74b 修复web-application-type 2026-06-09 16:28:36 +08:00
liwentao de7a359ead 将member-config.yml迁移至manage-app模块
删除重复的V6sql语句
2026-06-09 11:21:19 +08:00
future fbb41911a2 修复websoket重名问题 2026-06-09 10:53:35 +08:00
future 223a427614 完成签到模块功能 2026-06-09 10:07:30 +08:00
future 78c80c4b1d 签到模块 2026-06-09 09:43:31 +08:00
116 changed files with 4706 additions and 9569 deletions
+452
View File
@@ -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
+48
View File
@@ -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
+252
View File
@@ -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>
@@ -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();
}
}
@@ -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 = "";
}
@@ -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("-", "");
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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)));
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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,如果启用Logologo-enabled: true),必须设置为 H
logo-enabled: false # 是否启用Logo(启用时error-correction必须为H
# logo-path: static/logo.png # Logo图片路径(支持相对路径或绝对路径)
@@ -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
+33
View File
@@ -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
+108
View File
@@ -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>
@@ -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 {
}
@@ -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;
}
}
}
@@ -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;
}
@@ -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() {}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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};
}
}
@@ -0,0 +1 @@
cn.novalon.gym.manage.datacount.config.DataCountAutoConfiguration
+152 -200
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -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>
@@ -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 {
@@ -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:
+4
View File
@@ -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>
@@ -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:";
}
@@ -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 '记录更新时间';
@@ -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
@@ -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");
}
@@ -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 {
@@ -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) {
+2
View File
@@ -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>
-4
View File
@@ -1,4 +0,0 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
-28
View File
@@ -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>
-13
View File
@@ -1,13 +0,0 @@
/**
* 环境配置文件
* 当前仅使用模拟数据(开发模式)
*/
import { groupCourseMockApi } from './groupCourse.mock.js'
/**
* 团课服务(仅使用模拟数据)
*/
export const groupCourseService = groupCourseMockApi
export default groupCourseService
-407
View File
@@ -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
-152
View File
@@ -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
}
-130
View File
@@ -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);
}
@@ -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,97 +0,0 @@
<template>
<view class="qr-status">
<!-- 加载中状态 -->
<view v-if="status === 'loading'" class="status-loading">
<view class="status-icon">
<view class="loading-spinner"></view>
</view>
<text>生成中...</text>
</view>
<!-- 签到成功状态 -->
<view v-else-if="status === 'scanned'" class="status-success">
<view class="status-icon">
<uni-icons type="checkmarkcircle" size="40rpx" color="#2ECC71"></uni-icons>
</view>
<text>签到成功</text>
</view>
<!-- 错误状态支持自定义文案 -->
<view v-else-if="status === 'error'" class="status-error">
<view class="status-icon">
<uni-icons type="closecircle" size="40rpx" color="#E74C3C"></uni-icons>
</view>
<text>{{ errorText || '签到失败,请重试' }}</text>
</view>
</view>
</template>
<script setup>
import { defineProps } from 'vue';
// 扩展Props,支持自定义错误文案
const props = defineProps({
status: {
type: String,
required: true,
default: ''
},
// 自定义错误文本(可选)
errorText: {
type: String,
required: false,
default: ''
}
});
</script>
<style scoped>
/* 保留原样式,新增加载中样式 */
.qr-status {
margin-bottom: 48rpx;
}
.status-loading,
.status-waiting,
.status-success,
.status-error {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
font-size: 29rpx;
}
.status-icon {
display: flex;
align-items: center;
}
/* 加载中样式 */
.status-loading {
color: #FF6B35;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E9EDF2;
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.status-success {
color: #2ECC71;
}
.status-error {
color: #E74C3C;
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
-139
View File
@@ -1,139 +0,0 @@
<template>
<!-- 底部导航栏容器 -->
<view class="tab-bar">
<!-- 导航栏项 -->
<view
v-for="(tab, index) in tabs"
:key="index"
:class="['tab-item', { active: currentActive === index }]"
@click="switchTab(index)"
>
<!-- 导航栏图标 -->
<image :src="currentActive === index ? tab.iconActive : tab.icon" mode="aspectFit" class="tab-icon" />
<!-- 导航栏标签文字 -->
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
// 当前激活的导航栏索引(从外部传入)
const props = defineProps({
activeTab: {
type: Number,
default: 0
}
})
// 当前激活状态
const currentActive = ref(props.activeTab)
// 监听外部传入的激活状态变化
watch(() => props.activeTab, (newVal) => {
currentActive.value = newVal
})
// 导航栏数据列表
const tabs = [
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/home.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/home.png',
label: '首页',
path: '/pages/index/index'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/course.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/course.png',
label: '课程',
path: '/pages/groupCourse/list'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/train.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/train.png',
label: '训练',
path: '/pages/train/index'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/discover.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/discover.png',
label: '发现',
path: '/pages/discover/index'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/profile.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/profile.png',
label: '我的',
path: '/pages/profile/index'
}
]
// 切换标签页
const switchTab = (index) => {
currentActive.value = index
const tab = tabs[index]
// 获取当前页面路径
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const currentPath = '/' + currentPage.route
// 如果点击的是当前页面,不跳转
if (currentPath === tab.path) {
return
}
// 跳转对应页面
uni.redirectTo({
url: tab.path
})
}
</script>
<style lang="scss" scoped>
/* 底部导航栏容器样式 */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 120rpx;
background: #1A4A6F;
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
border-radius: 32rpx 32rpx 0 0;
z-index: 100;
}
/* 导航栏项样式 */
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
}
/* 导航栏图标样式 */
.tab-icon {
width: 40rpx;
height: 40rpx;
}
/* 导航栏标签文字样式 */
.tab-label {
font-size: 22rpx;
color: #94a3b8;
}
/* 导航栏激活状态文字样式 */
.tab-item.active .tab-label {
color: #f97316;
font-weight: 600;
}
</style>
@@ -1,487 +0,0 @@
<template>
<!-- 团课卡片容器 -->
<view class="course-card" @click="goDetail">
<!-- 卡片顶部图片区域 -->
<view class="card-top">
<!-- 图片骨架屏 -->
<view v-if="!imageLoaded" class="skeleton skeleton-image"></view>
<!-- 课程封面图片 -->
<image
:src="course.coverImage"
mode="aspectFill"
class="cover-image"
:class="{ hidden: !imageLoaded }"
@load="imageLoaded = true"
@error="imageLoaded = true"
/>
<!-- 课程状态标签 -->
<view :class="['status-tag', statusClass]">
{{ statusText }}
</view>
<!-- 剩余名额标签 -->
<view class="spots-tag" v-if="course.currentMembers < course.maxMembers">
<span class="iconfont_courseCard icon-renwu-ren"></span>
<text>{{ course.maxMembers - course.currentMembers }}个名额</text>
</view>
</view>
<!-- 卡片内容区域 -->
<view class="card-content">
<!-- 课程名称 -->
<view class="course-name-wrapper">
<text class="course-name">{{ course.courseName }}</text>
</view>
<!-- 课程信息 -->
<view class="course-info">
<!-- 上课时间 -->
<view class="info-item">
<span class="iconfont_courseCard icon-shijian "></span>
<text class="info-text">{{ formatTime(course.startTime) }}</text>
</view>
<!-- 上课地点 -->
<view class="info-item">
<span class="iconfont_courseCard icon-didian"></span>
<text class="info-text">{{ course.location }}</text>
</view>
</view>
<!-- 课程时长 -->
<view class="course-duration">
<uni-icons type="time" size="14" color="#8A99B4" />
<text>{{ formatDuration(course.startTime, course.endTime) }}</text>
</view>
</view>
<!-- 卡片底部操作区域 -->
<view class="card-footer">
<!-- 价格信息 -->
<view class="price-info">
<!-- 免费课程 -->
<view v-if="course.storedValueAmount === 0 && course.pointCardAmount === 0" class="price-free">
<text class="free-text">免费</text>
</view>
<!-- 支持多种支付方式 -->
<view v-else-if="course.storedValueAmount > 0 && course.pointCardAmount > 0" class="price-multi">
<view class="price-item stored-value">
<text class="currency">¥</text>
<text class="amount">{{ course.storedValueAmount }}</text>
<text class="label">储值卡</text>
</view>
<view class="price-divider"></view>
<view class="price-item point-card">
<uni-icons type="shop" size="14" color="#FF6B35" />
<text class="amount">{{ course.pointCardAmount }}</text>
<text class="label">次卡</text>
</view>
</view>
<!-- 仅储值卡支付 -->
<view v-else-if="course.storedValueAmount > 0" class="price-single">
<text class="price">
<text class="currency">¥</text>{{ course.storedValueAmount }}
</text>
<text class="pay-label">储值卡</text>
</view>
<!-- 仅次卡支付 -->
<view v-else-if="course.pointCardAmount > 0" class="price-single">
<text class="price points">
<uni-icons type="shop" size="14" color="#FF6B35" />
<text>{{ course.pointCardAmount }}</text>
</text>
<text class="pay-label">次卡</text>
</view>
</view>
<!-- 预约按钮 -->
<view :class="['booking-btn', { disabled: !canBook }]" @click.stop="handleBooking">
<text>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : '已结束') }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
// 图片加载状态
const imageLoaded = ref(false)
const props = defineProps({
course: {
type: Object,
required: true
}
})
const emit = defineEmits(['booking', 'detail'])
const statusText = computed(() => {
const status = props.course.status
if (status === '0') return '进行中'
if (status === '1') return '已取消'
if (status === '2') return '已结束'
return '未知'
})
const statusClass = computed(() => {
const status = props.course.status
if (status === '0') return 'active'
if (status === '1') return 'canceled'
if (status === '2') return 'ended'
return ''
})
const canBook = computed(() => {
const status = props.course.status
const isFull = props.course.currentMembers >= props.course.maxMembers
return status === '0' && !isFull
})
const formatTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const month = date.getMonth() + 1
const day = date.getDate()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekDay = weekDays[date.getDay()]
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${month}${day}${weekDay} ${hours}:${minutes}`
}
const formatDuration = (startStr, endStr) => {
if (!startStr || !endStr) return ''
const start = new Date(startStr)
const end = new Date(endStr)
const minutes = Math.floor((end.getTime() - start.getTime()) / 60000)
return `${minutes}分钟`
}
const goDetail = () => {
emit('detail', props.course.id)
}
const handleBooking = () => {
if (canBook.value) {
emit('booking', props.course)
}
}
</script>
<style lang="scss" scoped>
@import "@/common/style/iconfont_courseCard.css";
/* 团课卡片容器 */
.course-card {
background: #ffffff;
border-radius: 28rpx;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
margin-bottom: 28rpx;
position: relative;
z-index: 1;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
}
/* 卡片顶部图片区域 */
.card-top {
position: relative;
height: 320rpx;
overflow: hidden;
}
/* 课程封面图片 */
.cover-image {
width: 100%;
height: 100%;
transition: opacity 0.3s ease;
&.hidden {
opacity: 0;
visibility: hidden;
}
}
/* 骨架屏基础样式 */
.skeleton {
position: absolute;
top: 0;
left: 0;
background: linear-gradient(90deg, #f6f7f8 0%, #e0e0e0 20%, #f6f7f8 40%, #f6f7f8 100%);
background-size: 100% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: inherit;
}
/* 骨架屏动画 */
@keyframes skeleton-loading {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
/* 图片骨架屏 */
.skeleton-image {
width: 100%;
height: 100%;
}
/* 课程状态标签 */
.status-tag {
position: absolute;
top: 24rpx;
left: 24rpx;
padding: 10rpx 24rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
backdrop-filter: blur(8rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
&.active {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
}
&.canceled {
background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%);
}
&.ended {
background: linear-gradient(135deg, #D1D5DB 0%, #9CA3AF 100%);
}
}
/* 剩余名额标签 */
.spots-tag {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 10rpx 20rpx;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8rpx);
border-radius: 24rpx;
font-size: 22rpx;
color: #ffffff;
display: flex;
align-items: center;
gap: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
/* 卡片内容区域 */
.card-content {
padding: 28rpx 32rpx;
}
/* 课程名称容器 */
.course-name-wrapper {
margin-bottom: 20rpx;
}
/* 课程名称 */
.course-name {
font-size: 34rpx;
font-weight: 700;
color: #1F2937;
display: block;
line-height: 1.4;
letter-spacing: 0.5rpx;
}
/* 课程信息容器 */
.course-info {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-bottom: 20rpx;
}
/* 信息项 */
.info-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 16rpx;
background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);
border-radius: 16rpx;
transition: all 0.2s ease;
&:active {
background: linear-gradient(135deg, #F3F4F6 0%, #E5E7EB 100%);
}
}
/* 信息图标 */
.info-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* 图标字体垂直居中 */
.iconfont_courseCard {
vertical-align: middle;
}
/* 信息文字 */
.info-text {
font-size: 26rpx;
color: #4B5563;
line-height: 1.5;
flex: 1;
}
/* 课程时长 */
.course-duration {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 20rpx;
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
border-radius: 20rpx;
font-size: 24rpx;
color: #3B82F6;
font-weight: 500;
}
/* 卡片底部区域 */
.card-footer {
padding: 24rpx 32rpx 28rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1rpx solid #F3F4F6;
margin-top: 4rpx;
}
/* 价格信息 */
.price-info {
display: flex;
align-items: center;
.price {
font-size: 36rpx;
font-weight: 700;
color: #FF6B35;
display: flex;
align-items: baseline;
gap: 4rpx;
.currency {
font-size: 24rpx;
font-weight: 600;
}
&.points {
color: #FF6B35;
gap: 6rpx;
}
}
}
/* 免费课程样式 */
.price-free {
.free-text {
font-size: 36rpx;
font-weight: 700;
color: #10B981;
}
}
/* 多种支付方式样式 */
.price-multi {
display: flex;
align-items: center;
gap: 16rpx;
.price-item {
display: flex;
align-items: center;
gap: 6rpx;
.currency {
font-size: 20rpx;
font-weight: 600;
color: #FF6B35;
}
.amount {
font-size: 30rpx;
font-weight: 700;
color: #FF6B35;
}
.label {
font-size: 20rpx;
color: #6B7280;
margin-left: 4rpx;
}
&.stored-value {
.currency, .amount {
color: #FF6B35;
}
}
&.point-card {
.amount {
color: #FF6B35;
}
}
}
.price-divider {
width: 2rpx;
height: 32rpx;
background: #E5E7EB;
}
}
/* 单一支付方式样式 */
.price-single {
display: flex;
align-items: baseline;
gap: 8rpx;
.pay-label {
font-size: 20rpx;
color: #6B7280;
padding: 4rpx 12rpx;
background: #F3F4F6;
border-radius: 8rpx;
}
}
/* 预约按钮 */
.booking-btn {
padding: 18rpx 48rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-radius: 44rpx;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 53, 0.25);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: 1rpx;
&:active {
transform: scale(0.95);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.2);
}
&.disabled {
background: linear-gradient(135deg, #F3F4F6 0%, #E5E7EB 100%);
color: #9CA3AF;
box-shadow: none;
}
}
</style>
@@ -1,202 +0,0 @@
<template>
<view class="filter-section">
<!-- 时间区间筛选 -->
<view class="filter-item" @click="handleTimePick">
<uni-icons type="calendar" size="18" color="#5E6F8D" class="filter-icon" />
<text class="filter-text">{{ timeRangeText || '选择时间' }}</text>
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
</view>
<!-- 排序方式 -->
<picker
mode="selector"
:range="sortOptions"
range-key="label"
:value="sortIndex"
@change="onSortChange"
>
<view class="filter-item">
<uni-icons type="list" size="18" color="#5E6F8D" class="filter-icon" />
<text class="filter-text">{{ sortOptions[sortIndex].label }}</text>
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
</view>
</picker>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
timeRangeText: {
type: String,
default: ''
},
sortOptions: {
type: Array,
default: () => [
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'priceAsc' },
{ label: '价格从高到低', value: 'priceDesc' },
{ label: '剩余名额最多', value: 'spotsDesc' },
{ label: '仅次数卡', value: 'pointCardOnly' },
{ label: '仅储值卡', value: 'storedValueOnly' },
{ label: '两种支付', value: 'bothPayment' }
]
},
sortIndex: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:sortIndex', 'timePick'])
const localSortIndex = ref(props.sortIndex)
watch(() => props.sortIndex, (val) => {
localSortIndex.value = val
})
const onSortChange = (e) => {
localSortIndex.value = e.detail.value
console.log('[FilterSection] 排序方式变更:', {
index: localSortIndex.value,
value: props.sortOptions[localSortIndex.value]
})
emit('update:sortIndex', localSortIndex.value)
}
const handleTimePick = () => {
console.log('[FilterSection] 触发时间选择器')
emit('timePick')
}
const getFilterParams = () => {
const params = {
sortType: props.sortOptions[localSortIndex.value].value,
timeRangeText: props.timeRangeText
}
console.log('[FilterSection] 获取筛选参数:', params)
return params
}
const getSortType = () => {
return props.sortOptions[localSortIndex.value].value
}
const comparePrice = (a, b, ascending = true) => {
const getEffectivePrice = (item) => {
if (item.storedValueAmount > 0 && item.pointCardAmount > 0) {
return Math.min(item.storedValueAmount, item.pointCardAmount * 50)
} else if (item.storedValueAmount > 0) {
return item.storedValueAmount
} else if (item.pointCardAmount > 0) {
return item.pointCardAmount * 50
}
return 0
}
const priceA = getEffectivePrice(a)
const priceB = getEffectivePrice(b)
return ascending ? priceA - priceB : priceB - priceA
}
const compareByPaymentType = (a, b, sortType) => {
const getPaymentType = (item) => {
const hasPointCard = item.pointCardAmount > 0
const hasStoredValue = item.storedValueAmount > 0
if (hasPointCard && !hasStoredValue) return 1
if (!hasPointCard && hasStoredValue) return 2
if (hasPointCard && hasStoredValue) return 3
return 0
}
const typeA = getPaymentType(a)
const typeB = getPaymentType(b)
switch (sortType) {
case 'pointCardOnly':
if (typeA === 1 && typeB !== 1) return -1
if (typeA !== 1 && typeB === 1) return 1
return 0
case 'storedValueOnly':
if (typeA === 2 && typeB !== 2) return -1
if (typeA !== 2 && typeB === 2) return 1
return 0
case 'bothPayment':
if (typeA === 3 && typeB !== 3) return -1
if (typeA !== 3 && typeB === 3) return 1
return 0
default:
return 0
}
}
const sortCourses = (courses) => {
const sortType = getSortType()
if (!courses || !Array.isArray(courses)) return []
const sorted = [...courses]
switch (sortType) {
case 'priceAsc':
sorted.sort((a, b) => comparePrice(a, b, true))
break
case 'priceDesc':
sorted.sort((a, b) => comparePrice(a, b, false))
break
case 'spotsDesc':
sorted.sort((a, b) => (b.maxMembers - b.currentMembers) - (a.maxMembers - a.currentMembers))
break
case 'pointCardOnly':
case 'storedValueOnly':
case 'bothPayment':
sorted.sort((a, b) => compareByPaymentType(a, b, sortType))
break
default:
sorted.sort((a, b) => new Date(a.startTime) - new Date(b.startTime))
}
return sorted
}
defineExpose({
getFilterParams,
getSortType,
sortCourses
})
</script>
<style lang="scss" scoped>
.filter-section {
display: flex;
gap: 16rpx;
.filter-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 20rpx 24rpx;
background: #F5F7FA;
border-radius: 16rpx;
font-size: 26rpx;
color: #5E6F8D;
.filter-icon {
display: flex;
align-items: center;
}
.filter-arrow {
margin-left: auto;
display: flex;
align-items: center;
}
}
}
</style>
@@ -1,165 +0,0 @@
<template>
<view class="search-bar-wrapper">
<!-- 搜索框 -->
<view class="search-bar">
<view class="search-input-wrapper">
<uni-icons type="search" size="20" color="#A0AEC0" class="search-icon" />
<input
class="search-input"
v-model="keyword"
placeholder="搜索课程名称"
placeholder-class="input-placeholder"
@confirm="handleSearch"
/>
<uni-icons
v-if="keyword"
type="closeempty"
size="16"
color="#A0AEC0"
class="clear-icon"
@click="clearSearch"
/>
</view>
<view class="search-btn" @click="handleSearch">搜索</view>
</view>
<!-- 热门关键词 -->
<view class="hot-keywords">
<text class="hot-label">热门搜索</text>
<view
v-for="(kw, index) in hotKeywords"
:key="index"
:class="['hot-tag', { active: keyword === kw }]"
@click="selectKeyword(kw)"
>
{{ kw }}
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
hotKeywords: {
type: Array,
default: () => ['燃脂', '瑜伽', '单车', '普拉提', '高强度']
}
})
const emit = defineEmits(['update:modelValue', 'search'])
const keyword = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
keyword.value = val
})
const handleSearch = () => {
console.log('[SearchBar] 搜索参数:', { keyword: keyword.value })
emit('update:modelValue', keyword.value)
emit('search', { keyword: keyword.value })
}
const clearSearch = () => {
keyword.value = ''
emit('update:modelValue', '')
console.log('[SearchBar] 已清除搜索关键词')
}
const selectKeyword = (kw) => {
keyword.value = kw
handleSearch()
}
const getSearchParams = () => {
console.log('[SearchBar] 获取搜索参数:', { keyword: keyword.value })
return { keyword: keyword.value }
}
defineExpose({
getSearchParams,
clearSearch
})
</script>
<style lang="scss" scoped>
/* 搜索框 */
.search-bar {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
background: #F5F7FA;
border-radius: 44rpx;
padding: 0 24rpx;
height: 72rpx;
.search-icon {
margin-right: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1a202c;
}
.input-placeholder {
color: #A0AEC0;
}
.clear-icon {
padding: 8rpx;
}
}
.search-btn {
padding: 0 32rpx;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
border-radius: 44rpx;
}
}
/* 热门关键词 */
.hot-keywords {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
.hot-label {
font-size: 26rpx;
color: #8A99B4;
}
.hot-tag {
padding: 8rpx 20rpx;
background: #F5F7FA;
color: #5E6F8D;
font-size: 24rpx;
border-radius: 28rpx;
transition: all 0.2s ease;
&.active {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
color: #ffffff;
}
}
}
</style>
@@ -1,191 +0,0 @@
<template>
<view class="time-period-selector">
<view class="sort-header">
<uni-icons type="calendar" size="16" color="#FF6B35" class="sort-icon" />
<text class="sort-label">上课时段</text>
</view>
<view class="slider-wrapper">
<view
v-for="(option, index) in timePeriodOptions"
:key="index"
:class="['slider-item', { active: currentIndex === index }]"
@click="handlePeriodChange(index)"
>
<span class="iconfont_time_select" v-bind:class="getPeriodIcon(option.value)"></span>
<text :class="['slider-text', { active: currentIndex === index }]">
{{ option.label.split(' ')[0] }}
</text>
</view>
<!-- 滑动指示器 -->
<view
class="slider-indicator"
:style="{ left: `calc(8rpx + ${currentIndex} * (100% - 16rpx) / 4)` }"
></view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
timePeriodOptions: {
type: Array,
default: () => [
{ label: '全部', value: 'all' },
{ label: '早上 (6-12 点)', value: 'morning', startHour: 6, endHour: 12 },
{ label: '下午 (12-18 点)', value: 'afternoon', startHour: 12, endHour: 18 },
{ label: '晚上 (18-24 点)', value: 'evening', startHour: 18, endHour: 24 }
]
},
modelValue: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const currentIndex = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
currentIndex.value = val
})
const getPeriodIcon = (value) => {
const iconMap = {
'all': 'icon-gengduo ',
'morning': 'icon-zaochen',
'afternoon': 'icon-xiawucha ',
'evening': 'icon-yewan '
}
return iconMap[value] || iconMap[all]
}
const handlePeriodChange = (index) => {
currentIndex.value = index
const option = props.timePeriodOptions[index]
console.log('[TimePeriodSelector] 时间段变更:', {
index,
value: option.value,
label: option.label
})
emit('update:modelValue', index)
emit('change', option)
}
const getTimePeriodParams = () => {
const option = props.timePeriodOptions[currentIndex.value]
const params = {
index: currentIndex.value,
value: option.value,
label: option.label,
startHour: option.startHour,
endHour: option.endHour
}
console.log('[TimePeriodSelector] 获取时间段参数:', params)
return params
}
defineExpose({
getTimePeriodParams,
getPeriodIcon
})
</script>
<style lang="scss" scoped>
@import "@/common/style/iconfont_time_select.css";
.time-period-selector {
padding: 24rpx;
background: linear-gradient(135deg, #F5F7FA 0%, #E9EDF2 100%);
border-radius: 20rpx;
box-shadow: inset 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
.sort-header {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 20rpx;
.sort-icon {
display: flex;
align-items: center;
}
.sort-label {
font-size: 28rpx;
color: #1a202c;
font-weight: 600;
}
}
.slider-wrapper {
position: relative;
display: flex;
background: #ffffff;
border-radius: 16rpx;
padding: 8rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
.slider-item {
position: relative;
z-index: 2;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx 0;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.slider-icon {
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.slider-text {
font-size: 24rpx;
color: #8A99B4;
font-weight: 500;
transition: all 0.3s ease;
&.active {
color: #ffffff;
font-weight: 600;
}
}
&.active {
.slider-icon {
color: #ffffff !important;
}
.slider-text {
color: #ffffff !important;
}
}
}
/* 滑动指示器 */
.slider-indicator {
position: absolute;
top: 8rpx;
left: 8rpx;
width: calc((100% - 16rpx) / 4);
height: calc(100% - 16rpx);
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.4);
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
}
}
</style>
@@ -1,728 +0,0 @@
<template>
<view>
<!-- 时间范围选择弹窗 -->
<Transition name="modal">
<view v-if="visible" class="time-range-modal">
<view class="modal-mask" @click="handleClose"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">选择时间范围</text>
<view class="header-actions">
<text class="modal-clear" @click="handleClear">清空</text>
<text class="modal-close" @click="handleClose">×</text>
</view>
</view>
<view class="modal-body">
<!-- 开始日期 -->
<view class="date-item">
<text class="date-label">开始日期</text>
<view class="date-value" @click="toggleStartPicker">
<text>{{ localStartDate || '请选择' }}</text>
<text class="date-arrow"></text>
</view>
</view>
<!-- 结束日期 -->
<view class="date-item">
<text class="date-label">结束日期</text>
<view class="date-value" @click="toggleEndPicker">
<text>{{ localEndDate || '请选择' }}</text>
<text class="date-arrow"></text>
</view>
</view>
<!-- 快捷选择 -->
<view class="quick-select">
<text class="quick-label">快捷选择</text>
<view class="quick-btns">
<view
v-for="item in quickOptions"
:key="item.value"
class="quick-btn"
:class="{ active: quickSelected === item.value }"
@click="applyQuickOption(item.value)"
>
{{ item.label }}
</view>
</view>
</view>
</view>
<view class="modal-footer">
<view class="btn btn-cancel" @click="handleClose">取消</view>
<view class="btn btn-confirm" @click="handleConfirm">确定</view>
</view>
</view>
<!-- 日期选择器弹窗 -->
<Transition name="date-picker">
<view v-if="showDatePicker" class="date-picker-modal">
<view class="date-mask" @click="showDatePicker = false"></view>
<view class="date-picker-content">
<view class="date-header">
<text class="date-prev" @click="prevMonth"></text>
<text class="date-title">{{ currentYear }}{{ currentMonth }}</text>
<text class="date-next" @click="nextMonth"></text>
</view>
<view class="date-weekdays">
<text v-for="day in weekdays" :key="day">{{ day }}</text>
</view>
<view class="date-days">
<view
v-for="(day, index) in calendarDays"
:key="index"
class="date-day"
:class="{
'other-month': !day.currentMonth,
'today': day.isToday,
'selected': day.date === selectedDate,
'disabled': day.disabled
}"
@click="selectDate(day)"
>
{{ day.day }}
</view>
</view>
</view>
</view>
</Transition>
</view>
</Transition>
</view>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
startDate: {
type: String,
default: ''
},
endDate: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'update:startDate', 'update:endDate', 'confirm', 'cancel'])
const localStartDate = ref(props.startDate)
const localEndDate = ref(props.endDate)
const showDatePicker = ref(false)
const pickerType = ref('start') // 'start' | 'end'
const currentYear = ref(2024)
const currentMonth = ref(1)
const selectedDate = ref('')
const quickSelected = ref('')
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const quickOptions = [
{ label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' },
{ label: '本月', value: 'month' },
{ label: '上月', value: 'lastMonth' }
]
// 监听 props 变化
watch(() => props.startDate, (val) => {
localStartDate.value = val
})
watch(() => props.endDate, (val) => {
localEndDate.value = val
})
watch(() => props.visible, (val) => {
if (val) {
// 弹窗打开时设置当前日期
const now = new Date()
currentYear.value = now.getFullYear()
currentMonth.value = now.getMonth() + 1
}
})
// 计算时间范围文本
const timeRangeText = computed(() => {
if (localStartDate.value && localEndDate.value) {
return `${localStartDate.value}${localEndDate.value}`
} else if (localStartDate.value) {
return `${localStartDate.value}`
} else if (localEndDate.value) {
return `${localEndDate.value}`
}
return ''
})
// 生成日历日期
const calendarDays = computed(() => {
const days = []
const firstDay = new Date(currentYear.value, currentMonth.value - 1, 1)
const lastDay = new Date(currentYear.value, currentMonth.value, 0)
const startDay = firstDay.getDay()
const totalDays = lastDay.getDate()
// 上个月的天数
const prevMonthLastDay = new Date(currentYear.value, currentMonth.value - 1, 0).getDate()
for (let i = startDay - 1; i >= 0; i--) {
days.push({
day: prevMonthLastDay - i,
date: formatDate(currentYear.value, currentMonth.value - 1, prevMonthLastDay - i),
currentMonth: false,
isToday: false,
disabled: true
})
}
// 本月的天数
const today = new Date()
for (let i = 1; i <= totalDays; i++) {
const dateStr = formatDate(currentYear.value, currentMonth.value, i)
days.push({
day: i,
date: dateStr,
currentMonth: true,
isToday: isToday(currentYear.value, currentMonth.value, i),
disabled: isFuture(currentYear.value, currentMonth.value, i)
})
}
// 下个月的天数
const remaining = 42 - days.length
for (let i = 1; i <= remaining; i++) {
days.push({
day: i,
date: formatDate(currentYear.value, currentMonth.value + 1, i),
currentMonth: false,
isToday: false,
disabled: true
})
}
return days
})
// 格式化日期
function formatDate(year, month, day) {
const m = month.toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
return `${year}-${m}-${d}`
}
// 判断是否是今天
function isToday(year, month, day) {
const today = new Date()
return year === today.getFullYear() &&
month === today.getMonth() + 1 &&
day === today.getDate()
}
// 判断是否是未来日期
function isFuture(year, month, day) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const date = new Date(year, month - 1, day)
return date > today
}
// 切换日期选择器
const toggleStartPicker = () => {
pickerType.value = 'start'
selectedDate.value = localStartDate.value
showDatePicker.value = true
}
const toggleEndPicker = () => {
pickerType.value = 'end'
selectedDate.value = localEndDate.value
showDatePicker.value = true
}
// 月份切换
const prevMonth = () => {
if (currentMonth.value === 1) {
currentYear.value--
currentMonth.value = 12
} else {
currentMonth.value--
}
}
const nextMonth = () => {
if (currentMonth.value === 12) {
currentYear.value++
currentMonth.value = 1
} else {
currentMonth.value++
}
}
// 选择日期
const selectDate = (day) => {
if (day.disabled) return
selectedDate.value = day.date
if (pickerType.value === 'start') {
localStartDate.value = day.date
emit('update:startDate', day.date)
} else {
localEndDate.value = day.date
emit('update:endDate', day.date)
}
showDatePicker.value = false
console.log(`[TimeRangePicker] ${pickerType.value === 'start' ? '开始' : '结束'}日期变更:`, day.date)
}
// 应用快捷选项
const applyQuickOption = (value) => {
quickSelected.value = value
const today = new Date()
let startDate, endDate
switch (value) {
case '7d':
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
endDate = today
break
case '30d':
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
endDate = today
break
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth(), 1)
endDate = today
break
case 'lastMonth':
startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1)
endDate = new Date(today.getFullYear(), today.getMonth(), 0)
break
}
localStartDate.value = formatDate(startDate.getFullYear(), startDate.getMonth() + 1, startDate.getDate())
localEndDate.value = formatDate(endDate.getFullYear(), endDate.getMonth() + 1, endDate.getDate())
emit('update:startDate', localStartDate.value)
emit('update:endDate', localEndDate.value)
console.log('[TimeRangePicker] 快捷选择:', value, { start: localStartDate.value, end: localEndDate.value })
}
// 清空选择
const handleClear = () => {
localStartDate.value = ''
localEndDate.value = ''
quickSelected.value = ''
emit('update:startDate', '')
emit('update:endDate', '')
console.log('[TimeRangePicker] 已清空时间选择')
}
// 关闭弹窗
const handleClose = () => {
console.log('[TimeRangePicker] 关闭时间选择器')
emit('update:visible', false)
emit('cancel')
}
// 确认选择
const handleConfirm = () => {
console.log('[TimeRangePicker] 确认时间范围:', {
startDate: localStartDate.value,
endDate: localEndDate.value,
timeRangeText: timeRangeText.value
})
emit('update:visible', false)
emit('confirm', {
startDate: localStartDate.value,
endDate: localEndDate.value,
timeRangeText: timeRangeText.value
})
}
// 获取参数
const getTimeRangeParams = () => {
const params = {
startDate: localStartDate.value,
endDate: localEndDate.value,
timeRangeText: timeRangeText.value
}
console.log('[TimeRangePicker] 获取时间范围参数:', params)
return params
}
// 重置日期
const resetDate = () => {
localStartDate.value = ''
localEndDate.value = ''
quickSelected.value = ''
emit('update:startDate', '')
emit('update:endDate', '')
console.log('[TimeRangePicker] 已重置日期')
}
defineExpose({
getTimeRangeParams,
resetDate,
timeRangeText
})
</script>
<style lang="scss" scoped>
.time-range-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
transition: background 0.3s ease;
}
.modal-content {
position: relative;
width: 640rpx;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
opacity: 1;
transform: scale(1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 1rpx solid #E9EDF2;
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #1a202c;
}
.header-actions {
display: flex;
align-items: center;
gap: 24rpx;
}
.modal-clear {
font-size: 28rpx;
color: #8A99B4;
padding: 8rpx 16rpx;
}
.modal-close {
font-size: 48rpx;
color: #8A99B4;
line-height: 1;
}
}
.modal-body {
padding: 32rpx;
.date-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0F2F5;
&:last-of-type {
border-bottom: none;
}
.date-label {
font-size: 28rpx;
color: #6B7280;
}
.date-value {
display: flex;
align-items: center;
gap: 12rpx;
text {
font-size: 28rpx;
color: #1a202c;
}
.date-arrow {
font-size: 32rpx;
color: #C4C9D4;
}
}
}
.quick-select {
margin-top: 24rpx;
.quick-label {
font-size: 26rpx;
color: #9CA3AF;
margin-bottom: 16rpx;
display: block;
}
.quick-btns {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.quick-btn {
padding: 16rpx 28rpx;
background: #F5F7FA;
border-radius: 32rpx;
font-size: 26rpx;
color: #6B7280;
&.active {
background: #FF6B35;
color: #ffffff;
}
}
}
}
}
.modal-footer {
display: flex;
border-top: 1rpx solid #E9EDF2;
.btn {
flex: 1;
padding: 32rpx;
text-align: center;
font-size: 30rpx;
&.btn-cancel {
color: #6B7280;
border-right: 1rpx solid #E9EDF2;
}
&.btn-confirm {
color: #FF6B35;
font-weight: 600;
}
}
}
}
}
.date-picker-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
display: flex;
align-items: flex-end;
.date-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
transition: background 0.3s ease;
}
.date-picker-content {
position: relative;
width: 100%;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
transform: translateY(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.date-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
.date-prev, .date-next {
font-size: 40rpx;
color: #1a202c;
width: 64rpx;
text-align: center;
}
.date-title {
font-size: 32rpx;
font-weight: 600;
color: #1a202c;
}
}
.date-weekdays {
display: flex;
padding: 0 24rpx;
text {
flex: 1;
text-align: center;
font-size: 26rpx;
color: #9CA3AF;
padding: 16rpx 0;
}
}
.date-days {
display: flex;
flex-wrap: wrap;
padding: 16rpx 24rpx 32rpx;
.date-day {
width: calc(100% / 7);
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #1a202c;
border-radius: 50%;
&.other-month {
color: #D1D5DB;
}
&.today {
background: #F5F7FA;
color: #FF6B35;
font-weight: 600;
}
&.selected {
background: #FF6B35;
color: #ffffff;
}
&.disabled {
color: #E5E7EB;
pointer-events: none;
}
}
}
}
}
/* 居中弹窗进入动画 */
.modal-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-enter-active .modal-mask {
transition: background 0.3s ease;
}
.modal-enter-active .modal-content {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-enter-from .modal-mask {
background: rgba(0, 0, 0, 0);
}
.modal-enter-from .modal-content {
opacity: 0;
transform: scale(0.9);
}
/* 居中弹窗退出动画 */
.modal-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-leave-active .modal-mask {
transition: background 0.3s ease;
}
.modal-leave-active .modal-content {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-leave-to .modal-mask {
background: rgba(0, 0, 0, 0);
}
.modal-leave-to .modal-content {
opacity: 0;
transform: scale(0.9);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 日期选择器进入动画 */
.date-picker-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-enter-active .date-mask {
transition: background 0.3s ease;
}
.date-picker-enter-active .date-picker-content {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-enter-from .date-mask {
background: rgba(0, 0, 0, 0);
}
.date-picker-enter-from .date-picker-content {
transform: translateY(100%);
}
/* 日期选择器退出动画 */
.date-picker-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-leave-active .date-mask {
transition: background 0.3s ease;
}
.date-picker-leave-active .date-picker-content {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-leave-to .date-mask {
background: rgba(0, 0, 0, 0);
}
.date-picker-leave-to .date-picker-content {
transform: translateY(100%);
}
</style>
@@ -1,171 +0,0 @@
<template>
<!-- 轮播图容器 -->
<view class="banner-container">
<!-- 轮播图组件 -->
<swiper
class="banner-swiper"
:circular="true"
:autoplay="true"
:interval="4000"
:duration="500"
:indicator-dots="false"
@change="onSwiperChange"
>
<!-- 轮播项 -->
<swiper-item v-for="(banner, index) in banners" :key="index">
<!-- 轮播内容 -->
<view class="banner-content">
<!-- 轮播图片 -->
<image :src="banner.image" mode="aspectFill" class="banner-image" />
<!-- 图片遮罩层 -->
<view class="banner-overlay"></view>
<!-- 轮播文字信息 -->
<view class="banner-text">
<text class="banner-title">{{ banner.title }}</text>
<text class="banner-subtitle">{{ banner.subtitle }}</text>
<text class="banner-desc">{{ banner.desc }}</text>
</view>
</view>
</swiper-item>
</swiper>
<!-- 轮播指示器点 -->
<view class="banner-dots">
<view
v-for="(_, index) in banners"
:key="index"
:class="['dot', { active: currentIndex === index }]"
></view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
// 轮播图数据列表
const banners = [
{
image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80',
title: '突破自我',
subtitle: '超越极限',
desc: '科学训练 · 遇见更好的自己'
},
{
image: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=800&q=80',
title: '专业指导',
subtitle: '高效训练',
desc: '私人定制 · 专属健身方案'
},
{
image: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=800&q=80',
title: '健康生活',
subtitle: '从这里开始',
desc: '全方位 · 打造完美体态'
}
]
// 当前轮播索引,用于控制指示器激活状态
const currentIndex = ref(0)
// 轮播图切换时的回调函数,更新当前索引
const onSwiperChange = (e) => {
currentIndex.value = e.detail.current
}
</script>
<style lang="scss" scoped>
/* 轮播图容器样式 */
.banner-container {
position: relative;
width: 100%;
}
/* 轮播图组件样式 */
.banner-swiper {
width: 100%;
height: 360rpx;
}
/* 轮播内容容器样式 */
.banner-content {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
/* 轮播图片样式 */
.banner-image {
width: 100%;
height: 100%;
}
/* 图片遮罩层样式,添加渐变效果 */
.banner-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(11, 43, 75, 0.85) 0%, rgba(26, 74, 111, 0.6) 100%);
}
/* 轮播文字信息容器样式 */
.banner-text {
position: absolute;
left: 32rpx;
top: 50%;
transform: translateY(-50%);
z-index: 2;
}
/* 轮播标题样式 */
.banner-title {
display: block;
font-size: 48rpx;
font-weight: 800;
color: #ffffff;
margin-bottom: 8rpx;
text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
}
/* 轮播副标题样式 */
.banner-subtitle {
display: block;
font-size: 56rpx;
font-weight: 800;
color: #f97316;
margin-bottom: 16rpx;
text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
}
/* 轮播描述文字样式 */
.banner-desc {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
/* 轮播指示器容器样式 */
.banner-dots {
display: flex;
justify-content: center;
gap: 16rpx;
margin-top: 24rpx;
}
/* 轮播指示器点样式 */
.dot {
width: 48rpx;
height: 8rpx;
border-radius: 9999rpx;
background: #d1d5db;
transition: all 0.3s ease;
}
/* 轮播指示器激活状态样式 */
.dot.active {
width: 64rpx;
background: #f97316;
}
</style>
@@ -1,123 +0,0 @@
<template>
<!-- 快捷入口容器 -->
<view class="quick-entry">
<!-- 快捷入口项 -->
<view
v-for="(item, index) in entries"
:key="index"
class="entry-item"
@tap="QEClick(item.path)"
>
<!-- 入口图标容器 -->
<view :class="['entry-icon', { accent: item.accent }]">
<!-- 入口图标图片 -->
<image :src="item.icon" mode="aspectFit" class="icon-img" />
</view>
<!-- 入口标题 -->
<text class="entry-title">{{ item.title }}</text>
<!-- 入口描述 -->
<text class="entry-desc">{{ item.desc }}</text>
</view>
</view>
</template>
<script setup>
const QEClick = () => {
uni.navigateTo({
url:"/pages/checkIn/checkIn"
})
}
// 快捷入口数据列表
const entries = [
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png',
title: '找课程',
desc: '精品课程',
accent: false
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png',
title: '训练计划',
desc: '个性定制',
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png',
title: '健身数据',
desc: '记录分析',
accent: false
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png',
title: '消息',
desc: '通知消息',
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png',
title: '签到',
desc: '打卡签到',
accent: false,
path: "/pages/checkIn/checkIn"
}
]
</script>
<style lang="scss" scoped>
/* 快捷入口容器样式 */
.quick-entry {
display: flex;
justify-content: space-between;
padding: 32rpx 24rpx;
background: #ffffff;
margin: 24rpx;
border-radius: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
}
/* 快捷入口项样式 */
.entry-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
/* 入口图标容器样式 */
.entry-icon {
width: 104rpx;
height: 104rpx;
border-radius: 20rpx;
background: #072A4E;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
/* 入口图标图片样式 */
.icon-img {
width: 52rpx;
height: 52rpx;
}
/* 入口图标强调色样式(橙色背景) */
.entry-icon.accent {
background: #FC5A15;
}
/* 入口标题样式 */
.entry-title {
font-size: 26rpx;
font-weight: 600;
color: #1a202c;
margin-bottom: 4rpx;
}
/* 入口描述文字样式 */
.entry-desc {
font-size: 22rpx;
color: #94a3b8;
}
</style>
@@ -1,291 +0,0 @@
<template>
<!-- 推荐课程容器 -->
<view class="recommend-courses">
<!-- 区域标题栏 -->
<view class="section-header">
<!-- 区域标题 -->
<text class="section-title">推荐课程</text>
<!-- 查看更多按钮 -->
<view class="view-more">
<text>查看更多</text>
<text class="arrow">
<uni-icons type="right" size="20" color="#94a3b8"/>
</text>
</view>
</view>
<!-- 课程横向滚动容器 -->
<scroll-view class="courses-scroll" scroll-x="true" :show-scrollbar="false">
<!-- 课程列表 -->
<view class="courses-list">
<!-- 课程卡片 -->
<view
v-for="(course, index) in courses"
:key="index"
class="course-card"
>
<!-- 课程图片区域 -->
<view class="course-image">
<!-- 课程封面图片 -->
<image :src="course.image" mode="aspectFill" class="img" />
<!-- 图片渐变遮罩 -->
<view class="course-overlay"></view>
<!-- 课程标签 -->
<text :class="['course-tag', course.tagType]">{{ course.tag }}</text>
<!-- 课程信息区域 -->
<view class="course-info">
<!-- 课程名称 -->
<text class="course-name">{{ course.name }}</text>
<!-- 课程元信息时长难度 -->
<view class="course-meta">
<!-- 时长信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png"/>
</text>
<text>{{ course.duration }}</text>
</view>
<!-- 难度信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png"/>
</text>
<text>{{ course.level }}</text>
</view>
</view>
</view>
</view>
<!-- 课程底部区域 -->
<view class="course-footer">
<!-- 参与人数信息 -->
<view class="participants">
<text class="fire-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png"/>
</text>
<text>{{ course.participants }}人参与</text>
</view>
<!-- 去参与按钮 -->
<view class="join-btn">
<text>去参与</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
// 推荐课程数据列表
const courses = [
{
image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
tag: '限时免费',
tagType: 'free',
name: 'HIIT高强度燃脂',
duration: '30分钟',
level: '中级',
participants: '4587'
},
{
image: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
tag: '人气TOP',
tagType: 'hot',
name: '力量进阶训练',
duration: '45分钟',
level: '高级',
participants: '6231'
},
{
image: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
tag: '新课上线',
tagType: 'new',
name: '瑜伽·身心平衡',
duration: '60分钟',
level: '初级',
participants: '3210'
}
]
</script>
<style lang="scss">
/* 推荐课程容器样式 */
.recommend-courses {
padding: 0 24rpx;
margin-bottom: 32rpx;
}
/* 区域标题栏样式 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
/* 区域标题样式 */
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #1a202c;
}
/* 查看更多按钮样式 */
.view-more {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 26rpx;
color: #94a3b8;
}
/* 箭头图标样式 */
.arrow {
font-size: 32rpx;
}
/* 课程横向滚动容器样式 */
.courses-scroll {
white-space: nowrap;
}
/* 课程列表样式 */
.courses-list {
display: inline-flex;
gap: 48rpx;
}
/* 课程卡片样式 */
.course-card {
width: 320rpx;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
display: inline-block;
vertical-align: top;
}
/* 课程图片区域样式 */
.course-image {
height: 280rpx;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20rpx;
}
/* 课程封面图片样式 */
.img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
/* 图片渐变遮罩样式 */
.course-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, transparent 60%);
}
/* 课程标签样式 */
.course-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
background: #f97316;
}
/* 课程信息区域样式 */
.course-info {
position: relative;
z-index: 2;
}
/* 课程名称样式 */
.course-name {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
}
/* 课程元信息容器样式 */
.course-meta {
display: flex;
gap: 16rpx;
align-items: center;
}
/* 课程元信息项样式 */
.meta-item {
display: flex;
align-items: end;
gap: 6rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
/* 元信息图标样式 */
.meta-icon {
font-size: 20rpx;
image{
display: flex;
align-items: center;
width: 25rpx;
height: 25rpx;
}
}
/* 课程底部区域样式 */
.course-footer {
padding: 16rpx 10rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
/* 参与人数信息样式 */
.participants {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 22rpx;
color: #94a3b8;
}
/* 火热图标样式 */
.fire-icon {
font-size: 24rpx;
image{
display: flex;
align-items: center;
width: 30rpx;
height: 30rpx;
}
}
/* 去参与按钮样式 */
.join-btn {
padding: 12rpx 28rpx;
background: transparent;
border: 2rpx solid #f97316;
border-radius: 9999rpx;
font-size: 22rpx;
font-weight: 600;
color: #f97316;
}
</style>
@@ -1,213 +0,0 @@
<template>
<!-- 今日推荐容器 -->
<view class="today-recommend">
<!-- 区域标题栏 -->
<view class="section-header">
<!-- 区域标题 -->
<text class="section-title">今日推荐</text>
<!-- 查看更多按钮 -->
<view class="view-more">
<text>查看更多</text>
<text class="arrow">
<uni-icons type="right" size="20" color="#94a3b8"></uni-icons>
</text>
</view>
</view>
<!-- 推荐列表 -->
<view class="recommend-list">
<!-- 推荐项 -->
<view
v-for="(item, index) in recommends"
:key="index"
class="recommend-item"
>
<!-- 推荐项图片 -->
<image :src="item.image" mode="aspectFill" class="item-image" />
<!-- 推荐项内容区域 -->
<view class="item-content">
<!-- 推荐项标题 -->
<text class="item-title">{{ item.title }}</text>
<!-- 推荐项标签列表 -->
<view class="item-tags">
<text v-for="(tag, tagIndex) in item.tags" :key="tagIndex" class="tag">{{ tag }}</text>
</view>
<!-- 推荐项描述 -->
<text class="item-desc">{{ item.desc }}</text>
</view>
<!-- 推荐项操作区域 -->
<view class="item-action">
<!-- 开始训练按钮 -->
<view class="start-btn">
<text class="start-btn-text">开始训练</text>
</view>
<!-- 参与人数 -->
<text class="participants">{{ item.participants }}人参与</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
// 今日推荐数据列表
const recommends = [
{
image: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=300&q=80',
title: '晨间活力唤醒跑',
tags: ['20分钟', '初级'],
desc: '唤醒身体,开启活力一天',
participants: '2784'
},
{
image: 'https://images.unsplash.com/photo-1581009146145-b5ef050c149a?w=300&q=80',
title: '全身力量塑形',
tags: ['50分钟', '中级'],
desc: '全身综合训练,塑造完美线条',
participants: '4126'
},
{
image: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=300&q=80',
title: '蛋白增肌饮食指南',
tags: ['营养饮食', '12分钟'],
desc: '科学饮食搭配,助力肌肉增长',
participants: '1865'
}
]
</script>
<style lang="scss" scoped>
/* 今日推荐容器样式 */
.today-recommend {
padding: 0 24rpx;
}
/* 区域标题栏样式 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
/* 区域标题样式 */
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #1a202c;
}
/* 查看更多按钮样式 */
.view-more {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 26rpx;
color: #94a3b8;
}
/* 箭头图标样式 */
.arrow {
font-size: 32rpx;
}
/* 推荐列表样式 */
.recommend-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* 推荐项卡片样式 */
.recommend-item {
display: flex;
gap: 24rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
}
/* 推荐项图片样式 */
.item-image {
width: 200rpx;
height: 160rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
/* 推荐项内容区域样式 */
.item-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
/* 推荐项标题样式 */
.item-title {
font-size: 30rpx;
font-weight: 600;
color: #1a202c;
margin-bottom: 12rpx;
}
/* 推荐项标签列表样式 */
.item-tags {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
/* 推荐项标签样式 */
.tag {
padding: 6rpx 16rpx;
background: #f1f5f9;
border-radius: 8rpx;
font-size: 22rpx;
color: #64748b;
}
/* 推荐项描述文字样式 */
.item-desc {
font-size: 24rpx;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 推荐项操作区域样式 */
.item-action {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 16rpx;
flex-shrink: 0;
}
/* 开始训练按钮样式 */
.start-btn {
padding: 16rpx 28rpx;
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
border-radius: 9999rpx;
box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.4);
}
/* 开始训练按钮文字样式 */
.start-btn-text {
font-size: 24rpx;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
}
/* 参与人数文字样式 */
.participants {
font-size: 22rpx;
color: #94a3b8;
white-space: nowrap;
}
</style>
@@ -1,228 +0,0 @@
import { ref, computed } from 'vue'
import { groupCourseService } from '@/api/envConfig.js'
export function useGroupCourseList() {
// 分页相关
const pageNum = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = ref(0)
const loading = ref(false)
const hasMore = ref(true)
// 团课列表数据
const courseList = ref([])
// 搜索相关
const searchKeyword = ref('')
const hotKeywords = ref(['燃脂', '瑜伽', '单车', '普拉提', '高强度'])
// 排序相关
const sortOptions = ref([
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'priceAsc' },
{ label: '价格从高到低', value: 'priceDesc' },
{ label: '剩余名额最多', value: 'spotsDesc' }
])
const sortIndex = ref(0)
// 时间段选择相关
const timePeriodOptions = ref([
{ label: '全部', value: 'all' },
{ label: '早上 (6-12 点)', value: 'morning', startHour: 6, endHour: 12 },
{ label: '下午 (12-18 点)', value: 'afternoon', startHour: 12, endHour: 18 },
{ label: '晚上 (18-24 点)', value: 'evening', startHour: 18, endHour: 24 }
])
const timePeriodIndex = ref(0)
// 时间筛选相关
const showTimePicker = ref(false)
const startDate = ref('')
const endDate = ref('')
const timeRangeText = ref('')
// 筛选后的课程列表
const filteredCourseList = computed(() => {
let result = [...courseList.value]
// 关键词搜索
if (searchKeyword.value) {
result = result.filter(course =>
course.courseName.includes(searchKeyword.value)
)
}
// 时间范围筛选
if (startDate.value || endDate.value) {
result = result.filter(course => {
const courseDate = course.startTime.split('T')[0]
if (startDate.value && courseDate < startDate.value) return false
if (endDate.value && courseDate > endDate.value) return false
return true
})
}
// 时间段筛选
const timePeriod = timePeriodOptions.value[timePeriodIndex.value]
if (timePeriod.value !== 'all') {
result = result.filter(course => {
const courseHour = new Date(course.startTime).getHours()
return courseHour >= timePeriod.startHour && courseHour < timePeriod.endHour
})
}
// 排序
const sortType = sortOptions.value[sortIndex.value].value
if (sortType === 'priceAsc') {
result.sort((a, b) => (a.storedValueAmount || a.pointCardAmount) - (b.storedValueAmount || b.pointCardAmount))
} else if (sortType === 'priceDesc') {
result.sort((a, b) => (b.storedValueAmount || b.pointCardAmount) - (a.storedValueAmount || a.pointCardAmount))
} else if (sortType === 'spotsDesc') {
result.sort((a, b) => (b.maxMembers - b.currentMembers) - (a.maxMembers - a.currentMembers))
}
return result
})
// 获取所有搜索参数
const getAllSearchParams = (searchBarRef, filterSectionRef, timePeriodRef, timeRangePickerRef) => {
const searchParams = searchBarRef?.getSearchParams?.() || { keyword: searchKeyword.value }
const filterParams = filterSectionRef?.getFilterParams?.() || { sortType: sortOptions.value[sortIndex.value].value }
const timePeriodParams = timePeriodRef?.getTimePeriodParams?.() || { index: timePeriodIndex.value, value: timePeriodOptions.value[timePeriodIndex.value].value }
const timeRangeParams = timeRangePickerRef?.getTimeRangeParams?.() || { startDate: startDate.value, endDate: endDate.value, timeRangeText: timeRangeText.value }
const allParams = {
search: searchParams,
filter: filterParams,
timePeriod: timePeriodParams,
timeRange: timeRangeParams
}
console.log('[useGroupCourseList] 获取所有搜索参数:', allParams)
return allParams
}
// 搜索处理
const handleSearch = (params) => {
console.log('[useGroupCourseList] 搜索触发:', params)
uni.showToast({
title: params.keyword ? `搜索:${params.keyword}` : '请输入关键词',
icon: 'none'
})
}
// 时间段变化处理
const onTimePeriodChange = (option) => {
console.log('[useGroupCourseList] 时间段选择:', option)
}
// 时间范围确认处理
const onTimeRangeConfirm = (params) => {
console.log('[useGroupCourseList] 时间范围确认:', params)
timeRangeText.value = params.timeRangeText
}
// 预约处理
const handleBooking = (course) => {
console.log('[useGroupCourseList] 预约课程:', course)
uni.showToast({
title: `预约课程:${course.courseName}`,
icon: 'success'
})
}
// 跳转详情
const goDetail = (courseId) => {
console.log('[useGroupCourseList] 跳转到课程详情:', courseId)
uni.navigateTo({
url: `/pages/groupCourse/detail?id=${courseId}`
})
}
// 获取团课列表
const fetchCourseList = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
try {
const result = await groupCourseService.getList({
pageNum: pageNum.value,
pageSize: pageSize.value
})
if (result.code === 0 && result.data) {
const { list, total: totalCount, pageNum: currentPage, totalPages: pages } = result.data
if (isLoadMore) {
courseList.value = [...courseList.value, ...list]
} else {
courseList.value = list
}
total.value = totalCount
pageNum.value = currentPage
totalPages.value = pages
hasMore.value = pageNum.value < totalPages.value
console.log('[useGroupCourseList] 团课列表获取成功:', {
total: total.value,
currentPage: pageNum.value,
totalPages: totalPages.value,
hasMore: hasMore.value
})
} else {
console.error('[useGroupCourseList] 获取团课列表失败:', result.message)
}
} catch (error) {
console.error('[useGroupCourseList] 获取团课列表异常:', error)
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = () => {
if (!hasMore.value || loading.value) return
pageNum.value++
fetchCourseList(true)
}
// 滚动到底部触发
const onScrollToLower = () => {
loadMore()
}
return {
// 状态
pageNum,
pageSize,
total,
totalPages,
loading,
hasMore,
courseList,
searchKeyword,
hotKeywords,
sortOptions,
sortIndex,
timePeriodOptions,
timePeriodIndex,
showTimePicker,
startDate,
endDate,
timeRangeText,
filteredCourseList,
// 方法
getAllSearchParams,
handleSearch,
onTimePeriodChange,
onTimeRangeConfirm,
handleBooking,
goDetail,
fetchCourseList,
loadMore,
onScrollToLower
}
}
-234
View File
@@ -1,234 +0,0 @@
# 团课管理系统 API 文档
## 1. 项目概述
本项目是一个基于 UniApp 的健身房团课管理系统,包含完整的 API 层设计,支持开发/生产环境的快速切换。
### 1.1 项目结构
```
gym-manage-uniapp/
├── api/ # API 层目录
│ ├── requestBase.js # 基础请求封装
│ ├── groupCourse.js # 团课真实API
│ ├── groupCourse.mock.js # 团课模拟数据API
│ └── envConfig.js # 环境配置与服务导出
├── pages/
│ └── groupCourse/
│ └── list.vue # 团课列表页面
└── components/ # 组件目录
```
---
## 2. API 层设计
### 2.1 架构设计
| 层级 | 文件 | 职责 |
|------|------|------|
| 基础层 | `requestBase.js` | 封装 uni.request,统一处理加载状态、错误提示 |
| 接口层 | `groupCourse.js` | 定义真实后端API接口 |
| 模拟层 | `groupCourse.mock.js` | 提供模拟数据,支持开发测试 |
| 配置层 | `envConfig.js` | 环境配置,统一服务导出入口 |
### 2.2 环境切换机制
通过修改 `envConfig.js` 中的 `ENV_MODE` 变量实现环境切换:
```javascript
// envConfig.js
export const ENV_MODE = 'development' // 'production' | 'development'
```
- **`production`**:生产环境,使用真实网络请求
- **`development`**:开发环境,使用模拟数据
---
## 3. 文件详细说明
### 3.1 requestBase.js - 基础请求封装
**功能定位**:封装 `uni.request`,提供统一的请求处理逻辑。
**核心特性**
- 自动显示/隐藏加载提示
- 统一的响应状态码处理
- 统一的错误提示机制
**接口定义**
| 方法 | 说明 | 参数 | 返回值 |
|------|------|------|--------|
| `request(options)` | 发起网络请求 | `options` 对象 | `Promise` |
**options 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `url` | string | 是 | 请求路径 |
| `method` | string | 否 | 请求方法,默认 `GET` |
| `data` | object | 否 | 请求数据 |
| `header` | object | 否 | 请求头 |
**响应数据结构**
```javascript
// 成功响应
{
code: 0, // 状态码,0表示成功
message: 'success', // 提示信息
data: {} // 业务数据
}
```
### 3.2 groupCourse.js - 团课真实 API
**功能定位**:定义团课相关的真实后端接口。
**接口列表**
| 方法 | 说明 | 参数 | HTTP方法 | 路径 |
|------|------|------|----------|------|
| `getList()` | 获取团课列表 | 无 | GET | `/api/groupCourse/list` |
| `getDetail(id)` | 获取团课详情 | `id`: 课程ID | GET | `/api/groupCourse/detail/{id}` |
| `book(data)` | 预约团课 | `data`: 预约数据 | POST | `/api/groupCourse/book` |
| `cancelBooking(id)` | 取消预约 | `id`: 预约记录ID | POST | `/api/groupCourse/cancel/{id}` |
**book 方法参数结构**
```javascript
{
courseId: string, // 课程ID
memberId: string // 会员ID
}
```
### 3.3 groupCourse.mock.js - 团课模拟数据 API
**功能定位**:提供模拟数据,支持开发测试,无需后端服务。
**特性**
- 模拟网络延迟(300-500ms
- 接口签名与真实API完全一致
- 包含6条完整的模拟团课数据
**模拟数据结构**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 课程唯一标识 |
| `courseName` | string | 课程名称 |
| `coachName` | string | 教练姓名 |
| `coachAvatar` | string | 教练头像 |
| `startTime` | string | 开始时间(ISO格式) |
| `endTime` | string | 结束时间(ISO格式) |
| `duration` | number | 课程时长(分钟) |
| `location` | string | 上课地点 |
| `maxMembers` | number | 最大人数 |
| `currentMembers` | number | 当前人数 |
| `storedValueAmount` | number | 储值卡价格(元) |
| `pointCardAmount` | number | 次卡次数 |
| `courseType` | string | 课程类型 |
| `level` | string | 难度级别 |
| `description` | string | 课程描述 |
| `tags` | array | 标签列表 |
| `status` | string | 状态(available/closed |
### 3.4 envConfig.js - 环境配置
**功能定位**:统一服务导出入口,根据环境模式自动选择 API 实现。
**导出内容**
| 导出项 | 类型 | 说明 |
|--------|------|------|
| `ENV_MODE` | string | 当前环境模式 |
| `groupCourseService` | object | 团课服务实例 |
---
## 4. 使用示例
### 4.1 在页面中使用
```javascript
// pages/groupCourse/list.vue
import { groupCourseService } from '@/api/envConfig.js'
// 获取团课列表
const fetchCourseList = async () => {
try {
const data = await groupCourseService.getList()
courseList.value = data
console.log('团课列表获取成功:', data)
} catch (error) {
console.error('获取团课列表异常:', error)
}
}
```
### 4.2 切换环境
```javascript
// api/envConfig.js
// 开发环境 - 使用模拟数据
export const ENV_MODE = 'development'
// 生产环境 - 使用真实网络请求
export const ENV_MODE = 'production'
```
---
## 5. 数据流转图
```
┌─────────────────────────────────────────────────────────────┐
│ 页面层 (View) │
│ pages/groupCourse/list.vue │
└───────────────────────────┬─────────────────────────────────┘
│ import & call
┌─────────────────────────────────────────────────────────────┐
│ 环境配置层 (Config) │
│ api/envConfig.js │
│ 根据 ENV_MODE 选择对应的服务实现 │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────┴─────────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────┐
│ groupCourse.js │ │ groupCourse.mock.js │
│ (production 环境) │ │ (development 环境) │
└───────────┬─────────────┘ └─────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────┐
│ requestBase.js │ │ 本地模拟数据 │
│ (真实网络请求) │ │ (无需后端) │
└───────────┬─────────────┘ └─────────────────────┘
┌─────────────────────────┐
│ 后端服务器 API │
└─────────────────────────┘
```
---
## 6. 注意事项
1. **环境变量配置**:部署前务必确认 `ENV_MODE` 设置正确
2. **数据一致性**:模拟数据结构应与真实API保持一致
3. **错误处理**:所有API调用都应包含 try-catch 错误处理
4. **日志记录**:建议在关键节点添加日志,便于调试
---
## 7. 版本历史
| 版本 | 日期 | 更新内容 |
|------|------|----------|
| v1.0 | 2026-06-04 | 初始版本,完成基础API层设计 |
-570
View File
@@ -1,570 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>健身房管理系统 | 动感配色方案</title>
<!-- 引入Font Awesome 6 (免费图标库,增强视觉) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
background: #F5F7FC; /* 整体背景柔和灰白,衬托主色 */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
padding: 24px 20px 48px;
color: #1E2A3A;
}
/* 主色调变量定义 – 活力运动风格
主色: 动感深蓝 (#0B2B4B) – 权威、专业、沉稳
辅色: 活力橙 (#FF6B35) – 热情、能量、行动召唤
背景浅色: #F9FAFE, 卡片白, 强调蓝绿渐变点缀
*/
:root {
--primary-dark: #0B2B4B; /* 深蓝主色,用于头部、重要按钮、边框强调 */
--primary-deep: #1A4A6F; /* 中深蓝,用于hover、次级强调 */
--accent-orange: #FF6B35; /* 活力橙,CTA、会员卡片、高亮标签 */
--accent-orange-light: #FF8C5A;
--bg-light: #F9FAFE;
--card-white: #FFFFFF;
--text-dark: #1E2A3A;
--text-muted: #5E6F8D;
--border-light: #E9EDF2;
--success-green: #2ECC71;
--warning-amber: #F39C12;
--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);
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
--gradient-blue: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
}
/* 整体容器 */
.demo-container {
max-width: 450px;
margin: 0 auto;
background: var(--bg-light);
border-radius: 36px;
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
}
/* 状态栏模拟 (小程序顶部风格) */
.status-bar {
background: var(--primary-dark);
padding: 12px 20px 6px;
color: white;
display: flex;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
}
/* 主头部导航 */
.main-header {
background: var(--primary-dark);
padding: 12px 20px 20px;
color: white;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 20px;
}
.logo-area h2 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 6px;
}
.logo-area h2 i {
color: var(--accent-orange);
font-size: 1.7rem;
}
.logo-area p {
font-size: 0.7rem;
opacity: 0.8;
letter-spacing: 0.5px;
}
.header-actions i {
font-size: 1.4rem;
background: rgba(255,255,255,0.15);
padding: 8px;
border-radius: 30px;
margin-left: 8px;
cursor: default;
}
/* 欢迎卡片 + 活力橙强调 */
.welcome-card {
background: white;
border-radius: 28px;
padding: 18px 20px;
margin-top: 12px;
box-shadow: var(--shadow-sm);
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text h4 {
font-size: 1rem;
color: var(--text-muted);
font-weight: 500;
}
.welcome-text h3 {
font-size: 1.4rem;
margin-top: 4px;
color: var(--primary-dark);
}
.badge-member {
background: var(--gradient-orange);
padding: 8px 16px;
border-radius: 40px;
color: white;
font-weight: 600;
font-size: 0.8rem;
box-shadow: 0 4px 8px rgba(255,107,53,0.2);
}
/* 主要指标卡片区 */
.stats-grid {
display: flex;
gap: 12px;
padding: 20px;
background: var(--bg-light);
}
.stat-card {
background: var(--card-white);
flex: 1;
border-radius: 24px;
padding: 14px 0;
text-align: center;
box-shadow: var(--shadow-sm);
transition: all 0.2s;
border: 1px solid var(--border-light);
}
.stat-card i {
font-size: 1.8rem;
color: var(--accent-orange);
margin-bottom: 6px;
}
.stat-number {
font-size: 1.6rem;
font-weight: 800;
color: var(--primary-dark);
}
.stat-label {
font-size: 0.7rem;
color: var(--text-muted);
}
/* 功能菜单 grid */
.menu-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 8px 20px 20px;
}
.menu-item {
background: var(--card-white);
border-radius: 20px;
padding: 12px 6px;
text-align: center;
transition: all 0.2s ease;
border: 1px solid var(--border-light);
cursor: default;
}
.menu-item i {
font-size: 1.8rem;
color: var(--primary-deep);
margin-bottom: 6px;
display: block;
}
.menu-item span {
font-size: 0.7rem;
font-weight: 500;
color: var(--text-dark);
}
.menu-item:active {
transform: scale(0.96);
background: #FEF5F0;
}
/* 课程/热门活动区域 */
.section-title {
display: flex;
justify-content: space-between;
padding: 8px 20px 4px;
align-items: baseline;
}
.section-title h3 {
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-dark);
}
.section-title a {
font-size: 0.75rem;
color: var(--accent-orange);
font-weight: 600;
}
.class-list {
padding: 8px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.class-card {
background: white;
border-radius: 20px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: var(--shadow-sm);
border-left: 5px solid var(--accent-orange);
transition: all 0.2s;
}
.class-icon {
width: 48px;
height: 48px;
background: rgba(255,107,53,0.1);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.class-icon i {
font-size: 1.8rem;
color: var(--accent-orange);
}
.class-info {
flex: 1;
}
.class-info h4 {
font-size: 1rem;
font-weight: 700;
}
.class-info p {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
}
.class-time {
font-size: 0.75rem;
background: #F0F3F9;
padding: 4px 10px;
border-radius: 40px;
font-weight: 600;
color: var(--primary-deep);
}
/* 底部导航栏 (Tab Bar 模拟) */
.bottom-tab {
background: white;
backdrop-filter: blur(0px);
border-top: 1px solid var(--border-light);
display: flex;
justify-content: space-around;
padding: 10px 20px 22px;
border-radius: 0 0 36px 36px;
}
.tab-item {
text-align: center;
color: var(--text-muted);
transition: 0.1s;
cursor: default;
}
.tab-item i {
font-size: 1.5rem;
}
.tab-item span {
font-size: 0.7rem;
display: block;
margin-top: 4px;
}
.tab-item.active {
color: var(--accent-orange);
font-weight: 600;
}
/* 教练推荐卡片额外装饰 */
.trainer-spot {
background: var(--gradient-blue);
margin: 0 20px 20px;
border-radius: 28px;
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
color: white;
}
.trainer-info h4 {
font-size: 0.9rem;
opacity: 0.9;
}
.trainer-info h3 {
font-size: 1.1rem;
font-weight: 700;
}
.trainer-btn {
background: var(--accent-orange);
border: none;
padding: 8px 18px;
border-radius: 50px;
color: white;
font-weight: 600;
font-size: 0.8rem;
cursor: default;
}
/* 配色展示条 / 设计说明 */
.color-palette-show {
max-width: 450px;
margin: 28px auto 0;
background: white;
border-radius: 32px;
padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.color-title {
font-weight: 700;
margin-bottom: 12px;
color: var(--primary-dark);
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.color-swatches {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.swatch {
flex: 1;
min-width: 70px;
text-align: center;
}
.swatch-circle {
height: 50px;
border-radius: 16px;
margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
.swatch span {
font-size: 0.7rem;
font-weight: 500;
color: var(--text-dark);
}
hr {
margin: 20px 0 8px;
border: none;
height: 1px;
background: var(--border-light);
}
.note {
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
margin-top: 12px;
}
i, .tab-item, .menu-item, .stat-card {
cursor: default;
}
</style>
</head>
<body>
<div class="demo-container">
<!-- 模拟小程序状态栏/胶囊区 -->
<div class="status-bar">
<span>9:41</span>
<span><i class="fas fa-signal"></i> <i class="fas fa-battery-full"></i></span>
</div>
<!-- 主头部深蓝区 -->
<div class="main-header">
<div class="header-top">
<div class="logo-area">
<h2><i class="fas fa-dumbbell"></i> IRONPULSE</h2>
<p>智能健身 · 即刻燃动</p>
</div>
<div class="header-actions">
<i class="fas fa-bell"></i>
<i class="fas fa-user-circle"></i>
</div>
</div>
<!-- 用户欢迎卡片,橙色高亮会员 -->
<div class="welcome-card">
<div class="welcome-text">
<h4>欢迎回来,</h4>
<h3>张峻铭 <span style="font-size: 0.8rem;">💪</span></h3>
</div>
<div class="badge-member">MVP 黑金会员</div>
</div>
</div>
<!-- 关键指标 活力橙点缀 -->
<div class="stats-grid">
<div class="stat-card">
<i class="fas fa-fire"></i>
<div class="stat-number">1,280</div>
<div class="stat-label">今日卡路里</div>
</div>
<div class="stat-card">
<i class="fas fa-clock"></i>
<div class="stat-number">126</div>
<div class="stat-label">运动分钟</div>
</div>
<div class="stat-card">
<i class="fas fa-calendar-check"></i>
<div class="stat-number">12</div>
<div class="stat-label">本月课程</div>
</div>
</div>
<!-- 功能区 网格菜单(四大模块) -->
<div class="menu-grid">
<div class="menu-item"><i class="fas fa-qrcode"></i><span>签到</span></div>
<div class="menu-item"><i class="fas fa-chalkboard-user"></i><span>团课预约</span></div>
<div class="menu-item"><i class="fas fa-chart-line"></i><span>体测报告</span></div>
<div class="menu-item"><i class="fas fa-cog"></i><span>我的会籍</span></div>
</div>
<!-- 热门课程板块 (动态橙边风格) -->
<div class="section-title">
<h3><i class="fas fa-heartbeat" style="color: var(--accent-orange); margin-right: 6px;"></i> 热门团课</h3>
<a href="#">查看全部 →</a>
</div>
<div class="class-list">
<div class="class-card">
<div class="class-icon"><i class="fas fa-bicycle"></i></div>
<div class="class-info">
<h4>极速燃脂 · 动感单车</h4>
<p>张教练 | 综合有氧</p>
</div>
<div class="class-time">19:30 满员</div>
</div>
<div class="class-card">
<div class="class-icon"><i class="fas fa-hand-fist"></i></div>
<div class="class-info">
<h4>搏击风暴 · 泰拳基础</h4>
<p>李娜 | 格斗区</p>
</div>
<div class="class-time">18:00 火热</div>
</div>
<div class="class-card">
<div class="class-icon"><i class="fas fa-person-walking"></i></div>
<div class="class-info">
<h4>普拉提核心唤醒</h4>
<p>Elena | 瑜伽室</p>
</div>
<div class="class-time">明早 09:30</div>
</div>
</div>
<!-- 教练推荐 使用深蓝渐变+橙色按钮 -->
<div class="trainer-spot">
<div class="trainer-info">
<h4>🌟 明星私教推荐</h4>
<h3>Alex 王 · 增肌塑形专家</h3>
<p style="font-size: 0.7rem; opacity:0.85;">剩余3个时段可约</p>
</div>
<div class="trainer-btn">立即预约</div>
</div>
<!-- 底部tab栏 当前选中“首页”凸显橙色-->
<div class="bottom-tab">
<div class="tab-item active"><i class="fas fa-home"></i><span>首页</span></div>
<div class="tab-item"><i class="fas fa-calendar-alt"></i><span>日程</span></div>
<div class="tab-item"><i class="fas fa-chart-simple"></i><span>数据</span></div>
<div class="tab-item"><i class="fas fa-user"></i><span>我的</span></div>
</div>
</div>
<!-- 配色方案展示区,直观看到颜色搭配,帮助设计决策 -->
<div class="color-palette-show">
<div class="color-title">
<i class="fas fa-palette" style="color: #FF6B35;"></i> 健身房管理系统 · 能量配色方案
</div>
<div class="color-swatches">
<div class="swatch">
<div class="swatch-circle" style="background: #0B2B4B;"></div>
<span>深蓝主色 #0B2B4B<br>专业信赖</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #FF6B35;"></div>
<span>活力橙 #FF6B35<br>行动/热情</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #1A4A6F;"></div>
<span>深邃辅助 #1A4A6F</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #F9FAFE; border:1px solid #E2E8F0;"></div>
<span>浅色背景 #F9FAFE</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #FFFFFF; border:1px solid #E2E8F0;"></div>
<span>卡片白 #FFFFFF</span>
</div>
</div>
<hr>
<div class="note">
<i class="fas fa-lightbulb" style="color: #FF6B35;"></i> 设计理念:深蓝色传递专业与稳定感,活力橙色提升运动热情与CTA转化。<br>
圆润卡片 + 强烈对比,适合健身管理小程序的年轻、力量与现代氛围。
</div>
</div>
<!-- 额外注释:颜色可访问性强,橙蓝互补但明度舒适 -->
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif
+60 -68
View File
@@ -1,72 +1,64 @@
{
"name" : "gym-manage-uniapp",
"appid" : "__UNI__1F1874C",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
"name": "gym-manage-uniapp",
"appid": "",
"description": "Gym Management System Mobile App",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx8f0d644d1d8985f6",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3"
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "gym-manage-uniapp",
"version": "1.0.0",
"description": "Gym Management System Mobile App",
"main": "main.js",
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"dev:h5": "uni",
"build:h5": "uni build"
},
"keywords": [
"uniapp",
"gym",
"management"
],
"author": "Novalon",
"license": "MIT"
}
+26 -27
View File
@@ -1,29 +1,28 @@
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "健身房"
}
},
{
"path": "pages/checkIn/checkIn",
"style": {
"navigationBarTitleText": "会员签到"
}
},
{
"path": "pages/groupCourse/list",
"style": {
"navigationBarTitleText": "团课列表"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "健身房管理系统",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#007AFF",
"borderStyle": "black",
"backgroundColor": "#F8F8F8",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
}
]
}
}
-979
View File
@@ -1,979 +0,0 @@
<template>
<view class="checkin-page">
<!-- 顶部导航栏固定顶部 -->
<view class="header">
<view class="header-title">会员签到</view>
<view class="header-subtitle">扫码入场开启今日训练</view>
</view>
<!-- 会员信息卡片 -->
<view class="member-card card-default">
<view class="member-avatar">
<image src="/static/default-avatar.png" mode="aspectFill"></image>
</view>
<view class="member-info">
<view class="member-name">尊敬的会员</view>
<view class="member-level">
<text class="level-badge">黄金会员</text>
<text class="valid-date">有效期至 2026-12-31</text>
</view>
</view>
<view class="member-points">
<view class="points-value">1280</view>
<view class="points-label">积分</view>
</view>
</view>
<!-- 二维码核心区域 -->
<view class="qr-container card-default">
<view class="qr-header">
<view class="qr-title">出示二维码签到</view>
<view class="qr-tip">将二维码对准前台扫码设备</view>
</view>
<view class="QRBox">
<view class="QR">
<image :src="image" mode="" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' } "></image>
</view>
<view v-if="!image || STQRC" class="loadingBox" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' }">
<view class="loading-spinner">
<view v-if="!isCheckIn" class="spinner-circle"></view>
<view v-else>
<uni-icons type="checkmarkempty" size="30"></uni-icons>
</view>
<text class="loading-text">{{ QRStatus }}</text>
</view>
</view>
<!-- 二维码装饰边框 -->
<view v-else class="qr-border" :style="{width: Math.min(width, 500) + 80 + 'rpx',height: Math.min(height, 500) + 80 + 'rpx' }">
<view class="corner top-left"></view>
<view class="corner top-right"></view>
<view class="corner bottom-left"></view>
<view class="corner bottom-right"></view>
</view>
</view>
<!-- 状态组件传递状态和自定义错误文案- 已签到时不显示 -->
<QrStatus
v-if="!isCheckIn"
:status="status"
:errorText="errorText"
/>
</view>
<!-- 操作提示 -->
<view class="tips-section">
<view class="tips-title">温馨提示</view>
<view class="tips-list">
<view class="tip-item">
<view class="tip-dot"></view>
<text>二维码每5分钟自动刷新请勿截图使用</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text>签到成功后可进入场馆开始训练</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text>如有问题请联系前台工作人员</text>
</view>
</view>
</view>
<!-- 底部按钮z-index永久置顶 -->
<view class="bottom-actions">
<button @tap="handleLongPress">签到</button>
<button class="btn-refresh" @tap="refreshQR">
<uni-icons type="refresh" size="36rpx" color="#5E6F8D"></uni-icons>
<text>刷新二维码</text>
</button>
<!-- 测试用手动清除缓存按钮 -->
<button class="btn-clear-cache" @tap="handleClearCache">
<uni-icons type="trash" size="36rpx" color="#ef4444"></uni-icons>
<text>清除缓存</text>
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app'
// 引入状态组件(路径与你保持一致)
import QrStatus from '@/components/QRCode/StatusCard.vue'
// 引入API封装
import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
let image = ref("")
let width = ref(0)
let height = ref(0)
let status = ref('loading')
let socketTask = null
const scanStatus = ref(false)
const QRStatus = ref("生成中...")
const STQRC = ref(false)//是否扫码
const isCheckIn = ref(false)
const qrcode = ref("")
// 新增:自定义错误文本变量
const errorText = ref('')
/**
* 缓存键名前缀
*/
const CACHE_PREFIX = 'QR_'
/**
* 获取带前缀的缓存键名
* @param {string} key - 原始键名
* @returns {string} 带前缀的键名
*/
const getCacheKey = (key) => {
return CACHE_PREFIX + key
}
/**
* 获取今日23:59:59的时间戳(当日过期时间)
* @returns {number} 今日23:59:59的时间戳
*/
const getTodayExpireTime = () => {
const now = new Date()
const expire = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
return expire.getTime()
}
/**
* 检查缓存是否过期
* @param {string} key - 缓存键名(不带前缀)
* @returns {boolean} 是否有效(未过期)
*/
const isCacheValid = (key) => {
try {
const cacheKey = getCacheKey(key)
const cacheData = uni.getStorageSync(cacheKey)
if (!cacheData) return false
if (typeof cacheData === 'object' && cacheData.expireTime) {
const now = Date.now()
return now <= cacheData.expireTime
}
return false
} catch (e) {
console.error('检查缓存有效性失败:', e)
return false
}
}
/**
* 获取缓存数据(自动校验过期时间)
* @param {string} key - 缓存键名(不带前缀)
* @returns {any} 缓存数据(过期返回null)
*/
const getCacheData = (key) => {
try {
if (!isCacheValid(key)) {
// 缓存已过期,清除缓存
uni.removeStorageSync(getCacheKey(key))
return null
}
const cacheData = uni.getStorageSync(getCacheKey(key))
if (typeof cacheData === 'object' && cacheData.data !== undefined) {
return cacheData.data
}
return null
} catch (e) {
console.error('获取缓存数据失败:', e)
return null
}
}
/**
* 设置缓存数据(自动设置当日23:59:59过期)
* @param {string} key - 缓存键名(不带前缀)
* @param {any} data - 要缓存的数据
*/
const setCacheData = (key, data) => {
try {
const cacheData = {
data: data,
expireTime: getTodayExpireTime()
}
uni.setStorageSync(getCacheKey(key), cacheData)
} catch (e) {
console.error('设置缓存数据失败:', e)
}
}
/**
* 清除所有QR_开头的缓存(用于测试阶段)
*/
const clearQRCache = () => {
try {
const keys = uni.getStorageInfoSync().keys || []
let clearedCount = 0
for (const key of keys) {
if (key.startsWith(CACHE_PREFIX)) {
uni.removeStorageSync(key)
clearedCount++
}
}
console.log(`已清除 ${clearedCount} 个QR_开头的缓存`)
return clearedCount
} catch (e) {
console.error('清除QR缓存失败:', e)
return 0
}
}
/**
* 测试用:手动清除缓存按钮点击事件
*/
const handleClearCache = () => {
uni.showModal({
title: '清除缓存',
content: '确定要清除所有签到相关的缓存吗?(测试用)',
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
const clearedCount = clearQRCache()
// 重置页面状态
image.value = ""
width.value = 0
height.value = 0
status.value = 'loading'
QRStatus.value = "生成中..."
STQRC.value = false
isCheckIn.value = false
uni.showToast({
title: `已清除 ${clearedCount} 个缓存`,
icon: 'success',
duration: 2000
})
// 重新请求二维码
setTimeout(() => {
getStorage(null)
}, 500)
}
}
})
}
onLoad(() => {
uni.showLoading({
title: '生成签到二维码...',
mask: true
})
// 读取签到状态缓存(自动检查过期)
// isCheckIn 代表签到状态,从缓存读取
const cachedIsCheckIn = getCacheData("isCheckIn")
// checkInTime 代表具体签到时间,从缓存读取(显示在 loading-text 中)
const cachedCheckInTime = getCacheData("checkInTime")
if(cachedIsCheckIn != null) {
console.log("进入缓存 - 签到状态")
isCheckIn.value = cachedIsCheckIn
STQRC.value = true
}
// 如果已经签到成功,直接显示成功状态,不需要请求后端
if(isCheckIn.value) {
console.log("已签到且有缓存,无需请求后端")
// 读取二维码图片缓存用于显示
const cachedQRInfo = getCacheData("QRInfo")
if(cachedQRInfo) {
image.value = cachedQRInfo.qrCodeBase64
width.value = cachedQRInfo.width * 2
height.value = cachedQRInfo.height * 2
}
// QRStatus 显示具体签到时间(从缓存读取,显示在 loading-text 中)
QRStatus.value = cachedCheckInTime || "已完成签到"
uni.hideLoading()
return
}
// 未签到或缓存失效,需要请求后端获取二维码
// QRStatus 重置为默认的请求状态
QRStatus.value = "生成中..."
getStorage(null)
})
// 页面卸载时关闭WebSocket连接(不清除缓存,让缓存自然过期)
onUnload(() => {
closeWebSocket()
// 缓存会在当日23:59:59自动过期,页面卸载时不主动清除
// 如需测试,使用页面上的"清除缓存"按钮手动清除
})
// 获取二维码接口
const fetchQRCode = () => {
console.log(1111)
status.value = ''
errorText.value = '' // 重置错误文本
image.value = ""
getQRCode(true).then(res => {
console.log(res)
// 保存到本地缓存(用于签到状态判断)
setCacheData("QRInfo", res)
getStorage(res)
qrcode.value = res.qrContent
}).catch(err => {
console.error('获取二维码失败:', err)
status.value = 'error'
errorText.value = err.message || '获取二维码失败'
uni.showToast({
title: errorText.value,
icon: 'error'
})
}).finally(() => {
uni.hideLoading()
})
}
const refreshQR = () => {
// 如果已签到,不允许刷新二维码
if (isCheckIn.value) {
uni.showToast({
title: '您已签到',
icon: 'none',
duration: 2000
})
return
}
image.value = ""
QRStatus.value = "正在刷新二维码..."
setTimeout(() => {
getStorage(null)
}, 500)
}
const getStorage = res => {
let data = res
if(data == null) {
// 使用带过期检查的缓存读取
data = getCacheData("QRInfo")
uni.hideLoading()
}
if(!data) fetchQRCode()
if(data) {
image.value = data.qrCodeBase64
// 使用带过期时间的缓存(当日23:59:59过期)
setCacheData("QRInfo", data)
width.value = data.width * 2
height.value = data.height * 2
// 只有在未签到的情况下才连接WebSocket等待扫码
if (data.qrContent && !isCheckIn.value) {
console.log("未签到,连接WebSocket等待扫码")
connectWebSocket(data.qrContent)
} else if (isCheckIn.value) {
console.log("已签到,无需连接WebSocket")
}
}
}
const handleLongPress = () => {
uni.scanCode({
onlyFromCamera: false,
scanType: ['qrCode'],
success: (res) => {
console.log(res)
checkIn(res.result)
},
fail: (err) => {
console.error('扫码失败:', err)
uni.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
}
// 手动签到接口
const checkIn = (qrContent) => {
console.log(qrContent)
apiCheckIn(qrContent).then(res => {
closeWebSocket()
console.log(res)
status.value = 'scanned'
errorText.value = '' // 成功重置错误文本
QRStatus.value = res.dateTime + " 成功签到"
isCheckIn.value = true
// 使用带过期时间的缓存(当日23:59:59过期)
setCacheData("checkInTime", QRStatus.value)
setCacheData("isCheckIn", isCheckIn.value)
uni.showToast({
title: '签到成功!',
icon: 'success',
duration: 2000
})
}).catch(err => {
console.error('签到请求失败:', err)
status.value = 'error'
errorText.value = err.message || '签到失败,请重试' // 对应错误文案
})
}
// 建立WebSocket连接
const connectWebSocket = (qrContent) => {
const wsUrl = `ws://192.168.43.89:8084/webSocket/checkIn`
console.log('WebSocket 连接地址:', wsUrl)
socketTask = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket 连接中...')
},
fail: (err) => {
console.error('WebSocket 连接失败:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
}
})
// 连接打开成功
socketTask.onOpen(() => {
console.log('WebSocket 连接成功')
status.value = 'waiting'
errorText.value = '' // 成功重置错误文本
sendQRCodeInfo(qrContent)
})
// 发送二维码信息到后端
const sendQRCodeInfo = (qrContent) => {
const qrCodeDto = {
qrContent: qrContent,
used: false
}
console.log('发送二维码信息:', qrCodeDto)
socketTask.send({
data: JSON.stringify(qrCodeDto),
success: () => {
console.log('二维码信息发送成功')
},
fail: (err) => {
console.error('发送失败:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
}
})
}
// 接收后端消息
socketTask.onMessage((res) => {
console.log('收到 WebSocket 消息:', res.data)
const message = res.data
if (message === '正在进行签到') {
QRStatus.value = "正在进行签到..."
STQRC.value = true
// status.value = 'scanned'
// errorText.value = '' // 成功重置错误文本
// uni.showToast({
// title: '签到成功!',
// icon: 'success',
// duration: 2000
// })
} else if (message.startsWith('二维码无效')) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
status.value = 'error'
errorText.value = '二维码无效,请刷新' // 对应错误文案
setTimeout(() => {
closeWebSocket()
}, 3000)
} else if (message === '消息格式错误') {
uni.showToast({
title: '消息格式错误',
icon: 'none'
})
status.value = 'error'
errorText.value = '消息格式错误' // 对应错误文案
} else {
console.log('未知消息:', message)
}
})
// 连接关闭
socketTask.onClose(() => {
console.log('WebSocket 连接关闭')
status.value = 'closed'
errorText.value = '' // 重置错误文本
})
// 连接错误
socketTask.onError((err) => {
console.error('WebSocket 错误:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
uni.showToast({
title: '连接失败',
icon: 'none'
})
})
}
// 关闭WebSocket连接
const closeWebSocket = () => {
if (socketTask) {
socketTask.close({
success: () => {
console.log('主动关闭 WebSocket')
}
})
socketTask = null
}
}
</script>
<style lang="scss" scoped>
.checkin-page {
min-height: 100vh;
background-color: #F9FAFE;
padding-top: 200rpx; /* 为固定顶部导航预留空间 */
padding-bottom: 160rpx; /* 为底部按钮预留空间 */
box-sizing: border-box;
}
/* 顶部导航(固定顶部) */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 99998; /* 低于底部按钮,确保底部按钮永远最上层 */
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
padding: 64rpx 48rpx 48rpx;
text-align: center;
color: #FFFFFF;
border-bottom-left-radius: 56rpx;
border-bottom-right-radius: 56rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
.header-title {
font-size: 45rpx;
font-weight: 700;
margin-bottom: 8rpx;
}
.header-subtitle {
font-size: 26rpx;
opacity: 0.8;
}
}
/* 通用卡片样式 */
.card-default {
background: #FFFFFF;
border-radius: 40rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.03), 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
border: 1rpx solid #E9EDF2;
padding: 32rpx;
}
/* 会员信息卡片 */
.member-card {
margin: 0 32rpx 48rpx;
display: flex;
align-items: center;
padding: 32rpx;
flex-wrap: nowrap;
.member-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 999px;
overflow: hidden;
border: 4rpx solid #FF6B35;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
}
}
.member-info {
flex: 1;
margin-left: 32rpx;
flex-shrink: 1;
min-width: 0;
.member-name {
font-size: 32rpx;
font-weight: 500;
color: #1E2A3A;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-level {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
.level-badge {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
color: white;
padding: 4rpx 16rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
}
.valid-date {
font-size: 22rpx;
color: #5E6F8D;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.member-points {
text-align: center;
flex-shrink: 0;
margin-left: 16rpx;
.points-value {
font-size: 38rpx;
font-weight: 700;
color: #FF6B35;
}
.points-label {
font-size: 22rpx;
color: #5E6F8D;
}
}
}
/* 二维码容器 */
.qr-container {
margin: 0 32rpx 48rpx;
padding: 48rpx 32rpx;
text-align: center;
.qr-header {
margin-bottom: 64rpx;
.qr-title {
font-size: 38rpx;
font-weight: 700;
color: #1E2A3A;
margin-bottom: 8rpx;
}
.qr-tip {
font-size: 26rpx;
color: #5E6F8D;
}
}
}
/* 二维码核心区域 */
.QRBox {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto 64rpx;
max-width: 100%;
.QR {
position: relative;
z-index: 2;
padding: 20rpx;
background: white;
border-radius: 40rpx;
}
.loadingBox {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0.95);
border-radius: 40rpx;
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
.spinner-circle {
width: 80rpx;
height: 80rpx;
border: 6rpx solid #E9EDF2;
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 26rpx;
color: #5E6F8D;
width: 250rpx;
}
}
}
/* 二维码装饰边框 */
.qr-border {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
.corner {
position: absolute;
width: 48rpx;
height: 48rpx;
border: 6rpx solid #FF6B35;
&.top-left {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
border-top-left-radius: 40rpx;
}
&.top-right {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
border-top-right-radius: 40rpx;
}
&.bottom-left {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
border-bottom-left-radius: 40rpx;
}
&.bottom-right {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
border-bottom-right-radius: 40rpx;
}
}
}
}
/* 温馨提示 */
.tips-section {
margin: 0 32rpx 48rpx;
.tips-title {
font-size: 32rpx;
font-weight: 500;
color: #1E2A3A;
margin-bottom: 32rpx;
}
.tips-list {
background: #FFFFFF;
border-radius: 40rpx;
padding: 32rpx;
border: 1rpx solid #E9EDF2;
.tip-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.tip-dot {
width: 12rpx;
height: 12rpx;
background: #FF6B35;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
}
text {
font-size: 26rpx;
color: #5E6F8D;
line-height: 1.6;
}
}
}
}
/* 底部按钮(z-index永久置顶+安全区域适配) */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99999; /* 极高z-index确保永远在最上层 */
padding: 32rpx 48rpx calc(32rpx + env(safe-area-inset-bottom));
background: #FFFFFF;
border-top: 1rpx solid #E9EDF2;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.btn-refresh {
width: 100%;
height: 88rpx;
background: #F2F5F9;
color: #5E6F8D;
border: none;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 20rpx;
&:active {
background: #E9EDF2;
}
}
}
/* 测试用:清除缓存按钮 */
.btn-clear-cache {
width: calc(100% - 96rpx);
height: 88rpx;
background: #FEF2F2;
color: #EF4444;
border: 1rpx solid #FECACA;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
&:active {
background: #FEE2E2;
}
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.checkin-page {
background-color: #121826;
}
.header {
background: linear-gradient(135deg, #123A5E 0%, #1A4A6F 100%);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
}
.card-default {
background: #1E2636;
border-color: #2A3346;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.4);
}
.member-card .member-info .member-name {
color: #EDF2F7;
}
.member-card .member-info .member-level .valid-date,
.member-card .member-points .points-label {
color: #9AA9C1;
}
.qr-container .qr-header .qr-title {
color: #EDF2F7;
}
.qr-container .qr-header .qr-tip {
color: #9AA9C1;
}
.QRBox .loadingBox {
background: rgba(30, 38, 54, 0.95);
}
.QRBox .loadingBox .loading-spinner .loading-text {
color: #9AA9C1;
}
.tips-section .tips-title {
color: #EDF2F7;
}
.tips-section .tips-list {
background: #1E2636;
border-color: #2A3346;
}
.tips-section .tips-list .tip-item text {
color: #9AA9C1;
}
.bottom-actions {
background: #1E2636;
border-color: #2A3346;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.2);
}
.bottom-actions .btn-refresh {
background: #0F141F;
color: #9AA9C1;
&:active {
background: #2A3346;
}
}
.btn-clear-cache {
background: #3D1919;
color: #FCA5A5;
border-color: #5C2B2B;
&:active {
background: #4A2525;
}
}
}
</style>
@@ -1,172 +0,0 @@
<template>
<view class="group-course-page">
<!-- 搜索区域 -->
<view class="search-section">
<!-- 搜索框组件 -->
<SearchBar
v-model="searchKeyword"
:hot-keywords="hotKeywords"
@search="handleSearch"
ref="searchBarRef"
/>
<!-- 筛选条件组件 -->
<FilterSection
:time-range-text="timeRangeText"
:sort-options="sortOptions"
v-model:sort-index="sortIndex"
@time-pick="showTimePicker = true"
ref="filterSectionRef"
/>
<!-- 时间段选择组件 -->
<TimePeriodSelector
:time-period-options="timePeriodOptions"
v-model="timePeriodIndex"
@change="onTimePeriodChange"
ref="timePeriodRef"
/>
</view>
<!-- 团课列表 -->
<scroll-view
scroll-y
class="course-list"
@scrolltolower="onScrollToLower"
scroll-with-animation
>
<GroupCourseCard
v-for="course in filteredCourseList"
:key="course.id"
:course="course"
@booking="handleBooking"
@detail="goDetail"
></GroupCourseCard>
<!-- 加载更多提示 -->
<view class="load-more">
<text v-if="loading">加载中...</text>
<text v-else-if="!hasMore">没有更多数据了</text>
<text v-else class="load-more-text" @tap="loadMore">点击加载更多</text>
</view>
</scroll-view>
<!-- 底部导航 -->
<TabBar :active-tab="1" />
<!-- 时间选择器组件 -->
<TimeRangePicker
v-model:visible="showTimePicker"
v-model:start-date="startDate"
v-model:end-date="endDate"
@confirm="onTimeRangeConfirm"
ref="timeRangePickerRef"
/>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import TabBar from '@/components/TabBar.vue'
import GroupCourseCard from '@/components/groupCourse/CourseCard.vue'
import SearchBar from '@/components/groupCourse/SearchBar.vue'
import FilterSection from '@/components/groupCourse/FilterSection.vue'
import TimePeriodSelector from '@/components/groupCourse/TimePeriodSelector.vue'
import TimeRangePicker from '@/components/groupCourse/TimeRangePicker.vue'
import { useGroupCourseList } from '@/composables/useGroupCourseList.js'
// 组件引用
const searchBarRef = ref(null)
const filterSectionRef = ref(null)
const timePeriodRef = ref(null)
const timeRangePickerRef = ref(null)
// 使用组合式函数
const {
// 状态
loading,
hasMore,
searchKeyword,
hotKeywords,
sortOptions,
sortIndex,
timePeriodOptions,
timePeriodIndex,
showTimePicker,
startDate,
endDate,
timeRangeText,
filteredCourseList,
// 方法
getAllSearchParams,
handleSearch,
onTimePeriodChange,
onTimeRangeConfirm,
handleBooking,
goDetail,
fetchCourseList,
loadMore,
onScrollToLower
} = useGroupCourseList()
// 组件挂载时调用接口获取团课列表
onMounted(() => {
console.log('[list.vue] 页面组件已挂载,开始获取团课列表')
fetchCourseList()
console.log('[list.vue] 可用的搜索参数获取方法:')
console.log(' - searchBarRef.getSearchParams()')
console.log(' - filterSectionRef.getFilterParams()')
console.log(' - timePeriodRef.getTimePeriodParams()')
console.log(' - timeRangePickerRef.getTimeRangeParams()')
console.log(' - getAllSearchParams() 获取所有参数')
})
// 暴露方法供外部调用
defineExpose({
getAllSearchParams: () => getAllSearchParams(searchBarRef.value, filterSectionRef.value, timePeriodRef.value, timeRangePickerRef.value),
searchBarRef,
filterSectionRef,
timePeriodRef,
timeRangePickerRef
})
</script>
<style lang="scss" scoped>
.group-course-page {
min-height: 100vh;
background: #F5F7FA;
}
/* 搜索区域 */
.search-section {
background: #ffffff;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* 课程列表 */
.course-list {
height: calc(100vh - 380rpx);
padding: 24rpx 24rpx;
padding-bottom: calc(160rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 加载更多提示 */
.load-more {
text-align: center;
padding: 30rpx 0;
color: #999999;
font-size: 26rpx;
}
.load-more-text {
color: #1890ff;
}
</style>
-41
View File
@@ -1,41 +0,0 @@
<template>
<view class="home-page">
<!-- Banner轮播 -->
<BannerSwiper />
<!-- 功能入口 -->
<QuickEntry />
<!-- 推荐课程 -->
<RecommendCourses />
<!-- 今日推荐 -->
<TodayRecommend />
<!-- 底部占位 -->
<view class="bottom-placeholder"></view>
<!-- 底部导航 -->
<TabBar />
</view>
</template>
<script setup>
import BannerSwiper from '@/components/index/BannerSwiper.vue'
import QuickEntry from '@/components/index/QuickEntry.vue'
import RecommendCourses from '@/components/index/RecommendCourses.vue'
import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue'
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
background-color: #f0f4f8;
padding-bottom: 160rpx;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
@@ -1,5 +1,4 @@
// const BASE_URL = 'http://localhost:8080/api'
const BASE_URL = '/api'
const BASE_URL = 'http://localhost:8080/api'
export const request = (options) => {
return new Promise((resolve, reject) => {
@@ -0,0 +1,40 @@
<template>
<view class="container">
<text class="title">健身房管理系统</text>
<text class="subtitle">欢迎使用 UniApp 移动端</text>
</view>
</template>
<script>
export default {
data() {
return {
title: '健身房管理系统'
}
},
onLoad() {
console.log('Index page loaded')
}
}
</script>
<style>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.title {
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
}
.subtitle {
font-size: 16px;
color: #666;
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,13 +0,0 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});
-76
View File
@@ -1,76 +0,0 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
@@ -1,44 +0,0 @@
## 2.0.122025-08-26
- 优化 uni-app x 下 size 类型问题
## 2.0.112025-08-18
- 修复 图标点击事件返回
## 2.0.92024-01-12
fix: 修复图标大小默认值错误的问题
## 2.0.82023-12-14
- 修复 项目未使用 ts 情况下,打包报错的bug
## 2.0.72023-12-14
- 修复 size 属性为 string 时,不加单位导致尺寸异常的bug
## 2.0.62023-12-11
- 优化 兼容老版本icon类型,如 top bottom 等
## 2.0.52023-12-11
- 优化 兼容老版本icon类型,如 top bottom 等
## 2.0.42023-12-06
- 优化 uni-app x 下示例项目图标排序
## 2.0.32023-12-06
- 修复 nvue下引入组件报错的bug
## 2.0.22023-12-05
-优化 size 属性支持单位
## 2.0.12023-12-05
- 新增 uni-app x 支持定义图标
## 1.3.52022-01-24
- 优化 size 属性可以传入不带单位的字符串数值
## 1.3.42022-01-24
- 优化 size 支持其他单位
## 1.3.32022-01-17
- 修复 nvue 有些图标不显示的bug,兼容老版本图标
## 1.3.22021-12-01
- 优化 示例可复制图标名称
## 1.3.12021-11-23
- 优化 兼容旧组件 type 值
## 1.3.02021-11-19
- 新增 更多图标
- 优化 自定义图标使用方式
- 优化 组件UI,并提供设计资源,详见:[https://uniapp.dcloud.io/component/uniui/resource](https://uniapp.dcloud.io/component/uniui/resource)
- 文档迁移,详见:[https://uniapp.dcloud.io/component/uniui/uni-icons](https://uniapp.dcloud.io/component/uniui/uni-icons)
## 1.1.72021-11-08
## 1.2.02021-07-30
- 组件兼容 vue3,如何创建vue3项目,详见 [uni-app 项目支持 vue3 介绍](https://ask.dcloud.net.cn/article/37834)
## 1.1.52021-05-12
- 新增 组件示例地址
## 1.1.42021-02-05
- 调整为uni_modules目录规范
@@ -1,91 +0,0 @@
<template>
<text class="uni-icons" :style="styleObj">
<slot>{{unicode}}</slot>
</text>
</template>
<script>
import { fontData, IconsDataItem } from './uniicons_file'
/**
* Icons 图标
* @description 用于展示 icon 图标
* @tutorial https://ext.dcloud.net.cn/plugin?id=28
* @property {Number} size 图标大小
* @property {String} type 图标图案,参考示例
* @property {String} color 图标颜色
* @property {String} customPrefix 自定义图标
* @event {Function} click 点击 Icon 触发事件
*/
export default {
name: "uni-icons",
props: {
type: {
type: String,
default: ''
},
color: {
type: String,
default: '#333333'
},
size: {
type: [Number, String],
default: 16
},
fontFamily: {
type: String,
default: ''
}
},
data() {
return {};
},
computed: {
unicode() : string {
let codes = fontData.find((item : IconsDataItem) : boolean => { return item.font_class == this.type })
if (codes !== null) {
return codes.unicode
}
return ''
},
iconSize() : string {
const size = this.size
if (typeof size == 'string') {
const reg = /^[0-9]*$/g
return reg.test(size as string) ? '' + size + 'px' : '' + size;
// return '' + this.size
}
return this.getFontSize(size as number)
},
styleObj() : UTSJSONObject {
if (this.fontFamily !== '') {
return { color: this.color, fontSize: this.iconSize, fontFamily: this.fontFamily }
}
return { color: this.color, fontSize: this.iconSize }
}
},
created() { },
methods: {
/**
* 字体大小
*/
getFontSize(size : number) : string {
return size + 'px';
},
},
}
</script>
<style scoped>
@font-face {
font-family: UniIconsFontFamily;
src: url('./uniicons.ttf');
}
.uni-icons {
font-family: UniIconsFontFamily;
font-size: 18px;
font-style: normal;
color: #333;
}
</style>
@@ -1,110 +0,0 @@
<template>
<!-- #ifdef APP-NVUE -->
<text :style="styleObj" class="uni-icons" @click="_onClick">{{unicode}}</text>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<text :style="styleObj" class="uni-icons" :class="['uniui-'+type,customPrefix,customPrefix?type:'']" @click="_onClick">
<slot></slot>
</text>
<!-- #endif -->
</template>
<script>
import { fontData } from './uniicons_file_vue.js';
const getVal = (val) => {
const reg = /^[0-9]*$/g
return (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;
}
// #ifdef APP-NVUE
var domModule = weex.requireModule('dom');
import iconUrl from './uniicons.ttf'
domModule.addRule('fontFace', {
'fontFamily': "uniicons",
'src': "url('" + iconUrl + "')"
});
// #endif
/**
* Icons 图标
* @description 用于展示 icons 图标
* @tutorial https://ext.dcloud.net.cn/plugin?id=28
* @property {Number} size 图标大小
* @property {String} type 图标图案,参考示例
* @property {String} color 图标颜色
* @property {String} customPrefix 自定义图标
* @event {Function} click 点击 Icon 触发事件
*/
export default {
name: 'UniIcons',
emits: ['click'],
props: {
type: {
type: String,
default: ''
},
color: {
type: String,
default: '#333333'
},
size: {
type: [Number, String],
default: 16
},
customPrefix: {
type: String,
default: ''
},
fontFamily: {
type: String,
default: ''
}
},
data() {
return {
icons: fontData
}
},
computed: {
unicode() {
let code = this.icons.find(v => v.font_class === this.type)
if (code) {
return code.unicode
}
return ''
},
iconSize() {
return getVal(this.size)
},
styleObj() {
if (this.fontFamily !== '') {
return `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`
}
return `color: ${this.color}; font-size: ${this.iconSize};`
}
},
methods: {
_onClick(e) {
this.$emit('click', e)
}
}
}
</script>
<style lang="scss">
/* #ifndef APP-NVUE */
@import './uniicons.css';
@font-face {
font-family: uniicons;
src: url('./uniicons.ttf');
}
/* #endif */
.uni-icons {
font-family: uniicons;
text-decoration: none;
text-align: center;
}
</style>
@@ -1,664 +0,0 @@
.uniui-cart-filled:before {
content: "\e6d0";
}
.uniui-gift-filled:before {
content: "\e6c4";
}
.uniui-color:before {
content: "\e6cf";
}
.uniui-wallet:before {
content: "\e6b1";
}
.uniui-settings-filled:before {
content: "\e6ce";
}
.uniui-auth-filled:before {
content: "\e6cc";
}
.uniui-shop-filled:before {
content: "\e6cd";
}
.uniui-staff-filled:before {
content: "\e6cb";
}
.uniui-vip-filled:before {
content: "\e6c6";
}
.uniui-plus-filled:before {
content: "\e6c7";
}
.uniui-folder-add-filled:before {
content: "\e6c8";
}
.uniui-color-filled:before {
content: "\e6c9";
}
.uniui-tune-filled:before {
content: "\e6ca";
}
.uniui-calendar-filled:before {
content: "\e6c0";
}
.uniui-notification-filled:before {
content: "\e6c1";
}
.uniui-wallet-filled:before {
content: "\e6c2";
}
.uniui-medal-filled:before {
content: "\e6c3";
}
.uniui-fire-filled:before {
content: "\e6c5";
}
.uniui-refreshempty:before {
content: "\e6bf";
}
.uniui-location-filled:before {
content: "\e6af";
}
.uniui-person-filled:before {
content: "\e69d";
}
.uniui-personadd-filled:before {
content: "\e698";
}
.uniui-arrowthinleft:before {
content: "\e6d2";
}
.uniui-arrowthinup:before {
content: "\e6d3";
}
.uniui-arrowthindown:before {
content: "\e6d4";
}
.uniui-back:before {
content: "\e6b9";
}
.uniui-forward:before {
content: "\e6ba";
}
.uniui-arrow-right:before {
content: "\e6bb";
}
.uniui-arrow-left:before {
content: "\e6bc";
}
.uniui-arrow-up:before {
content: "\e6bd";
}
.uniui-arrow-down:before {
content: "\e6be";
}
.uniui-arrowthinright:before {
content: "\e6d1";
}
.uniui-down:before {
content: "\e6b8";
}
.uniui-bottom:before {
content: "\e6b8";
}
.uniui-arrowright:before {
content: "\e6d5";
}
.uniui-right:before {
content: "\e6b5";
}
.uniui-up:before {
content: "\e6b6";
}
.uniui-top:before {
content: "\e6b6";
}
.uniui-left:before {
content: "\e6b7";
}
.uniui-arrowup:before {
content: "\e6d6";
}
.uniui-eye:before {
content: "\e651";
}
.uniui-eye-filled:before {
content: "\e66a";
}
.uniui-eye-slash:before {
content: "\e6b3";
}
.uniui-eye-slash-filled:before {
content: "\e6b4";
}
.uniui-info-filled:before {
content: "\e649";
}
.uniui-reload:before {
content: "\e6b2";
}
.uniui-micoff-filled:before {
content: "\e6b0";
}
.uniui-map-pin-ellipse:before {
content: "\e6ac";
}
.uniui-map-pin:before {
content: "\e6ad";
}
.uniui-location:before {
content: "\e6ae";
}
.uniui-starhalf:before {
content: "\e683";
}
.uniui-star:before {
content: "\e688";
}
.uniui-star-filled:before {
content: "\e68f";
}
.uniui-calendar:before {
content: "\e6a0";
}
.uniui-fire:before {
content: "\e6a1";
}
.uniui-medal:before {
content: "\e6a2";
}
.uniui-font:before {
content: "\e6a3";
}
.uniui-gift:before {
content: "\e6a4";
}
.uniui-link:before {
content: "\e6a5";
}
.uniui-notification:before {
content: "\e6a6";
}
.uniui-staff:before {
content: "\e6a7";
}
.uniui-vip:before {
content: "\e6a8";
}
.uniui-folder-add:before {
content: "\e6a9";
}
.uniui-tune:before {
content: "\e6aa";
}
.uniui-auth:before {
content: "\e6ab";
}
.uniui-person:before {
content: "\e699";
}
.uniui-email-filled:before {
content: "\e69a";
}
.uniui-phone-filled:before {
content: "\e69b";
}
.uniui-phone:before {
content: "\e69c";
}
.uniui-email:before {
content: "\e69e";
}
.uniui-personadd:before {
content: "\e69f";
}
.uniui-chatboxes-filled:before {
content: "\e692";
}
.uniui-contact:before {
content: "\e693";
}
.uniui-chatbubble-filled:before {
content: "\e694";
}
.uniui-contact-filled:before {
content: "\e695";
}
.uniui-chatboxes:before {
content: "\e696";
}
.uniui-chatbubble:before {
content: "\e697";
}
.uniui-upload-filled:before {
content: "\e68e";
}
.uniui-upload:before {
content: "\e690";
}
.uniui-weixin:before {
content: "\e691";
}
.uniui-compose:before {
content: "\e67f";
}
.uniui-qq:before {
content: "\e680";
}
.uniui-download-filled:before {
content: "\e681";
}
.uniui-pyq:before {
content: "\e682";
}
.uniui-sound:before {
content: "\e684";
}
.uniui-trash-filled:before {
content: "\e685";
}
.uniui-sound-filled:before {
content: "\e686";
}
.uniui-trash:before {
content: "\e687";
}
.uniui-videocam-filled:before {
content: "\e689";
}
.uniui-spinner-cycle:before {
content: "\e68a";
}
.uniui-weibo:before {
content: "\e68b";
}
.uniui-videocam:before {
content: "\e68c";
}
.uniui-download:before {
content: "\e68d";
}
.uniui-help:before {
content: "\e679";
}
.uniui-navigate-filled:before {
content: "\e67a";
}
.uniui-plusempty:before {
content: "\e67b";
}
.uniui-smallcircle:before {
content: "\e67c";
}
.uniui-minus-filled:before {
content: "\e67d";
}
.uniui-micoff:before {
content: "\e67e";
}
.uniui-closeempty:before {
content: "\e66c";
}
.uniui-clear:before {
content: "\e66d";
}
.uniui-navigate:before {
content: "\e66e";
}
.uniui-minus:before {
content: "\e66f";
}
.uniui-image:before {
content: "\e670";
}
.uniui-mic:before {
content: "\e671";
}
.uniui-paperplane:before {
content: "\e672";
}
.uniui-close:before {
content: "\e673";
}
.uniui-help-filled:before {
content: "\e674";
}
.uniui-paperplane-filled:before {
content: "\e675";
}
.uniui-plus:before {
content: "\e676";
}
.uniui-mic-filled:before {
content: "\e677";
}
.uniui-image-filled:before {
content: "\e678";
}
.uniui-locked-filled:before {
content: "\e668";
}
.uniui-info:before {
content: "\e669";
}
.uniui-locked:before {
content: "\e66b";
}
.uniui-camera-filled:before {
content: "\e658";
}
.uniui-chat-filled:before {
content: "\e659";
}
.uniui-camera:before {
content: "\e65a";
}
.uniui-circle:before {
content: "\e65b";
}
.uniui-checkmarkempty:before {
content: "\e65c";
}
.uniui-chat:before {
content: "\e65d";
}
.uniui-circle-filled:before {
content: "\e65e";
}
.uniui-flag:before {
content: "\e65f";
}
.uniui-flag-filled:before {
content: "\e660";
}
.uniui-gear-filled:before {
content: "\e661";
}
.uniui-home:before {
content: "\e662";
}
.uniui-home-filled:before {
content: "\e663";
}
.uniui-gear:before {
content: "\e664";
}
.uniui-smallcircle-filled:before {
content: "\e665";
}
.uniui-map-filled:before {
content: "\e666";
}
.uniui-map:before {
content: "\e667";
}
.uniui-refresh-filled:before {
content: "\e656";
}
.uniui-refresh:before {
content: "\e657";
}
.uniui-cloud-upload:before {
content: "\e645";
}
.uniui-cloud-download-filled:before {
content: "\e646";
}
.uniui-cloud-download:before {
content: "\e647";
}
.uniui-cloud-upload-filled:before {
content: "\e648";
}
.uniui-redo:before {
content: "\e64a";
}
.uniui-images-filled:before {
content: "\e64b";
}
.uniui-undo-filled:before {
content: "\e64c";
}
.uniui-more:before {
content: "\e64d";
}
.uniui-more-filled:before {
content: "\e64e";
}
.uniui-undo:before {
content: "\e64f";
}
.uniui-images:before {
content: "\e650";
}
.uniui-paperclip:before {
content: "\e652";
}
.uniui-settings:before {
content: "\e653";
}
.uniui-search:before {
content: "\e654";
}
.uniui-redo-filled:before {
content: "\e655";
}
.uniui-list:before {
content: "\e644";
}
.uniui-mail-open-filled:before {
content: "\e63a";
}
.uniui-hand-down-filled:before {
content: "\e63c";
}
.uniui-hand-down:before {
content: "\e63d";
}
.uniui-hand-up-filled:before {
content: "\e63e";
}
.uniui-hand-up:before {
content: "\e63f";
}
.uniui-heart-filled:before {
content: "\e641";
}
.uniui-mail-open:before {
content: "\e643";
}
.uniui-heart:before {
content: "\e639";
}
.uniui-loop:before {
content: "\e633";
}
.uniui-pulldown:before {
content: "\e632";
}
.uniui-scan:before {
content: "\e62a";
}
.uniui-bars:before {
content: "\e627";
}
.uniui-checkbox:before {
content: "\e62b";
}
.uniui-checkbox-filled:before {
content: "\e62c";
}
.uniui-shop:before {
content: "\e62f";
}
.uniui-headphones:before {
content: "\e630";
}
.uniui-cart:before {
content: "\e631";
}
@@ -1,664 +0,0 @@
export type IconsData = {
id : string
name : string
font_family : string
css_prefix_text : string
description : string
glyphs : Array<IconsDataItem>
}
export type IconsDataItem = {
font_class : string
unicode : string
}
export const fontData = [
{
"font_class": "arrow-down",
"unicode": "\ue6be"
},
{
"font_class": "arrow-left",
"unicode": "\ue6bc"
},
{
"font_class": "arrow-right",
"unicode": "\ue6bb"
},
{
"font_class": "arrow-up",
"unicode": "\ue6bd"
},
{
"font_class": "auth",
"unicode": "\ue6ab"
},
{
"font_class": "auth-filled",
"unicode": "\ue6cc"
},
{
"font_class": "back",
"unicode": "\ue6b9"
},
{
"font_class": "bars",
"unicode": "\ue627"
},
{
"font_class": "calendar",
"unicode": "\ue6a0"
},
{
"font_class": "calendar-filled",
"unicode": "\ue6c0"
},
{
"font_class": "camera",
"unicode": "\ue65a"
},
{
"font_class": "camera-filled",
"unicode": "\ue658"
},
{
"font_class": "cart",
"unicode": "\ue631"
},
{
"font_class": "cart-filled",
"unicode": "\ue6d0"
},
{
"font_class": "chat",
"unicode": "\ue65d"
},
{
"font_class": "chat-filled",
"unicode": "\ue659"
},
{
"font_class": "chatboxes",
"unicode": "\ue696"
},
{
"font_class": "chatboxes-filled",
"unicode": "\ue692"
},
{
"font_class": "chatbubble",
"unicode": "\ue697"
},
{
"font_class": "chatbubble-filled",
"unicode": "\ue694"
},
{
"font_class": "checkbox",
"unicode": "\ue62b"
},
{
"font_class": "checkbox-filled",
"unicode": "\ue62c"
},
{
"font_class": "checkmarkempty",
"unicode": "\ue65c"
},
{
"font_class": "circle",
"unicode": "\ue65b"
},
{
"font_class": "circle-filled",
"unicode": "\ue65e"
},
{
"font_class": "clear",
"unicode": "\ue66d"
},
{
"font_class": "close",
"unicode": "\ue673"
},
{
"font_class": "closeempty",
"unicode": "\ue66c"
},
{
"font_class": "cloud-download",
"unicode": "\ue647"
},
{
"font_class": "cloud-download-filled",
"unicode": "\ue646"
},
{
"font_class": "cloud-upload",
"unicode": "\ue645"
},
{
"font_class": "cloud-upload-filled",
"unicode": "\ue648"
},
{
"font_class": "color",
"unicode": "\ue6cf"
},
{
"font_class": "color-filled",
"unicode": "\ue6c9"
},
{
"font_class": "compose",
"unicode": "\ue67f"
},
{
"font_class": "contact",
"unicode": "\ue693"
},
{
"font_class": "contact-filled",
"unicode": "\ue695"
},
{
"font_class": "down",
"unicode": "\ue6b8"
},
{
"font_class": "bottom",
"unicode": "\ue6b8"
},
{
"font_class": "download",
"unicode": "\ue68d"
},
{
"font_class": "download-filled",
"unicode": "\ue681"
},
{
"font_class": "email",
"unicode": "\ue69e"
},
{
"font_class": "email-filled",
"unicode": "\ue69a"
},
{
"font_class": "eye",
"unicode": "\ue651"
},
{
"font_class": "eye-filled",
"unicode": "\ue66a"
},
{
"font_class": "eye-slash",
"unicode": "\ue6b3"
},
{
"font_class": "eye-slash-filled",
"unicode": "\ue6b4"
},
{
"font_class": "fire",
"unicode": "\ue6a1"
},
{
"font_class": "fire-filled",
"unicode": "\ue6c5"
},
{
"font_class": "flag",
"unicode": "\ue65f"
},
{
"font_class": "flag-filled",
"unicode": "\ue660"
},
{
"font_class": "folder-add",
"unicode": "\ue6a9"
},
{
"font_class": "folder-add-filled",
"unicode": "\ue6c8"
},
{
"font_class": "font",
"unicode": "\ue6a3"
},
{
"font_class": "forward",
"unicode": "\ue6ba"
},
{
"font_class": "gear",
"unicode": "\ue664"
},
{
"font_class": "gear-filled",
"unicode": "\ue661"
},
{
"font_class": "gift",
"unicode": "\ue6a4"
},
{
"font_class": "gift-filled",
"unicode": "\ue6c4"
},
{
"font_class": "hand-down",
"unicode": "\ue63d"
},
{
"font_class": "hand-down-filled",
"unicode": "\ue63c"
},
{
"font_class": "hand-up",
"unicode": "\ue63f"
},
{
"font_class": "hand-up-filled",
"unicode": "\ue63e"
},
{
"font_class": "headphones",
"unicode": "\ue630"
},
{
"font_class": "heart",
"unicode": "\ue639"
},
{
"font_class": "heart-filled",
"unicode": "\ue641"
},
{
"font_class": "help",
"unicode": "\ue679"
},
{
"font_class": "help-filled",
"unicode": "\ue674"
},
{
"font_class": "home",
"unicode": "\ue662"
},
{
"font_class": "home-filled",
"unicode": "\ue663"
},
{
"font_class": "image",
"unicode": "\ue670"
},
{
"font_class": "image-filled",
"unicode": "\ue678"
},
{
"font_class": "images",
"unicode": "\ue650"
},
{
"font_class": "images-filled",
"unicode": "\ue64b"
},
{
"font_class": "info",
"unicode": "\ue669"
},
{
"font_class": "info-filled",
"unicode": "\ue649"
},
{
"font_class": "left",
"unicode": "\ue6b7"
},
{
"font_class": "link",
"unicode": "\ue6a5"
},
{
"font_class": "list",
"unicode": "\ue644"
},
{
"font_class": "location",
"unicode": "\ue6ae"
},
{
"font_class": "location-filled",
"unicode": "\ue6af"
},
{
"font_class": "locked",
"unicode": "\ue66b"
},
{
"font_class": "locked-filled",
"unicode": "\ue668"
},
{
"font_class": "loop",
"unicode": "\ue633"
},
{
"font_class": "mail-open",
"unicode": "\ue643"
},
{
"font_class": "mail-open-filled",
"unicode": "\ue63a"
},
{
"font_class": "map",
"unicode": "\ue667"
},
{
"font_class": "map-filled",
"unicode": "\ue666"
},
{
"font_class": "map-pin",
"unicode": "\ue6ad"
},
{
"font_class": "map-pin-ellipse",
"unicode": "\ue6ac"
},
{
"font_class": "medal",
"unicode": "\ue6a2"
},
{
"font_class": "medal-filled",
"unicode": "\ue6c3"
},
{
"font_class": "mic",
"unicode": "\ue671"
},
{
"font_class": "mic-filled",
"unicode": "\ue677"
},
{
"font_class": "micoff",
"unicode": "\ue67e"
},
{
"font_class": "micoff-filled",
"unicode": "\ue6b0"
},
{
"font_class": "minus",
"unicode": "\ue66f"
},
{
"font_class": "minus-filled",
"unicode": "\ue67d"
},
{
"font_class": "more",
"unicode": "\ue64d"
},
{
"font_class": "more-filled",
"unicode": "\ue64e"
},
{
"font_class": "navigate",
"unicode": "\ue66e"
},
{
"font_class": "navigate-filled",
"unicode": "\ue67a"
},
{
"font_class": "notification",
"unicode": "\ue6a6"
},
{
"font_class": "notification-filled",
"unicode": "\ue6c1"
},
{
"font_class": "paperclip",
"unicode": "\ue652"
},
{
"font_class": "paperplane",
"unicode": "\ue672"
},
{
"font_class": "paperplane-filled",
"unicode": "\ue675"
},
{
"font_class": "person",
"unicode": "\ue699"
},
{
"font_class": "person-filled",
"unicode": "\ue69d"
},
{
"font_class": "personadd",
"unicode": "\ue69f"
},
{
"font_class": "personadd-filled",
"unicode": "\ue698"
},
{
"font_class": "personadd-filled-copy",
"unicode": "\ue6d1"
},
{
"font_class": "phone",
"unicode": "\ue69c"
},
{
"font_class": "phone-filled",
"unicode": "\ue69b"
},
{
"font_class": "plus",
"unicode": "\ue676"
},
{
"font_class": "plus-filled",
"unicode": "\ue6c7"
},
{
"font_class": "plusempty",
"unicode": "\ue67b"
},
{
"font_class": "pulldown",
"unicode": "\ue632"
},
{
"font_class": "pyq",
"unicode": "\ue682"
},
{
"font_class": "qq",
"unicode": "\ue680"
},
{
"font_class": "redo",
"unicode": "\ue64a"
},
{
"font_class": "redo-filled",
"unicode": "\ue655"
},
{
"font_class": "refresh",
"unicode": "\ue657"
},
{
"font_class": "refresh-filled",
"unicode": "\ue656"
},
{
"font_class": "refreshempty",
"unicode": "\ue6bf"
},
{
"font_class": "reload",
"unicode": "\ue6b2"
},
{
"font_class": "right",
"unicode": "\ue6b5"
},
{
"font_class": "scan",
"unicode": "\ue62a"
},
{
"font_class": "search",
"unicode": "\ue654"
},
{
"font_class": "settings",
"unicode": "\ue653"
},
{
"font_class": "settings-filled",
"unicode": "\ue6ce"
},
{
"font_class": "shop",
"unicode": "\ue62f"
},
{
"font_class": "shop-filled",
"unicode": "\ue6cd"
},
{
"font_class": "smallcircle",
"unicode": "\ue67c"
},
{
"font_class": "smallcircle-filled",
"unicode": "\ue665"
},
{
"font_class": "sound",
"unicode": "\ue684"
},
{
"font_class": "sound-filled",
"unicode": "\ue686"
},
{
"font_class": "spinner-cycle",
"unicode": "\ue68a"
},
{
"font_class": "staff",
"unicode": "\ue6a7"
},
{
"font_class": "staff-filled",
"unicode": "\ue6cb"
},
{
"font_class": "star",
"unicode": "\ue688"
},
{
"font_class": "star-filled",
"unicode": "\ue68f"
},
{
"font_class": "starhalf",
"unicode": "\ue683"
},
{
"font_class": "trash",
"unicode": "\ue687"
},
{
"font_class": "trash-filled",
"unicode": "\ue685"
},
{
"font_class": "tune",
"unicode": "\ue6aa"
},
{
"font_class": "tune-filled",
"unicode": "\ue6ca"
},
{
"font_class": "undo",
"unicode": "\ue64f"
},
{
"font_class": "undo-filled",
"unicode": "\ue64c"
},
{
"font_class": "up",
"unicode": "\ue6b6"
},
{
"font_class": "top",
"unicode": "\ue6b6"
},
{
"font_class": "upload",
"unicode": "\ue690"
},
{
"font_class": "upload-filled",
"unicode": "\ue68e"
},
{
"font_class": "videocam",
"unicode": "\ue68c"
},
{
"font_class": "videocam-filled",
"unicode": "\ue689"
},
{
"font_class": "vip",
"unicode": "\ue6a8"
},
{
"font_class": "vip-filled",
"unicode": "\ue6c6"
},
{
"font_class": "wallet",
"unicode": "\ue6b1"
},
{
"font_class": "wallet-filled",
"unicode": "\ue6c2"
},
{
"font_class": "weibo",
"unicode": "\ue68b"
},
{
"font_class": "weixin",
"unicode": "\ue691"
}
] as IconsDataItem[]
// export const fontData = JSON.parse<IconsDataItem>(fontDataJson)
@@ -1,649 +0,0 @@
export const fontData = [
{
"font_class": "arrow-down",
"unicode": "\ue6be"
},
{
"font_class": "arrow-left",
"unicode": "\ue6bc"
},
{
"font_class": "arrow-right",
"unicode": "\ue6bb"
},
{
"font_class": "arrow-up",
"unicode": "\ue6bd"
},
{
"font_class": "auth",
"unicode": "\ue6ab"
},
{
"font_class": "auth-filled",
"unicode": "\ue6cc"
},
{
"font_class": "back",
"unicode": "\ue6b9"
},
{
"font_class": "bars",
"unicode": "\ue627"
},
{
"font_class": "calendar",
"unicode": "\ue6a0"
},
{
"font_class": "calendar-filled",
"unicode": "\ue6c0"
},
{
"font_class": "camera",
"unicode": "\ue65a"
},
{
"font_class": "camera-filled",
"unicode": "\ue658"
},
{
"font_class": "cart",
"unicode": "\ue631"
},
{
"font_class": "cart-filled",
"unicode": "\ue6d0"
},
{
"font_class": "chat",
"unicode": "\ue65d"
},
{
"font_class": "chat-filled",
"unicode": "\ue659"
},
{
"font_class": "chatboxes",
"unicode": "\ue696"
},
{
"font_class": "chatboxes-filled",
"unicode": "\ue692"
},
{
"font_class": "chatbubble",
"unicode": "\ue697"
},
{
"font_class": "chatbubble-filled",
"unicode": "\ue694"
},
{
"font_class": "checkbox",
"unicode": "\ue62b"
},
{
"font_class": "checkbox-filled",
"unicode": "\ue62c"
},
{
"font_class": "checkmarkempty",
"unicode": "\ue65c"
},
{
"font_class": "circle",
"unicode": "\ue65b"
},
{
"font_class": "circle-filled",
"unicode": "\ue65e"
},
{
"font_class": "clear",
"unicode": "\ue66d"
},
{
"font_class": "close",
"unicode": "\ue673"
},
{
"font_class": "closeempty",
"unicode": "\ue66c"
},
{
"font_class": "cloud-download",
"unicode": "\ue647"
},
{
"font_class": "cloud-download-filled",
"unicode": "\ue646"
},
{
"font_class": "cloud-upload",
"unicode": "\ue645"
},
{
"font_class": "cloud-upload-filled",
"unicode": "\ue648"
},
{
"font_class": "color",
"unicode": "\ue6cf"
},
{
"font_class": "color-filled",
"unicode": "\ue6c9"
},
{
"font_class": "compose",
"unicode": "\ue67f"
},
{
"font_class": "contact",
"unicode": "\ue693"
},
{
"font_class": "contact-filled",
"unicode": "\ue695"
},
{
"font_class": "down",
"unicode": "\ue6b8"
},
{
"font_class": "bottom",
"unicode": "\ue6b8"
},
{
"font_class": "download",
"unicode": "\ue68d"
},
{
"font_class": "download-filled",
"unicode": "\ue681"
},
{
"font_class": "email",
"unicode": "\ue69e"
},
{
"font_class": "email-filled",
"unicode": "\ue69a"
},
{
"font_class": "eye",
"unicode": "\ue651"
},
{
"font_class": "eye-filled",
"unicode": "\ue66a"
},
{
"font_class": "eye-slash",
"unicode": "\ue6b3"
},
{
"font_class": "eye-slash-filled",
"unicode": "\ue6b4"
},
{
"font_class": "fire",
"unicode": "\ue6a1"
},
{
"font_class": "fire-filled",
"unicode": "\ue6c5"
},
{
"font_class": "flag",
"unicode": "\ue65f"
},
{
"font_class": "flag-filled",
"unicode": "\ue660"
},
{
"font_class": "folder-add",
"unicode": "\ue6a9"
},
{
"font_class": "folder-add-filled",
"unicode": "\ue6c8"
},
{
"font_class": "font",
"unicode": "\ue6a3"
},
{
"font_class": "forward",
"unicode": "\ue6ba"
},
{
"font_class": "gear",
"unicode": "\ue664"
},
{
"font_class": "gear-filled",
"unicode": "\ue661"
},
{
"font_class": "gift",
"unicode": "\ue6a4"
},
{
"font_class": "gift-filled",
"unicode": "\ue6c4"
},
{
"font_class": "hand-down",
"unicode": "\ue63d"
},
{
"font_class": "hand-down-filled",
"unicode": "\ue63c"
},
{
"font_class": "hand-up",
"unicode": "\ue63f"
},
{
"font_class": "hand-up-filled",
"unicode": "\ue63e"
},
{
"font_class": "headphones",
"unicode": "\ue630"
},
{
"font_class": "heart",
"unicode": "\ue639"
},
{
"font_class": "heart-filled",
"unicode": "\ue641"
},
{
"font_class": "help",
"unicode": "\ue679"
},
{
"font_class": "help-filled",
"unicode": "\ue674"
},
{
"font_class": "home",
"unicode": "\ue662"
},
{
"font_class": "home-filled",
"unicode": "\ue663"
},
{
"font_class": "image",
"unicode": "\ue670"
},
{
"font_class": "image-filled",
"unicode": "\ue678"
},
{
"font_class": "images",
"unicode": "\ue650"
},
{
"font_class": "images-filled",
"unicode": "\ue64b"
},
{
"font_class": "info",
"unicode": "\ue669"
},
{
"font_class": "info-filled",
"unicode": "\ue649"
},
{
"font_class": "left",
"unicode": "\ue6b7"
},
{
"font_class": "link",
"unicode": "\ue6a5"
},
{
"font_class": "list",
"unicode": "\ue644"
},
{
"font_class": "location",
"unicode": "\ue6ae"
},
{
"font_class": "location-filled",
"unicode": "\ue6af"
},
{
"font_class": "locked",
"unicode": "\ue66b"
},
{
"font_class": "locked-filled",
"unicode": "\ue668"
},
{
"font_class": "loop",
"unicode": "\ue633"
},
{
"font_class": "mail-open",
"unicode": "\ue643"
},
{
"font_class": "mail-open-filled",
"unicode": "\ue63a"
},
{
"font_class": "map",
"unicode": "\ue667"
},
{
"font_class": "map-filled",
"unicode": "\ue666"
},
{
"font_class": "map-pin",
"unicode": "\ue6ad"
},
{
"font_class": "map-pin-ellipse",
"unicode": "\ue6ac"
},
{
"font_class": "medal",
"unicode": "\ue6a2"
},
{
"font_class": "medal-filled",
"unicode": "\ue6c3"
},
{
"font_class": "mic",
"unicode": "\ue671"
},
{
"font_class": "mic-filled",
"unicode": "\ue677"
},
{
"font_class": "micoff",
"unicode": "\ue67e"
},
{
"font_class": "micoff-filled",
"unicode": "\ue6b0"
},
{
"font_class": "minus",
"unicode": "\ue66f"
},
{
"font_class": "minus-filled",
"unicode": "\ue67d"
},
{
"font_class": "more",
"unicode": "\ue64d"
},
{
"font_class": "more-filled",
"unicode": "\ue64e"
},
{
"font_class": "navigate",
"unicode": "\ue66e"
},
{
"font_class": "navigate-filled",
"unicode": "\ue67a"
},
{
"font_class": "notification",
"unicode": "\ue6a6"
},
{
"font_class": "notification-filled",
"unicode": "\ue6c1"
},
{
"font_class": "paperclip",
"unicode": "\ue652"
},
{
"font_class": "paperplane",
"unicode": "\ue672"
},
{
"font_class": "paperplane-filled",
"unicode": "\ue675"
},
{
"font_class": "person",
"unicode": "\ue699"
},
{
"font_class": "person-filled",
"unicode": "\ue69d"
},
{
"font_class": "personadd",
"unicode": "\ue69f"
},
{
"font_class": "personadd-filled",
"unicode": "\ue698"
},
{
"font_class": "personadd-filled-copy",
"unicode": "\ue6d1"
},
{
"font_class": "phone",
"unicode": "\ue69c"
},
{
"font_class": "phone-filled",
"unicode": "\ue69b"
},
{
"font_class": "plus",
"unicode": "\ue676"
},
{
"font_class": "plus-filled",
"unicode": "\ue6c7"
},
{
"font_class": "plusempty",
"unicode": "\ue67b"
},
{
"font_class": "pulldown",
"unicode": "\ue632"
},
{
"font_class": "pyq",
"unicode": "\ue682"
},
{
"font_class": "qq",
"unicode": "\ue680"
},
{
"font_class": "redo",
"unicode": "\ue64a"
},
{
"font_class": "redo-filled",
"unicode": "\ue655"
},
{
"font_class": "refresh",
"unicode": "\ue657"
},
{
"font_class": "refresh-filled",
"unicode": "\ue656"
},
{
"font_class": "refreshempty",
"unicode": "\ue6bf"
},
{
"font_class": "reload",
"unicode": "\ue6b2"
},
{
"font_class": "right",
"unicode": "\ue6b5"
},
{
"font_class": "scan",
"unicode": "\ue62a"
},
{
"font_class": "search",
"unicode": "\ue654"
},
{
"font_class": "settings",
"unicode": "\ue653"
},
{
"font_class": "settings-filled",
"unicode": "\ue6ce"
},
{
"font_class": "shop",
"unicode": "\ue62f"
},
{
"font_class": "shop-filled",
"unicode": "\ue6cd"
},
{
"font_class": "smallcircle",
"unicode": "\ue67c"
},
{
"font_class": "smallcircle-filled",
"unicode": "\ue665"
},
{
"font_class": "sound",
"unicode": "\ue684"
},
{
"font_class": "sound-filled",
"unicode": "\ue686"
},
{
"font_class": "spinner-cycle",
"unicode": "\ue68a"
},
{
"font_class": "staff",
"unicode": "\ue6a7"
},
{
"font_class": "staff-filled",
"unicode": "\ue6cb"
},
{
"font_class": "star",
"unicode": "\ue688"
},
{
"font_class": "star-filled",
"unicode": "\ue68f"
},
{
"font_class": "starhalf",
"unicode": "\ue683"
},
{
"font_class": "trash",
"unicode": "\ue687"
},
{
"font_class": "trash-filled",
"unicode": "\ue685"
},
{
"font_class": "tune",
"unicode": "\ue6aa"
},
{
"font_class": "tune-filled",
"unicode": "\ue6ca"
},
{
"font_class": "undo",
"unicode": "\ue64f"
},
{
"font_class": "undo-filled",
"unicode": "\ue64c"
},
{
"font_class": "up",
"unicode": "\ue6b6"
},
{
"font_class": "top",
"unicode": "\ue6b6"
},
{
"font_class": "upload",
"unicode": "\ue690"
},
{
"font_class": "upload-filled",
"unicode": "\ue68e"
},
{
"font_class": "videocam",
"unicode": "\ue68c"
},
{
"font_class": "videocam-filled",
"unicode": "\ue689"
},
{
"font_class": "vip",
"unicode": "\ue6a8"
},
{
"font_class": "vip-filled",
"unicode": "\ue6c6"
},
{
"font_class": "wallet",
"unicode": "\ue6b1"
},
{
"font_class": "wallet-filled",
"unicode": "\ue6c2"
},
{
"font_class": "weibo",
"unicode": "\ue68b"
},
{
"font_class": "weixin",
"unicode": "\ue691"
}
]
// export const fontData = JSON.parse<IconsDataItem>(fontDataJson)
@@ -1,111 +0,0 @@
{
"id": "uni-icons",
"displayName": "uni-icons 图标",
"version": "2.0.12",
"description": "图标组件,用于展示移动端常见的图标,可自定义颜色、大小。",
"keywords": [
"uni-ui",
"uniui",
"icon",
"图标"
],
"repository": "https://github.com/dcloudio/uni-ui",
"engines": {
"HBuilderX": "^3.2.14",
"uni-app": "^4.08",
"uni-app-x": "^4.61"
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/@dcloudio/uni-ui",
"type": "component-vue",
"darkmode": "x",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [
"uni-scss"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"vue": "√",
"nvue": "-",
"android": {
"extVersion": "",
"minVersion": "29"
},
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√",
"alipay": "√",
"toutiao": "√",
"baidu": "√",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "√",
"lark": "-"
},
"quickapp": {
"huawei": "√",
"union": "√"
}
},
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": {
"extVersion": "",
"minVersion": "29"
},
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
@@ -1,8 +0,0 @@
## Icons 图标
> **组件名:uni-icons**
> 代码块: `uIcons`
用于展示 icons 图标 。
### [查看文档](https://uniapp.dcloud.io/component/uniui/uni-icons)
#### 如使用过程中有任何问题,或者您对uni-ui有一些好的建议,欢迎加入 uni-ui 交流群:871950839
@@ -1,8 +0,0 @@
## 1.0.32022-01-21
- 优化 组件示例
## 1.0.22021-11-22
- 修复 / 符号在 vue 不同版本兼容问题引起的报错问题
## 1.0.12021-11-22
- 修复 vue3中scss语法兼容问题
## 1.0.02021-11-18
- init
@@ -1 +0,0 @@
@import './styles/index.scss';

Some files were not shown because too many files have changed in this diff Show More